Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/cli/src/command-internal/db-target-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export const VALUE_CONSUMING_LONG_FLAGS = new Set([
"sub",
...GEN_TYPES_LANGUAGE_VALUE_FLAG_NAMES,
"tail",
"target",
"template",
"timestamp",
"to",
Expand Down
13 changes: 8 additions & 5 deletions apps/cli/src/commands/feedback/add/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# `supabase feedback add [message...] [--project-ref <ref>]`
# `supabase feedback add [message...] [--project-ref <ref>] [--target <json>]...`

## Files Read

Expand All @@ -17,9 +17,9 @@

## API Routes

| Method | Path | Auth | Request body | Response (used fields) |
| ------ | ----------------------------------------------------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| `POST` | `<feedback-env-url>/rest/v1/rpc/submit_interfaces_feedback` | `apikey` = committed publishable key | `{ feedback, user_agent, project_ref (omitted when unlinked), user_id (omitted when logged out or consent-denied), metadata: { cli_version, source: "cli", os, arch, is_agent, agent_name? } }` | uuid delete token (issued exactly once, shown to the user) |
| Method | Path | Auth | Request body | Response (used fields) |
| ------ | ----------------------------------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| `POST` | `<feedback-env-url>/rest/v1/rpc/submit_interfaces_feedback` | `apikey` = committed publishable key | `{ feedback, user_agent, project_ref (omitted when unlinked), user_id (omitted when logged out or consent-denied), metadata: { cli_version, source: "cli", os, arch, is_agent, agent_name?, targets?: [{ url, quote, heading? }] } }` | uuid delete token (issued exactly once, shown to the user) |

`<feedback-env-url>` follows the resolved profile (`feedback.layers.ts`):
`supabase-staging` / `supabase-local` → the persistent staging branch of the
Expand Down Expand Up @@ -50,6 +50,8 @@ every command.
| `0` | success |
| `1` | no message from args, piped stdin, or an interactive prompt (`-o json` never prompts) |
| `1` | message over the 1000-character limit (checked client-side, no request sent) |
| `1` | invalid `--target` (not a JSON object, or a bad `url`, `quote` or `heading`; no request sent) |
| `1` | more than 3 `--target` flags (validated pre-run) |
| `1` | `--project-ref` or `SUPABASE_PROJECT_ID` is set but not a well-formed project ref (no request sent) |
| `1` | submit failure (PostgREST error, network failure, or 10s timeout) |
| `1` | `-o`/`--output` value outside the command's `pretty\|json` enum (validated pre-run) |
Expand All @@ -64,7 +66,8 @@ The feedback message content is NEVER included in any telemetry event: the
message is a positional argument, which `extractChangedFlagNames` structurally
excludes from the `flags` property (it only scans `-`-prefixed argv tokens).
`--project-ref` is recorded by name only; its value is redacted (no
telemetry-safe marking, same as `feedback delete`). Regression-tested in
telemetry-safe marking, same as `feedback delete`). `--target` values are
redacted the same way. Regression-tested in
`add.integration.test.ts`. The message goes only to the `interfaces_feedback`
table via the API route above.

Expand Down
23 changes: 18 additions & 5 deletions apps/cli/src/commands/feedback/add/add.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ const config = {
),
Flag.optional,
),
target: Flag.String("target").pipe(
Flag.withDescription(
'Docs section the feedback is about, as JSON: {"url":"https://supabase.com/docs/...","quote":"...","heading":"..."}. Repeat up to 3 times. URL is https://supabase.com/ followed by docs, kb, llms/, llms.txt or llms-full.txt; quote 1-400 characters, copied verbatim; heading at most 200.',
),
Flag.atMost(3),
),
} as const;

export type FeedbackAddArgs = CliCommand.Command.Config.Infer<typeof config>;
Expand All @@ -33,15 +39,17 @@ export type FeedbackAddArgs = CliCommand.Command.Config.Infer<typeof config>;
export const feedbackAddHandler = (args: FeedbackAddArgs) =>
feedbackAdd(args).pipe(
// The message is a positional (structurally excluded from telemetry) and
// `--project-ref` is a plain string flag, so its value is redacted — the
// same treatment as `feedback delete`. Feedback's own `-o` enum is
// `pretty|json` (see feedback-output.ts).
// `--project-ref` and `--target` are plain string flags, so their values
// are redacted — the same treatment as `feedback delete`. Feedback's own
// `-o` enum is `pretty|json` (see feedback-output.ts).
withCommandTelemetry({ flags: args, outputFormats: FEEDBACK_OUTPUT_FORMATS }),
withJsonErrorHandling,
);

export const feedbackAddCommand = Command.make("add", config).pipe(
Command.withDescription("Send quick feedback about the Supabase CLI to the Supabase team."),
Command.withDescription(
"Send quick feedback about the Supabase CLI or docs to the Supabase team.",
),
Command.withShortDescription("Send feedback to the Supabase team"),
Command.withExamples([
{
Expand All @@ -57,8 +65,13 @@ export const feedbackAddCommand = Command.make("add", config).pipe(
command: 'supabase feedback add --project-ref abcdefghijklmnopqrst "branching is slow here"',
description: "Attribute the feedback to a project from an unlinked directory",
},
{
command:
'supabase feedback add --target \'{"url":"https://supabase.com/docs/guides/auth/server-side","quote":"Create a server client."}\' "the guide does not say who refreshes the session"',
description: "Report a docs problem with the section it is about",
},
]),
withPermissions({ operations: [], noApiEffectFlags: ["project-ref"] }),
withPermissions({ operations: [], noApiEffectFlags: ["project-ref", "target"] }),
Command.withHandler(feedbackAddHandler),
Command.provide(commandRuntimeLayer(["feedback", "add"])),
Command.provide(telemetryStateLayer),
Expand Down
23 changes: 23 additions & 0 deletions apps/cli/src/commands/feedback/add/add.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,27 @@ describe("supabase feedback", () => {
),
E2E_TIMEOUT_MS,
);

it.live(
"feedback add rejects more than 3 --target flags before running",
() =>
withTempHome((home) =>
Effect.gen(function* () {
const target = '{"url":"https://supabase.com/docs/guides/auth","quote":"a"}';
const result = yield* runSupabaseEffect(
[
"feedback",
"add",
...Array.from({ length: 4 }, () => ["--target", target]).flat(),
"docs gap",
],
{ home: home.dir, env: { HOME: home.dir } },
);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("at most 3");
expect(result.stdout).toBe("");
}),
),
E2E_TIMEOUT_MS,
);
});
20 changes: 20 additions & 0 deletions apps/cli/src/commands/feedback/add/add.errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,23 @@ export class FeedbackMessageTooLongError extends Data.TaggedError("FeedbackMessa
return actionability.invalidInput;
}
}

export function feedbackTargetInvalidMessage(position: number, field?: string): string {
return (
(field === undefined
? `--target ${position} is not a JSON object. `
: `--target ${position} has a missing or invalid "${field}". `) +
`Pass JSON like {"url":"https://supabase.com/docs/guides/auth","quote":"..."}. ` +
`The URL must be https://supabase.com/ followed by docs, kb, llms/, llms.txt or llms-full.txt, with spaces percent-encoded and no ".." segments. ` +
`The quote must be 1-400 characters, and the optional heading at most 200 characters.`
);
}

/** A `--target` value that is not a valid docs target. */
export class FeedbackTargetInvalidError extends Data.TaggedError("FeedbackTargetInvalidError")<{
readonly message: string;
}> {
get [ErrorActionabilityId](): CliErrorActionabilityDeclaration {
return actionability.invalidInput;
}
}
49 changes: 48 additions & 1 deletion apps/cli/src/commands/feedback/add/add.handler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Effect, Option, Stream } from "effect";
import { Effect, Option, Schema, SchemaIssue, Stream } from "effect";
import type { PlatformError } from "effect/PlatformError";
import { FeedbackClient } from "../../../shared/feedback/feedback-client.service.ts";
import { Output } from "../../../shared/output/output.service.ts";
Expand All @@ -21,9 +21,32 @@ import {
FEEDBACK_PIPE_TOO_LONG_MESSAGE,
FeedbackEmptyMessageError,
FeedbackMessageTooLongError,
FeedbackTargetInvalidError,
feedbackTargetInvalidMessage,
feedbackTooLongMessage,
} from "./add.errors.ts";

const decodeDocsTarget = Schema.decodeUnknownEffect(
Schema.fromJsonString(
Schema.Struct({
url: Schema.String.check(
Schema.isMaxLength(500),
Schema.makeFilter((value: string) => {
const url = URL.parse(value);
// The parser resolves dot segments and encodes unsafe characters, so a changed href means unsafe input.
return (
url?.href === value &&
value.startsWith("https://supabase.com/") &&
/^\/(docs|kb)(\/|$)|^\/llms(-full)?\.txt$|^\/llms\//.test(url.pathname)
);
}),
),
quote: Schema.String.check(Schema.isPattern(/\S/), Schema.isMaxCodePoints(400)),
heading: Schema.optionalKey(Schema.String.check(Schema.isMaxCodePoints(200))),
}),
),
);

// Collects piped stdin in constant memory, bailing out as over-limit once the
// byte cap is crossed — the documented character limit makes anything past the
// cap over-limit without buffering the rest of the pipe. Read errors degrade
Expand Down Expand Up @@ -106,6 +129,29 @@ export const feedbackAdd = Effect.fn("feedback.add")(function* (args: FeedbackAd
// the submission succeeds or fails — the telemetry-state finalizer every
// command runs.
yield* Effect.gen(function* () {
// Check the targets first, so a bad target does not discard a typed or piped message.
const targets = yield* Effect.forEach(args.target, (raw, index) =>
decodeDocsTarget(raw).pipe(
Effect.map(({ url, quote, heading }) => ({
url,
quote,
...(heading?.trim() ? { heading } : {}),
})),
Effect.mapError((cause) => {
// The error names the field but not its value, because the value can be private docs text.
const segment = SchemaIssue.makeFormatterStandardSchemaV1()(cause.issue).issues[0]
?.path?.[0];
return new FeedbackTargetInvalidError({
message: feedbackTargetInvalidMessage(
index + 1,
segment === undefined
? undefined
: String(typeof segment === "object" ? segment.key : segment),
),
});
}),
),
);
const message = yield* resolveFeedbackMessage(args);
// The backend enforces the 1000-character limit; mirroring it client-side
// keeps a user mistake from surfacing as a cryptic PostgREST error
Expand Down Expand Up @@ -158,6 +204,7 @@ export const feedbackAdd = Effect.fn("feedback.add")(function* (args: FeedbackAd
isAgent,
agentName,
},
targets,
})
.pipe(settleFeedbackTask(sending));

Expand Down
Loading