From 1207065c915cdcf95dfd0cda3fc5f0d4a30fbc78 Mon Sep 17 00:00:00 2001 From: Kanad Gupta Date: Tue, 28 Jul 2026 08:17:51 -0700 Subject: [PATCH 01/21] initial draft --- apps/cli/docs/go-cli-porting-status.md | 1 + apps/cli/src/legacy/cli/root.ts | 2 + .../legacy/commands/feedback/SIDE_EFFECTS.md | 90 ++++++ .../commands/feedback/feedback.command.ts | 44 +++ .../commands/feedback/feedback.e2e.test.ts | 31 +++ .../commands/feedback/feedback.errors.ts | 10 + .../commands/feedback/feedback.handler.ts | 69 +++++ .../feedback/feedback.integration.test.ts | 259 ++++++++++++++++++ .../services/services.integration.test.ts | 12 +- .../feedback/feedback-submitter.service.ts | 39 +++ .../feedback/feedback-submitter.stub.layer.ts | 19 ++ 11 files changed, 574 insertions(+), 2 deletions(-) create mode 100644 apps/cli/src/legacy/commands/feedback/SIDE_EFFECTS.md create mode 100644 apps/cli/src/legacy/commands/feedback/feedback.command.ts create mode 100644 apps/cli/src/legacy/commands/feedback/feedback.e2e.test.ts create mode 100644 apps/cli/src/legacy/commands/feedback/feedback.errors.ts create mode 100644 apps/cli/src/legacy/commands/feedback/feedback.handler.ts create mode 100644 apps/cli/src/legacy/commands/feedback/feedback.integration.test.ts create mode 100644 apps/cli/src/shared/feedback/feedback-submitter.service.ts create mode 100644 apps/cli/src/shared/feedback/feedback-submitter.stub.layer.ts diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 3eeb785c83..488155e41c 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -50,6 +50,7 @@ These commands exist in the TS CLI today but have no direct top-level equivalent | TS command | TS path | Notes | | ----------------- | ------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `dev` | `planned` | Reserved for a TS-native long-running local development workflow command that watches files and orchestrates subcommands. Track this as TS-only unless a direct Go equivalent emerges. | +| `feedback` | [`../src/legacy/commands/feedback/feedback.command.ts`](../src/legacy/commands/feedback/feedback.command.ts) | Quick feedback submission (alias `btw`), legacy shell only. Submitter is a local stub until the backend destination is decided (CLI-1946). | | `logs` | [`../src/next/commands/logs/logs.command.ts`](../src/next/commands/logs/logs.command.ts) | Streams local stack logs. No top-level `logs` command exists in the old Go CLI reference. | | `api` | [`../src/next/commands/platform/api.command.ts`](../src/next/commands/platform/api.command.ts) | Low-level Management API client. It supersedes the old generated tree with explicit discovery via `supabase api routes` and execution via `supabase api request [--method ]`. | | `stack` | [`../src/next/cli/root.ts`](../src/next/cli/root.ts) | TS-only local runtime namespace exposing `stack start`, `stack stop`, `stack status`, `stack list`, and `stack update`. Top-level `start`, `stop`, and `status` remain aliases. | diff --git a/apps/cli/src/legacy/cli/root.ts b/apps/cli/src/legacy/cli/root.ts index cf261f9ff0..fee994cb10 100644 --- a/apps/cli/src/legacy/cli/root.ts +++ b/apps/cli/src/legacy/cli/root.ts @@ -8,6 +8,7 @@ import { legacyConfigCommand } from "../commands/config/config.command.ts"; import { legacyDbCommand } from "../commands/db/db.command.ts"; import { legacyDomainsCommand } from "../commands/domains/domains.command.ts"; import { legacyEncryptionCommand } from "../commands/encryption/encryption.command.ts"; +import { legacyFeedbackCommand } from "../commands/feedback/feedback.command.ts"; import { legacyFunctionsCommand } from "../commands/functions/functions.command.ts"; import { legacyGenCommand } from "../commands/gen/gen.command.ts"; import { legacyInitCommand } from "../commands/init/init.command.ts"; @@ -70,6 +71,7 @@ export const legacyRoot = Command.make("supabase").pipe( legacyDbCommand, legacyDomainsCommand, legacyEncryptionCommand, + legacyFeedbackCommand, legacyFunctionsCommand, legacyGenCommand, legacyInitCommand, diff --git a/apps/cli/src/legacy/commands/feedback/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/feedback/SIDE_EFFECTS.md new file mode 100644 index 0000000000..9b3bd355be --- /dev/null +++ b/apps/cli/src/legacy/commands/feedback/SIDE_EFFECTS.md @@ -0,0 +1,90 @@ +# `supabase feedback [message...]` + +Alias: `supabase btw [message...]` + +## Files Read + +| Path | Format | When | +| ---- | ------ | ---- | +| — | — | — | + +## Files Written + +| Path | Format | When | +| ---- | ------ | ---- | +| — | — | — | + +## API Routes + +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---- | ---- | ------------ | ---------------------- | +| — | — | — | — | — | + +The submitter is intentionally a local stub until the backend destination for +CLI feedback is decided (CLI-1946). Add the API route row when the real +submitter layer replaces `feedbackSubmitterStubLayer`. + +## Environment Variables + +| Variable | Purpose | Required? | +| -------- | ------- | --------- | +| — | — | — | + +Agent-detection env vars (e.g. `CLAUDECODE`) are read indirectly by +`@vercel/detect-agent` via `aiToolLayer` to set the submission's +`isAgent`/`agentName` context. Global telemetry consent env applies as with +every command. + +## Exit Codes + +| Code | Condition | +| ---- | -------------------------------------------------------------------- | +| `0` | success | +| `1` | no message from args, piped stdin, or an interactive prompt | +| `1` | submitter failure (unreachable with the stub; wired for the backend) | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | + +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). +Regression-tested in `feedback.integration.test.ts`. + +## Output + +### `--output-format text` + +``` +Thanks for the feedback! +``` + +Rendered as a clack success line. When no message is passed on an interactive +terminal, a "What's on your mind?" text prompt collects it first. + +### `--output-format json` + +```json +{ "id": "…", "submitted_at": "…", "message": "Thanks for the feedback!" } +``` + +### `--output-format stream-json` + +```ndjson +{"type":"result","data":{"id":"…","submitted_at":"…"},"message":"Thanks for the feedback!"} +``` + +## Notes + +- TS-only command — no Go CLI counterpart, so no Go-parity constraints apply. +- `btw` is a first-class command alias (`Command.withAlias`); telemetry always + records `command: "feedback"` regardless of which name was invoked. +- Messages starting with a dash need the `--` end-of-options sentinel: + `supabase feedback -- "--yes should be the default"`. +- Message resolution order: positional args → piped stdin (non-TTY) → + interactive prompt (TTY, text mode) → error. +- The stub receipt `id` is a locally generated UUID; it does not imply + server-side persistence. diff --git a/apps/cli/src/legacy/commands/feedback/feedback.command.ts b/apps/cli/src/legacy/commands/feedback/feedback.command.ts new file mode 100644 index 0000000000..b77bc4a273 --- /dev/null +++ b/apps/cli/src/legacy/commands/feedback/feedback.command.ts @@ -0,0 +1,44 @@ +import { Argument, Command } from "effect/unstable/cli"; +import type * as CliCommand from "effect/unstable/cli/Command"; +import { feedbackSubmitterStubLayer } from "../../../shared/feedback/feedback-submitter.stub.layer.ts"; +import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; +import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; +import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; +import { aiToolLayer } from "../../../shared/telemetry/ai-tool.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../telemetry/legacy-command-instrumentation.ts"; +import { legacyFeedback } from "./feedback.handler.ts"; + +const config = { + message: Argument.string("message").pipe( + Argument.withDescription("Freeform feedback. Bare words are joined with spaces."), + Argument.variadic, + ), +} as const; + +export type LegacyFeedbackArgs = CliCommand.Command.Config.Infer; + +// Exported so integration tests can drive the exact wiring `Command.withHandler` +// uses below, instead of re-asserting the generic instrumentation mechanism. +export const legacyFeedbackHandler = (args: LegacyFeedbackArgs) => + legacyFeedback(args).pipe(withLegacyCommandInstrumentation(), withJsonErrorHandling); + +export const legacyFeedbackCommand = Command.make("feedback", config).pipe( + Command.withAlias("btw"), + Command.withDescription("Send quick feedback about the Supabase CLI to the Supabase team."), + Command.withShortDescription("Send feedback to the Supabase team"), + Command.withExamples([ + { + command: 'supabase btw "when I run multiple stacks in parallel I get port conflicts"', + description: "Send quick feedback about a papercut in one line", + }, + { + command: 'supabase feedback -- "--yes should be the default in CI"', + description: "Use -- when the message starts with a dash", + }, + ]), + Command.withHandler(legacyFeedbackHandler), + Command.provide(commandRuntimeLayer(["feedback"])), + Command.provide(stdinLayer), + Command.provide(aiToolLayer), + Command.provide(feedbackSubmitterStubLayer), +); diff --git a/apps/cli/src/legacy/commands/feedback/feedback.e2e.test.ts b/apps/cli/src/legacy/commands/feedback/feedback.e2e.test.ts new file mode 100644 index 0000000000..8f9e87ed3d --- /dev/null +++ b/apps/cli/src/legacy/commands/feedback/feedback.e2e.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "vitest"; + +import { makeTempHome, runSupabase } from "../../../../tests/helpers/cli.ts"; + +const E2E_TIMEOUT_MS = 30_000; + +describe("supabase feedback (legacy)", () => { + // Golden path through the real CLI surface: the `btw` alias resolves in the + // command parser (unreachable from handler-level integration tests) and the + // json acknowledgement lands on stdout. + test( + "btw alias submits feedback and prints a json acknowledgement", + { timeout: E2E_TIMEOUT_MS }, + async () => { + using home = makeTempHome(); + const { exitCode, stdout } = await runSupabase( + ["btw", "e2e feedback message", "--output-format", "json"], + { + entrypoint: "legacy", + home: home.dir, + env: { HOME: home.dir }, + }, + ); + expect(exitCode).toBe(0); + const payload = JSON.parse(stdout); + expect(payload).toMatchObject({ message: "Thanks for the feedback!" }); + expect(payload.id).toEqual(expect.any(String)); + expect(payload.submitted_at).toEqual(expect.any(String)); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/feedback/feedback.errors.ts b/apps/cli/src/legacy/commands/feedback/feedback.errors.ts new file mode 100644 index 0000000000..dd2e266b79 --- /dev/null +++ b/apps/cli/src/legacy/commands/feedback/feedback.errors.ts @@ -0,0 +1,10 @@ +import { Data } from "effect"; + +export const LEGACY_FEEDBACK_EMPTY_MESSAGE = + `Nothing to submit. Pass a message (e.g. supabase feedback "port conflicts when running two stacks"), ` + + `pipe it via stdin, or put -- before a message that starts with a dash.`; + +/** No message from arguments, piped stdin, or an interactive prompt. */ +export class LegacyFeedbackEmptyMessageError extends Data.TaggedError( + "LegacyFeedbackEmptyMessageError", +)<{ readonly message: string }> {} diff --git a/apps/cli/src/legacy/commands/feedback/feedback.handler.ts b/apps/cli/src/legacy/commands/feedback/feedback.handler.ts new file mode 100644 index 0000000000..3ff8efde1b --- /dev/null +++ b/apps/cli/src/legacy/commands/feedback/feedback.handler.ts @@ -0,0 +1,69 @@ +import { Effect, Option } from "effect"; +import { FeedbackSubmitter } from "../../../shared/feedback/feedback-submitter.service.ts"; +import { Output } from "../../../shared/output/output.service.ts"; +import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; +import { Stdin } from "../../../shared/runtime/stdin.service.ts"; +import { AiTool } from "../../../shared/telemetry/ai-tool.service.ts"; +import { TelemetryRuntime } from "../../../shared/telemetry/runtime.service.ts"; +import type { LegacyFeedbackArgs } from "./feedback.command.ts"; +import { + LEGACY_FEEDBACK_EMPTY_MESSAGE, + LegacyFeedbackEmptyMessageError, +} from "./feedback.errors.ts"; + +// Resolution order: positional words → piped stdin (non-TTY) → interactive +// prompt (TTY, text mode only — json/stream-json layers report +// interactive: false) → LegacyFeedbackEmptyMessageError. Whitespace-only +// input falls through to the next source. +const legacyResolveFeedbackMessage = Effect.fnUntraced(function* (args: LegacyFeedbackArgs) { + const fromArgs = args.message.join(" ").trim(); + if (fromArgs.length > 0) return fromArgs; + + const stdin = yield* Stdin; + if (!stdin.isTTY) { + const piped = yield* stdin.readPipedText; + if (Option.isSome(piped)) { + const fromPipe = piped.value.trim(); + if (fromPipe.length > 0) return fromPipe; + } + } + + const output = yield* Output; + if (output.interactive) { + const typed = yield* output.promptText("What's on your mind?", { + validate: (value) => + value.trim().length === 0 ? "Feedback message cannot be empty." : undefined, + }); + return typed.trim(); + } + + return yield* Effect.fail( + new LegacyFeedbackEmptyMessageError({ message: LEGACY_FEEDBACK_EMPTY_MESSAGE }), + ); +}); + +export const legacyFeedback = Effect.fn("legacy.feedback")(function* (args: LegacyFeedbackArgs) { + const output = yield* Output; + const runtimeInfo = yield* RuntimeInfo; + const telemetryRuntime = yield* TelemetryRuntime; + const aiTool = yield* AiTool; + const submitter = yield* FeedbackSubmitter; + + const message = yield* legacyResolveFeedbackMessage(args); + + const receipt = yield* submitter.submit({ + message, + context: { + cliVersion: telemetryRuntime.cliVersion, + os: runtimeInfo.platform, + arch: runtimeInfo.arch, + isAgent: Option.isSome(aiTool.name), + ...(Option.isSome(aiTool.name) ? { agentName: aiTool.name.value } : {}), + }, + }); + + yield* output.success("Thanks for the feedback!", { + id: receipt.id, + submitted_at: receipt.submittedAt, + }); +}); diff --git a/apps/cli/src/legacy/commands/feedback/feedback.integration.test.ts b/apps/cli/src/legacy/commands/feedback/feedback.integration.test.ts new file mode 100644 index 0000000000..ed5b5d9730 --- /dev/null +++ b/apps/cli/src/legacy/commands/feedback/feedback.integration.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Option, Stdio } from "effect"; +import type { FeedbackSubmission } from "../../../shared/feedback/feedback-submitter.service.ts"; +import { + FeedbackSubmitError, + FeedbackSubmitter, +} from "../../../shared/feedback/feedback-submitter.service.ts"; +import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; +import { CurrentAnalyticsContext } from "../../../shared/telemetry/analytics-context.ts"; +import { Analytics } from "../../../shared/telemetry/analytics.service.ts"; +import { AiTool } from "../../../shared/telemetry/ai-tool.service.ts"; +import { + mockOutput, + mockProcessControl, + mockRuntimeInfo, + mockStdin, + mockTelemetryRuntime, +} from "../../../../tests/helpers/mocks.ts"; +import { legacyFeedbackHandler } from "./feedback.command.ts"; +import { LEGACY_FEEDBACK_EMPTY_MESSAGE } from "./feedback.errors.ts"; +import { legacyFeedback } from "./feedback.handler.ts"; + +function mockFeedbackSubmitter(opts: { failWith?: string } = {}) { + const submissions: FeedbackSubmission[] = []; + return { + layer: Layer.succeed( + FeedbackSubmitter, + FeedbackSubmitter.of({ + submit: (submission) => + opts.failWith !== undefined + ? Effect.fail(new FeedbackSubmitError({ message: opts.failWith })) + : Effect.sync(() => { + submissions.push(submission); + return { id: "receipt-1", submittedAt: "2026-07-22T00:00:00.000Z" }; + }), + }), + ), + submissions, + }; +} + +function mockAiTool(agentName?: string) { + return Layer.succeed( + AiTool, + AiTool.of({ name: agentName === undefined ? Option.none() : Option.some(agentName) }), + ); +} + +// `withLegacyCommandInstrumentation` threads `flags`/`command`/etc. through +// `CurrentAnalyticsContext`, not the direct `capture()` call args — mirrors +// the identical local helper in `functions/delete/delete.integration.test.ts`. +function mockContextualAnalytics() { + const captured: Array<{ event: string; properties: Record }> = []; + const layer = Layer.succeed( + Analytics, + Analytics.of({ + capture: (event: string, properties: Record = {}) => + Effect.gen(function* () { + const context = yield* CurrentAnalyticsContext; + captured.push({ event, properties: { ...context, ...properties } }); + }), + identify: () => Effect.void, + alias: () => Effect.void, + groupIdentify: () => Effect.void, + }), + ); + return { layer, captured }; +} + +function setupLegacyFeedback( + opts: { + output?: Parameters[0]; + stdinIsTTY?: boolean; + pipedInput?: string; + agentName?: string; + submitFailWith?: string; + } = {}, +) { + const out = mockOutput(opts.output); + const submitter = mockFeedbackSubmitter( + opts.submitFailWith === undefined ? {} : { failWith: opts.submitFailWith }, + ); + const layer = Layer.mergeAll( + out.layer, + submitter.layer, + mockStdin(opts.stdinIsTTY ?? true, opts.pipedInput), + mockRuntimeInfo({ platform: "darwin", arch: "arm64" }), + mockTelemetryRuntime({ cliVersion: "9.9.9" }), + mockAiTool(opts.agentName), + ); + return { layer, out, submitter }; +} + +// Extra layers required by the wrapped `legacyFeedbackHandler` (the exact +// wiring `Command.withHandler` uses): instrumentation + json error handling. +function setupLegacyFeedbackHandler( + opts: Parameters[0] & { args?: ReadonlyArray } = {}, +) { + const base = setupLegacyFeedback(opts); + const analytics = mockContextualAnalytics(); + const processControl = mockProcessControl(); + const layer = Layer.mergeAll( + base.layer, + analytics.layer, + processControl.layer, + commandRuntimeLayer(["feedback"]), + Stdio.layerTest({ args: Effect.succeed([...(opts.args ?? ["feedback"])]) }), + ); + return { ...base, layer, analytics, processControl }; +} + +describe("legacy feedback", () => { + it.live("submits a quoted message with CLI version, os, and arch attached", () => { + const { layer, out, submitter } = setupLegacyFeedback(); + return Effect.gen(function* () { + yield* legacyFeedback({ message: ["port conflicts when running two stacks"] }); + + expect(submitter.submissions).toEqual([ + { + message: "port conflicts when running two stacks", + context: { + cliVersion: "9.9.9", + os: "darwin", + arch: "arm64", + isAgent: false, + }, + }, + ]); + expect(out.messages).toContainEqual( + expect.objectContaining({ type: "success", message: "Thanks for the feedback!" }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("joins bare words into a single message", () => { + const { layer, submitter } = setupLegacyFeedback(); + return Effect.gen(function* () { + yield* legacyFeedback({ message: ["ports", "conflict", "a", "lot"] }); + + expect(submitter.submissions[0]?.message).toBe("ports conflict a lot"); + }).pipe(Effect.provide(layer)); + }); + + it.live("marks the submission as agent feedback when an AI tool is detected", () => { + const { layer, submitter } = setupLegacyFeedback({ agentName: "claude_code" }); + return Effect.gen(function* () { + yield* legacyFeedback({ message: ["agents need --yes everywhere"] }); + + expect(submitter.submissions[0]?.context.isAgent).toBe(true); + expect(submitter.submissions[0]?.context.agentName).toBe("claude_code"); + }).pipe(Effect.provide(layer)); + }); + + it.live("reads the message from piped stdin when no argument is given", () => { + const { layer, submitter } = setupLegacyFeedback({ + stdinIsTTY: false, + pipedInput: "piped feedback\n", + }); + return Effect.gen(function* () { + yield* legacyFeedback({ message: [] }); + + expect(submitter.submissions[0]?.message).toBe("piped feedback"); + }).pipe(Effect.provide(layer)); + }); + + it.live("prompts for the message on an interactive terminal", () => { + const { layer, submitter } = setupLegacyFeedback({ + output: { interactive: true, promptTextResponses: ["typed feedback"] }, + }); + return Effect.gen(function* () { + yield* legacyFeedback({ message: [] }); + + expect(submitter.submissions[0]?.message).toBe("typed feedback"); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with a helpful error when there is no message anywhere", () => { + // Whitespace-only args and whitespace-only pipe both fall through; a + // non-interactive terminal leaves nothing left to ask. + const { layer, submitter } = setupLegacyFeedback({ + output: { interactive: false }, + stdinIsTTY: false, + pipedInput: " \n", + }); + return Effect.gen(function* () { + const error = yield* legacyFeedback({ message: [" ", ""] }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "LegacyFeedbackEmptyMessageError", + message: LEGACY_FEEDBACK_EMPTY_MESSAGE, + }); + expect(submitter.submissions).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a machine-readable acknowledgement in json output format", () => { + const { layer, out, submitter } = setupLegacyFeedback({ output: { format: "json" } }); + return Effect.gen(function* () { + yield* legacyFeedback({ message: ["json mode feedback"] }); + + expect(submitter.submissions).toHaveLength(1); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + message: "Thanks for the feedback!", + data: { id: "receipt-1", submitted_at: "2026-07-22T00:00:00.000Z" }, + }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("reports a json error and exit code 1 when the message is missing in json mode", () => { + const { layer, out, submitter, processControl } = setupLegacyFeedbackHandler({ + output: { format: "json" }, + stdinIsTTY: false, + }); + return Effect.gen(function* () { + yield* legacyFeedbackHandler({ message: [] }); + + expect(submitter.submissions).toHaveLength(0); + expect(out.messages).toContainEqual( + expect.objectContaining({ type: "fail", message: LEGACY_FEEDBACK_EMPTY_MESSAGE }), + ); + expect(processControl.exitCode).toBe(1); + }).pipe(Effect.provide(layer)); + }); + + it.live("surfaces a submitter failure", () => { + const { layer, out } = setupLegacyFeedback({ submitFailWith: "backend unavailable" }); + return Effect.gen(function* () { + const error = yield* legacyFeedback({ message: ["doomed message"] }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "FeedbackSubmitError", + message: "backend unavailable", + }); + expect(out.messages).not.toContainEqual(expect.objectContaining({ type: "success" })); + }).pipe(Effect.provide(layer)); + }); + + it.live("never sends the feedback message content to PostHog", () => { + const { layer, analytics, submitter } = setupLegacyFeedbackHandler({ + args: ["feedback", "my", "secret", "papercut", "--debug"], + }); + return Effect.gen(function* () { + yield* legacyFeedbackHandler({ message: ["my", "secret", "papercut"] }); + + expect(submitter.submissions[0]?.message).toBe("my secret papercut"); + const events = analytics.captured.filter((c) => c.event === "cli_command_executed"); + expect(events).toHaveLength(1); + const serialized = JSON.stringify(events[0]); + expect(serialized).not.toContain("secret"); + expect(serialized).not.toContain("papercut"); + // Only the flag name survives into the event; positionals are + // structurally excluded from the flags map. + expect(Object.keys(events[0]?.properties.flags ?? {})).toEqual(["debug"]); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/services/services.integration.test.ts b/apps/cli/src/legacy/commands/services/services.integration.test.ts index 86c2ceb807..21c3fc5e97 100644 --- a/apps/cli/src/legacy/commands/services/services.integration.test.ts +++ b/apps/cli/src/legacy/commands/services/services.integration.test.ts @@ -19,7 +19,10 @@ import { mockTty, processEnvLayer, } from "../../../../tests/helpers/mocks.ts"; -import { mockLegacyTelemetryStateTracked } from "../../../../tests/helpers/legacy-mocks.ts"; +import { + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../tests/helpers/legacy-mocks.ts"; import { listLocalServiceVersions, postgresImageForDbMajorVersion, @@ -41,6 +44,11 @@ if (LOCAL_POSTGRES_SERVICE === undefined) { const LOCAL_POSTGRES_VERSION = LOCAL_POSTGRES_SERVICE.local; +// Isolated default workdir: `process.cwd()` would read the developer's real +// `apps/cli/supabase/.temp/` state (e.g. the pinned `postgres-version` written +// by a local `supabase start`), making these tests machine-dependent. +const defaultWorkdir = useLegacyTempWorkdir("supabase-services-legacy-"); + function setup( opts: { format?: "text" | "json" | "stream-json"; @@ -77,7 +85,7 @@ function setup( dashboardUrl: "https://supabase.com/dashboard", accessToken: Option.none(), projectId: Option.none(), - workdir: opts.workdir ?? process.cwd(), + workdir: opts.workdir ?? defaultWorkdir.current, userAgent: "SupabaseCLI/test", }), ), diff --git a/apps/cli/src/shared/feedback/feedback-submitter.service.ts b/apps/cli/src/shared/feedback/feedback-submitter.service.ts new file mode 100644 index 0000000000..3e7d9be783 --- /dev/null +++ b/apps/cli/src/shared/feedback/feedback-submitter.service.ts @@ -0,0 +1,39 @@ +import type { Effect } from "effect"; +import { Context, Data } from "effect"; + +/** Environment details attached to every submission alongside the message. */ +interface FeedbackContext { + readonly cliVersion: string; + readonly os: string; + readonly arch: string; + readonly isAgent: boolean; + readonly agentName?: string; +} + +export interface FeedbackSubmission { + readonly message: string; + readonly context: FeedbackContext; +} + +interface FeedbackReceipt { + readonly id: string; + readonly submittedAt: string; +} + +/** + * Failure channel reserved for the real backend submitter (CLI-1946); the + * stub layer never fails, but handlers and tests are wired for it already. + */ +export class FeedbackSubmitError extends Data.TaggedError("FeedbackSubmitError")<{ + readonly message: string; +}> {} + +interface FeedbackSubmitterShape { + readonly submit: ( + submission: FeedbackSubmission, + ) => Effect.Effect; +} + +export class FeedbackSubmitter extends Context.Service()( + "supabase/feedback/FeedbackSubmitter", +) {} diff --git a/apps/cli/src/shared/feedback/feedback-submitter.stub.layer.ts b/apps/cli/src/shared/feedback/feedback-submitter.stub.layer.ts new file mode 100644 index 0000000000..326f0a0c30 --- /dev/null +++ b/apps/cli/src/shared/feedback/feedback-submitter.stub.layer.ts @@ -0,0 +1,19 @@ +import { Effect, Layer } from "effect"; +import { FeedbackSubmitter } from "./feedback-submitter.service.ts"; + +/** + * Local no-op submitter: acknowledges receipt without any network I/O. The + * backend destination for CLI feedback is undecided (CLI-1946); once chosen, + * replace this layer with the real submitter — the handler and its error + * channel are already wired for the swap. + */ +export const feedbackSubmitterStubLayer = Layer.succeed( + FeedbackSubmitter, + FeedbackSubmitter.of({ + submit: () => + Effect.sync(() => ({ + id: crypto.randomUUID(), + submittedAt: new Date().toISOString(), + })), + }), +); From 8ab5d2c87e08465b47d771d1ee4887c4dcbfa3ce Mon Sep 17 00:00:00 2001 From: Kanad Gupta Date: Tue, 28 Jul 2026 17:16:55 -0700 Subject: [PATCH 02/21] chore: add config layer so we have access to user agent --- .../legacy/commands/feedback/SIDE_EFFECTS.md | 19 +++++++++++++------ .../commands/feedback/feedback.command.ts | 4 ++++ .../commands/feedback/feedback.handler.ts | 3 +++ .../feedback/feedback.integration.test.ts | 5 +++++ .../feedback/feedback-submitter.service.ts | 1 + 5 files changed, 26 insertions(+), 6 deletions(-) diff --git a/apps/cli/src/legacy/commands/feedback/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/feedback/SIDE_EFFECTS.md index 9b3bd355be..1f1a8cfa5d 100644 --- a/apps/cli/src/legacy/commands/feedback/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/feedback/SIDE_EFFECTS.md @@ -4,9 +4,10 @@ Alias: `supabase btw [message...]` ## Files Read -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| --------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------- | +| `~/.supabase/profile` | plain text (profile name) | when `--profile` and `SUPABASE_PROFILE` are unset (profile resolution via `legacyCliConfigLayer`) | +| `$SUPABASE_PROFILE` | YAML (`api_url:` / `gotrue_url:` …) | when `SUPABASE_PROFILE` is set to a file path instead of a built-in profile name | ## Files Written @@ -26,9 +27,12 @@ submitter layer replaces `feedbackSubmitterStubLayer`. ## Environment Variables -| Variable | Purpose | Required? | -| -------- | ------- | --------- | -| — | — | — | +| Variable | Purpose | Required? | +| ----------------------- | ----------------------------------------------- | ----------------------------------------------------- | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` → `supabase`) | +| `SUPABASE_WORKDIR` | project directory override | no (falls back to `--workdir` → cwd) | +| `SUPABASE_ACCESS_TOKEN` | access token captured by `legacyCliConfigLayer` | no (unused by this command) | +| `SUPABASE_PROJECT_ID` | project id captured by `legacyCliConfigLayer` | no (unused by this command) | Agent-detection env vars (e.g. `CLAUDECODE`) are read indirectly by `@vercel/detect-agent` via `aiToolLayer` to set the submission's @@ -86,5 +90,8 @@ terminal, a "What's on your mind?" text prompt collects it first. `supabase feedback -- "--yes should be the default"`. - Message resolution order: positional args → piped stdin (non-TTY) → interactive prompt (TTY, text mode) → error. +- Submission context: CLI version, user agent (`SupabaseCLI/` from + `LegacyCliConfig`), OS/arch, and agent detection. The context never includes + the resolved access token or project id. - The stub receipt `id` is a locally generated UUID; it does not imply server-side persistence. diff --git a/apps/cli/src/legacy/commands/feedback/feedback.command.ts b/apps/cli/src/legacy/commands/feedback/feedback.command.ts index b77bc4a273..170ba3c89f 100644 --- a/apps/cli/src/legacy/commands/feedback/feedback.command.ts +++ b/apps/cli/src/legacy/commands/feedback/feedback.command.ts @@ -1,5 +1,8 @@ +import { Layer } from "effect"; import { Argument, Command } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { legacyCliConfigLayer } from "../../config/legacy-cli-config.layer.ts"; +import { legacyDebugLoggerLayer } from "../../shared/legacy-debug-logger.layer.ts"; import { feedbackSubmitterStubLayer } from "../../../shared/feedback/feedback-submitter.stub.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; @@ -41,4 +44,5 @@ export const legacyFeedbackCommand = Command.make("feedback", config).pipe( Command.provide(stdinLayer), Command.provide(aiToolLayer), Command.provide(feedbackSubmitterStubLayer), + Command.provide(legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer))), ); diff --git a/apps/cli/src/legacy/commands/feedback/feedback.handler.ts b/apps/cli/src/legacy/commands/feedback/feedback.handler.ts index 3ff8efde1b..c366c53a92 100644 --- a/apps/cli/src/legacy/commands/feedback/feedback.handler.ts +++ b/apps/cli/src/legacy/commands/feedback/feedback.handler.ts @@ -1,4 +1,5 @@ import { Effect, Option } from "effect"; +import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; import { FeedbackSubmitter } from "../../../shared/feedback/feedback-submitter.service.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; @@ -44,6 +45,7 @@ const legacyResolveFeedbackMessage = Effect.fnUntraced(function* (args: LegacyFe export const legacyFeedback = Effect.fn("legacy.feedback")(function* (args: LegacyFeedbackArgs) { const output = yield* Output; + const cliConfig = yield* LegacyCliConfig; const runtimeInfo = yield* RuntimeInfo; const telemetryRuntime = yield* TelemetryRuntime; const aiTool = yield* AiTool; @@ -55,6 +57,7 @@ export const legacyFeedback = Effect.fn("legacy.feedback")(function* (args: Lega message, context: { cliVersion: telemetryRuntime.cliVersion, + userAgent: cliConfig.userAgent, os: runtimeInfo.platform, arch: runtimeInfo.arch, isAgent: Option.isSome(aiTool.name), diff --git a/apps/cli/src/legacy/commands/feedback/feedback.integration.test.ts b/apps/cli/src/legacy/commands/feedback/feedback.integration.test.ts index ed5b5d9730..d8dacfaa64 100644 --- a/apps/cli/src/legacy/commands/feedback/feedback.integration.test.ts +++ b/apps/cli/src/legacy/commands/feedback/feedback.integration.test.ts @@ -16,10 +16,13 @@ import { mockStdin, mockTelemetryRuntime, } from "../../../../tests/helpers/mocks.ts"; +import { mockLegacyCliConfig, useLegacyTempWorkdir } from "../../../../tests/helpers/legacy-mocks.ts"; import { legacyFeedbackHandler } from "./feedback.command.ts"; import { LEGACY_FEEDBACK_EMPTY_MESSAGE } from "./feedback.errors.ts"; import { legacyFeedback } from "./feedback.handler.ts"; +const tempRoot = useLegacyTempWorkdir("supabase-feedback-int-"); + function mockFeedbackSubmitter(opts: { failWith?: string } = {}) { const submissions: FeedbackSubmission[] = []; return { @@ -86,6 +89,7 @@ function setupLegacyFeedback( mockStdin(opts.stdinIsTTY ?? true, opts.pipedInput), mockRuntimeInfo({ platform: "darwin", arch: "arm64" }), mockTelemetryRuntime({ cliVersion: "9.9.9" }), + mockLegacyCliConfig({ workdir: tempRoot.current, userAgent: "SupabaseCLI/9.9.9" }), mockAiTool(opts.agentName), ); return { layer, out, submitter }; @@ -120,6 +124,7 @@ describe("legacy feedback", () => { message: "port conflicts when running two stacks", context: { cliVersion: "9.9.9", + userAgent: "SupabaseCLI/9.9.9", os: "darwin", arch: "arm64", isAgent: false, diff --git a/apps/cli/src/shared/feedback/feedback-submitter.service.ts b/apps/cli/src/shared/feedback/feedback-submitter.service.ts index 3e7d9be783..4ccecff2fe 100644 --- a/apps/cli/src/shared/feedback/feedback-submitter.service.ts +++ b/apps/cli/src/shared/feedback/feedback-submitter.service.ts @@ -4,6 +4,7 @@ import { Context, Data } from "effect"; /** Environment details attached to every submission alongside the message. */ interface FeedbackContext { readonly cliVersion: string; + readonly userAgent: string; readonly os: string; readonly arch: string; readonly isAgent: boolean; From 4a1d76559512c177671f7c60a75e601df984663f Mon Sep 17 00:00:00 2001 From: Kanad Gupta Date: Tue, 28 Jul 2026 20:58:41 -0700 Subject: [PATCH 03/21] feat: claude pass at wiring up supabase-js --- apps/cli/.prettierignore | 3 + apps/cli/docs/go-cli-porting-status.md | 16 +- apps/cli/package.json | 2 + .../legacy/commands/feedback/SIDE_EFFECTS.md | 48 +- .../commands/feedback/feedback.command.ts | 25 +- .../commands/feedback/feedback.e2e.test.ts | 20 +- .../legacy/commands/feedback/feedback.env.ts | 16 + .../commands/feedback/feedback.handler.ts | 33 +- .../feedback/feedback.integration.test.ts | 27 +- .../cli/src/shared/feedback/database.types.ts | 982 ++++++++++++++++++ .../src/shared/feedback/feedback-config.ts | 18 + .../feedback-submitter.integration.test.ts | 145 +++ .../feedback/feedback-submitter.layer.ts | 64 ++ .../feedback/feedback-submitter.service.ts | 15 +- .../feedback/feedback-submitter.stub.layer.ts | 19 - pnpm-lock.yaml | 3 + 16 files changed, 1346 insertions(+), 90 deletions(-) create mode 100644 apps/cli/.prettierignore create mode 100644 apps/cli/src/legacy/commands/feedback/feedback.env.ts create mode 100644 apps/cli/src/shared/feedback/database.types.ts create mode 100644 apps/cli/src/shared/feedback/feedback-config.ts create mode 100644 apps/cli/src/shared/feedback/feedback-submitter.integration.test.ts create mode 100644 apps/cli/src/shared/feedback/feedback-submitter.layer.ts delete mode 100644 apps/cli/src/shared/feedback/feedback-submitter.stub.layer.ts diff --git a/apps/cli/.prettierignore b/apps/cli/.prettierignore new file mode 100644 index 0000000000..99bbedf86f --- /dev/null +++ b/apps/cli/.prettierignore @@ -0,0 +1,3 @@ +# oxfmt reads .prettierignore by default. Autogenerated files that must stay +# byte-identical to their generator's output (never reformat): +src/shared/feedback/database.types.ts diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 488155e41c..6faffce08c 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -47,14 +47,14 @@ This tracker is command-focused, but root global flag drift is large enough to n These commands exist in the TS CLI today but have no direct top-level equivalent in the old Go CLI reference. -| TS command | TS path | Notes | -| ----------------- | ------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `dev` | `planned` | Reserved for a TS-native long-running local development workflow command that watches files and orchestrates subcommands. Track this as TS-only unless a direct Go equivalent emerges. | -| `feedback` | [`../src/legacy/commands/feedback/feedback.command.ts`](../src/legacy/commands/feedback/feedback.command.ts) | Quick feedback submission (alias `btw`), legacy shell only. Submitter is a local stub until the backend destination is decided (CLI-1946). | -| `logs` | [`../src/next/commands/logs/logs.command.ts`](../src/next/commands/logs/logs.command.ts) | Streams local stack logs. No top-level `logs` command exists in the old Go CLI reference. | -| `api` | [`../src/next/commands/platform/api.command.ts`](../src/next/commands/platform/api.command.ts) | Low-level Management API client. It supersedes the old generated tree with explicit discovery via `supabase api routes` and execution via `supabase api request [--method ]`. | -| `stack` | [`../src/next/cli/root.ts`](../src/next/cli/root.ts) | TS-only local runtime namespace exposing `stack start`, `stack stop`, `stack status`, `stack list`, and `stack update`. Top-level `start`, `stop`, and `status` remain aliases. | -| `branches switch` | [`../src/next/commands/branches/switch/switch.command.ts`](../src/next/commands/branches/switch/switch.command.ts) | No direct Go equivalent. Updates local active-branch state so subsequent commands target the selected branch. | +| TS command | TS path | Notes | +| ----------------- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `dev` | `planned` | Reserved for a TS-native long-running local development workflow command that watches files and orchestrates subcommands. Track this as TS-only unless a direct Go equivalent emerges. | +| `feedback` | [`../src/legacy/commands/feedback/feedback.command.ts`](../src/legacy/commands/feedback/feedback.command.ts) | Quick feedback submission (alias `btw`), legacy shell only. Posts to the `interfaces_feedback` table via supabase-js with a committed publishable key; profile-driven staging/production environments (production currently reuses staging, CLI-1946). | +| `logs` | [`../src/next/commands/logs/logs.command.ts`](../src/next/commands/logs/logs.command.ts) | Streams local stack logs. No top-level `logs` command exists in the old Go CLI reference. | +| `api` | [`../src/next/commands/platform/api.command.ts`](../src/next/commands/platform/api.command.ts) | Low-level Management API client. It supersedes the old generated tree with explicit discovery via `supabase api routes` and execution via `supabase api request [--method ]`. | +| `stack` | [`../src/next/cli/root.ts`](../src/next/cli/root.ts) | TS-only local runtime namespace exposing `stack start`, `stack stop`, `stack status`, `stack list`, and `stack update`. Top-level `start`, `stop`, and `status` remain aliases. | +| `branches switch` | [`../src/next/commands/branches/switch/switch.command.ts`](../src/next/commands/branches/switch/switch.command.ts) | No direct Go equivalent. Updates local active-branch state so subsequent commands target the selected branch. | ## Quick Start diff --git a/apps/cli/package.json b/apps/cli/package.json index 7f80212f3d..a82bbd5362 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -57,6 +57,7 @@ "@supabase/config": "workspace:*", "@supabase/process-compose": "workspace:*", "@supabase/stack": "workspace:*", + "@supabase/supabase-js": "^2.110.7", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", "@types/pg": "^8.15.0", @@ -119,6 +120,7 @@ "ignore": [ "scripts/*.ts", "tests/**/*.ts", + "src/shared/feedback/database.types.ts", "src/shared/telemetry/event-catalog.ts" ], "ignoreBinaries": [ diff --git a/apps/cli/src/legacy/commands/feedback/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/feedback/SIDE_EFFECTS.md index 1f1a8cfa5d..a562343736 100644 --- a/apps/cli/src/legacy/commands/feedback/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/feedback/SIDE_EFFECTS.md @@ -17,13 +17,17 @@ Alias: `supabase btw [message...]` ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---- | ---- | ------------ | ---------------------- | -| — | — | — | — | — | - -The submitter is intentionally a local stub until the backend destination for -CLI feedback is decided (CLI-1946). Add the API route row when the real -submitter layer replaces `feedbackSubmitterStubLayer`. +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------------------ | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | +| `POST` | `/rest/v1/interfaces_feedback` | `apikey` = committed publishable key | `{ feedback, source: "cli", user_agent, project_ref (or null), metadata: { cli_version, os, arch, is_agent, agent_name? } }` | none (fire-and-forget; only the error status is checked) | + +`` follows the resolved profile (`feedback.env.ts`): +`supabase-staging` / `supabase-local` → the staging feedback project; +every other profile (incl. YAML-file profiles) → production. Production +currently reuses the staging project until a dedicated one is provisioned +(CLI-1946); connection constants live in +`src/shared/feedback/feedback-config.ts` and are safe to commit (publishable +key, insert-only RLS). The request times out after 10 s. ## Environment Variables @@ -41,11 +45,11 @@ every command. ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------------------------------------- | -| `0` | success | -| `1` | no message from args, piped stdin, or an interactive prompt | -| `1` | submitter failure (unreachable with the stub; wired for the backend) | +| Code | Condition | +| ---- | ----------------------------------------------------------------- | +| `0` | success | +| `1` | no message from args, piped stdin, or an interactive prompt | +| `1` | submit failure (PostgREST error, network failure, or 10s timeout) | ## Telemetry Events Fired @@ -56,7 +60,8 @@ every command. 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). -Regression-tested in `feedback.integration.test.ts`. +Regression-tested in `feedback.integration.test.ts`. The message goes only to +the `interfaces_feedback` table via the API route above. ## Output @@ -66,19 +71,20 @@ Regression-tested in `feedback.integration.test.ts`. Thanks for the feedback! ``` -Rendered as a clack success line. When no message is passed on an interactive -terminal, a "What's on your mind?" text prompt collects it first. +Rendered as a clack success line after a "Sending feedback..." spinner. When +no message is passed on an interactive terminal, a "What's on your mind?" +text prompt collects it first. ### `--output-format json` ```json -{ "id": "…", "submitted_at": "…", "message": "Thanks for the feedback!" } +{ "message": "Thanks for the feedback!" } ``` ### `--output-format stream-json` ```ndjson -{"type":"result","data":{"id":"…","submitted_at":"…"},"message":"Thanks for the feedback!"} +{"type":"result","data":{"message":"Thanks for the feedback!"},"timestamp":"…"} ``` ## Notes @@ -91,7 +97,7 @@ terminal, a "What's on your mind?" text prompt collects it first. - Message resolution order: positional args → piped stdin (non-TTY) → interactive prompt (TTY, text mode) → error. - Submission context: CLI version, user agent (`SupabaseCLI/` from - `LegacyCliConfig`), OS/arch, and agent detection. The context never includes - the resolved access token or project id. -- The stub receipt `id` is a locally generated UUID; it does not imply - server-side persistence. + `LegacyCliConfig`), OS/arch, agent detection, and — when the workdir has a + linked project — its project ref. The resolved access token is never sent. +- Fire-and-forget insert (no `.select()`), matching the docs feedback widget + and the table's insert-only RLS: the ack carries no server receipt. diff --git a/apps/cli/src/legacy/commands/feedback/feedback.command.ts b/apps/cli/src/legacy/commands/feedback/feedback.command.ts index 170ba3c89f..4d9325a3a2 100644 --- a/apps/cli/src/legacy/commands/feedback/feedback.command.ts +++ b/apps/cli/src/legacy/commands/feedback/feedback.command.ts @@ -1,14 +1,16 @@ -import { Layer } from "effect"; +import { Effect, Layer } from "effect"; import { Argument, Command } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; import { legacyCliConfigLayer } from "../../config/legacy-cli-config.layer.ts"; import { legacyDebugLoggerLayer } from "../../shared/legacy-debug-logger.layer.ts"; -import { feedbackSubmitterStubLayer } from "../../../shared/feedback/feedback-submitter.stub.layer.ts"; +import { feedbackSubmitterLayer } from "../../../shared/feedback/feedback-submitter.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { aiToolLayer } from "../../../shared/telemetry/ai-tool.layer.ts"; import { withLegacyCommandInstrumentation } from "../../telemetry/legacy-command-instrumentation.ts"; +import { legacyFeedbackEnvironment } from "./feedback.env.ts"; import { legacyFeedback } from "./feedback.handler.ts"; const config = { @@ -25,6 +27,19 @@ export type LegacyFeedbackArgs = CliCommand.Command.Config.Infer; export const legacyFeedbackHandler = (args: LegacyFeedbackArgs) => legacyFeedback(args).pipe(withLegacyCommandInstrumentation(), withJsonErrorHandling); +const legacyFeedbackCliConfigLayer = legacyCliConfigLayer.pipe( + Layer.provide(legacyDebugLoggerLayer), +); + +// The feedback backend environment follows the resolved profile the same way +// the Management API url does: staging profiles post to the staging project. +const legacyFeedbackSubmitterLayer = Layer.unwrap( + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + return feedbackSubmitterLayer({ environment: legacyFeedbackEnvironment(config.profile) }); + }), +).pipe(Layer.provide(legacyFeedbackCliConfigLayer)); + export const legacyFeedbackCommand = Command.make("feedback", config).pipe( Command.withAlias("btw"), Command.withDescription("Send quick feedback about the Supabase CLI to the Supabase team."), @@ -43,6 +58,8 @@ export const legacyFeedbackCommand = Command.make("feedback", config).pipe( Command.provide(commandRuntimeLayer(["feedback"])), Command.provide(stdinLayer), Command.provide(aiToolLayer), - Command.provide(feedbackSubmitterStubLayer), - Command.provide(legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer))), + // `Layer.provide` does not share to siblings: the handler and the submitter + // each get their own cli-config provision (legacy CLAUDE.md item 5). + Command.provide(legacyFeedbackSubmitterLayer), + Command.provide(legacyFeedbackCliConfigLayer), ); diff --git a/apps/cli/src/legacy/commands/feedback/feedback.e2e.test.ts b/apps/cli/src/legacy/commands/feedback/feedback.e2e.test.ts index 8f9e87ed3d..aadf6c5f1f 100644 --- a/apps/cli/src/legacy/commands/feedback/feedback.e2e.test.ts +++ b/apps/cli/src/legacy/commands/feedback/feedback.e2e.test.ts @@ -7,14 +7,23 @@ const E2E_TIMEOUT_MS = 30_000; describe("supabase feedback (legacy)", () => { // Golden path through the real CLI surface: the `btw` alias resolves in the // command parser (unreachable from handler-level integration tests) and the - // json acknowledgement lands on stdout. + // json acknowledgement lands on stdout. Pinned to --profile supabase-staging + // so this posts one real row to the STAGING feedback project per run + // (staging exists for local dev/tests), never to production. test( - "btw alias submits feedback and prints a json acknowledgement", + "btw alias submits feedback to staging and prints a json acknowledgement", { timeout: E2E_TIMEOUT_MS }, async () => { using home = makeTempHome(); const { exitCode, stdout } = await runSupabase( - ["btw", "e2e feedback message", "--output-format", "json"], + [ + "btw", + "cli-e2e golden path (feedback.e2e.test.ts)", + "--profile", + "supabase-staging", + "--output-format", + "json", + ], { entrypoint: "legacy", home: home.dir, @@ -22,10 +31,7 @@ describe("supabase feedback (legacy)", () => { }, ); expect(exitCode).toBe(0); - const payload = JSON.parse(stdout); - expect(payload).toMatchObject({ message: "Thanks for the feedback!" }); - expect(payload.id).toEqual(expect.any(String)); - expect(payload.submitted_at).toEqual(expect.any(String)); + expect(JSON.parse(stdout)).toEqual({ message: "Thanks for the feedback!" }); }, ); }); diff --git a/apps/cli/src/legacy/commands/feedback/feedback.env.ts b/apps/cli/src/legacy/commands/feedback/feedback.env.ts new file mode 100644 index 0000000000..b59703ed7c --- /dev/null +++ b/apps/cli/src/legacy/commands/feedback/feedback.env.ts @@ -0,0 +1,16 @@ +import type { FeedbackEnvironment } from "../../../shared/feedback/feedback-config.ts"; +import { FEEDBACK_PRODUCTION, FEEDBACK_STAGING } from "../../../shared/feedback/feedback-config.ts"; + +/** + * Profile → feedback environment. Total lookup with a production fallback for + * unknown and YAML-file profiles, mirroring `legacy-profile.ts`. + */ +export function legacyFeedbackEnvironment(profile: string): FeedbackEnvironment { + switch (profile) { + case "supabase-staging": + case "supabase-local": + return FEEDBACK_STAGING; + default: + return FEEDBACK_PRODUCTION; + } +} diff --git a/apps/cli/src/legacy/commands/feedback/feedback.handler.ts b/apps/cli/src/legacy/commands/feedback/feedback.handler.ts index c366c53a92..0f614dc51e 100644 --- a/apps/cli/src/legacy/commands/feedback/feedback.handler.ts +++ b/apps/cli/src/legacy/commands/feedback/feedback.handler.ts @@ -53,20 +53,23 @@ export const legacyFeedback = Effect.fn("legacy.feedback")(function* (args: Lega const message = yield* legacyResolveFeedbackMessage(args); - const receipt = yield* submitter.submit({ - message, - context: { - cliVersion: telemetryRuntime.cliVersion, - userAgent: cliConfig.userAgent, - os: runtimeInfo.platform, - arch: runtimeInfo.arch, - isAgent: Option.isSome(aiTool.name), - ...(Option.isSome(aiTool.name) ? { agentName: aiTool.name.value } : {}), - }, - }); + const sending = yield* output.task("Sending feedback..."); - yield* output.success("Thanks for the feedback!", { - id: receipt.id, - submitted_at: receipt.submittedAt, - }); + yield* submitter + .submit({ + message, + ...(Option.isSome(cliConfig.projectId) ? { projectRef: cliConfig.projectId.value } : {}), + context: { + cliVersion: telemetryRuntime.cliVersion, + userAgent: cliConfig.userAgent, + os: runtimeInfo.platform, + arch: runtimeInfo.arch, + isAgent: Option.isSome(aiTool.name), + ...(Option.isSome(aiTool.name) ? { agentName: aiTool.name.value } : {}), + }, + }) + .pipe(Effect.tapError(() => sending.fail())); + + yield* sending.clear(); + yield* output.success("Thanks for the feedback!"); }); diff --git a/apps/cli/src/legacy/commands/feedback/feedback.integration.test.ts b/apps/cli/src/legacy/commands/feedback/feedback.integration.test.ts index d8dacfaa64..ea4e321785 100644 --- a/apps/cli/src/legacy/commands/feedback/feedback.integration.test.ts +++ b/apps/cli/src/legacy/commands/feedback/feedback.integration.test.ts @@ -16,7 +16,11 @@ import { mockStdin, mockTelemetryRuntime, } from "../../../../tests/helpers/mocks.ts"; -import { mockLegacyCliConfig, useLegacyTempWorkdir } from "../../../../tests/helpers/legacy-mocks.ts"; +import { + LEGACY_VALID_REF, + mockLegacyCliConfig, + useLegacyTempWorkdir, +} from "../../../../tests/helpers/legacy-mocks.ts"; import { legacyFeedbackHandler } from "./feedback.command.ts"; import { LEGACY_FEEDBACK_EMPTY_MESSAGE } from "./feedback.errors.ts"; import { legacyFeedback } from "./feedback.handler.ts"; @@ -34,7 +38,6 @@ function mockFeedbackSubmitter(opts: { failWith?: string } = {}) { ? Effect.fail(new FeedbackSubmitError({ message: opts.failWith })) : Effect.sync(() => { submissions.push(submission); - return { id: "receipt-1", submittedAt: "2026-07-22T00:00:00.000Z" }; }), }), ), @@ -77,6 +80,7 @@ function setupLegacyFeedback( pipedInput?: string; agentName?: string; submitFailWith?: string; + linkedProjectId?: string; } = {}, ) { const out = mockOutput(opts.output); @@ -89,7 +93,12 @@ function setupLegacyFeedback( mockStdin(opts.stdinIsTTY ?? true, opts.pipedInput), mockRuntimeInfo({ platform: "darwin", arch: "arm64" }), mockTelemetryRuntime({ cliVersion: "9.9.9" }), - mockLegacyCliConfig({ workdir: tempRoot.current, userAgent: "SupabaseCLI/9.9.9" }), + mockLegacyCliConfig({ + workdir: tempRoot.current, + userAgent: "SupabaseCLI/9.9.9", + projectId: + opts.linkedProjectId === undefined ? Option.none() : Option.some(opts.linkedProjectId), + }), mockAiTool(opts.agentName), ); return { layer, out, submitter }; @@ -156,6 +165,15 @@ describe("legacy feedback", () => { }).pipe(Effect.provide(layer)); }); + it.live("attaches the linked project ref when the workdir has one", () => { + const { layer, submitter } = setupLegacyFeedback({ linkedProjectId: LEGACY_VALID_REF }); + return Effect.gen(function* () { + yield* legacyFeedback({ message: ["linked project feedback"] }); + + expect(submitter.submissions[0]?.projectRef).toBe(LEGACY_VALID_REF); + }).pipe(Effect.provide(layer)); + }); + it.live("reads the message from piped stdin when no argument is given", () => { const { layer, submitter } = setupLegacyFeedback({ stdinIsTTY: false, @@ -204,11 +222,12 @@ describe("legacy feedback", () => { yield* legacyFeedback({ message: ["json mode feedback"] }); expect(submitter.submissions).toHaveLength(1); + // Fire-and-forget: the ack carries no receipt payload, just the message. expect(out.messages).toContainEqual( expect.objectContaining({ type: "success", message: "Thanks for the feedback!", - data: { id: "receipt-1", submitted_at: "2026-07-22T00:00:00.000Z" }, + data: undefined, }), ); }).pipe(Effect.provide(layer)); diff --git a/apps/cli/src/shared/feedback/database.types.ts b/apps/cli/src/shared/feedback/database.types.ts new file mode 100644 index 0000000000..9ea302fd7a --- /dev/null +++ b/apps/cli/src/shared/feedback/database.types.ts @@ -0,0 +1,982 @@ +export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[] + +export type Database = { + // Allows to automatically instantiate createClient with right options + // instead of createClient(URL, KEY) + __InternalSupabase: { + PostgrestVersion: "14.5" + } + graphql_public: { + Tables: { + [_ in never]: never + } + Views: { + [_ in never]: never + } + Functions: { + graphql: { + Args: { + extensions?: Json + operationName?: string + query?: string + variables?: Json + } + Returns: Json + } + } + Enums: { + [_ in never]: never + } + CompositeTypes: { + [_ in never]: never + } + } + public: { + Tables: { + feedback: { + Row: { + date_created: string + id: number + metadata: Json | null + page: string + vote: Database["public"]["Enums"]["feedback_vote"] + } + Insert: { + date_created?: string + id?: never + metadata?: Json | null + page: string + vote: Database["public"]["Enums"]["feedback_vote"] + } + Update: { + date_created?: string + id?: never + metadata?: Json | null + page?: string + vote?: Database["public"]["Enums"]["feedback_vote"] + } + Relationships: [] + } + feedback_comments: { + Row: { + comment: string + created_at: string + id: number + metadata: Json | null + page: string + title: string | null + user_id: string | null + vote: Database["public"]["Enums"]["feedback_vote"] | null + } + Insert: { + comment: string + created_at?: string + id?: never + metadata?: Json | null + page: string + title?: string | null + user_id?: string | null + vote?: Database["public"]["Enums"]["feedback_vote"] | null + } + Update: { + comment?: string + created_at?: string + id?: never + metadata?: Json | null + page?: string + title?: string | null + user_id?: string | null + vote?: Database["public"]["Enums"]["feedback_vote"] | null + } + Relationships: [] + } + incident_status_cache: { + Row: { + affected_regions: string[] | null + affects_project_creation: boolean + id: number + incident_id: string + shortlink: string + updated_at: string + } + Insert: { + affected_regions?: string[] | null + affects_project_creation?: boolean + id?: never + incident_id: string + shortlink: string + updated_at?: string + } + Update: { + affected_regions?: string[] | null + affects_project_creation?: boolean + id?: never + incident_id?: string + shortlink?: string + updated_at?: string + } + Relationships: [] + } + interfaces_feedback: { + Row: { + created_at: string + feedback: string + id: number + metadata: Json | null + project_ref: string | null + source: string + user_agent: string | null + user_id: string | null + } + Insert: { + created_at?: string + feedback: string + id?: never + metadata?: Json | null + project_ref?: string | null + source: string + user_agent?: string | null + user_id?: string | null + } + Update: { + created_at?: string + feedback?: string + id?: never + metadata?: Json | null + project_ref?: string | null + source?: string + user_agent?: string | null + user_id?: string | null + } + Relationships: [] + } + last_changed: { + Row: { + checksum: string + heading: string + id: number + last_checked: string + last_updated: string + parent_page: string + } + Insert: { + checksum: string + heading: string + id?: never + last_checked?: string + last_updated?: string + parent_page: string + } + Update: { + checksum?: string + heading?: string + id?: never + last_checked?: string + last_updated?: string + parent_page?: string + } + Relationships: [] + } + launch_weeks: { + Row: { + created_at: string + end_date: string | null + id: string + start_date: string | null + } + Insert: { + created_at?: string + end_date?: string | null + id: string + start_date?: string | null + } + Update: { + created_at?: string + end_date?: string | null + id?: string + start_date?: string | null + } + Relationships: [] + } + meetups: { + Row: { + city: string | null + country: string | null + created_at: string + display_info: string | null + id: string + is_live: boolean + is_published: boolean + launch_week: string + link: string | null + start_at: string | null + timezone: string | null + title: string | null + } + Insert: { + city?: string | null + country?: string | null + created_at?: string + display_info?: string | null + id?: string + is_live?: boolean + is_published?: boolean + launch_week: string + link?: string | null + start_at?: string | null + timezone?: string | null + title?: string | null + } + Update: { + city?: string | null + country?: string | null + created_at?: string + display_info?: string | null + id?: string + is_live?: boolean + is_published?: boolean + launch_week?: string + link?: string | null + start_at?: string | null + timezone?: string | null + title?: string | null + } + Relationships: [ + { + foreignKeyName: "meetups_launch_week_fkey" + columns: ["launch_week"] + isOneToOne: false + referencedRelation: "launch_weeks" + referencedColumns: ["id"] + }, + ] + } + page: { + Row: { + checksum: string | null + content: string | null + fts_tokens: unknown + id: number + last_refresh: string | null + meta: Json | null + path: string + source: string | null + title_tokens: unknown + type: string | null + version: string | null + } + Insert: { + checksum?: string | null + content?: string | null + fts_tokens?: unknown + id?: number + last_refresh?: string | null + meta?: Json | null + path: string + source?: string | null + title_tokens?: unknown + type?: string | null + version?: string | null + } + Update: { + checksum?: string | null + content?: string | null + fts_tokens?: unknown + id?: number + last_refresh?: string | null + meta?: Json | null + path?: string + source?: string | null + title_tokens?: unknown + type?: string | null + version?: string | null + } + Relationships: [] + } + page_nimbus: { + Row: { + checksum: string | null + content: string | null + fts_tokens: unknown + id: number + last_refresh: string | null + meta: Json | null + path: string + source: string | null + title_tokens: unknown + type: string | null + version: string | null + } + Insert: { + checksum?: string | null + content?: string | null + fts_tokens?: unknown + id?: never + last_refresh?: string | null + meta?: Json | null + path: string + source?: string | null + title_tokens?: unknown + type?: string | null + version?: string | null + } + Update: { + checksum?: string | null + content?: string | null + fts_tokens?: unknown + id?: never + last_refresh?: string | null + meta?: Json | null + path?: string + source?: string | null + title_tokens?: unknown + type?: string | null + + version?: string | null + } + Relationships: [] + } + page_section: { + Row: { + content: string | null + embedding: string | null + heading: string | null + id: number + page_id: number + rag_ignore: boolean | null + slug: string | null + token_count: number | null + } + Insert: { + content?: string | null + embedding?: string | null + heading?: string | null + id?: number + page_id: number + rag_ignore?: boolean | null + slug?: string | null + token_count?: number | null + } + Update: { + content?: string | null + embedding?: string | null + heading?: string | null + id?: number + page_id?: number + rag_ignore?: boolean | null + slug?: string | null + token_count?: number | null + } + Relationships: [ + { + foreignKeyName: "page_section_page_id_fkey" + columns: ["page_id"] + isOneToOne: false + referencedRelation: "page" + referencedColumns: ["id"] + }, + ] + } + page_section_nimbus: { + Row: { + content: string | null + embedding: string | null + heading: string | null + id: number + page_id: number + rag_ignore: boolean | null + slug: string | null + token_count: number | null + } + Insert: { + content?: string | null + embedding?: string | null + heading?: string | null + id?: never + page_id: number + rag_ignore?: boolean | null + slug?: string | null + token_count?: number | null + } + Update: { + content?: string | null + embedding?: string | null + heading?: string | null + id?: never + page_id?: number + rag_ignore?: boolean | null + slug?: string | null + token_count?: number | null + } + Relationships: [ + { + foreignKeyName: "page_section_nimbus_page_id_fkey" + columns: ["page_id"] + isOneToOne: false + referencedRelation: "page_nimbus" + referencedColumns: ["id"] + }, + ] + } + tickets: { + Row: { + company: string | null + created_at: string + email: string | null + game_won_at: string | null + id: string + launch_week: string + location: string | null + metadata: Json | null + name: string | null + referred_by: string | null + role: string | null + shared_on_linkedin: string | null + shared_on_twitter: string | null + ticket_number: number + user_id: string + username: string | null + } + Insert: { + company?: string | null + created_at?: string + email?: string | null + game_won_at?: string | null + id?: string + launch_week: string + location?: string | null + metadata?: Json | null + name?: string | null + referred_by?: string | null + role?: string | null + shared_on_linkedin?: string | null + shared_on_twitter?: string | null + ticket_number?: number + user_id: string + username?: string | null + } + Update: { + company?: string | null + created_at?: string + email?: string | null + game_won_at?: string | null + id?: string + launch_week?: string + location?: string | null + metadata?: Json | null + name?: string | null + referred_by?: string | null + role?: string | null + shared_on_linkedin?: string | null + shared_on_twitter?: string | null + ticket_number?: number + user_id?: string + username?: string | null + } + Relationships: [ + { + foreignKeyName: "tickets_launch_week_fkey" + columns: ["launch_week"] + isOneToOne: false + referencedRelation: "launch_weeks" + referencedColumns: ["id"] + }, + ] + } + troubleshooting_entries: { + Row: { + api: Json | null + checksum: string + date_created: string + date_updated: string + errors: Json[] | null + github_id: string + github_url: string + id: string + keywords: string[] | null + title: string + topics: string[] + } + Insert: { + api?: Json | null + checksum: string + date_created?: string + date_updated?: string + errors?: Json[] | null + github_id: string + github_url: string + id?: string + keywords?: string[] | null + title: string + topics: string[] + } + Update: { + api?: Json | null + checksum?: string + date_created?: string + date_updated?: string + errors?: Json[] | null + github_id?: string + github_url?: string + id?: string + keywords?: string[] | null + title?: string + topics?: string[] + } + Relationships: [] + } + validation_history: { + Row: { + created_at: string + id: number + tag: string + } + Insert: { + created_at?: string + id?: never + tag: string + } + Update: { + created_at?: string + id?: never + tag?: string + } + Relationships: [] + } + } + Views: { + tickets_view: { + Row: { + company: string | null + created_at: string | null + id: string | null + launch_week: string | null + location: string | null + metadata: Json | null + name: string | null + platinum: boolean | null + referrals: number | null + role: string | null + secret: boolean | null + shared_on_linkedin: string | null + shared_on_twitter: string | null + ticket_number: number | null + username: string | null + } + Relationships: [ + { + foreignKeyName: "tickets_launch_week_fkey" + columns: ["launch_week"] + isOneToOne: false + referencedRelation: "launch_weeks" + referencedColumns: ["id"] + }, + ] + } + } + Functions: { + cleanup_last_changed_pages: { Args: never; Returns: number } + docs_search_embeddings: { + Args: { embedding: string; match_threshold: number } + Returns: { + description: string + headings: string[] + id: number + path: string + slugs: string[] + subtitle: string + title: string + type: string + }[] + } + docs_search_embeddings_nimbus: { + Args: { embedding: string; match_threshold: number } + Returns: { + description: string + headings: string[] + id: number + path: string + slugs: string[] + subtitle: string + title: string + type: string + }[] + } + docs_search_fts: { + Args: { query: string } + Returns: { + description: string + id: number + path: string + subtitle: string + title: string + type: string + }[] + } + docs_search_fts_nimbus: { + Args: { query: string } + Returns: { + description: string + id: number + path: string + subtitle: string + title: string + type: string + }[] + } + get_full_content_url: { + Args: { path: string; slug: string; type: string } + Returns: string + } + get_last_revalidation_for_tags: { + Args: { tags: string[] } + Returns: { + created_at: string + tag: string + }[] + } + ipv6_active_status: { + Args: { project_ref: string } + Returns: { + pgbouncer_active: boolean + vercel_active: boolean + }[] + } + json_matches_schema: { + Args: { instance: Json; schema: Json } + Returns: boolean + } + jsonb_matches_schema: { + Args: { instance: Json; schema: Json } + Returns: boolean + } + jsonschema_is_valid: { Args: { schema: Json }; Returns: boolean } + jsonschema_validation_errors: { + Args: { instance: Json; schema: Json } + Returns: string[] + } + match_embedding: { + Args: { + embedding: string + match_threshold?: number + max_results?: number + } + Returns: { + content: string | null + embedding: string | null + heading: string | null + id: number + page_id: number + rag_ignore: boolean | null + slug: string | null + token_count: number | null + }[] + SetofOptions: { + from: "*" + to: "page_section" + isOneToOne: false + isSetofReturn: true + } + } + match_embedding_nimbus: { + Args: { + embedding: string + match_threshold?: number + max_results?: number + } + Returns: { + content: string | null + embedding: string | null + heading: string | null + id: number + page_id: number + rag_ignore: boolean | null + slug: string | null + token_count: number | null + }[] + SetofOptions: { + from: "*" + to: "page_section_nimbus" + isOneToOne: false + isSetofReturn: true + } + } + match_page_sections_v2: { + Args: { + embedding: string + match_threshold: number + min_content_length: number + } + Returns: { + content: string | null + embedding: string | null + heading: string | null + id: number + page_id: number + rag_ignore: boolean | null + slug: string | null + token_count: number | null + }[] + SetofOptions: { + from: "*" + to: "page_section" + isOneToOne: false + isSetofReturn: true + } + } + match_page_sections_v2_nimbus: { + Args: { + embedding: string + match_threshold: number + min_content_length: number + } + Returns: { + content: string | null + embedding: string | null + heading: string | null + id: number + page_id: number + rag_ignore: boolean | null + slug: string | null + token_count: number | null + }[] + SetofOptions: { + from: "*" + to: "page_section_nimbus" + isOneToOne: false + isSetofReturn: true + } + } + search_content: { + Args: { + embedding: string + include_full_content?: boolean + match_threshold?: number + max_result?: number + } + Returns: { + content: string + href: string + id: number + metadata: Json + page_title: string + subsections: Json[] + type: string + }[] + } + search_content_hybrid: { + Args: { + full_text_weight?: number + include_full_content?: boolean + match_threshold?: number + max_result?: number + query_embedding: string + query_text: string + rrf_k?: number + semantic_weight?: number + } + Returns: { + content: string + href: string + id: number + metadata: Json + page_title: string + subsections: Json[] + type: string + }[] + } + search_content_hybrid_nimbus: { + Args: { + full_text_weight?: number + include_full_content?: boolean + match_threshold?: number + max_result?: number + query_embedding: string + query_text: string + rrf_k?: number + semantic_weight?: number + } + Returns: { + content: string + href: string + id: number + metadata: Json + page_title: string + subsections: Json[] + type: string + }[] + } + search_content_nimbus: { + Args: { + embedding: string + include_full_content?: boolean + match_threshold?: number + max_result?: number + } + Returns: { + content: string + href: string + id: number + metadata: Json + page_title: string + subsections: Json[] + type: string + }[] + } + update_last_changed_checksum: { + Args: { + check_time: string + git_update_time: string + new_checksum: string + new_heading: string + new_parent_page: string + } + Returns: string + } + validate_troubleshooting_errors: { + Args: { errors: Json[] } + Returns: boolean + } + } + Enums: { + feedback_vote: "yes" | "no" + } + CompositeTypes: { + [_ in never]: never + } + } +} + +type DatabaseWithoutInternals = Omit + +type DefaultSchema = DatabaseWithoutInternals[Extract] + +export type Tables< + DefaultSchemaTableNameOrOptions extends + | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends { + Row: infer R + } + ? R + : never + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & + DefaultSchema["Views"]) + ? (DefaultSchema["Tables"] & + DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R + } + ? R + : never + : never + +export type TablesInsert< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Insert: infer I + } + ? I + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Insert: infer I + } + ? I + : never + : never + +export type TablesUpdate< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Update: infer U + } + ? U + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Update: infer U + } + ? U + : never + : never + +export type Enums< + DefaultSchemaEnumNameOrOptions extends + | keyof DefaultSchema["Enums"] + | { schema: keyof DatabaseWithoutInternals }, + EnumName extends DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] + : never = never, +> = DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName] + : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] + ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions] + : never + +export type CompositeTypes< + PublicCompositeTypeNameOrOptions extends + | keyof DefaultSchema["CompositeTypes"] + | { schema: keyof DatabaseWithoutInternals }, + CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] + : never = never, +> = PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] + : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"] + ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] + : never + +export const Constants = { + graphql_public: { + Enums: {}, + }, + public: { + Enums: { + feedback_vote: ["yes", "no"], + }, + }, +} as const diff --git a/apps/cli/src/shared/feedback/feedback-config.ts b/apps/cli/src/shared/feedback/feedback-config.ts new file mode 100644 index 0000000000..713654b2cf --- /dev/null +++ b/apps/cli/src/shared/feedback/feedback-config.ts @@ -0,0 +1,18 @@ +/** + * Feedback backend connection config. The keys are publishable (anon) keys, + * safe to commit — writes are gated by insert-only RLS on the + * `interfaces_feedback` table, exactly like the docs feedback widget. + */ +export interface FeedbackEnvironment { + readonly url: string; + readonly key: string; +} + +export const FEEDBACK_STAGING: FeedbackEnvironment = { + url: "https://imrwaufzgcaczqmpnxyr.supabase.co", + key: "sb_publishable_puOyAlqG5J_XfBMTDM2Ckw_L5mieFdb", +}; + +// No dedicated production feedback project exists yet (CLI-1946): production +// intentionally reuses the staging values until one is provisioned. +export const FEEDBACK_PRODUCTION: FeedbackEnvironment = { ...FEEDBACK_STAGING }; diff --git a/apps/cli/src/shared/feedback/feedback-submitter.integration.test.ts b/apps/cli/src/shared/feedback/feedback-submitter.integration.test.ts new file mode 100644 index 0000000000..5f6dc9adb2 --- /dev/null +++ b/apps/cli/src/shared/feedback/feedback-submitter.integration.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { FEEDBACK_STAGING } from "./feedback-config.ts"; +import { feedbackSubmitterLayer } from "./feedback-submitter.layer.ts"; +import type { FeedbackSubmission } from "./feedback-submitter.service.ts"; +import { FeedbackSubmitter } from "./feedback-submitter.service.ts"; + +const SUBMISSION: FeedbackSubmission = { + message: "port conflicts when running two stacks", + projectRef: "abcdefghijklmnopqrst", + context: { + cliVersion: "9.9.9", + userAgent: "SupabaseCLI/9.9.9", + os: "darwin", + arch: "arm64", + isAgent: true, + agentName: "claude_code", + }, +}; + +// A recording fetch injected through supabase-js's `global.fetch` option — the +// PostgREST request goes through this instead of the network. `preconnect` is +// part of Bun's `typeof fetch` and must exist on the impostor too. +function recordingFetch( + respond: (request: Request) => Response | Promise = () => + new Response(null, { status: 201 }), +) { + const requests: Array<{ request: Request; bodyText: string }> = []; + const fetch: typeof globalThis.fetch = Object.assign( + async (input: string | URL | Request, init?: RequestInit) => { + const request = + input instanceof Request ? new Request(input, init) : new Request(String(input), init); + requests.push({ request, bodyText: await request.clone().text() }); + return respond(request); + }, + { preconnect: () => Promise.resolve() }, + ); + return { fetch, requests }; +} + +describe("feedbackSubmitterLayer", () => { + it.live("posts the submission as an interfaces_feedback row", () => { + const transport = recordingFetch(); + const layer = feedbackSubmitterLayer({ + environment: FEEDBACK_STAGING, + fetch: transport.fetch, + }); + return Effect.gen(function* () { + const submitter = yield* FeedbackSubmitter; + yield* submitter.submit(SUBMISSION); + + expect(transport.requests).toHaveLength(1); + const { request, bodyText } = transport.requests[0]!; + expect(request.method).toBe("POST"); + expect(request.url).toBe(`${FEEDBACK_STAGING.url}/rest/v1/interfaces_feedback`); + expect(request.headers.get("apikey")).toBe(FEEDBACK_STAGING.key); + expect(JSON.parse(bodyText)).toEqual({ + feedback: "port conflicts when running two stacks", + source: "cli", + user_agent: "SupabaseCLI/9.9.9", + project_ref: "abcdefghijklmnopqrst", + metadata: { + cli_version: "9.9.9", + os: "darwin", + arch: "arm64", + is_agent: true, + agent_name: "claude_code", + }, + }); + }).pipe(Effect.provide(layer)); + }); + + it.live("sends null project_ref and no agent_name for an unlinked non-agent run", () => { + const transport = recordingFetch(); + const layer = feedbackSubmitterLayer({ + environment: FEEDBACK_STAGING, + fetch: transport.fetch, + }); + return Effect.gen(function* () { + const submitter = yield* FeedbackSubmitter; + yield* submitter.submit({ + message: "no project linked", + context: { + cliVersion: "9.9.9", + userAgent: "SupabaseCLI/9.9.9", + os: "linux", + arch: "x64", + isAgent: false, + }, + }); + + const body = JSON.parse(transport.requests[0]!.bodyText); + expect(body.project_ref).toBeNull(); + expect(body.metadata).toEqual({ + cli_version: "9.9.9", + os: "linux", + arch: "x64", + is_agent: false, + }); + }).pipe(Effect.provide(layer)); + }); + + it.live("maps a PostgREST error response to FeedbackSubmitError", () => { + // Shape PostgREST returns for an RLS denial. + const transport = recordingFetch( + () => + new Response( + JSON.stringify({ + message: 'new row violates row-level security policy for table "interfaces_feedback"', + code: "42501", + details: null, + hint: null, + }), + { status: 401, headers: { "content-type": "application/json" } }, + ), + ); + const layer = feedbackSubmitterLayer({ + environment: FEEDBACK_STAGING, + fetch: transport.fetch, + }); + return Effect.gen(function* () { + const submitter = yield* FeedbackSubmitter; + const error = yield* submitter.submit(SUBMISSION).pipe(Effect.flip); + + expect(error._tag).toBe("FeedbackSubmitError"); + expect(error.message).toContain("row-level security"); + }).pipe(Effect.provide(layer)); + }); + + it.live("maps a network failure to FeedbackSubmitError", () => { + const transport = recordingFetch(() => { + throw new Error("network down"); + }); + const layer = feedbackSubmitterLayer({ + environment: FEEDBACK_STAGING, + fetch: transport.fetch, + }); + return Effect.gen(function* () { + const submitter = yield* FeedbackSubmitter; + const error = yield* submitter.submit(SUBMISSION).pipe(Effect.flip); + + expect(error._tag).toBe("FeedbackSubmitError"); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/shared/feedback/feedback-submitter.layer.ts b/apps/cli/src/shared/feedback/feedback-submitter.layer.ts new file mode 100644 index 0000000000..1ee01d8417 --- /dev/null +++ b/apps/cli/src/shared/feedback/feedback-submitter.layer.ts @@ -0,0 +1,64 @@ +import { createClient } from "@supabase/supabase-js"; +import { Effect, Layer } from "effect"; +import type { Database, TablesInsert } from "./database.types.ts"; +import type { FeedbackEnvironment } from "./feedback-config.ts"; +import type { FeedbackSubmission } from "./feedback-submitter.service.ts"; +import { FeedbackSubmitError, FeedbackSubmitter } from "./feedback-submitter.service.ts"; + +const SUBMIT_TIMEOUT_MS = 10_000; + +export interface FeedbackSubmitterOptions { + readonly environment: FeedbackEnvironment; + /** Injectable transport for hermetic tests, like `legacyDohFetch`'s `innerFetch`. */ + readonly fetch?: typeof globalThis.fetch; +} + +function toInsertRow(submission: FeedbackSubmission): TablesInsert<"interfaces_feedback"> { + const { context } = submission; + return { + feedback: submission.message, + source: "cli", + user_agent: context.userAgent, + project_ref: submission.projectRef ?? null, + metadata: { + cli_version: context.cliVersion, + os: context.os, + arch: context.arch, + is_agent: context.isAgent, + ...(context.agentName === undefined ? {} : { agent_name: context.agentName }), + }, + }; +} + +export function feedbackSubmitterLayer( + options: FeedbackSubmitterOptions, +): Layer.Layer { + return Layer.sync(FeedbackSubmitter, () => { + const client = createClient(options.environment.url, options.environment.key, { + auth: { persistSession: false }, + global: options.fetch === undefined ? {} : { fetch: options.fetch }, + }); + + return FeedbackSubmitter.of({ + submit: (submission) => + Effect.tryPromise({ + try: () => + client + .from("interfaces_feedback") + .insert(toInsertRow(submission)) + .abortSignal(AbortSignal.timeout(SUBMIT_TIMEOUT_MS)), + catch: (cause) => + new FeedbackSubmitError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + }).pipe( + // PostgREST reports failures as a returned `error`, not a rejection. + Effect.flatMap(({ error }) => + error === null + ? Effect.void + : Effect.fail(new FeedbackSubmitError({ message: error.message })), + ), + ), + }); + }); +} diff --git a/apps/cli/src/shared/feedback/feedback-submitter.service.ts b/apps/cli/src/shared/feedback/feedback-submitter.service.ts index 4ccecff2fe..fa1a2c3146 100644 --- a/apps/cli/src/shared/feedback/feedback-submitter.service.ts +++ b/apps/cli/src/shared/feedback/feedback-submitter.service.ts @@ -13,26 +13,17 @@ interface FeedbackContext { export interface FeedbackSubmission { readonly message: string; + readonly projectRef?: string; readonly context: FeedbackContext; } -interface FeedbackReceipt { - readonly id: string; - readonly submittedAt: string; -} - -/** - * Failure channel reserved for the real backend submitter (CLI-1946); the - * stub layer never fails, but handlers and tests are wired for it already. - */ +/** A rejected insert (PostgREST error) or a failed/timed-out network call. */ export class FeedbackSubmitError extends Data.TaggedError("FeedbackSubmitError")<{ readonly message: string; }> {} interface FeedbackSubmitterShape { - readonly submit: ( - submission: FeedbackSubmission, - ) => Effect.Effect; + readonly submit: (submission: FeedbackSubmission) => Effect.Effect; } export class FeedbackSubmitter extends Context.Service()( diff --git a/apps/cli/src/shared/feedback/feedback-submitter.stub.layer.ts b/apps/cli/src/shared/feedback/feedback-submitter.stub.layer.ts deleted file mode 100644 index 326f0a0c30..0000000000 --- a/apps/cli/src/shared/feedback/feedback-submitter.stub.layer.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Effect, Layer } from "effect"; -import { FeedbackSubmitter } from "./feedback-submitter.service.ts"; - -/** - * Local no-op submitter: acknowledges receipt without any network I/O. The - * backend destination for CLI feedback is undecided (CLI-1946); once chosen, - * replace this layer with the real submitter — the handler and its error - * channel are already wired for the swap. - */ -export const feedbackSubmitterStubLayer = Layer.succeed( - FeedbackSubmitter, - FeedbackSubmitter.of({ - submit: () => - Effect.sync(() => ({ - id: crypto.randomUUID(), - submittedAt: new Date().toISOString(), - })), - }), -); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81de64d66b..9ef27d0953 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -147,6 +147,9 @@ importers: '@supabase/stack': specifier: workspace:* version: link:../../packages/stack + '@supabase/supabase-js': + specifier: ^2.110.7 + version: 2.110.7 '@tsconfig/bun': specifier: 'catalog:' version: 1.0.10 From 8edaba55361e766ceeace4ac32e389f76f64af30 Mon Sep 17 00:00:00 2001 From: Kanad Gupta Date: Tue, 28 Jul 2026 20:59:27 -0700 Subject: [PATCH 04/21] chore: regen --- apps/cli/src/shared/feedback/database.types.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/cli/src/shared/feedback/database.types.ts b/apps/cli/src/shared/feedback/database.types.ts index 9ea302fd7a..4fe2ec4a83 100644 --- a/apps/cli/src/shared/feedback/database.types.ts +++ b/apps/cli/src/shared/feedback/database.types.ts @@ -337,7 +337,6 @@ export type Database = { source?: string | null title_tokens?: unknown type?: string | null - version?: string | null } Relationships: [] From 6971e043a98ed73d5f40759aab266b676e911882 Mon Sep 17 00:00:00 2001 From: Kanad Gupta Date: Tue, 28 Jul 2026 21:31:18 -0700 Subject: [PATCH 05/21] chore: claude DRY pass --- .../legacy/commands/feedback/SIDE_EFFECTS.md | 6 +-- .../commands/feedback/feedback.command.ts | 23 ++++++++++-- .../legacy/commands/feedback/feedback.env.ts | 16 -------- .../commands/feedback/feedback.handler.ts | 7 ++-- .../feedback/feedback.integration.test.ts | 24 +----------- .../delete/delete.integration.test.ts | 27 +------------- .../download/download.integration.test.ts | 26 +------------ ...egacy-command-instrumentation.unit.test.ts | 37 +++---------------- .../src/shared/feedback/feedback-config.ts | 18 --------- .../feedback-submitter.integration.test.ts | 3 +- .../feedback/feedback-submitter.layer.ts | 20 +++++++++- apps/cli/tests/helpers/mocks.ts | 24 ++++++++++++ 12 files changed, 78 insertions(+), 153 deletions(-) delete mode 100644 apps/cli/src/legacy/commands/feedback/feedback.env.ts delete mode 100644 apps/cli/src/shared/feedback/feedback-config.ts diff --git a/apps/cli/src/legacy/commands/feedback/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/feedback/SIDE_EFFECTS.md index a562343736..45dd170156 100644 --- a/apps/cli/src/legacy/commands/feedback/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/feedback/SIDE_EFFECTS.md @@ -21,13 +21,13 @@ Alias: `supabase btw [message...]` | ------ | ------------------------------------------------ | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | | `POST` | `/rest/v1/interfaces_feedback` | `apikey` = committed publishable key | `{ feedback, source: "cli", user_agent, project_ref (or null), metadata: { cli_version, os, arch, is_agent, agent_name? } }` | none (fire-and-forget; only the error status is checked) | -`` follows the resolved profile (`feedback.env.ts`): +`` follows the resolved profile (`feedback.command.ts`): `supabase-staging` / `supabase-local` → the staging feedback project; every other profile (incl. YAML-file profiles) → production. Production currently reuses the staging project until a dedicated one is provisioned (CLI-1946); connection constants live in -`src/shared/feedback/feedback-config.ts` and are safe to commit (publishable -key, insert-only RLS). The request times out after 10 s. +`src/shared/feedback/feedback-submitter.layer.ts` and are safe to commit +(publishable key, insert-only RLS). The request times out after 10 s. ## Environment Variables diff --git a/apps/cli/src/legacy/commands/feedback/feedback.command.ts b/apps/cli/src/legacy/commands/feedback/feedback.command.ts index 4d9325a3a2..a9d71359ca 100644 --- a/apps/cli/src/legacy/commands/feedback/feedback.command.ts +++ b/apps/cli/src/legacy/commands/feedback/feedback.command.ts @@ -4,13 +4,17 @@ import type * as CliCommand from "effect/unstable/cli/Command"; import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; import { legacyCliConfigLayer } from "../../config/legacy-cli-config.layer.ts"; import { legacyDebugLoggerLayer } from "../../shared/legacy-debug-logger.layer.ts"; -import { feedbackSubmitterLayer } from "../../../shared/feedback/feedback-submitter.layer.ts"; +import { + FEEDBACK_PRODUCTION, + FEEDBACK_STAGING, + type FeedbackEnvironment, + feedbackSubmitterLayer, +} from "../../../shared/feedback/feedback-submitter.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { aiToolLayer } from "../../../shared/telemetry/ai-tool.layer.ts"; import { withLegacyCommandInstrumentation } from "../../telemetry/legacy-command-instrumentation.ts"; -import { legacyFeedbackEnvironment } from "./feedback.env.ts"; import { legacyFeedback } from "./feedback.handler.ts"; const config = { @@ -27,12 +31,23 @@ export type LegacyFeedbackArgs = CliCommand.Command.Config.Infer; export const legacyFeedbackHandler = (args: LegacyFeedbackArgs) => legacyFeedback(args).pipe(withLegacyCommandInstrumentation(), withJsonErrorHandling); +// Profile → feedback environment, mirroring how the Management API url follows +// the resolved profile: staging profiles post to the staging project, with a +// production fallback for unknown and YAML-file profiles (`legacy-profile.ts`). +function legacyFeedbackEnvironment(profile: string): FeedbackEnvironment { + switch (profile) { + case "supabase-staging": + case "supabase-local": + return FEEDBACK_STAGING; + default: + return FEEDBACK_PRODUCTION; + } +} + const legacyFeedbackCliConfigLayer = legacyCliConfigLayer.pipe( Layer.provide(legacyDebugLoggerLayer), ); -// The feedback backend environment follows the resolved profile the same way -// the Management API url does: staging profiles post to the staging project. const legacyFeedbackSubmitterLayer = Layer.unwrap( Effect.gen(function* () { const config = yield* LegacyCliConfig; diff --git a/apps/cli/src/legacy/commands/feedback/feedback.env.ts b/apps/cli/src/legacy/commands/feedback/feedback.env.ts deleted file mode 100644 index b59703ed7c..0000000000 --- a/apps/cli/src/legacy/commands/feedback/feedback.env.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { FeedbackEnvironment } from "../../../shared/feedback/feedback-config.ts"; -import { FEEDBACK_PRODUCTION, FEEDBACK_STAGING } from "../../../shared/feedback/feedback-config.ts"; - -/** - * Profile → feedback environment. Total lookup with a production fallback for - * unknown and YAML-file profiles, mirroring `legacy-profile.ts`. - */ -export function legacyFeedbackEnvironment(profile: string): FeedbackEnvironment { - switch (profile) { - case "supabase-staging": - case "supabase-local": - return FEEDBACK_STAGING; - default: - return FEEDBACK_PRODUCTION; - } -} diff --git a/apps/cli/src/legacy/commands/feedback/feedback.handler.ts b/apps/cli/src/legacy/commands/feedback/feedback.handler.ts index 0f614dc51e..8aed073866 100644 --- a/apps/cli/src/legacy/commands/feedback/feedback.handler.ts +++ b/apps/cli/src/legacy/commands/feedback/feedback.handler.ts @@ -52,20 +52,21 @@ export const legacyFeedback = Effect.fn("legacy.feedback")(function* (args: Lega const submitter = yield* FeedbackSubmitter; const message = yield* legacyResolveFeedbackMessage(args); + const agentName = Option.getOrUndefined(aiTool.name); const sending = yield* output.task("Sending feedback..."); yield* submitter .submit({ message, - ...(Option.isSome(cliConfig.projectId) ? { projectRef: cliConfig.projectId.value } : {}), + projectRef: Option.getOrUndefined(cliConfig.projectId), context: { cliVersion: telemetryRuntime.cliVersion, userAgent: cliConfig.userAgent, os: runtimeInfo.platform, arch: runtimeInfo.arch, - isAgent: Option.isSome(aiTool.name), - ...(Option.isSome(aiTool.name) ? { agentName: aiTool.name.value } : {}), + isAgent: agentName !== undefined, + agentName, }, }) .pipe(Effect.tapError(() => sending.fail())); diff --git a/apps/cli/src/legacy/commands/feedback/feedback.integration.test.ts b/apps/cli/src/legacy/commands/feedback/feedback.integration.test.ts index ea4e321785..ee8c6dabd1 100644 --- a/apps/cli/src/legacy/commands/feedback/feedback.integration.test.ts +++ b/apps/cli/src/legacy/commands/feedback/feedback.integration.test.ts @@ -6,10 +6,9 @@ import { FeedbackSubmitter, } from "../../../shared/feedback/feedback-submitter.service.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; -import { CurrentAnalyticsContext } from "../../../shared/telemetry/analytics-context.ts"; -import { Analytics } from "../../../shared/telemetry/analytics.service.ts"; import { AiTool } from "../../../shared/telemetry/ai-tool.service.ts"; import { + mockContextualAnalytics, mockOutput, mockProcessControl, mockRuntimeInfo, @@ -52,27 +51,6 @@ function mockAiTool(agentName?: string) { ); } -// `withLegacyCommandInstrumentation` threads `flags`/`command`/etc. through -// `CurrentAnalyticsContext`, not the direct `capture()` call args — mirrors -// the identical local helper in `functions/delete/delete.integration.test.ts`. -function mockContextualAnalytics() { - const captured: Array<{ event: string; properties: Record }> = []; - const layer = Layer.succeed( - Analytics, - Analytics.of({ - capture: (event: string, properties: Record = {}) => - Effect.gen(function* () { - const context = yield* CurrentAnalyticsContext; - captured.push({ event, properties: { ...context, ...properties } }); - }), - identify: () => Effect.void, - alias: () => Effect.void, - groupIdentify: () => Effect.void, - }), - ); - return { layer, captured }; -} - function setupLegacyFeedback( opts: { output?: Parameters[0]; diff --git a/apps/cli/src/legacy/commands/functions/delete/delete.integration.test.ts b/apps/cli/src/legacy/commands/functions/delete/delete.integration.test.ts index 6c897b64db..5f7beef2d2 100644 --- a/apps/cli/src/legacy/commands/functions/delete/delete.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/delete/delete.integration.test.ts @@ -2,8 +2,6 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Layer, Option, Stdio } from "effect"; import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; -import { CurrentAnalyticsContext } from "../../../../shared/telemetry/analytics-context.ts"; -import { Analytics } from "../../../../shared/telemetry/analytics.service.ts"; import { buildLegacyTestRuntime, mockLegacyCliConfig, @@ -12,35 +10,12 @@ import { mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; -import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { mockContextualAnalytics, mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { legacyFunctionsDeleteHandler } from "./delete.command.ts"; import { legacyFunctionsDelete } from "./delete.handler.ts"; const tempRoot = useLegacyTempWorkdir("supabase-functions-delete-legacy-"); -// `withLegacyCommandInstrumentation` threads `flags`/`command`/etc. through -// `CurrentAnalyticsContext`, not the direct `capture()` call args — mirrors -// the identical local helper in `legacy-command-instrumentation.unit.test.ts`. -// The shared `mockAnalytics()` in tests/helpers/mocks.ts deliberately doesn't -// merge this context (most callers don't need it). -function mockContextualAnalytics() { - const captured: Array<{ event: string; properties: Record }> = []; - const layer = Layer.succeed( - Analytics, - Analytics.of({ - capture: (event: string, properties: Record = {}) => - Effect.gen(function* () { - const context = yield* CurrentAnalyticsContext; - captured.push({ event, properties: { ...context, ...properties } }); - }), - identify: () => Effect.void, - alias: () => Effect.void, - groupIdentify: () => Effect.void, - }), - ); - return { layer, captured }; -} - describe("legacy functions delete", () => { it.live("deletes a function natively through the Management API", () => { const out = mockOutput({ format: "text" }); diff --git a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts index af37969828..cc18470ebb 100644 --- a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts @@ -5,8 +5,6 @@ import { Effect, Exit, Layer, Option, Stdio } from "effect"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; -import { CurrentAnalyticsContext } from "../../../../shared/telemetry/analytics-context.ts"; -import { Analytics } from "../../../../shared/telemetry/analytics.service.ts"; import { buildLegacyTestRuntime, legacyJsonResponse, @@ -16,7 +14,7 @@ import { mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; -import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { mockContextualAnalytics, mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; import { ConflictingFunctionDownloadFlagsError } from "../../../../shared/functions/download.errors.ts"; import { legacyFunctionsDownloadHandler } from "./download.command.ts"; @@ -25,28 +23,6 @@ import { legacyFunctionsDownload } from "./download.handler.ts"; const tempRoot = useLegacyTempWorkdir("supabase-functions-download-legacy-"); -// `withLegacyCommandInstrumentation` threads `flags`/`command`/etc. through -// `CurrentAnalyticsContext`, not the direct `capture()` call args — mirrors -// the identical local helper in `legacy-command-instrumentation.unit.test.ts`. -// The shared `mockAnalytics()` in tests/helpers/mocks.ts deliberately doesn't -// merge this context (most callers don't need it). -function mockContextualAnalytics() { - const captured: Array<{ event: string; properties: Record }> = []; - const layer = Layer.succeed( - Analytics, - Analytics.of({ - capture: (event: string, properties: Record = {}) => - Effect.gen(function* () { - const context = yield* CurrentAnalyticsContext; - captured.push({ event, properties: { ...context, ...properties } }); - }), - identify: () => Effect.void, - alias: () => Effect.void, - groupIdentify: () => Effect.void, - }), - ); - return { layer, captured }; -} const baseFlags: LegacyFunctionsDownloadFlags = { functionName: Option.some("hello-world"), projectRef: Option.none(), diff --git a/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.unit.test.ts b/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.unit.test.ts index eb5be95e3d..387663cb1a 100644 --- a/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.unit.test.ts +++ b/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.unit.test.ts @@ -9,8 +9,6 @@ import { LegacyOutputFlag, LegacyWorkdirFlag, } from "../../shared/legacy/global-flags.ts"; -import { CurrentAnalyticsContext } from "../../shared/telemetry/analytics-context.ts"; -import { Analytics } from "../../shared/telemetry/analytics.service.ts"; import { ProcessControl } from "../../shared/runtime/process-control.service.ts"; import { LegacyIdentityStitch } from "../shared/legacy-identity-stitch.ts"; import { withLegacyCommandInstrumentation } from "./legacy-command-instrumentation.ts"; @@ -18,7 +16,11 @@ import { LEGACY_QUERY_OUTPUT_FORMATS, LegacyInvalidOutputFormatError, } from "../shared/legacy-go-output-flag.ts"; -import { mockOutput, mockProcessControl } from "../../../tests/helpers/mocks.ts"; +import { + mockContextualAnalytics, + mockOutput, + mockProcessControl, +} from "../../../tests/helpers/mocks.ts"; function mockLegacyIdentityStitch(opts: { stitchedDistinctId?: string }) { return { @@ -32,35 +34,6 @@ function mockLegacyIdentityStitch(opts: { stitchedDistinctId?: string }) { }; } -function mockContextualAnalytics() { - const captured: Array<{ - event: string; - properties: Record; - }> = []; - - const layer = Layer.succeed( - Analytics, - Analytics.of({ - capture: (event: string, properties: Record = {}) => - Effect.gen(function* () { - const context = yield* CurrentAnalyticsContext; - captured.push({ - event, - properties: { - ...context, - ...properties, - }, - }); - }), - identify: () => Effect.void, - alias: () => Effect.void, - groupIdentify: () => Effect.void, - }), - ); - - return { layer, captured }; -} - describe("withLegacyCommandInstrumentation", () => { it.live("annotates the command span and emits cli_command_executed", () => { const analytics = mockContextualAnalytics(); diff --git a/apps/cli/src/shared/feedback/feedback-config.ts b/apps/cli/src/shared/feedback/feedback-config.ts deleted file mode 100644 index 713654b2cf..0000000000 --- a/apps/cli/src/shared/feedback/feedback-config.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Feedback backend connection config. The keys are publishable (anon) keys, - * safe to commit — writes are gated by insert-only RLS on the - * `interfaces_feedback` table, exactly like the docs feedback widget. - */ -export interface FeedbackEnvironment { - readonly url: string; - readonly key: string; -} - -export const FEEDBACK_STAGING: FeedbackEnvironment = { - url: "https://imrwaufzgcaczqmpnxyr.supabase.co", - key: "sb_publishable_puOyAlqG5J_XfBMTDM2Ckw_L5mieFdb", -}; - -// No dedicated production feedback project exists yet (CLI-1946): production -// intentionally reuses the staging values until one is provisioned. -export const FEEDBACK_PRODUCTION: FeedbackEnvironment = { ...FEEDBACK_STAGING }; diff --git a/apps/cli/src/shared/feedback/feedback-submitter.integration.test.ts b/apps/cli/src/shared/feedback/feedback-submitter.integration.test.ts index 5f6dc9adb2..2244035b7a 100644 --- a/apps/cli/src/shared/feedback/feedback-submitter.integration.test.ts +++ b/apps/cli/src/shared/feedback/feedback-submitter.integration.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect } from "effect"; -import { FEEDBACK_STAGING } from "./feedback-config.ts"; -import { feedbackSubmitterLayer } from "./feedback-submitter.layer.ts"; +import { FEEDBACK_STAGING, feedbackSubmitterLayer } from "./feedback-submitter.layer.ts"; import type { FeedbackSubmission } from "./feedback-submitter.service.ts"; import { FeedbackSubmitter } from "./feedback-submitter.service.ts"; diff --git a/apps/cli/src/shared/feedback/feedback-submitter.layer.ts b/apps/cli/src/shared/feedback/feedback-submitter.layer.ts index 1ee01d8417..1261544a48 100644 --- a/apps/cli/src/shared/feedback/feedback-submitter.layer.ts +++ b/apps/cli/src/shared/feedback/feedback-submitter.layer.ts @@ -1,10 +1,28 @@ import { createClient } from "@supabase/supabase-js"; import { Effect, Layer } from "effect"; import type { Database, TablesInsert } from "./database.types.ts"; -import type { FeedbackEnvironment } from "./feedback-config.ts"; import type { FeedbackSubmission } from "./feedback-submitter.service.ts"; import { FeedbackSubmitError, FeedbackSubmitter } from "./feedback-submitter.service.ts"; +/** + * Feedback backend connection config. The keys are publishable (anon) keys, + * safe to commit — writes are gated by insert-only RLS on the + * `interfaces_feedback` table, exactly like the docs feedback widget. + */ +export interface FeedbackEnvironment { + readonly url: string; + readonly key: string; +} + +export const FEEDBACK_STAGING: FeedbackEnvironment = { + url: "https://imrwaufzgcaczqmpnxyr.supabase.co", + key: "sb_publishable_puOyAlqG5J_XfBMTDM2Ckw_L5mieFdb", +}; + +// No dedicated production feedback project exists yet (CLI-1946): production +// intentionally reuses the staging values until one is provisioned. +export const FEEDBACK_PRODUCTION: FeedbackEnvironment = { ...FEEDBACK_STAGING }; + const SUBMIT_TIMEOUT_MS = 10_000; export interface FeedbackSubmitterOptions { diff --git a/apps/cli/tests/helpers/mocks.ts b/apps/cli/tests/helpers/mocks.ts index d9e1aca763..0313ae345e 100644 --- a/apps/cli/tests/helpers/mocks.ts +++ b/apps/cli/tests/helpers/mocks.ts @@ -44,6 +44,7 @@ import { import { RuntimeInfo } from "../../src/shared/runtime/runtime-info.service.ts"; import { Stdin } from "../../src/shared/runtime/stdin.service.ts"; import { Tty } from "../../src/shared/runtime/tty.service.ts"; +import { CurrentAnalyticsContext } from "../../src/shared/telemetry/analytics-context.ts"; import { Analytics } from "../../src/shared/telemetry/analytics.service.ts"; import { TelemetryRuntime } from "../../src/shared/telemetry/runtime.service.ts"; import { makeTelemetryIdentity } from "../../src/shared/telemetry/identity.ts"; @@ -565,6 +566,29 @@ export function mockAnalytics() { }; } +// `withCommandInstrumentation`/`withLegacyCommandInstrumentation` thread +// `flags`/`command`/etc. through `CurrentAnalyticsContext`, not the direct +// `capture()` call args. `mockAnalytics()` above deliberately doesn't merge +// that context (most callers don't need it) — use this variant when asserting +// on instrumentation-provided properties. +export function mockContextualAnalytics() { + const captured: Array<{ event: string; properties: Record }> = []; + const layer = Layer.succeed( + Analytics, + Analytics.of({ + capture: (event: string, properties: Record = {}) => + Effect.gen(function* () { + const context = yield* CurrentAnalyticsContext; + captured.push({ event, properties: { ...context, ...properties } }); + }), + identify: () => Effect.void, + alias: () => Effect.void, + groupIdentify: () => Effect.void, + }), + ); + return { layer, captured }; +} + export function mockTelemetryRuntime( opts: Partial<{ configDir: string; From 8c53f2ee86e97baee1e0777d2e5be4f72b1768f8 Mon Sep 17 00:00:00 2001 From: Kanad Gupta Date: Tue, 28 Jul 2026 21:32:48 -0700 Subject: [PATCH 06/21] chore: exclude .repos from vscode search The vendored effect clone in .repos/ drowns out workspace results in editor-wide search. Co-Authored-By: Claude Fable 5 --- .vscode/settings.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.vscode/settings.json b/.vscode/settings.json index 0ff9ffd7c9..72eef4b91e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,4 +4,10 @@ "editor.formatOnSaveMode": "file", "typescript.experimental.useTsgo": true, "oxc.useExecPath": true, + "search.exclude": { + // "**/node_modules": true, + // "**/bower_components": true, + // "**/*.code-search": true, + ".repos/**": true, + }, } From 4cbc747f2f5b3fbb92fdc40dfc9cffa5a1c1b6a3 Mon Sep 17 00:00:00 2001 From: Kanad Gupta Date: Tue, 28 Jul 2026 21:35:51 -0700 Subject: [PATCH 07/21] Revert "chore: exclude .repos from vscode search" This reverts commit 8c53f2ee86e97baee1e0777d2e5be4f72b1768f8. --- .vscode/settings.json | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 72eef4b91e..0ff9ffd7c9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,10 +4,4 @@ "editor.formatOnSaveMode": "file", "typescript.experimental.useTsgo": true, "oxc.useExecPath": true, - "search.exclude": { - // "**/node_modules": true, - // "**/bower_components": true, - // "**/*.code-search": true, - ".repos/**": true, - }, } From 656f13a6672ebe7be8d88315659d5a5831fcce6e Mon Sep 17 00:00:00 2001 From: Kanad Gupta Date: Tue, 28 Jul 2026 22:14:04 -0700 Subject: [PATCH 08/21] fix(cli): resolve linked project ref from .temp/project-ref in feedback LegacyCliConfig.projectId is a bare SUPABASE_PROJECT_ID env passthrough, so the feedback submission's project_ref was null in a linked project unless that env var happened to be set. Fall back to /supabase/.temp/ project-ref, the file supabase link writes, mirroring the soft-load half of LegacyProjectRefResolver.resolveOptional. The file is read directly rather than through the resolver so the command keeps working unauthenticated; a broken ref file degrades to unlinked instead of failing the submission. The previous integration test injected projectId straight into the config mock, so it only proved the handler forwarded the field and never exercised resolution -- despite being named for the workdir-linked scenario that did not work. Replace it with coverage that seeds the real file, plus env precedence, unlinked, and unreadable-file cases. Also correct the AGENTS.md row claiming LegacyCliConfig reads project-id from config.toml, which is what made this field look project-aware. Co-Authored-By: Claude Fable 5 --- apps/cli/AGENTS.md | 17 ++--- .../legacy/commands/feedback/SIDE_EFFECTS.md | 27 +++++--- .../commands/feedback/feedback.handler.ts | 25 +++++++- .../feedback/feedback.integration.test.ts | 64 +++++++++++++++++-- 4 files changed, 108 insertions(+), 25 deletions(-) diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index 6596169854..636fcd3c73 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -90,14 +90,15 @@ Always check `src/shared/` before writing new infrastructure. Do not duplicate w Also check the following `legacy/` infrastructure before writing equivalent helpers from scratch: -| Path | What it provides | -| ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `legacy/config/legacy-cli-config.layer.ts` | `LegacyCliConfig` — resolves `SUPABASE_PROFILE` (built-in name **or** YAML file path), `--workdir`, `--experimental`, project-id from `supabase/config.toml` | -| `legacy/config/legacy-project-ref.layer.ts` | `LegacyProjectRefResolver` — `--project-ref` flag → env → `supabase/.temp/project-ref` file → prompt; matches Go's resolver order | -| `legacy/telemetry/legacy-telemetry-state.layer.ts` | `LegacyTelemetryState.flush` — writes `~/.supabase/telemetry.json`, runs in every command's `Effect.ensuring` | -| `legacy/telemetry/legacy-linked-project-cache.layer.ts` | `LegacyLinkedProjectCache.cache(ref)` — writes `/supabase/.temp/linked-project.json` after `--project-ref` resolves; bypasses generated schema validation (uses raw HTTP client) | -| `legacy/auth/legacy-http-debug.layer.ts` | `legacyHttpClientLayer` — wraps the HTTP transport with a `--debug` stderr logger in Go's `log.LstdFlags` format | -| `legacy/output/legacy-glamour-table.ts` | `renderGlamourTable(headers, rows)` — byte-exact ASCII match for Go's `glamour.RenderTable(..., AsciiStyle)` | +| Path | What it provides | +| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `legacy/config/legacy-cli-config.layer.ts` | `LegacyCliConfig` — resolves `SUPABASE_PROFILE` (built-in name **or** YAML file path), `--workdir`, `--experimental`. Its `projectId` is a bare `SUPABASE_PROJECT_ID` env passthrough — it does **not** read `config.toml` or the linked-project file, so it is `None` in a linked project unless that env var is set. Never treat it as "the linked project ref" on its own; see the two rows below | +| `legacy/config/legacy-project-ref.layer.ts` | `LegacyProjectRefResolver` — `--project-ref` flag → env → `supabase/.temp/project-ref` file → prompt; matches Go's resolver order. `resolveOptional` is the soft-load variant (no prompt, no error when unlinked). Requires `LegacyPlatformApiFactory` for its prompt path, so commands that must work unauthenticated should read the file directly instead | +| `legacy/shared/legacy-temp-paths.ts` | `legacyTempPaths(path, workdir)` — the `/supabase/.temp/*` paths `supabase link` writes; `legacyReadProjectRefFile(fs, path, workdir)` reads the linked ref with no API/TTY dependency (absent or blank file → `None`; other read errors fail) | +| `legacy/telemetry/legacy-telemetry-state.layer.ts` | `LegacyTelemetryState.flush` — writes `~/.supabase/telemetry.json`, runs in every command's `Effect.ensuring` | +| `legacy/telemetry/legacy-linked-project-cache.layer.ts` | `LegacyLinkedProjectCache.cache(ref)` — writes `/supabase/.temp/linked-project.json` after `--project-ref` resolves; bypasses generated schema validation (uses raw HTTP client) | +| `legacy/auth/legacy-http-debug.layer.ts` | `legacyHttpClientLayer` — wraps the HTTP transport with a `--debug` stderr logger in Go's `log.LstdFlags` format | +| `legacy/output/legacy-glamour-table.ts` | `renderGlamourTable(headers, rows)` — byte-exact ASCII match for Go's `glamour.RenderTable(..., AsciiStyle)` | --- diff --git a/apps/cli/src/legacy/commands/feedback/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/feedback/SIDE_EFFECTS.md index 45dd170156..4a53b84078 100644 --- a/apps/cli/src/legacy/commands/feedback/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/feedback/SIDE_EFFECTS.md @@ -4,10 +4,11 @@ Alias: `supabase btw [message...]` ## Files Read -| Path | Format | When | -| --------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------- | -| `~/.supabase/profile` | plain text (profile name) | when `--profile` and `SUPABASE_PROFILE` are unset (profile resolution via `legacyCliConfigLayer`) | -| `$SUPABASE_PROFILE` | YAML (`api_url:` / `gotrue_url:` …) | when `SUPABASE_PROFILE` is set to a file path instead of a built-in profile name | +| Path | Format | When | +| -------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/profile` | plain text (profile name) | when `--profile` and `SUPABASE_PROFILE` are unset (profile resolution via `legacyCliConfigLayer`) | +| `$SUPABASE_PROFILE` | YAML (`api_url:` / `gotrue_url:` …) | when `SUPABASE_PROFILE` is set to a file path instead of a built-in profile name | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `SUPABASE_PROJECT_ID` is unset — supplies the submission's `project_ref`. Absent, blank, or unreadable → `null` (never fails the submission) | ## Files Written @@ -31,12 +32,12 @@ currently reuses the staging project until a dedicated one is provisioned ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ----------------------------------------------- | ----------------------------------------------------- | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` → `supabase`) | -| `SUPABASE_WORKDIR` | project directory override | no (falls back to `--workdir` → cwd) | -| `SUPABASE_ACCESS_TOKEN` | access token captured by `legacyCliConfigLayer` | no (unused by this command) | -| `SUPABASE_PROJECT_ID` | project id captured by `legacyCliConfigLayer` | no (unused by this command) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` → `supabase`) | +| `SUPABASE_WORKDIR` | project directory override | no (falls back to `--workdir` → cwd) | +| `SUPABASE_ACCESS_TOKEN` | access token captured by `legacyCliConfigLayer` | no (unused by this command) | +| `SUPABASE_PROJECT_ID` | overrides the submission's `project_ref`, taking priority over the linked-ref file | no (falls back to `/supabase/.temp/project-ref` → `null`) | Agent-detection env vars (e.g. `CLAUDECODE`) are read indirectly by `@vercel/detect-agent` via `aiToolLayer` to set the submission's @@ -99,5 +100,11 @@ text prompt collects it first. - Submission context: CLI version, user agent (`SupabaseCLI/` from `LegacyCliConfig`), OS/arch, agent detection, and — when the workdir has a linked project — its project ref. The resolved access token is never sent. +- Project-ref resolution order: `SUPABASE_PROJECT_ID` → + `/supabase/.temp/project-ref` (written by `supabase link`) → `null`. + This mirrors the soft-load half of `LegacyProjectRefResolver.resolveOptional` + but reads the file directly, so the command has no auth dependency and works + when the user is not logged in. Note `LegacyCliConfig.projectId` alone is only + the env var — it is NOT linked-project-aware. - Fire-and-forget insert (no `.select()`), matching the docs feedback widget and the table's insert-only RLS: the ack carries no server receipt. diff --git a/apps/cli/src/legacy/commands/feedback/feedback.handler.ts b/apps/cli/src/legacy/commands/feedback/feedback.handler.ts index 8aed073866..4321eb328f 100644 --- a/apps/cli/src/legacy/commands/feedback/feedback.handler.ts +++ b/apps/cli/src/legacy/commands/feedback/feedback.handler.ts @@ -1,5 +1,6 @@ -import { Effect, Option } from "effect"; +import { Effect, FileSystem, Option, Path } from "effect"; import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; +import { legacyReadProjectRefFile } from "../../shared/legacy-temp-paths.ts"; import { FeedbackSubmitter } from "../../../shared/feedback/feedback-submitter.service.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; @@ -43,6 +44,25 @@ const legacyResolveFeedbackMessage = Effect.fnUntraced(function* (args: LegacyFe ); }); +// Mirrors the soft-load half of `LegacyProjectRefResolver.resolveOptional` +// (`legacy-project-ref.layer.ts`): `SUPABASE_PROJECT_ID` (captured by +// `LegacyCliConfig`) → `/supabase/.temp/project-ref`, the file +// `supabase link` writes. The file is read directly rather than via the full +// resolver because that layer requires `LegacyPlatformApiFactory` for its +// prompt path, and feedback must keep working when the user isn't logged in. +// A broken ref file degrades to "unlinked" instead of failing the submission. +const legacyResolveFeedbackProjectRef = Effect.fnUntraced(function* ( + workdir: string, + fromEnv: Option.Option, +) { + if (Option.isSome(fromEnv)) return fromEnv; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* legacyReadProjectRefFile(fs, path, workdir).pipe( + Effect.orElseSucceed(() => Option.none()), + ); +}); + export const legacyFeedback = Effect.fn("legacy.feedback")(function* (args: LegacyFeedbackArgs) { const output = yield* Output; const cliConfig = yield* LegacyCliConfig; @@ -53,13 +73,14 @@ export const legacyFeedback = Effect.fn("legacy.feedback")(function* (args: Lega const message = yield* legacyResolveFeedbackMessage(args); const agentName = Option.getOrUndefined(aiTool.name); + const projectRef = yield* legacyResolveFeedbackProjectRef(cliConfig.workdir, cliConfig.projectId); const sending = yield* output.task("Sending feedback..."); yield* submitter .submit({ message, - projectRef: Option.getOrUndefined(cliConfig.projectId), + projectRef: Option.getOrUndefined(projectRef), context: { cliVersion: telemetryRuntime.cliVersion, userAgent: cliConfig.userAgent, diff --git a/apps/cli/src/legacy/commands/feedback/feedback.integration.test.ts b/apps/cli/src/legacy/commands/feedback/feedback.integration.test.ts index ee8c6dabd1..903a19f0fb 100644 --- a/apps/cli/src/legacy/commands/feedback/feedback.integration.test.ts +++ b/apps/cli/src/legacy/commands/feedback/feedback.integration.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; import { Effect, Layer, Option, Stdio } from "effect"; import type { FeedbackSubmission } from "../../../shared/feedback/feedback-submitter.service.ts"; import { @@ -26,6 +29,20 @@ import { legacyFeedback } from "./feedback.handler.ts"; const tempRoot = useLegacyTempWorkdir("supabase-feedback-int-"); +// Seeds `/supabase/.temp/project-ref`, the file `supabase link` writes. +// Passing `asDirectory` creates the path as a directory instead, which makes the +// read fail with a non-NotFound error (the "broken ref file" degradation path). +function writeLinkedProjectRef(workdir: string, ref: string, opts: { asDirectory?: boolean } = {}) { + const tempDir = join(workdir, "supabase", ".temp"); + mkdirSync(tempDir, { recursive: true }); + const refPath = join(tempDir, "project-ref"); + if (opts.asDirectory === true) { + mkdirSync(refPath, { recursive: true }); + return; + } + writeFileSync(refPath, `${ref}\n`); +} + function mockFeedbackSubmitter(opts: { failWith?: string } = {}) { const submissions: FeedbackSubmission[] = []; return { @@ -58,7 +75,8 @@ function setupLegacyFeedback( pipedInput?: string; agentName?: string; submitFailWith?: string; - linkedProjectId?: string; + /** Simulates `SUPABASE_PROJECT_ID`, the only source `LegacyCliConfig` reads. */ + projectIdEnv?: string; } = {}, ) { const out = mockOutput(opts.output); @@ -74,10 +92,12 @@ function setupLegacyFeedback( mockLegacyCliConfig({ workdir: tempRoot.current, userAgent: "SupabaseCLI/9.9.9", - projectId: - opts.linkedProjectId === undefined ? Option.none() : Option.some(opts.linkedProjectId), + projectId: opts.projectIdEnv === undefined ? Option.none() : Option.some(opts.projectIdEnv), }), mockAiTool(opts.agentName), + // Real filesystem: the handler reads `supabase/.temp/project-ref` from the + // temp workdir, so this must not be stubbed out. + BunServices.layer, ); return { layer, out, submitter }; } @@ -143,8 +163,9 @@ describe("legacy feedback", () => { }).pipe(Effect.provide(layer)); }); - it.live("attaches the linked project ref when the workdir has one", () => { - const { layer, submitter } = setupLegacyFeedback({ linkedProjectId: LEGACY_VALID_REF }); + it.live("attaches the linked project ref written by supabase link", () => { + const { layer, submitter } = setupLegacyFeedback(); + writeLinkedProjectRef(tempRoot.current, LEGACY_VALID_REF); return Effect.gen(function* () { yield* legacyFeedback({ message: ["linked project feedback"] }); @@ -152,6 +173,39 @@ describe("legacy feedback", () => { }).pipe(Effect.provide(layer)); }); + it.live("prefers SUPABASE_PROJECT_ID over the linked ref file", () => { + const { layer, submitter } = setupLegacyFeedback({ projectIdEnv: "envenvenvenvenvenvre" }); + writeLinkedProjectRef(tempRoot.current, LEGACY_VALID_REF); + return Effect.gen(function* () { + yield* legacyFeedback({ message: ["env override feedback"] }); + + expect(submitter.submissions[0]?.projectRef).toBe("envenvenvenvenvenvre"); + }).pipe(Effect.provide(layer)); + }); + + it.live("sends no project ref when the workdir is not linked", () => { + const { layer, submitter } = setupLegacyFeedback(); + return Effect.gen(function* () { + yield* legacyFeedback({ message: ["unlinked feedback"] }); + + expect(submitter.submissions[0]?.projectRef).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("still submits when the linked ref file cannot be read", () => { + // A broken ref file must not block feedback — it degrades to "unlinked". + const { layer, out, submitter } = setupLegacyFeedback(); + writeLinkedProjectRef(tempRoot.current, LEGACY_VALID_REF, { asDirectory: true }); + return Effect.gen(function* () { + yield* legacyFeedback({ message: ["broken ref file feedback"] }); + + expect(submitter.submissions[0]?.projectRef).toBeUndefined(); + expect(out.messages).toContainEqual( + expect.objectContaining({ type: "success", message: "Thanks for the feedback!" }), + ); + }).pipe(Effect.provide(layer)); + }); + it.live("reads the message from piped stdin when no argument is given", () => { const { layer, submitter } = setupLegacyFeedback({ stdinIsTTY: false, From eec4d2090b8b742a6105db4641d9653d51424a9e Mon Sep 17 00:00:00 2001 From: Kanad Gupta Date: Wed, 12 Aug 2026 18:59:50 -0700 Subject: [PATCH 09/21] feat(cli): move feedback submission to feedback add and drop the btw alias Restructures the TS-only feedback command from a single `supabase feedback` command (with a `btw` alias) into a `feedback` group with an `add` subcommand, following the nested-subcommand layout. Telemetry now records `command: "feedback add"`; behavior is otherwise unchanged. Co-Authored-By: Claude Fable 5 --- apps/cli/docs/go-cli-porting-status.md | 16 ++-- .../feedback/{ => add}/SIDE_EFFECTS.md | 13 ++- .../commands/feedback/add/add.command.ts | 80 ++++++++++++++++++ .../commands/feedback/add/add.e2e.test.ts | 39 +++++++++ .../{feedback.errors.ts => add/add.errors.ts} | 2 +- .../add.handler.ts} | 29 ++++--- .../add.integration.test.ts} | 58 ++++++------- .../commands/feedback/feedback.command.ts | 82 ++----------------- .../commands/feedback/feedback.e2e.test.ts | 37 --------- 9 files changed, 181 insertions(+), 175 deletions(-) rename apps/cli/src/legacy/commands/feedback/{ => add}/SIDE_EFFECTS.md (93%) create mode 100644 apps/cli/src/legacy/commands/feedback/add/add.command.ts create mode 100644 apps/cli/src/legacy/commands/feedback/add/add.e2e.test.ts rename apps/cli/src/legacy/commands/feedback/{feedback.errors.ts => add/add.errors.ts} (76%) rename apps/cli/src/legacy/commands/feedback/{feedback.handler.ts => add/add.handler.ts} (76%) rename apps/cli/src/legacy/commands/feedback/{feedback.integration.test.ts => add/add.integration.test.ts} (84%) delete mode 100644 apps/cli/src/legacy/commands/feedback/feedback.e2e.test.ts diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 6faffce08c..0ba6c28f47 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -47,14 +47,14 @@ This tracker is command-focused, but root global flag drift is large enough to n These commands exist in the TS CLI today but have no direct top-level equivalent in the old Go CLI reference. -| TS command | TS path | Notes | -| ----------------- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `dev` | `planned` | Reserved for a TS-native long-running local development workflow command that watches files and orchestrates subcommands. Track this as TS-only unless a direct Go equivalent emerges. | -| `feedback` | [`../src/legacy/commands/feedback/feedback.command.ts`](../src/legacy/commands/feedback/feedback.command.ts) | Quick feedback submission (alias `btw`), legacy shell only. Posts to the `interfaces_feedback` table via supabase-js with a committed publishable key; profile-driven staging/production environments (production currently reuses staging, CLI-1946). | -| `logs` | [`../src/next/commands/logs/logs.command.ts`](../src/next/commands/logs/logs.command.ts) | Streams local stack logs. No top-level `logs` command exists in the old Go CLI reference. | -| `api` | [`../src/next/commands/platform/api.command.ts`](../src/next/commands/platform/api.command.ts) | Low-level Management API client. It supersedes the old generated tree with explicit discovery via `supabase api routes` and execution via `supabase api request [--method ]`. | -| `stack` | [`../src/next/cli/root.ts`](../src/next/cli/root.ts) | TS-only local runtime namespace exposing `stack start`, `stack stop`, `stack status`, `stack list`, and `stack update`. Top-level `start`, `stop`, and `status` remain aliases. | -| `branches switch` | [`../src/next/commands/branches/switch/switch.command.ts`](../src/next/commands/branches/switch/switch.command.ts) | No direct Go equivalent. Updates local active-branch state so subsequent commands target the selected branch. | +| TS command | TS path | Notes | +| ----------------- | ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `dev` | `planned` | Reserved for a TS-native long-running local development workflow command that watches files and orchestrates subcommands. Track this as TS-only unless a direct Go equivalent emerges. | +| `feedback add` | [`../src/legacy/commands/feedback/add/add.command.ts`](../src/legacy/commands/feedback/add/add.command.ts) | Quick feedback submission, legacy shell only. Posts to the `interfaces_feedback` table via supabase-js with a committed publishable key; profile-driven staging/production environments (production currently reuses staging, CLI-1946). | +| `logs` | [`../src/next/commands/logs/logs.command.ts`](../src/next/commands/logs/logs.command.ts) | Streams local stack logs. No top-level `logs` command exists in the old Go CLI reference. | +| `api` | [`../src/next/commands/platform/api.command.ts`](../src/next/commands/platform/api.command.ts) | Low-level Management API client. It supersedes the old generated tree with explicit discovery via `supabase api routes` and execution via `supabase api request [--method ]`. | +| `stack` | [`../src/next/cli/root.ts`](../src/next/cli/root.ts) | TS-only local runtime namespace exposing `stack start`, `stack stop`, `stack status`, `stack list`, and `stack update`. Top-level `start`, `stop`, and `status` remain aliases. | +| `branches switch` | [`../src/next/commands/branches/switch/switch.command.ts`](../src/next/commands/branches/switch/switch.command.ts) | No direct Go equivalent. Updates local active-branch state so subsequent commands target the selected branch. | ## Quick Start diff --git a/apps/cli/src/legacy/commands/feedback/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/feedback/add/SIDE_EFFECTS.md similarity index 93% rename from apps/cli/src/legacy/commands/feedback/SIDE_EFFECTS.md rename to apps/cli/src/legacy/commands/feedback/add/SIDE_EFFECTS.md index 4a53b84078..adcfeb15b7 100644 --- a/apps/cli/src/legacy/commands/feedback/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/feedback/add/SIDE_EFFECTS.md @@ -1,6 +1,4 @@ -# `supabase feedback [message...]` - -Alias: `supabase btw [message...]` +# `supabase feedback add [message...]` ## Files Read @@ -22,7 +20,7 @@ Alias: `supabase btw [message...]` | ------ | ------------------------------------------------ | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | | `POST` | `/rest/v1/interfaces_feedback` | `apikey` = committed publishable key | `{ feedback, source: "cli", user_agent, project_ref (or null), metadata: { cli_version, os, arch, is_agent, agent_name? } }` | none (fire-and-forget; only the error status is checked) | -`` follows the resolved profile (`feedback.command.ts`): +`` follows the resolved profile (`add.command.ts`): `supabase-staging` / `supabase-local` → the staging feedback project; every other profile (incl. YAML-file profiles) → production. Production currently reuses the staging project until a dedicated one is provisioned @@ -61,7 +59,7 @@ every command. 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). -Regression-tested in `feedback.integration.test.ts`. The message goes only to +Regression-tested in `add.integration.test.ts`. The message goes only to the `interfaces_feedback` table via the API route above. ## Output @@ -91,10 +89,9 @@ text prompt collects it first. ## Notes - TS-only command — no Go CLI counterpart, so no Go-parity constraints apply. -- `btw` is a first-class command alias (`Command.withAlias`); telemetry always - records `command: "feedback"` regardless of which name was invoked. +- Telemetry records `command: "feedback add"`. - Messages starting with a dash need the `--` end-of-options sentinel: - `supabase feedback -- "--yes should be the default"`. + `supabase feedback add -- "--yes should be the default"`. - Message resolution order: positional args → piped stdin (non-TTY) → interactive prompt (TTY, text mode) → error. - Submission context: CLI version, user agent (`SupabaseCLI/` from diff --git a/apps/cli/src/legacy/commands/feedback/add/add.command.ts b/apps/cli/src/legacy/commands/feedback/add/add.command.ts new file mode 100644 index 0000000000..bc383d01d6 --- /dev/null +++ b/apps/cli/src/legacy/commands/feedback/add/add.command.ts @@ -0,0 +1,80 @@ +import { Effect, Layer } from "effect"; +import { Argument, Command } from "effect/unstable/cli"; +import type * as CliCommand from "effect/unstable/cli/Command"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; +import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; +import { + FEEDBACK_PRODUCTION, + FEEDBACK_STAGING, + type FeedbackEnvironment, + feedbackSubmitterLayer, +} from "../../../../shared/feedback/feedback-submitter.layer.ts"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; +import { aiToolLayer } from "../../../../shared/telemetry/ai-tool.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyFeedbackAdd } from "./add.handler.ts"; + +const config = { + message: Argument.string("message").pipe( + Argument.withDescription("Freeform feedback. Bare words are joined with spaces."), + Argument.variadic, + ), +} as const; + +export type LegacyFeedbackAddArgs = CliCommand.Command.Config.Infer; + +// Exported so integration tests can drive the exact wiring `Command.withHandler` +// uses below, instead of re-asserting the generic instrumentation mechanism. +export const legacyFeedbackAddHandler = (args: LegacyFeedbackAddArgs) => + legacyFeedbackAdd(args).pipe(withLegacyCommandInstrumentation(), withJsonErrorHandling); + +// Profile → feedback environment, mirroring how the Management API url follows +// the resolved profile: staging profiles post to the staging project, with a +// production fallback for unknown and YAML-file profiles (`legacy-profile.ts`). +function legacyFeedbackEnvironment(profile: string): FeedbackEnvironment { + switch (profile) { + case "supabase-staging": + case "supabase-local": + return FEEDBACK_STAGING; + default: + return FEEDBACK_PRODUCTION; + } +} + +const legacyFeedbackCliConfigLayer = legacyCliConfigLayer.pipe( + Layer.provide(legacyDebugLoggerLayer), +); + +const legacyFeedbackSubmitterLayer = Layer.unwrap( + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + return feedbackSubmitterLayer({ environment: legacyFeedbackEnvironment(config.profile) }); + }), +).pipe(Layer.provide(legacyFeedbackCliConfigLayer)); + +export const legacyFeedbackAddCommand = Command.make("add", config).pipe( + Command.withDescription("Send quick feedback about the Supabase CLI to the Supabase team."), + Command.withShortDescription("Send feedback to the Supabase team"), + Command.withExamples([ + { + command: + 'supabase feedback add "when I run multiple stacks in parallel I get port conflicts"', + description: "Send quick feedback about a papercut in one line", + }, + { + command: 'supabase feedback add -- "--yes should be the default in CI"', + description: "Use -- when the message starts with a dash", + }, + ]), + Command.withHandler(legacyFeedbackAddHandler), + Command.provide(commandRuntimeLayer(["feedback", "add"])), + Command.provide(stdinLayer), + Command.provide(aiToolLayer), + // `Layer.provide` does not share to siblings: the handler and the submitter + // each get their own cli-config provision (legacy CLAUDE.md item 5). + Command.provide(legacyFeedbackSubmitterLayer), + Command.provide(legacyFeedbackCliConfigLayer), +); diff --git a/apps/cli/src/legacy/commands/feedback/add/add.e2e.test.ts b/apps/cli/src/legacy/commands/feedback/add/add.e2e.test.ts new file mode 100644 index 0000000000..f69231adfc --- /dev/null +++ b/apps/cli/src/legacy/commands/feedback/add/add.e2e.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "vitest"; + +import { makeTempHome, runSupabase } from "../../../../../tests/helpers/cli.ts"; + +const E2E_TIMEOUT_MS = 30_000; + +describe("supabase feedback add (legacy)", () => { + // Golden path through the real CLI surface: `feedback add` resolves through + // the command parser's subcommand routing (unreachable from handler-level + // integration tests) and the json acknowledgement lands on stdout. Pinned to + // --profile supabase-staging so this posts one real row to the STAGING + // feedback project per run (staging exists for local dev/tests), never to + // production. + test( + "feedback add submits to staging and prints a json acknowledgement", + { timeout: E2E_TIMEOUT_MS }, + async () => { + using home = makeTempHome(); + const { exitCode, stdout } = await runSupabase( + [ + "feedback", + "add", + "cli-e2e golden path (add.e2e.test.ts)", + "--profile", + "supabase-staging", + "--output-format", + "json", + ], + { + entrypoint: "legacy", + home: home.dir, + env: { HOME: home.dir }, + }, + ); + expect(exitCode).toBe(0); + expect(JSON.parse(stdout)).toEqual({ message: "Thanks for the feedback!" }); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/feedback/feedback.errors.ts b/apps/cli/src/legacy/commands/feedback/add/add.errors.ts similarity index 76% rename from apps/cli/src/legacy/commands/feedback/feedback.errors.ts rename to apps/cli/src/legacy/commands/feedback/add/add.errors.ts index dd2e266b79..a0ae5dde2f 100644 --- a/apps/cli/src/legacy/commands/feedback/feedback.errors.ts +++ b/apps/cli/src/legacy/commands/feedback/add/add.errors.ts @@ -1,7 +1,7 @@ import { Data } from "effect"; export const LEGACY_FEEDBACK_EMPTY_MESSAGE = - `Nothing to submit. Pass a message (e.g. supabase feedback "port conflicts when running two stacks"), ` + + `Nothing to submit. Pass a message (e.g. supabase feedback add "port conflicts when running two stacks"), ` + `pipe it via stdin, or put -- before a message that starts with a dash.`; /** No message from arguments, piped stdin, or an interactive prompt. */ diff --git a/apps/cli/src/legacy/commands/feedback/feedback.handler.ts b/apps/cli/src/legacy/commands/feedback/add/add.handler.ts similarity index 76% rename from apps/cli/src/legacy/commands/feedback/feedback.handler.ts rename to apps/cli/src/legacy/commands/feedback/add/add.handler.ts index 4321eb328f..af8d404d93 100644 --- a/apps/cli/src/legacy/commands/feedback/feedback.handler.ts +++ b/apps/cli/src/legacy/commands/feedback/add/add.handler.ts @@ -1,23 +1,20 @@ import { Effect, FileSystem, Option, Path } from "effect"; -import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; -import { legacyReadProjectRefFile } from "../../shared/legacy-temp-paths.ts"; -import { FeedbackSubmitter } from "../../../shared/feedback/feedback-submitter.service.ts"; -import { Output } from "../../../shared/output/output.service.ts"; -import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; -import { Stdin } from "../../../shared/runtime/stdin.service.ts"; -import { AiTool } from "../../../shared/telemetry/ai-tool.service.ts"; -import { TelemetryRuntime } from "../../../shared/telemetry/runtime.service.ts"; -import type { LegacyFeedbackArgs } from "./feedback.command.ts"; -import { - LEGACY_FEEDBACK_EMPTY_MESSAGE, - LegacyFeedbackEmptyMessageError, -} from "./feedback.errors.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { legacyReadProjectRefFile } from "../../../shared/legacy-temp-paths.ts"; +import { FeedbackSubmitter } from "../../../../shared/feedback/feedback-submitter.service.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; +import { Stdin } from "../../../../shared/runtime/stdin.service.ts"; +import { AiTool } from "../../../../shared/telemetry/ai-tool.service.ts"; +import { TelemetryRuntime } from "../../../../shared/telemetry/runtime.service.ts"; +import type { LegacyFeedbackAddArgs } from "./add.command.ts"; +import { LEGACY_FEEDBACK_EMPTY_MESSAGE, LegacyFeedbackEmptyMessageError } from "./add.errors.ts"; // Resolution order: positional words → piped stdin (non-TTY) → interactive // prompt (TTY, text mode only — json/stream-json layers report // interactive: false) → LegacyFeedbackEmptyMessageError. Whitespace-only // input falls through to the next source. -const legacyResolveFeedbackMessage = Effect.fnUntraced(function* (args: LegacyFeedbackArgs) { +const legacyResolveFeedbackMessage = Effect.fnUntraced(function* (args: LegacyFeedbackAddArgs) { const fromArgs = args.message.join(" ").trim(); if (fromArgs.length > 0) return fromArgs; @@ -63,7 +60,9 @@ const legacyResolveFeedbackProjectRef = Effect.fnUntraced(function* ( ); }); -export const legacyFeedback = Effect.fn("legacy.feedback")(function* (args: LegacyFeedbackArgs) { +export const legacyFeedbackAdd = Effect.fn("legacy.feedback.add")(function* ( + args: LegacyFeedbackAddArgs, +) { const output = yield* Output; const cliConfig = yield* LegacyCliConfig; const runtimeInfo = yield* RuntimeInfo; diff --git a/apps/cli/src/legacy/commands/feedback/feedback.integration.test.ts b/apps/cli/src/legacy/commands/feedback/add/add.integration.test.ts similarity index 84% rename from apps/cli/src/legacy/commands/feedback/feedback.integration.test.ts rename to apps/cli/src/legacy/commands/feedback/add/add.integration.test.ts index 903a19f0fb..fdec2ad18b 100644 --- a/apps/cli/src/legacy/commands/feedback/feedback.integration.test.ts +++ b/apps/cli/src/legacy/commands/feedback/add/add.integration.test.ts @@ -3,13 +3,13 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { Effect, Layer, Option, Stdio } from "effect"; -import type { FeedbackSubmission } from "../../../shared/feedback/feedback-submitter.service.ts"; +import type { FeedbackSubmission } from "../../../../shared/feedback/feedback-submitter.service.ts"; import { FeedbackSubmitError, FeedbackSubmitter, -} from "../../../shared/feedback/feedback-submitter.service.ts"; -import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; -import { AiTool } from "../../../shared/telemetry/ai-tool.service.ts"; +} from "../../../../shared/feedback/feedback-submitter.service.ts"; +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { AiTool } from "../../../../shared/telemetry/ai-tool.service.ts"; import { mockContextualAnalytics, mockOutput, @@ -17,17 +17,17 @@ import { mockRuntimeInfo, mockStdin, mockTelemetryRuntime, -} from "../../../../tests/helpers/mocks.ts"; +} from "../../../../../tests/helpers/mocks.ts"; import { LEGACY_VALID_REF, mockLegacyCliConfig, useLegacyTempWorkdir, -} from "../../../../tests/helpers/legacy-mocks.ts"; -import { legacyFeedbackHandler } from "./feedback.command.ts"; -import { LEGACY_FEEDBACK_EMPTY_MESSAGE } from "./feedback.errors.ts"; -import { legacyFeedback } from "./feedback.handler.ts"; +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { legacyFeedbackAddHandler } from "./add.command.ts"; +import { LEGACY_FEEDBACK_EMPTY_MESSAGE } from "./add.errors.ts"; +import { legacyFeedbackAdd } from "./add.handler.ts"; -const tempRoot = useLegacyTempWorkdir("supabase-feedback-int-"); +const tempRoot = useLegacyTempWorkdir("supabase-feedback-add-int-"); // Seeds `/supabase/.temp/project-ref`, the file `supabase link` writes. // Passing `asDirectory` creates the path as a directory instead, which makes the @@ -102,7 +102,7 @@ function setupLegacyFeedback( return { layer, out, submitter }; } -// Extra layers required by the wrapped `legacyFeedbackHandler` (the exact +// Extra layers required by the wrapped `legacyFeedbackAddHandler` (the exact // wiring `Command.withHandler` uses): instrumentation + json error handling. function setupLegacyFeedbackHandler( opts: Parameters[0] & { args?: ReadonlyArray } = {}, @@ -114,17 +114,17 @@ function setupLegacyFeedbackHandler( base.layer, analytics.layer, processControl.layer, - commandRuntimeLayer(["feedback"]), - Stdio.layerTest({ args: Effect.succeed([...(opts.args ?? ["feedback"])]) }), + commandRuntimeLayer(["feedback", "add"]), + Stdio.layerTest({ args: Effect.succeed([...(opts.args ?? ["feedback", "add"])]) }), ); return { ...base, layer, analytics, processControl }; } -describe("legacy feedback", () => { +describe("legacy feedback add", () => { it.live("submits a quoted message with CLI version, os, and arch attached", () => { const { layer, out, submitter } = setupLegacyFeedback(); return Effect.gen(function* () { - yield* legacyFeedback({ message: ["port conflicts when running two stacks"] }); + yield* legacyFeedbackAdd({ message: ["port conflicts when running two stacks"] }); expect(submitter.submissions).toEqual([ { @@ -147,7 +147,7 @@ describe("legacy feedback", () => { it.live("joins bare words into a single message", () => { const { layer, submitter } = setupLegacyFeedback(); return Effect.gen(function* () { - yield* legacyFeedback({ message: ["ports", "conflict", "a", "lot"] }); + yield* legacyFeedbackAdd({ message: ["ports", "conflict", "a", "lot"] }); expect(submitter.submissions[0]?.message).toBe("ports conflict a lot"); }).pipe(Effect.provide(layer)); @@ -156,7 +156,7 @@ describe("legacy feedback", () => { it.live("marks the submission as agent feedback when an AI tool is detected", () => { const { layer, submitter } = setupLegacyFeedback({ agentName: "claude_code" }); return Effect.gen(function* () { - yield* legacyFeedback({ message: ["agents need --yes everywhere"] }); + yield* legacyFeedbackAdd({ message: ["agents need --yes everywhere"] }); expect(submitter.submissions[0]?.context.isAgent).toBe(true); expect(submitter.submissions[0]?.context.agentName).toBe("claude_code"); @@ -167,7 +167,7 @@ describe("legacy feedback", () => { const { layer, submitter } = setupLegacyFeedback(); writeLinkedProjectRef(tempRoot.current, LEGACY_VALID_REF); return Effect.gen(function* () { - yield* legacyFeedback({ message: ["linked project feedback"] }); + yield* legacyFeedbackAdd({ message: ["linked project feedback"] }); expect(submitter.submissions[0]?.projectRef).toBe(LEGACY_VALID_REF); }).pipe(Effect.provide(layer)); @@ -177,7 +177,7 @@ describe("legacy feedback", () => { const { layer, submitter } = setupLegacyFeedback({ projectIdEnv: "envenvenvenvenvenvre" }); writeLinkedProjectRef(tempRoot.current, LEGACY_VALID_REF); return Effect.gen(function* () { - yield* legacyFeedback({ message: ["env override feedback"] }); + yield* legacyFeedbackAdd({ message: ["env override feedback"] }); expect(submitter.submissions[0]?.projectRef).toBe("envenvenvenvenvenvre"); }).pipe(Effect.provide(layer)); @@ -186,7 +186,7 @@ describe("legacy feedback", () => { it.live("sends no project ref when the workdir is not linked", () => { const { layer, submitter } = setupLegacyFeedback(); return Effect.gen(function* () { - yield* legacyFeedback({ message: ["unlinked feedback"] }); + yield* legacyFeedbackAdd({ message: ["unlinked feedback"] }); expect(submitter.submissions[0]?.projectRef).toBeUndefined(); }).pipe(Effect.provide(layer)); @@ -197,7 +197,7 @@ describe("legacy feedback", () => { const { layer, out, submitter } = setupLegacyFeedback(); writeLinkedProjectRef(tempRoot.current, LEGACY_VALID_REF, { asDirectory: true }); return Effect.gen(function* () { - yield* legacyFeedback({ message: ["broken ref file feedback"] }); + yield* legacyFeedbackAdd({ message: ["broken ref file feedback"] }); expect(submitter.submissions[0]?.projectRef).toBeUndefined(); expect(out.messages).toContainEqual( @@ -212,7 +212,7 @@ describe("legacy feedback", () => { pipedInput: "piped feedback\n", }); return Effect.gen(function* () { - yield* legacyFeedback({ message: [] }); + yield* legacyFeedbackAdd({ message: [] }); expect(submitter.submissions[0]?.message).toBe("piped feedback"); }).pipe(Effect.provide(layer)); @@ -223,7 +223,7 @@ describe("legacy feedback", () => { output: { interactive: true, promptTextResponses: ["typed feedback"] }, }); return Effect.gen(function* () { - yield* legacyFeedback({ message: [] }); + yield* legacyFeedbackAdd({ message: [] }); expect(submitter.submissions[0]?.message).toBe("typed feedback"); }).pipe(Effect.provide(layer)); @@ -238,7 +238,7 @@ describe("legacy feedback", () => { pipedInput: " \n", }); return Effect.gen(function* () { - const error = yield* legacyFeedback({ message: [" ", ""] }).pipe(Effect.flip); + const error = yield* legacyFeedbackAdd({ message: [" ", ""] }).pipe(Effect.flip); expect(error).toMatchObject({ _tag: "LegacyFeedbackEmptyMessageError", @@ -251,7 +251,7 @@ describe("legacy feedback", () => { it.live("emits a machine-readable acknowledgement in json output format", () => { const { layer, out, submitter } = setupLegacyFeedback({ output: { format: "json" } }); return Effect.gen(function* () { - yield* legacyFeedback({ message: ["json mode feedback"] }); + yield* legacyFeedbackAdd({ message: ["json mode feedback"] }); expect(submitter.submissions).toHaveLength(1); // Fire-and-forget: the ack carries no receipt payload, just the message. @@ -271,7 +271,7 @@ describe("legacy feedback", () => { stdinIsTTY: false, }); return Effect.gen(function* () { - yield* legacyFeedbackHandler({ message: [] }); + yield* legacyFeedbackAddHandler({ message: [] }); expect(submitter.submissions).toHaveLength(0); expect(out.messages).toContainEqual( @@ -284,7 +284,7 @@ describe("legacy feedback", () => { it.live("surfaces a submitter failure", () => { const { layer, out } = setupLegacyFeedback({ submitFailWith: "backend unavailable" }); return Effect.gen(function* () { - const error = yield* legacyFeedback({ message: ["doomed message"] }).pipe(Effect.flip); + const error = yield* legacyFeedbackAdd({ message: ["doomed message"] }).pipe(Effect.flip); expect(error).toMatchObject({ _tag: "FeedbackSubmitError", @@ -296,10 +296,10 @@ describe("legacy feedback", () => { it.live("never sends the feedback message content to PostHog", () => { const { layer, analytics, submitter } = setupLegacyFeedbackHandler({ - args: ["feedback", "my", "secret", "papercut", "--debug"], + args: ["feedback", "add", "my", "secret", "papercut", "--debug"], }); return Effect.gen(function* () { - yield* legacyFeedbackHandler({ message: ["my", "secret", "papercut"] }); + yield* legacyFeedbackAddHandler({ message: ["my", "secret", "papercut"] }); expect(submitter.submissions[0]?.message).toBe("my secret papercut"); const events = analytics.captured.filter((c) => c.event === "cli_command_executed"); diff --git a/apps/cli/src/legacy/commands/feedback/feedback.command.ts b/apps/cli/src/legacy/commands/feedback/feedback.command.ts index a9d71359ca..f71e5baa35 100644 --- a/apps/cli/src/legacy/commands/feedback/feedback.command.ts +++ b/apps/cli/src/legacy/commands/feedback/feedback.command.ts @@ -1,80 +1,8 @@ -import { Effect, Layer } from "effect"; -import { Argument, Command } from "effect/unstable/cli"; -import type * as CliCommand from "effect/unstable/cli/Command"; -import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; -import { legacyCliConfigLayer } from "../../config/legacy-cli-config.layer.ts"; -import { legacyDebugLoggerLayer } from "../../shared/legacy-debug-logger.layer.ts"; -import { - FEEDBACK_PRODUCTION, - FEEDBACK_STAGING, - type FeedbackEnvironment, - feedbackSubmitterLayer, -} from "../../../shared/feedback/feedback-submitter.layer.ts"; -import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; -import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; -import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; -import { aiToolLayer } from "../../../shared/telemetry/ai-tool.layer.ts"; -import { withLegacyCommandInstrumentation } from "../../telemetry/legacy-command-instrumentation.ts"; -import { legacyFeedback } from "./feedback.handler.ts"; +import { Command } from "effect/unstable/cli"; +import { legacyFeedbackAddCommand } from "./add/add.command.ts"; -const config = { - message: Argument.string("message").pipe( - Argument.withDescription("Freeform feedback. Bare words are joined with spaces."), - Argument.variadic, - ), -} as const; - -export type LegacyFeedbackArgs = CliCommand.Command.Config.Infer; - -// Exported so integration tests can drive the exact wiring `Command.withHandler` -// uses below, instead of re-asserting the generic instrumentation mechanism. -export const legacyFeedbackHandler = (args: LegacyFeedbackArgs) => - legacyFeedback(args).pipe(withLegacyCommandInstrumentation(), withJsonErrorHandling); - -// Profile → feedback environment, mirroring how the Management API url follows -// the resolved profile: staging profiles post to the staging project, with a -// production fallback for unknown and YAML-file profiles (`legacy-profile.ts`). -function legacyFeedbackEnvironment(profile: string): FeedbackEnvironment { - switch (profile) { - case "supabase-staging": - case "supabase-local": - return FEEDBACK_STAGING; - default: - return FEEDBACK_PRODUCTION; - } -} - -const legacyFeedbackCliConfigLayer = legacyCliConfigLayer.pipe( - Layer.provide(legacyDebugLoggerLayer), -); - -const legacyFeedbackSubmitterLayer = Layer.unwrap( - Effect.gen(function* () { - const config = yield* LegacyCliConfig; - return feedbackSubmitterLayer({ environment: legacyFeedbackEnvironment(config.profile) }); - }), -).pipe(Layer.provide(legacyFeedbackCliConfigLayer)); - -export const legacyFeedbackCommand = Command.make("feedback", config).pipe( - Command.withAlias("btw"), - Command.withDescription("Send quick feedback about the Supabase CLI to the Supabase team."), +export const legacyFeedbackCommand = Command.make("feedback").pipe( + Command.withDescription("Send feedback about the Supabase CLI to the Supabase team."), Command.withShortDescription("Send feedback to the Supabase team"), - Command.withExamples([ - { - command: 'supabase btw "when I run multiple stacks in parallel I get port conflicts"', - description: "Send quick feedback about a papercut in one line", - }, - { - command: 'supabase feedback -- "--yes should be the default in CI"', - description: "Use -- when the message starts with a dash", - }, - ]), - Command.withHandler(legacyFeedbackHandler), - Command.provide(commandRuntimeLayer(["feedback"])), - Command.provide(stdinLayer), - Command.provide(aiToolLayer), - // `Layer.provide` does not share to siblings: the handler and the submitter - // each get their own cli-config provision (legacy CLAUDE.md item 5). - Command.provide(legacyFeedbackSubmitterLayer), - Command.provide(legacyFeedbackCliConfigLayer), + Command.withSubcommands([legacyFeedbackAddCommand]), ); diff --git a/apps/cli/src/legacy/commands/feedback/feedback.e2e.test.ts b/apps/cli/src/legacy/commands/feedback/feedback.e2e.test.ts deleted file mode 100644 index aadf6c5f1f..0000000000 --- a/apps/cli/src/legacy/commands/feedback/feedback.e2e.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, test } from "vitest"; - -import { makeTempHome, runSupabase } from "../../../../tests/helpers/cli.ts"; - -const E2E_TIMEOUT_MS = 30_000; - -describe("supabase feedback (legacy)", () => { - // Golden path through the real CLI surface: the `btw` alias resolves in the - // command parser (unreachable from handler-level integration tests) and the - // json acknowledgement lands on stdout. Pinned to --profile supabase-staging - // so this posts one real row to the STAGING feedback project per run - // (staging exists for local dev/tests), never to production. - test( - "btw alias submits feedback to staging and prints a json acknowledgement", - { timeout: E2E_TIMEOUT_MS }, - async () => { - using home = makeTempHome(); - const { exitCode, stdout } = await runSupabase( - [ - "btw", - "cli-e2e golden path (feedback.e2e.test.ts)", - "--profile", - "supabase-staging", - "--output-format", - "json", - ], - { - entrypoint: "legacy", - home: home.dir, - env: { HOME: home.dir }, - }, - ); - expect(exitCode).toBe(0); - expect(JSON.parse(stdout)).toEqual({ message: "Thanks for the feedback!" }); - }, - ); -}); From c239cdb6e8af1cc27ec1da6709b656b3d1be3e76 Mon Sep 17 00:00:00 2001 From: Kanad Gupta Date: Thu, 13 Aug 2026 11:33:44 -0700 Subject: [PATCH 10/21] refactor: shuffle things around so we're exporting less --- .../commands/feedback/add/add.command.ts | 17 +-------------- .../feedback/feedback-submitter.layer.ts | 21 +++++++++++++++---- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/apps/cli/src/legacy/commands/feedback/add/add.command.ts b/apps/cli/src/legacy/commands/feedback/add/add.command.ts index bc383d01d6..d9a3ccdf62 100644 --- a/apps/cli/src/legacy/commands/feedback/add/add.command.ts +++ b/apps/cli/src/legacy/commands/feedback/add/add.command.ts @@ -5,10 +5,8 @@ import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; import { - FEEDBACK_PRODUCTION, - FEEDBACK_STAGING, - type FeedbackEnvironment, feedbackSubmitterLayer, + legacyFeedbackEnvironment, } from "../../../../shared/feedback/feedback-submitter.layer.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; @@ -31,19 +29,6 @@ export type LegacyFeedbackAddArgs = CliCommand.Command.Config.Infer legacyFeedbackAdd(args).pipe(withLegacyCommandInstrumentation(), withJsonErrorHandling); -// Profile → feedback environment, mirroring how the Management API url follows -// the resolved profile: staging profiles post to the staging project, with a -// production fallback for unknown and YAML-file profiles (`legacy-profile.ts`). -function legacyFeedbackEnvironment(profile: string): FeedbackEnvironment { - switch (profile) { - case "supabase-staging": - case "supabase-local": - return FEEDBACK_STAGING; - default: - return FEEDBACK_PRODUCTION; - } -} - const legacyFeedbackCliConfigLayer = legacyCliConfigLayer.pipe( Layer.provide(legacyDebugLoggerLayer), ); diff --git a/apps/cli/src/shared/feedback/feedback-submitter.layer.ts b/apps/cli/src/shared/feedback/feedback-submitter.layer.ts index 1261544a48..2767cbddcc 100644 --- a/apps/cli/src/shared/feedback/feedback-submitter.layer.ts +++ b/apps/cli/src/shared/feedback/feedback-submitter.layer.ts @@ -9,28 +9,41 @@ import { FeedbackSubmitError, FeedbackSubmitter } from "./feedback-submitter.ser * safe to commit — writes are gated by insert-only RLS on the * `interfaces_feedback` table, exactly like the docs feedback widget. */ -export interface FeedbackEnvironment { +interface FeedbackEnvironment { readonly url: string; readonly key: string; } -export const FEEDBACK_STAGING: FeedbackEnvironment = { +const FEEDBACK_STAGING: FeedbackEnvironment = { url: "https://imrwaufzgcaczqmpnxyr.supabase.co", key: "sb_publishable_puOyAlqG5J_XfBMTDM2Ckw_L5mieFdb", }; // No dedicated production feedback project exists yet (CLI-1946): production // intentionally reuses the staging values until one is provisioned. -export const FEEDBACK_PRODUCTION: FeedbackEnvironment = { ...FEEDBACK_STAGING }; +const FEEDBACK_PRODUCTION: FeedbackEnvironment = { ...FEEDBACK_STAGING }; const SUBMIT_TIMEOUT_MS = 10_000; -export interface FeedbackSubmitterOptions { +interface FeedbackSubmitterOptions { readonly environment: FeedbackEnvironment; /** Injectable transport for hermetic tests, like `legacyDohFetch`'s `innerFetch`. */ readonly fetch?: typeof globalThis.fetch; } +// Profile → feedback environment, mirroring how the Management API url follows +// the resolved profile: staging profiles post to the staging project, with a +// production fallback for unknown and YAML-file profiles (`legacy-profile.ts`). +export function legacyFeedbackEnvironment(profile: string): FeedbackEnvironment { + switch (profile) { + case "supabase-staging": + case "supabase-local": + return FEEDBACK_STAGING; + default: + return FEEDBACK_PRODUCTION; + } +} + function toInsertRow(submission: FeedbackSubmission): TablesInsert<"interfaces_feedback"> { const { context } = submission; return { From 409bbd8c91ed3d797713720cbc2b5bb01a61cb2a Mon Sep 17 00:00:00 2001 From: Kanad Gupta Date: Thu, 13 Aug 2026 12:04:19 -0700 Subject: [PATCH 11/21] chore: refresh types --- apps/cli/src/shared/feedback/database.types.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/shared/feedback/database.types.ts b/apps/cli/src/shared/feedback/database.types.ts index 4fe2ec4a83..9050d4c3af 100644 --- a/apps/cli/src/shared/feedback/database.types.ts +++ b/apps/cli/src/shared/feedback/database.types.ts @@ -126,31 +126,31 @@ export type Database = { interfaces_feedback: { Row: { created_at: string + delete_token: string feedback: string id: number metadata: Json | null project_ref: string | null - source: string user_agent: string | null user_id: string | null } Insert: { created_at?: string + delete_token?: string feedback: string id?: never metadata?: Json | null project_ref?: string | null - source: string user_agent?: string | null user_id?: string | null } Update: { created_at?: string + delete_token?: string feedback?: string id?: never metadata?: Json | null project_ref?: string | null - source?: string user_agent?: string | null user_id?: string | null } @@ -828,6 +828,16 @@ export type Database = { type: string }[] } + submit_interfaces_feedback: { + Args: { + feedback: string + metadata?: Json + project_ref?: string + user_agent?: string + user_id?: string + } + Returns: string + } update_last_changed_checksum: { Args: { check_time: string From 5c01daf58b4aff2bc43fee81968d2d83de237039 Mon Sep 17 00:00:00 2001 From: Kanad Gupta Date: Thu, 13 Aug 2026 12:04:48 -0700 Subject: [PATCH 12/21] fix: metadata tweaks --- apps/cli/src/shared/feedback/feedback-submitter.layer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/src/shared/feedback/feedback-submitter.layer.ts b/apps/cli/src/shared/feedback/feedback-submitter.layer.ts index 2767cbddcc..05a55af8e4 100644 --- a/apps/cli/src/shared/feedback/feedback-submitter.layer.ts +++ b/apps/cli/src/shared/feedback/feedback-submitter.layer.ts @@ -48,11 +48,11 @@ function toInsertRow(submission: FeedbackSubmission): TablesInsert<"interfaces_f const { context } = submission; return { feedback: submission.message, - source: "cli", user_agent: context.userAgent, project_ref: submission.projectRef ?? null, metadata: { cli_version: context.cliVersion, + source: "cli", os: context.os, arch: context.arch, is_agent: context.isAgent, From 87463facd6a6e6f8c2d398c3cbd082b96573f610 Mon Sep 17 00:00:00 2001 From: Kanad Gupta Date: Thu, 13 Aug 2026 17:50:13 -0700 Subject: [PATCH 13/21] feat(cli): submit feedback via RPC and add feedback delete command - `feedback add` now calls the SECURITY DEFINER `submit_interfaces_feedback` RPC (the upstream table no longer allows direct inserts) and surfaces the server-issued delete token exactly once in every output format - new `feedback delete ` previews the feedback text, confirms (`--yes`/`SUPABASE_YES` to skip; machine modes fail loudly without it), and hard-deletes via the token-gated RLS policy (`x-feedback-token` + optional `x-feedback-project-ref` context header) - reshape the shared `FeedbackSubmitter` service into `FeedbackClient` (submit/preview/delete) and hoist the shared feedback layers and project-ref resolver to the command family root CLI-1946, CLI-2188 Co-Authored-By: Claude Fable 5 --- apps/cli/docs/go-cli-porting-status.md | 17 +- .../commands/feedback/add/SIDE_EFFECTS.md | 32 +- .../commands/feedback/add/add.command.ts | 26 +- .../commands/feedback/add/add.e2e.test.ts | 48 ++- .../commands/feedback/add/add.handler.ts | 37 +- .../feedback/add/add.integration.test.ts | 39 +- .../commands/feedback/delete/SIDE_EFFECTS.md | 113 ++++++ .../feedback/delete/delete.command.ts | 46 +++ .../commands/feedback/delete/delete.errors.ts | 49 +++ .../feedback/delete/delete.handler.ts | 98 +++++ .../delete/delete.integration.test.ts | 368 ++++++++++++++++++ .../commands/feedback/feedback-project-ref.ts | 22 ++ .../commands/feedback/feedback.command.ts | 3 +- .../commands/feedback/feedback.layers.ts | 19 + .../feedback-client.integration.test.ts | 280 +++++++++++++ .../shared/feedback/feedback-client.layer.ts | 155 ++++++++ .../feedback/feedback-client.service.ts | 68 ++++ .../feedback-submitter.integration.test.ts | 144 ------- .../feedback/feedback-submitter.layer.ts | 95 ----- .../feedback/feedback-submitter.service.ts | 42 -- 20 files changed, 1326 insertions(+), 375 deletions(-) create mode 100644 apps/cli/src/legacy/commands/feedback/delete/SIDE_EFFECTS.md create mode 100644 apps/cli/src/legacy/commands/feedback/delete/delete.command.ts create mode 100644 apps/cli/src/legacy/commands/feedback/delete/delete.errors.ts create mode 100644 apps/cli/src/legacy/commands/feedback/delete/delete.handler.ts create mode 100644 apps/cli/src/legacy/commands/feedback/delete/delete.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/feedback/feedback-project-ref.ts create mode 100644 apps/cli/src/legacy/commands/feedback/feedback.layers.ts create mode 100644 apps/cli/src/shared/feedback/feedback-client.integration.test.ts create mode 100644 apps/cli/src/shared/feedback/feedback-client.layer.ts create mode 100644 apps/cli/src/shared/feedback/feedback-client.service.ts delete mode 100644 apps/cli/src/shared/feedback/feedback-submitter.integration.test.ts delete mode 100644 apps/cli/src/shared/feedback/feedback-submitter.layer.ts delete mode 100644 apps/cli/src/shared/feedback/feedback-submitter.service.ts diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index a7f1836213..896e007efe 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -49,14 +49,15 @@ This tracker is command-focused, but root global flag drift is large enough to n These commands exist in the TS CLI today but have no direct top-level equivalent in the old Go CLI reference. -| TS command | TS path | Notes | -| ----------------- | ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `dev` | `planned` | Reserved for a TS-native long-running local development workflow command that watches files and orchestrates subcommands. Track this as TS-only unless a direct Go equivalent emerges. | -| `feedback add` | [`../src/legacy/commands/feedback/add/add.command.ts`](../src/legacy/commands/feedback/add/add.command.ts) | Quick feedback submission, legacy shell only. Posts to the `interfaces_feedback` table via supabase-js with a committed publishable key; profile-driven staging/production environments (production currently reuses staging, CLI-1946). | -| `logs` | [`../src/next/commands/logs/logs.command.ts`](../src/next/commands/logs/logs.command.ts) | Streams local stack logs. No top-level `logs` command exists in the old Go CLI reference. | -| `api` | [`../src/next/commands/platform/api.command.ts`](../src/next/commands/platform/api.command.ts) | Low-level Management API client. It supersedes the old generated tree with explicit discovery via `supabase api routes` and execution via `supabase api request [--method ]`. | -| `stack` | [`../src/next/cli/root.ts`](../src/next/cli/root.ts) | TS-only local runtime namespace exposing `stack start`, `stack stop`, `stack status`, `stack list`, and `stack update`. Top-level `start`, `stop`, and `status` remain aliases. | -| `branches switch` | [`../src/next/commands/branches/switch/switch.command.ts`](../src/next/commands/branches/switch/switch.command.ts) | No direct Go equivalent. Updates local active-branch state so subsequent commands target the selected branch. | +| TS command | TS path | Notes | +| ----------------- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `dev` | `planned` | Reserved for a TS-native long-running local development workflow command that watches files and orchestrates subcommands. Track this as TS-only unless a direct Go equivalent emerges. | +| `feedback add` | [`../src/legacy/commands/feedback/add/add.command.ts`](../src/legacy/commands/feedback/add/add.command.ts) | Quick feedback submission, legacy shell only. Submits through the `submit_interfaces_feedback` RPC via supabase-js with a committed publishable key and prints the server-issued delete token once; profile-driven staging/production environments (production currently reuses staging, CLI-1946). | +| `feedback delete` | [`../src/legacy/commands/feedback/delete/delete.command.ts`](../src/legacy/commands/feedback/delete/delete.command.ts) | Deletes previously submitted feedback using the token printed by `feedback add` (CLI-2188). Previews the feedback text, then hard-deletes via the token-gated RLS policy (`x-feedback-token` + optional `x-feedback-project-ref` headers). | +| `logs` | [`../src/next/commands/logs/logs.command.ts`](../src/next/commands/logs/logs.command.ts) | Streams local stack logs. No top-level `logs` command exists in the old Go CLI reference. | +| `api` | [`../src/next/commands/platform/api.command.ts`](../src/next/commands/platform/api.command.ts) | Low-level Management API client. It supersedes the old generated tree with explicit discovery via `supabase api routes` and execution via `supabase api request [--method ]`. | +| `stack` | [`../src/next/cli/root.ts`](../src/next/cli/root.ts) | TS-only local runtime namespace exposing `stack start`, `stack stop`, `stack status`, `stack list`, and `stack update`. Top-level `start`, `stop`, and `status` remain aliases. | +| `branches switch` | [`../src/next/commands/branches/switch/switch.command.ts`](../src/next/commands/branches/switch/switch.command.ts) | No direct Go equivalent. Updates local active-branch state so subsequent commands target the selected branch. | ## Quick Start diff --git a/apps/cli/src/legacy/commands/feedback/add/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/feedback/add/SIDE_EFFECTS.md index adcfeb15b7..5c5c12b674 100644 --- a/apps/cli/src/legacy/commands/feedback/add/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/feedback/add/SIDE_EFFECTS.md @@ -16,17 +16,18 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------------------ | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | -| `POST` | `/rest/v1/interfaces_feedback` | `apikey` = committed publishable key | `{ feedback, source: "cli", user_agent, project_ref (or null), metadata: { cli_version, os, arch, is_agent, agent_name? } }` | none (fire-and-forget; only the error status is checked) | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ----------------------------------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------- | +| `POST` | `/rest/v1/rpc/submit_interfaces_feedback` | `apikey` = committed publishable key | `{ feedback, user_agent, project_ref (omitted when unlinked), metadata: { cli_version, source: "cli", os, arch, is_agent, agent_name? } }` | uuid delete token (issued exactly once, shown to the user) | -`` follows the resolved profile (`add.command.ts`): +`` follows the resolved profile (`feedback.layers.ts`): `supabase-staging` / `supabase-local` → the staging feedback project; every other profile (incl. YAML-file profiles) → production. Production currently reuses the staging project until a dedicated one is provisioned (CLI-1946); connection constants live in -`src/shared/feedback/feedback-submitter.layer.ts` and are safe to commit -(publishable key, insert-only RLS). The request times out after 10 s. +`src/shared/feedback/feedback-client.layer.ts` and are safe to commit +(publishable key; the SECURITY DEFINER RPC is the only insert path). The +request times out after 10 s. ## Environment Variables @@ -68,22 +69,24 @@ the `interfaces_feedback` table via the API route above. ``` Thanks for the feedback! +To delete this feedback later, run: supabase feedback delete ``` -Rendered as a clack success line after a "Sending feedback..." spinner. When -no message is passed on an interactive terminal, a "What's on your mind?" -text prompt collects it first. +Rendered as a clack success line plus an info line after a "Sending +feedback..." spinner. The delete token is shown exactly once — it is not +persisted anywhere by the CLI. When no message is passed on an interactive +terminal, a "What's on your mind?" text prompt collects it first. ### `--output-format json` ```json -{ "message": "Thanks for the feedback!" } +{ "delete_token": "", "message": "Thanks for the feedback!" } ``` ### `--output-format stream-json` ```ndjson -{"type":"result","data":{"message":"Thanks for the feedback!"},"timestamp":"…"} +{"type":"result","data":{"delete_token":"","message":"Thanks for the feedback!"},"timestamp":"…"} ``` ## Notes @@ -103,5 +106,8 @@ text prompt collects it first. but reads the file directly, so the command has no auth dependency and works when the user is not logged in. Note `LegacyCliConfig.projectId` alone is only the env var — it is NOT linked-project-aware. -- Fire-and-forget insert (no `.select()`), matching the docs feedback widget - and the table's insert-only RLS: the ack carries no server receipt. +- Submission goes through the SECURITY DEFINER `submit_interfaces_feedback` + RPC (the table has no insert grant), which returns a server-generated + `delete_token` exactly once. The token is the only way to delete the + feedback later (`supabase feedback delete `), so the ack surfaces it + in every output format; the CLI never stores it. diff --git a/apps/cli/src/legacy/commands/feedback/add/add.command.ts b/apps/cli/src/legacy/commands/feedback/add/add.command.ts index d9a3ccdf62..0ed066a3be 100644 --- a/apps/cli/src/legacy/commands/feedback/add/add.command.ts +++ b/apps/cli/src/legacy/commands/feedback/add/add.command.ts @@ -1,18 +1,11 @@ -import { Effect, Layer } from "effect"; import { Argument, Command } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; -import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; -import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; -import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; -import { - feedbackSubmitterLayer, - legacyFeedbackEnvironment, -} from "../../../../shared/feedback/feedback-submitter.layer.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; import { aiToolLayer } from "../../../../shared/telemetry/ai-tool.layer.ts"; import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyFeedbackClientLayer, legacyFeedbackCliConfigLayer } from "../feedback.layers.ts"; import { legacyFeedbackAdd } from "./add.handler.ts"; const config = { @@ -29,17 +22,6 @@ export type LegacyFeedbackAddArgs = CliCommand.Command.Config.Infer legacyFeedbackAdd(args).pipe(withLegacyCommandInstrumentation(), withJsonErrorHandling); -const legacyFeedbackCliConfigLayer = legacyCliConfigLayer.pipe( - Layer.provide(legacyDebugLoggerLayer), -); - -const legacyFeedbackSubmitterLayer = Layer.unwrap( - Effect.gen(function* () { - const config = yield* LegacyCliConfig; - return feedbackSubmitterLayer({ environment: legacyFeedbackEnvironment(config.profile) }); - }), -).pipe(Layer.provide(legacyFeedbackCliConfigLayer)); - export const legacyFeedbackAddCommand = Command.make("add", config).pipe( Command.withDescription("Send quick feedback about the Supabase CLI to the Supabase team."), Command.withShortDescription("Send feedback to the Supabase team"), @@ -58,8 +40,8 @@ export const legacyFeedbackAddCommand = Command.make("add", config).pipe( Command.provide(commandRuntimeLayer(["feedback", "add"])), Command.provide(stdinLayer), Command.provide(aiToolLayer), - // `Layer.provide` does not share to siblings: the handler and the submitter - // each get their own cli-config provision (legacy CLAUDE.md item 5). - Command.provide(legacyFeedbackSubmitterLayer), + // `Layer.provide` does not share to siblings: the handler and the feedback + // client each get their own cli-config provision (legacy CLAUDE.md item 5). + Command.provide(legacyFeedbackClientLayer), Command.provide(legacyFeedbackCliConfigLayer), ); diff --git a/apps/cli/src/legacy/commands/feedback/add/add.e2e.test.ts b/apps/cli/src/legacy/commands/feedback/add/add.e2e.test.ts index f69231adfc..5370ce6021 100644 --- a/apps/cli/src/legacy/commands/feedback/add/add.e2e.test.ts +++ b/apps/cli/src/legacy/commands/feedback/add/add.e2e.test.ts @@ -2,25 +2,46 @@ import { describe, expect, test } from "vitest"; import { makeTempHome, runSupabase } from "../../../../../tests/helpers/cli.ts"; -const E2E_TIMEOUT_MS = 30_000; +const E2E_TIMEOUT_MS = 60_000; -describe("supabase feedback add (legacy)", () => { +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +describe("supabase feedback (legacy)", () => { // Golden path through the real CLI surface: `feedback add` resolves through // the command parser's subcommand routing (unreachable from handler-level - // integration tests) and the json acknowledgement lands on stdout. Pinned to - // --profile supabase-staging so this posts one real row to the STAGING - // feedback project per run (staging exists for local dev/tests), never to - // production. + // integration tests), submits through the real `submit_interfaces_feedback` + // RPC, and the json acknowledgement carries the server-issued delete token. + // `feedback delete` then removes the row with that token — proving the full + // add → delete round trip AND cleaning the staging row up each run. Pinned + // to --profile supabase-staging so this only ever touches the STAGING + // feedback project (staging exists for local dev/tests), never production. test( - "feedback add submits to staging and prints a json acknowledgement", + "feedback add returns a delete token that feedback delete accepts", { timeout: E2E_TIMEOUT_MS }, async () => { using home = makeTempHome(); - const { exitCode, stdout } = await runSupabase( + const message = "cli-e2e golden path (add.e2e.test.ts)"; + const added = await runSupabase( + ["feedback", "add", message, "--profile", "supabase-staging", "--output-format", "json"], + { + entrypoint: "legacy", + home: home.dir, + env: { HOME: home.dir }, + }, + ); + expect(added.exitCode).toBe(0); + const receipt = JSON.parse(added.stdout); + expect(receipt).toEqual({ + delete_token: expect.stringMatching(UUID_PATTERN), + message: "Thanks for the feedback!", + }); + + const deleted = await runSupabase( [ "feedback", - "add", - "cli-e2e golden path (add.e2e.test.ts)", + "delete", + receipt.delete_token, + "--yes", "--profile", "supabase-staging", "--output-format", @@ -32,8 +53,11 @@ describe("supabase feedback add (legacy)", () => { env: { HOME: home.dir }, }, ); - expect(exitCode).toBe(0); - expect(JSON.parse(stdout)).toEqual({ message: "Thanks for the feedback!" }); + expect(deleted.exitCode).toBe(0); + expect(JSON.parse(deleted.stdout)).toEqual({ + feedback: message, + message: "Feedback deleted.", + }); }, ); }); diff --git a/apps/cli/src/legacy/commands/feedback/add/add.handler.ts b/apps/cli/src/legacy/commands/feedback/add/add.handler.ts index af8d404d93..882aea8036 100644 --- a/apps/cli/src/legacy/commands/feedback/add/add.handler.ts +++ b/apps/cli/src/legacy/commands/feedback/add/add.handler.ts @@ -1,12 +1,12 @@ -import { Effect, FileSystem, Option, Path } from "effect"; -import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; -import { legacyReadProjectRefFile } from "../../../shared/legacy-temp-paths.ts"; -import { FeedbackSubmitter } from "../../../../shared/feedback/feedback-submitter.service.ts"; +import { Effect, Option } from "effect"; +import { FeedbackClient } from "../../../../shared/feedback/feedback-client.service.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; import { Stdin } from "../../../../shared/runtime/stdin.service.ts"; import { AiTool } from "../../../../shared/telemetry/ai-tool.service.ts"; import { TelemetryRuntime } from "../../../../shared/telemetry/runtime.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { legacyResolveFeedbackProjectRef } from "../feedback-project-ref.ts"; import type { LegacyFeedbackAddArgs } from "./add.command.ts"; import { LEGACY_FEEDBACK_EMPTY_MESSAGE, LegacyFeedbackEmptyMessageError } from "./add.errors.ts"; @@ -41,25 +41,6 @@ const legacyResolveFeedbackMessage = Effect.fnUntraced(function* (args: LegacyFe ); }); -// Mirrors the soft-load half of `LegacyProjectRefResolver.resolveOptional` -// (`legacy-project-ref.layer.ts`): `SUPABASE_PROJECT_ID` (captured by -// `LegacyCliConfig`) → `/supabase/.temp/project-ref`, the file -// `supabase link` writes. The file is read directly rather than via the full -// resolver because that layer requires `LegacyPlatformApiFactory` for its -// prompt path, and feedback must keep working when the user isn't logged in. -// A broken ref file degrades to "unlinked" instead of failing the submission. -const legacyResolveFeedbackProjectRef = Effect.fnUntraced(function* ( - workdir: string, - fromEnv: Option.Option, -) { - if (Option.isSome(fromEnv)) return fromEnv; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - return yield* legacyReadProjectRefFile(fs, path, workdir).pipe( - Effect.orElseSucceed(() => Option.none()), - ); -}); - export const legacyFeedbackAdd = Effect.fn("legacy.feedback.add")(function* ( args: LegacyFeedbackAddArgs, ) { @@ -68,7 +49,7 @@ export const legacyFeedbackAdd = Effect.fn("legacy.feedback.add")(function* ( const runtimeInfo = yield* RuntimeInfo; const telemetryRuntime = yield* TelemetryRuntime; const aiTool = yield* AiTool; - const submitter = yield* FeedbackSubmitter; + const client = yield* FeedbackClient; const message = yield* legacyResolveFeedbackMessage(args); const agentName = Option.getOrUndefined(aiTool.name); @@ -76,7 +57,7 @@ export const legacyFeedbackAdd = Effect.fn("legacy.feedback.add")(function* ( const sending = yield* output.task("Sending feedback..."); - yield* submitter + const { deleteToken } = yield* client .submit({ message, projectRef: Option.getOrUndefined(projectRef), @@ -92,5 +73,11 @@ export const legacyFeedbackAdd = Effect.fn("legacy.feedback.add")(function* ( .pipe(Effect.tapError(() => sending.fail())); yield* sending.clear(); + + if (output.format !== "text") { + yield* output.success("Thanks for the feedback!", { delete_token: deleteToken }); + return; + } yield* output.success("Thanks for the feedback!"); + yield* output.info(`To delete this feedback later, run: supabase feedback delete ${deleteToken}`); }); diff --git a/apps/cli/src/legacy/commands/feedback/add/add.integration.test.ts b/apps/cli/src/legacy/commands/feedback/add/add.integration.test.ts index fdec2ad18b..f6ccdecb97 100644 --- a/apps/cli/src/legacy/commands/feedback/add/add.integration.test.ts +++ b/apps/cli/src/legacy/commands/feedback/add/add.integration.test.ts @@ -3,11 +3,11 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { Effect, Layer, Option, Stdio } from "effect"; -import type { FeedbackSubmission } from "../../../../shared/feedback/feedback-submitter.service.ts"; +import type { FeedbackSubmission } from "../../../../shared/feedback/feedback-client.service.ts"; import { - FeedbackSubmitError, - FeedbackSubmitter, -} from "../../../../shared/feedback/feedback-submitter.service.ts"; + FeedbackBackendError, + FeedbackClient, +} from "../../../../shared/feedback/feedback-client.service.ts"; import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; import { AiTool } from "../../../../shared/telemetry/ai-tool.service.ts"; import { @@ -43,18 +43,24 @@ function writeLinkedProjectRef(workdir: string, ref: string, opts: { asDirectory writeFileSync(refPath, `${ref}\n`); } -function mockFeedbackSubmitter(opts: { failWith?: string } = {}) { +const MOCK_DELETE_TOKEN = "123e4567-e89b-12d3-a456-426614174000"; + +function mockFeedbackClient(opts: { failWith?: string } = {}) { const submissions: FeedbackSubmission[] = []; return { layer: Layer.succeed( - FeedbackSubmitter, - FeedbackSubmitter.of({ + FeedbackClient, + FeedbackClient.of({ submit: (submission) => opts.failWith !== undefined - ? Effect.fail(new FeedbackSubmitError({ message: opts.failWith })) + ? Effect.fail(new FeedbackBackendError({ message: opts.failWith, operation: "submit" })) : Effect.sync(() => { submissions.push(submission); + return { deleteToken: MOCK_DELETE_TOKEN }; }), + // `feedback add` never previews or deletes. + preview: () => Effect.die("preview is not reachable from feedback add"), + delete: () => Effect.die("delete is not reachable from feedback add"), }), ), submissions, @@ -80,7 +86,7 @@ function setupLegacyFeedback( } = {}, ) { const out = mockOutput(opts.output); - const submitter = mockFeedbackSubmitter( + const submitter = mockFeedbackClient( opts.submitFailWith === undefined ? {} : { failWith: opts.submitFailWith }, ); const layer = Layer.mergeAll( @@ -141,6 +147,14 @@ describe("legacy feedback add", () => { expect(out.messages).toContainEqual( expect.objectContaining({ type: "success", message: "Thanks for the feedback!" }), ); + // The delete token is shown exactly once, at submit time — the user must + // keep it to delete the feedback later. + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "info", + message: `To delete this feedback later, run: supabase feedback delete ${MOCK_DELETE_TOKEN}`, + }), + ); }).pipe(Effect.provide(layer)); }); @@ -248,18 +262,17 @@ describe("legacy feedback add", () => { }).pipe(Effect.provide(layer)); }); - it.live("emits a machine-readable acknowledgement in json output format", () => { + it.live("emits the delete token in the json acknowledgement", () => { const { layer, out, submitter } = setupLegacyFeedback({ output: { format: "json" } }); return Effect.gen(function* () { yield* legacyFeedbackAdd({ message: ["json mode feedback"] }); expect(submitter.submissions).toHaveLength(1); - // Fire-and-forget: the ack carries no receipt payload, just the message. expect(out.messages).toContainEqual( expect.objectContaining({ type: "success", message: "Thanks for the feedback!", - data: undefined, + data: { delete_token: MOCK_DELETE_TOKEN }, }), ); }).pipe(Effect.provide(layer)); @@ -287,7 +300,7 @@ describe("legacy feedback add", () => { const error = yield* legacyFeedbackAdd({ message: ["doomed message"] }).pipe(Effect.flip); expect(error).toMatchObject({ - _tag: "FeedbackSubmitError", + _tag: "FeedbackBackendError", message: "backend unavailable", }); expect(out.messages).not.toContainEqual(expect.objectContaining({ type: "success" })); diff --git a/apps/cli/src/legacy/commands/feedback/delete/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/feedback/delete/SIDE_EFFECTS.md new file mode 100644 index 0000000000..ec77a3dece --- /dev/null +++ b/apps/cli/src/legacy/commands/feedback/delete/SIDE_EFFECTS.md @@ -0,0 +1,113 @@ +# `supabase feedback delete [--project-ref ]` + +## Files Read + +| Path | Format | When | +| -------------------------------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/profile` | plain text (profile name) | when `--profile` and `SUPABASE_PROFILE` are unset (profile resolution via `legacyCliConfigLayer`) | +| `$SUPABASE_PROFILE` | YAML (`api_url:` / `gotrue_url:` …) | when `SUPABASE_PROFILE` is set to a file path instead of a built-in profile name | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` and `SUPABASE_PROJECT_ID` are unset — supplies the `x-feedback-project-ref` context. Absent, blank, or unreadable → header omitted (never fails the command) | + +## Files Written + +| Path | Format | When | +| ---- | ------ | ---- | +| — | — | — | + +## API Routes + +| Method | Path | Auth / headers | Request body | Response (used fields) | +| -------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ------------ | ------------------------------------------------------------ | +| `GET` | `/rest/v1/interfaces_feedback?select=feedback&delete_token=eq.` | `apikey` = committed publishable key; `x-feedback-token: `; `x-feedback-project-ref: ` when resolved | — | `[{ feedback }]` or `[]` — previews the text before deletion | +| `DELETE` | `/rest/v1/interfaces_feedback?delete_token=eq.` | same headers, plus `Prefer: count=exact` | — | `Content-Range: */1` (deleted) vs `*/0` (no row matched) | + +`` follows the resolved profile exactly as `feedback add` +does (`feedback.layers.ts` / `src/shared/feedback/feedback-client.layer.ts`). +The `delete_token=eq.` URL filter only satisfies PostgREST's filterless-delete +rejection — the `x-feedback-token` header is the security boundary enforced by +RLS. Rows submitted with a `project_ref` additionally require the matching +`x-feedback-project-ref` header on both routes; sending it against a +context-free row is ignored server-side, so the CLI always sends whatever ref +resolves. Each request times out after 10 s. + +## Environment Variables + +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------- | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` → `supabase`) | +| `SUPABASE_WORKDIR` | project directory override | no (falls back to `--workdir` → cwd) | +| `SUPABASE_ACCESS_TOKEN` | access token captured by `legacyCliConfigLayer` | no (unused by this command) | +| `SUPABASE_PROJECT_ID` | supplies the project-ref context when `--project-ref` is unset | no (falls back to `/supabase/.temp/project-ref` → omitted) | +| `SUPABASE_YES` | auto-confirms the deletion prompt, same as `--yes` | no | + +Global telemetry consent env applies as with every command. + +## Exit Codes + +| Code | Condition | +| ---- | --------------------------------------------------------------------------------------------- | +| `0` | feedback deleted | +| `1` | token argument is not a UUID | +| `1` | no feedback matched (wrong token, already deleted, or project-ref context mismatch) | +| `1` | confirmation declined, or prompt unavailable (non-interactive / machine mode without `--yes`) | +| `1` | backend failure (PostgREST error, network failure, or 10 s timeout) on preview or delete | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | + +The delete token is NEVER included in any telemetry event: it 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). +Regression-tested in `delete.integration.test.ts`. + +## Output + +### `--output-format text` + +``` +Found feedback: "" +Permanently delete this feedback? [y/N] +Feedback deleted. +``` + +The preview renders as an info line after a "Looking up feedback..." spinner; +the delete runs behind a "Deleting feedback..." spinner. `--yes` / +`SUPABASE_YES` skips the confirmation prompt. + +### `--output-format json` + +```json +{ "feedback": "", "message": "Feedback deleted." } +``` + +Requires `--yes`: machine modes cannot prompt, and the command fails loudly +rather than deleting without confirmation. + +### `--output-format stream-json` + +```ndjson +{"type":"result","data":{"feedback":"","message":"Feedback deleted."},"timestamp":"…"} +``` + +Also requires `--yes`. + +## Notes + +- TS-only command — no Go CLI counterpart, so no Go-parity constraints apply. +- Telemetry records `command: "feedback delete"`. +- The token is validated client-side against the UUID shape before any request + (avoids PostgREST's cryptic uuid-cast error) and lowercased for the backend. +- The feedback text is previewed before deletion so the user can verify what + the token unlocks; machine modes return the deleted text in the result + payload instead. +- Deletion is a hard delete with no undo; tokens never expire. +- Project-ref resolution order: `--project-ref` → `SUPABASE_PROJECT_ID` → + `/supabase/.temp/project-ref` (written by `supabase link`) → + omitted. A row submitted from a linked project can only be previewed/deleted + with that same ref presented — rerun from the linked directory or pass + `--project-ref`. This mirrors `feedback add`'s resolution and works + logged-out (no auth dependency). diff --git a/apps/cli/src/legacy/commands/feedback/delete/delete.command.ts b/apps/cli/src/legacy/commands/feedback/delete/delete.command.ts new file mode 100644 index 0000000000..91c67f65fb --- /dev/null +++ b/apps/cli/src/legacy/commands/feedback/delete/delete.command.ts @@ -0,0 +1,46 @@ +import { Argument, Command, Flag } from "effect/unstable/cli"; +import type * as CliCommand from "effect/unstable/cli/Command"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyFeedbackClientLayer, legacyFeedbackCliConfigLayer } from "../feedback.layers.ts"; +import { legacyFeedbackDelete } from "./delete.handler.ts"; + +const config = { + token: Argument.string("token").pipe( + Argument.withDescription("Deletion token (UUID) printed when the feedback was submitted."), + ), + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project the feedback was submitted with."), + Flag.optional, + ), +} as const; + +export type LegacyFeedbackDeleteArgs = CliCommand.Command.Config.Infer; + +// Exported so integration tests can drive the exact wiring `Command.withHandler` +// uses below, instead of re-asserting the generic instrumentation mechanism. +export const legacyFeedbackDeleteHandler = (args: LegacyFeedbackDeleteArgs) => + legacyFeedbackDelete(args).pipe( + // The token is a positional (structurally excluded from telemetry) and + // `--project-ref` is a plain string flag, so its value is redacted. + withLegacyCommandInstrumentation({ flags: args }), + withJsonErrorHandling, + ); + +export const legacyFeedbackDeleteCommand = Command.make("delete", config).pipe( + Command.withDescription("Delete previously submitted feedback using its deletion token."), + Command.withShortDescription("Delete previously submitted feedback"), + Command.withExamples([ + { + command: "supabase feedback delete 123e4567-e89b-12d3-a456-426614174000", + description: "Delete feedback using the token printed when it was submitted", + }, + ]), + Command.withHandler(legacyFeedbackDeleteHandler), + Command.provide(commandRuntimeLayer(["feedback", "delete"])), + // `Layer.provide` does not share to siblings: the handler and the feedback + // client each get their own cli-config provision (legacy CLAUDE.md item 5). + Command.provide(legacyFeedbackClientLayer), + Command.provide(legacyFeedbackCliConfigLayer), +); diff --git a/apps/cli/src/legacy/commands/feedback/delete/delete.errors.ts b/apps/cli/src/legacy/commands/feedback/delete/delete.errors.ts new file mode 100644 index 0000000000..1311da8595 --- /dev/null +++ b/apps/cli/src/legacy/commands/feedback/delete/delete.errors.ts @@ -0,0 +1,49 @@ +import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + +export const LEGACY_FEEDBACK_INVALID_TOKEN_MESSAGE = + "The token must be a UUID (e.g. 123e4567-e89b-12d3-a456-426614174000). " + + "It was printed when the feedback was submitted."; + +export const LEGACY_FEEDBACK_NOT_FOUND_MESSAGE = + "No feedback found for this token. It may already be deleted, the token may be wrong, " + + "or the feedback was submitted from a linked project directory — rerun this command " + + "from that directory or pass --project-ref ."; + +export const LEGACY_FEEDBACK_DELETE_CANCELLED_MESSAGE = "Deletion cancelled."; + +/** The token argument is not a UUID (checked client-side before any request). */ +export class LegacyFeedbackInvalidTokenError extends Data.TaggedError( + "LegacyFeedbackInvalidTokenError", +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + +/** + * The token matched no row: wrong token, already deleted, or the row was + * submitted with a project ref that wasn't presented (`x-feedback-project-ref` + * context gate). The backend cannot distinguish these, so the message carries + * all three remediations. + */ +export class LegacyFeedbackNotFoundError extends Data.TaggedError("LegacyFeedbackNotFoundError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + +/** The user declined the confirmation prompt. */ +export class LegacyFeedbackDeleteCancelledError extends Data.TaggedError( + "LegacyFeedbackDeleteCancelledError", +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} diff --git a/apps/cli/src/legacy/commands/feedback/delete/delete.handler.ts b/apps/cli/src/legacy/commands/feedback/delete/delete.handler.ts new file mode 100644 index 0000000000..20e867411b --- /dev/null +++ b/apps/cli/src/legacy/commands/feedback/delete/delete.handler.ts @@ -0,0 +1,98 @@ +import { Effect, Option } from "effect"; +import { FeedbackClient } from "../../../../shared/feedback/feedback-client.service.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { legacyResolveYes } from "../../../../shared/legacy/global-flags.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { legacyResolveFeedbackProjectRef } from "../feedback-project-ref.ts"; +import type { LegacyFeedbackDeleteArgs } from "./delete.command.ts"; +import { + LEGACY_FEEDBACK_DELETE_CANCELLED_MESSAGE, + LEGACY_FEEDBACK_INVALID_TOKEN_MESSAGE, + LEGACY_FEEDBACK_NOT_FOUND_MESSAGE, + LegacyFeedbackDeleteCancelledError, + LegacyFeedbackInvalidTokenError, + LegacyFeedbackNotFoundError, +} from "./delete.errors.ts"; + +// Checked client-side so a malformed token fails with a friendly message +// instead of PostgREST's cryptic uuid-cast error (22P02) from the +// `delete_token=eq.` filter. +const LEGACY_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export const legacyFeedbackDelete = Effect.fn("legacy.feedback.delete")(function* ( + args: LegacyFeedbackDeleteArgs, +) { + const output = yield* Output; + const cliConfig = yield* LegacyCliConfig; + const client = yield* FeedbackClient; + + if (!LEGACY_UUID_PATTERN.test(args.token)) { + return yield* Effect.fail( + new LegacyFeedbackInvalidTokenError({ message: LEGACY_FEEDBACK_INVALID_TOKEN_MESSAGE }), + ); + } + // RLS compares the header lowercased; normalize so an uppercase paste works. + const token = args.token.toLowerCase(); + + // Project-ref context gate: rows submitted with a project ref only match + // when the same ref is presented. Flag → SUPABASE_PROJECT_ID → the linked + // ref file; extra context against a context-free row is ignored server-side, + // so sending whatever resolves is always safe. + const projectRef = yield* legacyResolveFeedbackProjectRef( + cliConfig.workdir, + Option.orElse(args.projectRef, () => cliConfig.projectId), + ).pipe(Effect.map(Option.getOrUndefined)); + + const looking = yield* output.task("Looking up feedback..."); + const preview = yield* client + .preview(token, projectRef) + .pipe(Effect.tapError(() => looking.fail())); + yield* looking.clear(); + + if (Option.isNone(preview)) { + return yield* Effect.fail( + new LegacyFeedbackNotFoundError({ message: LEGACY_FEEDBACK_NOT_FOUND_MESSAGE }), + ); + } + const feedbackText = preview.value; + + if (output.format === "text") { + yield* output.info(`Found feedback: "${feedbackText}"`); + } + + // `--yes`/`SUPABASE_YES` auto-confirms; otherwise prompt. In non-interactive + // contexts (json/stream-json, or piped text mode) the prompt fails loudly + // with NonInteractiveError rather than silently deleting — pass --yes there. + const yes = yield* legacyResolveYes; + if (!yes) { + const confirmed = yield* output.promptConfirm("Permanently delete this feedback?", { + defaultValue: false, + }); + if (!confirmed) { + return yield* Effect.fail( + new LegacyFeedbackDeleteCancelledError({ + message: LEGACY_FEEDBACK_DELETE_CANCELLED_MESSAGE, + }), + ); + } + } + + const deleting = yield* output.task("Deleting feedback..."); + const { deleted } = yield* client + .delete(token, projectRef) + .pipe(Effect.tapError(() => deleting.fail())); + yield* deleting.clear(); + + // The preview matched but the delete didn't: the row disappeared in between. + if (!deleted) { + return yield* Effect.fail( + new LegacyFeedbackNotFoundError({ message: LEGACY_FEEDBACK_NOT_FOUND_MESSAGE }), + ); + } + + if (output.format !== "text") { + yield* output.success("Feedback deleted.", { feedback: feedbackText }); + return; + } + yield* output.success("Feedback deleted."); +}); diff --git a/apps/cli/src/legacy/commands/feedback/delete/delete.integration.test.ts b/apps/cli/src/legacy/commands/feedback/delete/delete.integration.test.ts new file mode 100644 index 0000000000..3c8281cce7 --- /dev/null +++ b/apps/cli/src/legacy/commands/feedback/delete/delete.integration.test.ts @@ -0,0 +1,368 @@ +import { describe, expect, it } from "@effect/vitest"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, Layer, Option, Stdio } from "effect"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { + FeedbackBackendError, + FeedbackClient, +} from "../../../../shared/feedback/feedback-client.service.ts"; +import { LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { + mockContextualAnalytics, + mockOutput, + mockProcessControl, +} from "../../../../../tests/helpers/mocks.ts"; +import { + LEGACY_VALID_REF, + mockLegacyCliConfig, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import type { LegacyFeedbackDeleteArgs } from "./delete.command.ts"; +import { legacyFeedbackDeleteHandler } from "./delete.command.ts"; +import { + LEGACY_FEEDBACK_INVALID_TOKEN_MESSAGE, + LEGACY_FEEDBACK_NOT_FOUND_MESSAGE, +} from "./delete.errors.ts"; +import { legacyFeedbackDelete } from "./delete.handler.ts"; + +const tempRoot = useLegacyTempWorkdir("supabase-feedback-delete-int-"); + +const TOKEN = "123e4567-e89b-12d3-a456-426614174000"; + +function deleteArgs(overrides: Partial = {}): LegacyFeedbackDeleteArgs { + return { token: TOKEN, projectRef: Option.none(), ...overrides }; +} + +// Seeds `/supabase/.temp/project-ref`, the file `supabase link` writes. +// Passing `asDirectory` creates the path as a directory instead, which makes the +// read fail with a non-NotFound error (the "broken ref file" degradation path). +function writeLinkedProjectRef(workdir: string, ref: string, opts: { asDirectory?: boolean } = {}) { + const tempDir = join(workdir, "supabase", ".temp"); + mkdirSync(tempDir, { recursive: true }); + const refPath = join(tempDir, "project-ref"); + if (opts.asDirectory === true) { + mkdirSync(refPath, { recursive: true }); + return; + } + writeFileSync(refPath, `${ref}\n`); +} + +interface MockClientOpts { + /** Feedback text the preview finds; leave unset for a zero-row (not found) preview. */ + previewText?: string; + previewFailWith?: string; + /** Whether the delete matches a row; defaults to true. */ + deleteMatches?: boolean; + deleteFailWith?: string; +} + +function mockFeedbackClient(opts: MockClientOpts = {}) { + const previewCalls: Array<{ token: string; projectRef: string | undefined }> = []; + const deleteCalls: Array<{ token: string; projectRef: string | undefined }> = []; + return { + layer: Layer.succeed( + FeedbackClient, + FeedbackClient.of({ + submit: () => Effect.die("submit is not reachable from feedback delete"), + preview: (token, projectRef) => + Effect.suspend(() => { + previewCalls.push({ token, projectRef }); + return opts.previewFailWith !== undefined + ? Effect.fail( + new FeedbackBackendError({ + message: opts.previewFailWith, + operation: "preview", + }), + ) + : Effect.succeed(Option.fromNullishOr(opts.previewText)); + }), + delete: (token, projectRef) => + Effect.suspend(() => { + deleteCalls.push({ token, projectRef }); + return opts.deleteFailWith !== undefined + ? Effect.fail( + new FeedbackBackendError({ message: opts.deleteFailWith, operation: "delete" }), + ) + : Effect.succeed({ deleted: opts.deleteMatches ?? true }); + }), + }), + ), + previewCalls, + deleteCalls, + }; +} + +function setupLegacyFeedbackDelete( + opts: { + output?: Parameters[0]; + client?: MockClientOpts; + yes?: boolean; + /** Simulates `SUPABASE_PROJECT_ID`, the only source `LegacyCliConfig` reads. */ + projectIdEnv?: string; + } = {}, +) { + const out = mockOutput(opts.output ?? { promptConfirmResponses: [true] }); + const client = mockFeedbackClient(opts.client ?? { previewText: "my papercut" }); + const layer = Layer.mergeAll( + out.layer, + client.layer, + mockLegacyCliConfig({ + workdir: tempRoot.current, + userAgent: "SupabaseCLI/9.9.9", + projectId: opts.projectIdEnv === undefined ? Option.none() : Option.some(opts.projectIdEnv), + }), + Layer.succeed(LegacyYesFlag, opts.yes ?? false), + Layer.succeed(CliArgs, { args: [] }), + // Real filesystem: the handler reads `supabase/.temp/project-ref` from the + // temp workdir, so this must not be stubbed out. + BunServices.layer, + ); + return { layer, out, client }; +} + +// Extra layers required by the wrapped `legacyFeedbackDeleteHandler` (the exact +// wiring `Command.withHandler` uses): instrumentation + json error handling. +function setupLegacyFeedbackDeleteHandler( + opts: Parameters[0] & { args?: ReadonlyArray } = {}, +) { + const base = setupLegacyFeedbackDelete(opts); + const analytics = mockContextualAnalytics(); + const processControl = mockProcessControl(); + const layer = Layer.mergeAll( + base.layer, + analytics.layer, + processControl.layer, + commandRuntimeLayer(["feedback", "delete"]), + Stdio.layerTest({ args: Effect.succeed([...(opts.args ?? ["feedback", "delete", TOKEN])]) }), + ); + return { ...base, layer, analytics, processControl }; +} + +describe("legacy feedback delete", () => { + it.live("previews the feedback, confirms, and deletes it", () => { + const { layer, out, client } = setupLegacyFeedbackDelete(); + return Effect.gen(function* () { + yield* legacyFeedbackDelete(deleteArgs()); + + expect(out.messages).toContainEqual( + expect.objectContaining({ type: "info", message: 'Found feedback: "my papercut"' }), + ); + expect(out.promptConfirmCalls).toEqual([ + { message: "Permanently delete this feedback?", opts: { defaultValue: false } }, + ]); + expect(client.previewCalls).toEqual([{ token: TOKEN, projectRef: undefined }]); + expect(client.deleteCalls).toEqual([{ token: TOKEN, projectRef: undefined }]); + expect(out.messages).toContainEqual( + expect.objectContaining({ type: "success", message: "Feedback deleted." }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("rejects a token that is not a UUID before contacting the backend", () => { + const { layer, client } = setupLegacyFeedbackDelete(); + return Effect.gen(function* () { + const error = yield* legacyFeedbackDelete(deleteArgs({ token: "not-a-uuid" })).pipe( + Effect.flip, + ); + + expect(error).toMatchObject({ + _tag: "LegacyFeedbackInvalidTokenError", + message: LEGACY_FEEDBACK_INVALID_TOKEN_MESSAGE, + }); + expect(client.previewCalls).toHaveLength(0); + expect(client.deleteCalls).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.live("accepts an uppercase token and lowercases it for the backend", () => { + const { layer, client } = setupLegacyFeedbackDelete({ yes: true }); + return Effect.gen(function* () { + yield* legacyFeedbackDelete(deleteArgs({ token: TOKEN.toUpperCase() })); + + expect(client.previewCalls).toEqual([{ token: TOKEN, projectRef: undefined }]); + expect(client.deleteCalls).toEqual([{ token: TOKEN, projectRef: undefined }]); + }).pipe(Effect.provide(layer)); + }); + + it.live("cancels without deleting when the confirmation is declined", () => { + const { layer, client } = setupLegacyFeedbackDelete({ + output: { promptConfirmResponses: [false] }, + }); + return Effect.gen(function* () { + const error = yield* legacyFeedbackDelete(deleteArgs()).pipe(Effect.flip); + + expect(error).toMatchObject({ _tag: "LegacyFeedbackDeleteCancelledError" }); + expect(client.deleteCalls).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.live("--yes skips the confirmation prompt", () => { + const { layer, out, client } = setupLegacyFeedbackDelete({ yes: true }); + return Effect.gen(function* () { + yield* legacyFeedbackDelete(deleteArgs()); + + expect(out.promptConfirmCalls).toHaveLength(0); + expect(client.deleteCalls).toHaveLength(1); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with a remediation hint when the token matches no feedback", () => { + const { layer, client } = setupLegacyFeedbackDelete({ client: {} }); + return Effect.gen(function* () { + const error = yield* legacyFeedbackDelete(deleteArgs()).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "LegacyFeedbackNotFoundError", + message: LEGACY_FEEDBACK_NOT_FOUND_MESSAGE, + }); + expect(client.deleteCalls).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails as not found when the delete matches zero rows after the preview", () => { + // The row disappeared between preview and delete (e.g. deleted elsewhere). + const { layer, client } = setupLegacyFeedbackDelete({ + client: { previewText: "raced", deleteMatches: false }, + yes: true, + }); + return Effect.gen(function* () { + const error = yield* legacyFeedbackDelete(deleteArgs()).pipe(Effect.flip); + + expect(error).toMatchObject({ _tag: "LegacyFeedbackNotFoundError" }); + expect(client.deleteCalls).toHaveLength(1); + }).pipe(Effect.provide(layer)); + }); + + it.live("sends the linked project ref written by supabase link", () => { + const { layer, client } = setupLegacyFeedbackDelete({ yes: true }); + writeLinkedProjectRef(tempRoot.current, LEGACY_VALID_REF); + return Effect.gen(function* () { + yield* legacyFeedbackDelete(deleteArgs()); + + expect(client.previewCalls).toEqual([{ token: TOKEN, projectRef: LEGACY_VALID_REF }]); + expect(client.deleteCalls).toEqual([{ token: TOKEN, projectRef: LEGACY_VALID_REF }]); + }).pipe(Effect.provide(layer)); + }); + + it.live("prefers --project-ref over SUPABASE_PROJECT_ID and the linked ref file", () => { + const { layer, client } = setupLegacyFeedbackDelete({ + yes: true, + projectIdEnv: "envenvenvenvenvenvre", + }); + writeLinkedProjectRef(tempRoot.current, LEGACY_VALID_REF); + return Effect.gen(function* () { + yield* legacyFeedbackDelete(deleteArgs({ projectRef: Option.some("flagflagflagflagflag") })); + + expect(client.previewCalls).toEqual([{ token: TOKEN, projectRef: "flagflagflagflagflag" }]); + }).pipe(Effect.provide(layer)); + }); + + it.live("prefers SUPABASE_PROJECT_ID over the linked ref file", () => { + const { layer, client } = setupLegacyFeedbackDelete({ + yes: true, + projectIdEnv: "envenvenvenvenvenvre", + }); + writeLinkedProjectRef(tempRoot.current, LEGACY_VALID_REF); + return Effect.gen(function* () { + yield* legacyFeedbackDelete(deleteArgs()); + + expect(client.previewCalls).toEqual([{ token: TOKEN, projectRef: "envenvenvenvenvenvre" }]); + }).pipe(Effect.provide(layer)); + }); + + it.live("degrades to no project ref when the linked ref file cannot be read", () => { + const { layer, client } = setupLegacyFeedbackDelete({ yes: true }); + writeLinkedProjectRef(tempRoot.current, LEGACY_VALID_REF, { asDirectory: true }); + return Effect.gen(function* () { + yield* legacyFeedbackDelete(deleteArgs()); + + expect(client.previewCalls).toEqual([{ token: TOKEN, projectRef: undefined }]); + }).pipe(Effect.provide(layer)); + }); + + it.live("returns the deleted feedback text in json output format", () => { + const { layer, out } = setupLegacyFeedbackDelete({ + output: { format: "json" }, + client: { previewText: "json feedback" }, + yes: true, + }); + return Effect.gen(function* () { + yield* legacyFeedbackDelete(deleteArgs()); + + // Machine modes carry the text in the result payload instead of the + // text-mode "Found feedback" info line. + expect(out.messages).not.toContainEqual(expect.objectContaining({ type: "info" })); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + message: "Feedback deleted.", + data: { feedback: "json feedback" }, + }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails loudly in json mode without --yes instead of silently deleting", () => { + const { layer, out, client, processControl } = setupLegacyFeedbackDeleteHandler({ + output: { format: "json", promptConfirmFail: true }, + }); + return Effect.gen(function* () { + yield* legacyFeedbackDeleteHandler(deleteArgs()); + + expect(client.deleteCalls).toHaveLength(0); + expect(out.messages).toContainEqual(expect.objectContaining({ type: "fail" })); + expect(processControl.exitCode).toBe(1); + }).pipe(Effect.provide(layer)); + }); + + it.live("surfaces a backend failure during the preview", () => { + const { layer, out, client } = setupLegacyFeedbackDelete({ + client: { previewFailWith: "backend unavailable" }, + }); + return Effect.gen(function* () { + const error = yield* legacyFeedbackDelete(deleteArgs()).pipe(Effect.flip); + + expect(error).toMatchObject({ _tag: "FeedbackBackendError", operation: "preview" }); + expect(client.deleteCalls).toHaveLength(0); + expect(out.messages).not.toContainEqual(expect.objectContaining({ type: "success" })); + }).pipe(Effect.provide(layer)); + }); + + it.live("surfaces a backend failure during the delete", () => { + const { layer, out } = setupLegacyFeedbackDelete({ + client: { previewText: "doomed", deleteFailWith: "backend unavailable" }, + yes: true, + }); + return Effect.gen(function* () { + const error = yield* legacyFeedbackDelete(deleteArgs()).pipe(Effect.flip); + + expect(error).toMatchObject({ _tag: "FeedbackBackendError", operation: "delete" }); + expect(out.messages).not.toContainEqual(expect.objectContaining({ type: "success" })); + }).pipe(Effect.provide(layer)); + }); + + it.live("never sends the token or project ref value to PostHog", () => { + const { layer, analytics, client } = setupLegacyFeedbackDeleteHandler({ + yes: true, + args: ["feedback", "delete", TOKEN, "--project-ref", "abcdefghijklmnopqrst"], + }); + return Effect.gen(function* () { + yield* legacyFeedbackDeleteHandler( + deleteArgs({ projectRef: Option.some("abcdefghijklmnopqrst") }), + ); + + expect(client.deleteCalls).toHaveLength(1); + const events = analytics.captured.filter((c) => c.event === "cli_command_executed"); + expect(events).toHaveLength(1); + const serialized = JSON.stringify(events[0]); + // The token is a positional (structurally excluded from the flags map) + // and --project-ref has no telemetry-safe marking, so its value redacts. + expect(serialized).not.toContain(TOKEN); + expect(serialized).not.toContain("abcdefghijklmnopqrst"); + expect(Object.keys(events[0]?.properties.flags ?? {})).toEqual(["project-ref"]); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/feedback/feedback-project-ref.ts b/apps/cli/src/legacy/commands/feedback/feedback-project-ref.ts new file mode 100644 index 0000000000..c13d26fc17 --- /dev/null +++ b/apps/cli/src/legacy/commands/feedback/feedback-project-ref.ts @@ -0,0 +1,22 @@ +import { Effect, FileSystem, Option, Path } from "effect"; +import { legacyReadProjectRefFile } from "../../shared/legacy-temp-paths.ts"; + +// Mirrors the soft-load half of `LegacyProjectRefResolver.resolveOptional` +// (`legacy-project-ref.layer.ts`): the caller-supplied override (`--project-ref` +// and/or `SUPABASE_PROJECT_ID`, captured by `LegacyCliConfig`) → +// `/supabase/.temp/project-ref`, the file `supabase link` writes. The +// file is read directly rather than via the full resolver because that layer +// requires `LegacyPlatformApiFactory` for its prompt path, and feedback must +// keep working when the user isn't logged in. A broken ref file degrades to +// "unlinked" instead of failing the command. +export const legacyResolveFeedbackProjectRef = Effect.fnUntraced(function* ( + workdir: string, + fromEnv: Option.Option, +) { + if (Option.isSome(fromEnv)) return fromEnv; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* legacyReadProjectRefFile(fs, path, workdir).pipe( + Effect.orElseSucceed(() => Option.none()), + ); +}); diff --git a/apps/cli/src/legacy/commands/feedback/feedback.command.ts b/apps/cli/src/legacy/commands/feedback/feedback.command.ts index f71e5baa35..bf3a94f5ac 100644 --- a/apps/cli/src/legacy/commands/feedback/feedback.command.ts +++ b/apps/cli/src/legacy/commands/feedback/feedback.command.ts @@ -1,8 +1,9 @@ import { Command } from "effect/unstable/cli"; import { legacyFeedbackAddCommand } from "./add/add.command.ts"; +import { legacyFeedbackDeleteCommand } from "./delete/delete.command.ts"; export const legacyFeedbackCommand = Command.make("feedback").pipe( Command.withDescription("Send feedback about the Supabase CLI to the Supabase team."), Command.withShortDescription("Send feedback to the Supabase team"), - Command.withSubcommands([legacyFeedbackAddCommand]), + Command.withSubcommands([legacyFeedbackAddCommand, legacyFeedbackDeleteCommand]), ); diff --git a/apps/cli/src/legacy/commands/feedback/feedback.layers.ts b/apps/cli/src/legacy/commands/feedback/feedback.layers.ts new file mode 100644 index 0000000000..ef0658a7bd --- /dev/null +++ b/apps/cli/src/legacy/commands/feedback/feedback.layers.ts @@ -0,0 +1,19 @@ +import { Effect, Layer } from "effect"; +import { + feedbackClientLayer, + legacyFeedbackEnvironment, +} from "../../../shared/feedback/feedback-client.layer.ts"; +import { legacyCliConfigLayer } from "../../config/legacy-cli-config.layer.ts"; +import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; +import { legacyDebugLoggerLayer } from "../../shared/legacy-debug-logger.layer.ts"; + +export const legacyFeedbackCliConfigLayer = legacyCliConfigLayer.pipe( + Layer.provide(legacyDebugLoggerLayer), +); + +export const legacyFeedbackClientLayer = Layer.unwrap( + Effect.gen(function* () { + const config = yield* LegacyCliConfig; + return feedbackClientLayer({ environment: legacyFeedbackEnvironment(config.profile) }); + }), +).pipe(Layer.provide(legacyFeedbackCliConfigLayer)); diff --git a/apps/cli/src/shared/feedback/feedback-client.integration.test.ts b/apps/cli/src/shared/feedback/feedback-client.integration.test.ts new file mode 100644 index 0000000000..925abf25d4 --- /dev/null +++ b/apps/cli/src/shared/feedback/feedback-client.integration.test.ts @@ -0,0 +1,280 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Option } from "effect"; +import { feedbackClientLayer } from "./feedback-client.layer.ts"; +import type { FeedbackSubmission } from "./feedback-client.service.ts"; +import { FeedbackClient } from "./feedback-client.service.ts"; + +// The layer only needs a url/key shape — no request leaves the test, so the +// values are arbitrary. +const TEST_ENV = { + url: "https://feedback-project.supabase.co", + key: "sb_publishable_test_key", +}; + +const TOKEN = "123e4567-e89b-12d3-a456-426614174000"; +const PROJECT_REF = "abcdefghijklmnopqrst"; + +const SUBMISSION: FeedbackSubmission = { + message: "port conflicts when running two stacks", + projectRef: PROJECT_REF, + context: { + cliVersion: "9.9.9", + userAgent: "SupabaseCLI/9.9.9", + os: "darwin", + arch: "arm64", + isAgent: true, + agentName: "claude_code", + }, +}; + +// A recording fetch injected through supabase-js's `global.fetch` option — the +// PostgREST request goes through this instead of the network. `preconnect` is +// part of Bun's `typeof fetch` and must exist on the impostor too. +function recordingFetch( + respond: (request: Request) => Response | Promise = () => + new Response(null, { status: 201 }), +) { + const requests: Array<{ request: Request; bodyText: string }> = []; + const fetch: typeof globalThis.fetch = Object.assign( + async (input: string | URL | Request, init?: RequestInit) => { + const request = + input instanceof Request ? new Request(input, init) : new Request(String(input), init); + requests.push({ request, bodyText: await request.clone().text() }); + return respond(request); + }, + { preconnect: () => Promise.resolve() }, + ); + return { fetch, requests }; +} + +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function layerWith(transport: ReturnType) { + return feedbackClientLayer({ environment: TEST_ENV, fetch: transport.fetch }); +} + +describe("feedbackClientLayer", () => { + describe("submit", () => { + it.live("submits through the RPC and returns the server-issued delete token", () => { + const transport = recordingFetch(() => jsonResponse(TOKEN)); + return Effect.gen(function* () { + const client = yield* FeedbackClient; + const receipt = yield* client.submit(SUBMISSION); + + expect(receipt).toEqual({ deleteToken: TOKEN }); + expect(transport.requests).toHaveLength(1); + const { request, bodyText } = transport.requests[0]!; + expect(request.method).toBe("POST"); + expect(request.url).toBe(`${TEST_ENV.url}/rest/v1/rpc/submit_interfaces_feedback`); + expect(request.headers.get("apikey")).toBe(TEST_ENV.key); + expect(JSON.parse(bodyText)).toEqual({ + feedback: "port conflicts when running two stacks", + user_agent: "SupabaseCLI/9.9.9", + project_ref: PROJECT_REF, + metadata: { + cli_version: "9.9.9", + source: "cli", + os: "darwin", + arch: "arm64", + is_agent: true, + agent_name: "claude_code", + }, + }); + }).pipe(Effect.provide(layerWith(transport))); + }); + + it.live("omits project_ref and agent_name for an unlinked non-agent run", () => { + const transport = recordingFetch(() => jsonResponse(TOKEN)); + return Effect.gen(function* () { + const client = yield* FeedbackClient; + yield* client.submit({ + message: "no project linked", + context: { + cliVersion: "9.9.9", + userAgent: "SupabaseCLI/9.9.9", + os: "linux", + arch: "x64", + isAgent: false, + }, + }); + + const body = JSON.parse(transport.requests[0]!.bodyText); + // The RPC's `project_ref` parameter defaults to null server-side; the + // CLI simply leaves it (and `user_id`) out of the call. + expect(body).not.toHaveProperty("project_ref"); + expect(body).not.toHaveProperty("user_id"); + expect(body.metadata).toEqual({ + cli_version: "9.9.9", + source: "cli", + os: "linux", + arch: "x64", + is_agent: false, + }); + }).pipe(Effect.provide(layerWith(transport))); + }); + + it.live("maps a PostgREST error response to FeedbackBackendError", () => { + // Shape PostgREST returns when execute is denied on the RPC. + const transport = recordingFetch(() => + jsonResponse( + { + message: "permission denied for function submit_interfaces_feedback", + code: "42501", + details: null, + hint: null, + }, + 401, + ), + ); + return Effect.gen(function* () { + const client = yield* FeedbackClient; + const error = yield* client.submit(SUBMISSION).pipe(Effect.flip); + + expect(error._tag).toBe("FeedbackBackendError"); + expect(error.operation).toBe("submit"); + expect(error.message).toContain("permission denied"); + }).pipe(Effect.provide(layerWith(transport))); + }); + + it.live("maps a network failure to FeedbackBackendError", () => { + const transport = recordingFetch(() => { + throw new Error("network down"); + }); + return Effect.gen(function* () { + const client = yield* FeedbackClient; + const error = yield* client.submit(SUBMISSION).pipe(Effect.flip); + + expect(error._tag).toBe("FeedbackBackendError"); + expect(error.operation).toBe("submit"); + }).pipe(Effect.provide(layerWith(transport))); + }); + + it.live("fails when the backend returns no delete token", () => { + const transport = recordingFetch(() => jsonResponse(null)); + return Effect.gen(function* () { + const client = yield* FeedbackClient; + const error = yield* client.submit(SUBMISSION).pipe(Effect.flip); + + expect(error._tag).toBe("FeedbackBackendError"); + expect(error.message).toContain("no delete token"); + }).pipe(Effect.provide(layerWith(transport))); + }); + }); + + describe("preview", () => { + it.live("requests the feedback text with the token filter and capability headers", () => { + const transport = recordingFetch(() => jsonResponse([{ feedback: "my papercut" }])); + return Effect.gen(function* () { + const client = yield* FeedbackClient; + const preview = yield* client.preview(TOKEN, PROJECT_REF); + + expect(preview).toEqual(Option.some("my papercut")); + const { request } = transport.requests[0]!; + expect(request.method).toBe("GET"); + const url = new URL(request.url); + expect(url.pathname).toBe("/rest/v1/interfaces_feedback"); + expect(url.searchParams.get("select")).toBe("feedback"); + expect(url.searchParams.get("delete_token")).toBe(`eq.${TOKEN}`); + expect(request.headers.get("apikey")).toBe(TEST_ENV.key); + expect(request.headers.get("x-feedback-token")).toBe(TOKEN); + expect(request.headers.get("x-feedback-project-ref")).toBe(PROJECT_REF); + }).pipe(Effect.provide(layerWith(transport))); + }); + + it.live("sends no project-ref header when no ref is provided", () => { + const transport = recordingFetch(() => jsonResponse([{ feedback: "context-free" }])); + return Effect.gen(function* () { + const client = yield* FeedbackClient; + yield* client.preview(TOKEN); + + const { request } = transport.requests[0]!; + expect(request.headers.get("x-feedback-token")).toBe(TOKEN); + expect(request.headers.has("x-feedback-project-ref")).toBe(false); + }).pipe(Effect.provide(layerWith(transport))); + }); + + it.live("returns None when the token matches no row", () => { + const transport = recordingFetch(() => jsonResponse([])); + return Effect.gen(function* () { + const client = yield* FeedbackClient; + const preview = yield* client.preview(TOKEN, PROJECT_REF); + + expect(Option.isNone(preview)).toBe(true); + }).pipe(Effect.provide(layerWith(transport))); + }); + + // A thrown-fetch variant would work too, but postgrest-js retries + // idempotent GETs on network errors with ~7s of backoff — a non-retryable + // PostgREST error response exercises the same mapping without the wait. + it.live("maps a PostgREST error response to FeedbackBackendError", () => { + const transport = recordingFetch(() => + jsonResponse( + { message: "canceling statement due to statement timeout", code: "57014" }, + 500, + ), + ); + return Effect.gen(function* () { + const client = yield* FeedbackClient; + const error = yield* client.preview(TOKEN).pipe(Effect.flip); + + expect(error._tag).toBe("FeedbackBackendError"); + expect(error.operation).toBe("preview"); + }).pipe(Effect.provide(layerWith(transport))); + }); + }); + + describe("delete", () => { + it.live("deletes with the token filter, capability headers, and an exact count", () => { + const transport = recordingFetch( + () => new Response(null, { status: 204, headers: { "content-range": "*/1" } }), + ); + return Effect.gen(function* () { + const client = yield* FeedbackClient; + const result = yield* client.delete(TOKEN, PROJECT_REF); + + expect(result).toEqual({ deleted: true }); + const { request } = transport.requests[0]!; + expect(request.method).toBe("DELETE"); + const url = new URL(request.url); + expect(url.pathname).toBe("/rest/v1/interfaces_feedback"); + expect(url.searchParams.get("delete_token")).toBe(`eq.${TOKEN}`); + expect(request.headers.get("prefer")).toContain("count=exact"); + expect(request.headers.get("x-feedback-token")).toBe(TOKEN); + expect(request.headers.get("x-feedback-project-ref")).toBe(PROJECT_REF); + }).pipe(Effect.provide(layerWith(transport))); + }); + + it.live("reports deleted: false when the delete matched zero rows", () => { + // Wrong/stale token or a project-ref context mismatch: RLS matches + // nothing and PostgREST reports an empty range. + const transport = recordingFetch( + () => new Response(null, { status: 204, headers: { "content-range": "*/0" } }), + ); + return Effect.gen(function* () { + const client = yield* FeedbackClient; + const result = yield* client.delete(TOKEN); + + expect(result).toEqual({ deleted: false }); + expect(transport.requests[0]!.request.headers.has("x-feedback-project-ref")).toBe(false); + }).pipe(Effect.provide(layerWith(transport))); + }); + + it.live("maps a network failure to FeedbackBackendError", () => { + const transport = recordingFetch(() => { + throw new Error("network down"); + }); + return Effect.gen(function* () { + const client = yield* FeedbackClient; + const error = yield* client.delete(TOKEN).pipe(Effect.flip); + + expect(error._tag).toBe("FeedbackBackendError"); + expect(error.operation).toBe("delete"); + }).pipe(Effect.provide(layerWith(transport))); + }); + }); +}); diff --git a/apps/cli/src/shared/feedback/feedback-client.layer.ts b/apps/cli/src/shared/feedback/feedback-client.layer.ts new file mode 100644 index 0000000000..a346868c27 --- /dev/null +++ b/apps/cli/src/shared/feedback/feedback-client.layer.ts @@ -0,0 +1,155 @@ +import { createClient } from "@supabase/supabase-js"; +import { Effect, Layer, Option } from "effect"; +import type { Database } from "./database.types.ts"; +import type { FeedbackSubmission } from "./feedback-client.service.ts"; +import { FeedbackBackendError, FeedbackClient } from "./feedback-client.service.ts"; + +/** + * Feedback backend connection config. The keys are publishable (anon) keys, + * safe to commit. Submissions go exclusively through the SECURITY DEFINER + * `submit_interfaces_feedback` RPC (there is no insert grant on the table), + * which returns a server-generated delete token exactly once. Reads and + * deletes are gated by RLS policies that compare the row's `delete_token` + * against the `x-feedback-token` request header — plus a matching + * `x-feedback-project-ref` header when the row was submitted with one. + */ +interface FeedbackEnvironment { + readonly url: string; + readonly key: string; +} + +const FEEDBACK_STAGING: FeedbackEnvironment = { + url: "https://imrwaufzgcaczqmpnxyr.supabase.co", + key: "sb_publishable_puOyAlqG5J_XfBMTDM2Ckw_L5mieFdb", +}; + +// No dedicated production feedback project exists yet (CLI-1946): production +// intentionally reuses the staging values until one is provisioned. +const FEEDBACK_PRODUCTION: FeedbackEnvironment = { ...FEEDBACK_STAGING }; + +const REQUEST_TIMEOUT_MS = 10_000; + +interface FeedbackClientOptions { + readonly environment: FeedbackEnvironment; + /** Injectable transport for hermetic tests, like `legacyDohFetch`'s `innerFetch`. */ + readonly fetch?: typeof globalThis.fetch; +} + +// Profile → feedback environment, mirroring how the Management API url follows +// the resolved profile: staging profiles post to the staging project, with a +// production fallback for unknown and YAML-file profiles (`legacy-profile.ts`). +export function legacyFeedbackEnvironment(profile: string): FeedbackEnvironment { + switch (profile) { + case "supabase-staging": + case "supabase-local": + return FEEDBACK_STAGING; + default: + return FEEDBACK_PRODUCTION; + } +} + +type RpcArgs = Database["public"]["Functions"]["submit_interfaces_feedback"]["Args"]; + +// `user_id` is deliberately never sent: the CLI has no user-scoped identity in +// this flow, and omitting it keeps the row's delete authorization token-only. +function toRpcArgs(submission: FeedbackSubmission): RpcArgs { + const { context } = submission; + return { + feedback: submission.message, + user_agent: context.userAgent, + ...(submission.projectRef === undefined ? {} : { project_ref: submission.projectRef }), + metadata: { + cli_version: context.cliVersion, + source: "cli", + os: context.os, + arch: context.arch, + is_agent: context.isAgent, + ...(context.agentName === undefined ? {} : { agent_name: context.agentName }), + }, + }; +} + +export function feedbackClientLayer(options: FeedbackClientOptions): Layer.Layer { + return Layer.sync(FeedbackClient, () => { + const client = createClient(options.environment.url, options.environment.key, { + auth: { persistSession: false }, + global: options.fetch === undefined ? {} : { fetch: options.fetch }, + }); + + // PostgREST reports failures as a returned `error`, not a rejection; a + // thrown/timed-out fetch rejects. Both map to `FeedbackBackendError`. + const run = ( + operation: FeedbackBackendError["operation"], + request: () => PromiseLike<{ + data: A; + error: { message: string } | null; + count?: number | null; + }>, + ) => + Effect.tryPromise({ + try: request, + catch: (cause) => + new FeedbackBackendError({ + message: cause instanceof Error ? cause.message : String(cause), + operation, + }), + }).pipe( + Effect.flatMap((response) => + response.error === null + ? Effect.succeed(response) + : Effect.fail(new FeedbackBackendError({ message: response.error.message, operation })), + ), + ); + + return FeedbackClient.of({ + submit: (submission) => + run("submit", () => + client + .rpc("submit_interfaces_feedback", toRpcArgs(submission)) + .abortSignal(AbortSignal.timeout(REQUEST_TIMEOUT_MS)), + ).pipe( + Effect.flatMap(({ data }) => + typeof data === "string" && data.length > 0 + ? Effect.succeed({ deleteToken: data }) + : Effect.fail( + new FeedbackBackendError({ + message: "feedback backend returned no delete token", + operation: "submit", + }), + ), + ), + ), + + preview: (token, projectRef) => + run("preview", () => { + const request = client + .from("interfaces_feedback") + .select("feedback") + .eq("delete_token", token) + .setHeader("x-feedback-token", token) + .abortSignal(AbortSignal.timeout(REQUEST_TIMEOUT_MS)); + return projectRef === undefined + ? request + : request.setHeader("x-feedback-project-ref", projectRef); + }).pipe(Effect.map(({ data }) => Option.fromNullishOr(data?.[0]?.feedback))), + + delete: (token, projectRef) => + run("delete", () => { + // The `delete_token=eq.` filter satisfies PostgREST's filterless-delete + // rejection; the `x-feedback-token` header is the actual security + // boundary (RLS matches zero rows without it). `count: "exact"` asks + // for a Content-Range so the caller can tell a matched delete from a + // zero-row one. + const request = client + .from("interfaces_feedback") + .delete({ count: "exact" }) + .eq("delete_token", token) + .setHeader("x-feedback-token", token) + .abortSignal(AbortSignal.timeout(REQUEST_TIMEOUT_MS)); + return projectRef === undefined + ? request + : request.setHeader("x-feedback-project-ref", projectRef); + }).pipe(Effect.map(({ count }) => ({ deleted: count === 1 }))), + }); + }); +} diff --git a/apps/cli/src/shared/feedback/feedback-client.service.ts b/apps/cli/src/shared/feedback/feedback-client.service.ts new file mode 100644 index 0000000000..58c3f5d356 --- /dev/null +++ b/apps/cli/src/shared/feedback/feedback-client.service.ts @@ -0,0 +1,68 @@ +import type { Effect, Option } from "effect"; +import { Context, Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; + +/** Environment details attached to every submission alongside the message. */ +interface FeedbackContext { + readonly cliVersion: string; + readonly userAgent: string; + readonly os: string; + readonly arch: string; + readonly isAgent: boolean; + readonly agentName?: string; +} + +export interface FeedbackSubmission { + readonly message: string; + readonly projectRef?: string; + readonly context: FeedbackContext; +} + +/** + * Returned once per submission: the server-generated token that authorizes + * deleting the row later. Never persisted by the CLI — shown to the user and + * then forgotten. + */ +interface FeedbackSubmitReceipt { + readonly deleteToken: string; +} + +/** A rejected request (PostgREST error) or a failed/timed-out network call. */ +export class FeedbackBackendError extends Data.TaggedError("FeedbackBackendError")<{ + readonly message: string; + readonly operation: "submit" | "preview" | "delete"; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // Both branches (PostgREST rejection, network failure/timeout) are + // failures of the external feedback backend, not user mistakes. + return actionability.externalNetwork; + } +} + +interface FeedbackClientShape { + readonly submit: ( + submission: FeedbackSubmission, + ) => Effect.Effect; + /** + * The feedback text of the row the token unlocks, or `None` when no row + * matches (wrong token, already deleted, or a project-ref context mismatch — + * the backend cannot distinguish these). + */ + readonly preview: ( + token: string, + projectRef?: string, + ) => Effect.Effect, FeedbackBackendError>; + /** `deleted: false` means the delete matched zero rows (same causes as `preview` → `None`). */ + readonly delete: ( + token: string, + projectRef?: string, + ) => Effect.Effect<{ readonly deleted: boolean }, FeedbackBackendError>; +} + +export class FeedbackClient extends Context.Service()( + "supabase/feedback/FeedbackClient", +) {} diff --git a/apps/cli/src/shared/feedback/feedback-submitter.integration.test.ts b/apps/cli/src/shared/feedback/feedback-submitter.integration.test.ts deleted file mode 100644 index 2244035b7a..0000000000 --- a/apps/cli/src/shared/feedback/feedback-submitter.integration.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { Effect } from "effect"; -import { FEEDBACK_STAGING, feedbackSubmitterLayer } from "./feedback-submitter.layer.ts"; -import type { FeedbackSubmission } from "./feedback-submitter.service.ts"; -import { FeedbackSubmitter } from "./feedback-submitter.service.ts"; - -const SUBMISSION: FeedbackSubmission = { - message: "port conflicts when running two stacks", - projectRef: "abcdefghijklmnopqrst", - context: { - cliVersion: "9.9.9", - userAgent: "SupabaseCLI/9.9.9", - os: "darwin", - arch: "arm64", - isAgent: true, - agentName: "claude_code", - }, -}; - -// A recording fetch injected through supabase-js's `global.fetch` option — the -// PostgREST request goes through this instead of the network. `preconnect` is -// part of Bun's `typeof fetch` and must exist on the impostor too. -function recordingFetch( - respond: (request: Request) => Response | Promise = () => - new Response(null, { status: 201 }), -) { - const requests: Array<{ request: Request; bodyText: string }> = []; - const fetch: typeof globalThis.fetch = Object.assign( - async (input: string | URL | Request, init?: RequestInit) => { - const request = - input instanceof Request ? new Request(input, init) : new Request(String(input), init); - requests.push({ request, bodyText: await request.clone().text() }); - return respond(request); - }, - { preconnect: () => Promise.resolve() }, - ); - return { fetch, requests }; -} - -describe("feedbackSubmitterLayer", () => { - it.live("posts the submission as an interfaces_feedback row", () => { - const transport = recordingFetch(); - const layer = feedbackSubmitterLayer({ - environment: FEEDBACK_STAGING, - fetch: transport.fetch, - }); - return Effect.gen(function* () { - const submitter = yield* FeedbackSubmitter; - yield* submitter.submit(SUBMISSION); - - expect(transport.requests).toHaveLength(1); - const { request, bodyText } = transport.requests[0]!; - expect(request.method).toBe("POST"); - expect(request.url).toBe(`${FEEDBACK_STAGING.url}/rest/v1/interfaces_feedback`); - expect(request.headers.get("apikey")).toBe(FEEDBACK_STAGING.key); - expect(JSON.parse(bodyText)).toEqual({ - feedback: "port conflicts when running two stacks", - source: "cli", - user_agent: "SupabaseCLI/9.9.9", - project_ref: "abcdefghijklmnopqrst", - metadata: { - cli_version: "9.9.9", - os: "darwin", - arch: "arm64", - is_agent: true, - agent_name: "claude_code", - }, - }); - }).pipe(Effect.provide(layer)); - }); - - it.live("sends null project_ref and no agent_name for an unlinked non-agent run", () => { - const transport = recordingFetch(); - const layer = feedbackSubmitterLayer({ - environment: FEEDBACK_STAGING, - fetch: transport.fetch, - }); - return Effect.gen(function* () { - const submitter = yield* FeedbackSubmitter; - yield* submitter.submit({ - message: "no project linked", - context: { - cliVersion: "9.9.9", - userAgent: "SupabaseCLI/9.9.9", - os: "linux", - arch: "x64", - isAgent: false, - }, - }); - - const body = JSON.parse(transport.requests[0]!.bodyText); - expect(body.project_ref).toBeNull(); - expect(body.metadata).toEqual({ - cli_version: "9.9.9", - os: "linux", - arch: "x64", - is_agent: false, - }); - }).pipe(Effect.provide(layer)); - }); - - it.live("maps a PostgREST error response to FeedbackSubmitError", () => { - // Shape PostgREST returns for an RLS denial. - const transport = recordingFetch( - () => - new Response( - JSON.stringify({ - message: 'new row violates row-level security policy for table "interfaces_feedback"', - code: "42501", - details: null, - hint: null, - }), - { status: 401, headers: { "content-type": "application/json" } }, - ), - ); - const layer = feedbackSubmitterLayer({ - environment: FEEDBACK_STAGING, - fetch: transport.fetch, - }); - return Effect.gen(function* () { - const submitter = yield* FeedbackSubmitter; - const error = yield* submitter.submit(SUBMISSION).pipe(Effect.flip); - - expect(error._tag).toBe("FeedbackSubmitError"); - expect(error.message).toContain("row-level security"); - }).pipe(Effect.provide(layer)); - }); - - it.live("maps a network failure to FeedbackSubmitError", () => { - const transport = recordingFetch(() => { - throw new Error("network down"); - }); - const layer = feedbackSubmitterLayer({ - environment: FEEDBACK_STAGING, - fetch: transport.fetch, - }); - return Effect.gen(function* () { - const submitter = yield* FeedbackSubmitter; - const error = yield* submitter.submit(SUBMISSION).pipe(Effect.flip); - - expect(error._tag).toBe("FeedbackSubmitError"); - }).pipe(Effect.provide(layer)); - }); -}); diff --git a/apps/cli/src/shared/feedback/feedback-submitter.layer.ts b/apps/cli/src/shared/feedback/feedback-submitter.layer.ts deleted file mode 100644 index 05a55af8e4..0000000000 --- a/apps/cli/src/shared/feedback/feedback-submitter.layer.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { createClient } from "@supabase/supabase-js"; -import { Effect, Layer } from "effect"; -import type { Database, TablesInsert } from "./database.types.ts"; -import type { FeedbackSubmission } from "./feedback-submitter.service.ts"; -import { FeedbackSubmitError, FeedbackSubmitter } from "./feedback-submitter.service.ts"; - -/** - * Feedback backend connection config. The keys are publishable (anon) keys, - * safe to commit — writes are gated by insert-only RLS on the - * `interfaces_feedback` table, exactly like the docs feedback widget. - */ -interface FeedbackEnvironment { - readonly url: string; - readonly key: string; -} - -const FEEDBACK_STAGING: FeedbackEnvironment = { - url: "https://imrwaufzgcaczqmpnxyr.supabase.co", - key: "sb_publishable_puOyAlqG5J_XfBMTDM2Ckw_L5mieFdb", -}; - -// No dedicated production feedback project exists yet (CLI-1946): production -// intentionally reuses the staging values until one is provisioned. -const FEEDBACK_PRODUCTION: FeedbackEnvironment = { ...FEEDBACK_STAGING }; - -const SUBMIT_TIMEOUT_MS = 10_000; - -interface FeedbackSubmitterOptions { - readonly environment: FeedbackEnvironment; - /** Injectable transport for hermetic tests, like `legacyDohFetch`'s `innerFetch`. */ - readonly fetch?: typeof globalThis.fetch; -} - -// Profile → feedback environment, mirroring how the Management API url follows -// the resolved profile: staging profiles post to the staging project, with a -// production fallback for unknown and YAML-file profiles (`legacy-profile.ts`). -export function legacyFeedbackEnvironment(profile: string): FeedbackEnvironment { - switch (profile) { - case "supabase-staging": - case "supabase-local": - return FEEDBACK_STAGING; - default: - return FEEDBACK_PRODUCTION; - } -} - -function toInsertRow(submission: FeedbackSubmission): TablesInsert<"interfaces_feedback"> { - const { context } = submission; - return { - feedback: submission.message, - user_agent: context.userAgent, - project_ref: submission.projectRef ?? null, - metadata: { - cli_version: context.cliVersion, - source: "cli", - os: context.os, - arch: context.arch, - is_agent: context.isAgent, - ...(context.agentName === undefined ? {} : { agent_name: context.agentName }), - }, - }; -} - -export function feedbackSubmitterLayer( - options: FeedbackSubmitterOptions, -): Layer.Layer { - return Layer.sync(FeedbackSubmitter, () => { - const client = createClient(options.environment.url, options.environment.key, { - auth: { persistSession: false }, - global: options.fetch === undefined ? {} : { fetch: options.fetch }, - }); - - return FeedbackSubmitter.of({ - submit: (submission) => - Effect.tryPromise({ - try: () => - client - .from("interfaces_feedback") - .insert(toInsertRow(submission)) - .abortSignal(AbortSignal.timeout(SUBMIT_TIMEOUT_MS)), - catch: (cause) => - new FeedbackSubmitError({ - message: cause instanceof Error ? cause.message : String(cause), - }), - }).pipe( - // PostgREST reports failures as a returned `error`, not a rejection. - Effect.flatMap(({ error }) => - error === null - ? Effect.void - : Effect.fail(new FeedbackSubmitError({ message: error.message })), - ), - ), - }); - }); -} diff --git a/apps/cli/src/shared/feedback/feedback-submitter.service.ts b/apps/cli/src/shared/feedback/feedback-submitter.service.ts deleted file mode 100644 index 1fb65eb15f..0000000000 --- a/apps/cli/src/shared/feedback/feedback-submitter.service.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { Effect } from "effect"; -import { Context, Data } from "effect"; -import { - actionability, - type CliErrorActionabilityDeclaration, - ErrorActionabilityId, -} from "../telemetry/error-actionability.ts"; - -/** Environment details attached to every submission alongside the message. */ -interface FeedbackContext { - readonly cliVersion: string; - readonly userAgent: string; - readonly os: string; - readonly arch: string; - readonly isAgent: boolean; - readonly agentName?: string; -} - -export interface FeedbackSubmission { - readonly message: string; - readonly projectRef?: string; - readonly context: FeedbackContext; -} - -/** A rejected insert (PostgREST error) or a failed/timed-out network call. */ -export class FeedbackSubmitError extends Data.TaggedError("FeedbackSubmitError")<{ - readonly message: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - // Both branches (PostgREST rejection, network failure/timeout) are - // failures of the external feedback backend, not user mistakes. - return actionability.externalNetwork; - } -} - -interface FeedbackSubmitterShape { - readonly submit: (submission: FeedbackSubmission) => Effect.Effect; -} - -export class FeedbackSubmitter extends Context.Service()( - "supabase/feedback/FeedbackSubmitter", -) {} From c4ae2b29e52761845df9142ae5037820456494a3 Mon Sep 17 00:00:00 2001 From: kanad Date: Fri, 14 Aug 2026 14:07:37 -0700 Subject: [PATCH 14/21] feat(cli): include supabase user id in feedback context payload (#6190) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Attach the gotrue user UUID to `supabase feedback add` submissions via the `submit_interfaces_feedback` RPC's optional `user_id` parameter, mirroring the project-ref convention: best-effort and never failing the submission. The id is sourced from the persisted telemetry identity (distinct_id in ~/.supabase/telemetry.json, stamped at login) — a synchronous in-memory read, so the command keeps its zero-auth posture and works logged-out (user_id omitted). Submit-side attribution is gated on telemetry consent: opted-out users submit anonymously. No deviceId fallback — user_id is semantically a gotrue UUID, not an anonymous device id. Because the `interfaces_feedback` RLS policies require a matching `x-feedback-user-id` header to read or delete a row that was submitted with a `user_id`, `feedback delete` now presents the persisted id on both the preview and the delete requests. Unlike submission, the header is **not** consent-gated — it is functional auth context, and gating it would strand rows submitted before a consent opt-out. Logged-out runs omit the header, which still matches all anonymous rows. Stacked on #5988 (base: `kanad-claude-2026-07-22/feedback-command`); this is a clean reapplication of the change from #5998 onto the RPC-based `FeedbackClient`. ## Linked issue closes [CLI-2008](https://linear.app/supabase/issue/CLI-2008) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 --- apps/cli/docs/go-cli-divergences.md | 2 +- .../commands/feedback/add/SIDE_EFFECTS.md | 23 +++--- .../commands/feedback/add/add.handler.ts | 7 ++ .../feedback/add/add.integration.test.ts | 43 ++++++++++- .../commands/feedback/delete/SIDE_EFFECTS.md | 40 +++++++---- .../commands/feedback/delete/delete.errors.ts | 11 +-- .../feedback/delete/delete.handler.ts | 16 ++++- .../delete/delete.integration.test.ts | 71 +++++++++++++++++-- .../feedback-client.integration.test.ts | 19 +++-- .../shared/feedback/feedback-client.layer.ts | 35 +++++---- .../feedback/feedback-client.service.ts | 24 +++++-- 11 files changed, 232 insertions(+), 59 deletions(-) diff --git a/apps/cli/docs/go-cli-divergences.md b/apps/cli/docs/go-cli-divergences.md index 9575203cc6..535ad151d4 100644 --- a/apps/cli/docs/go-cli-divergences.md +++ b/apps/cli/docs/go-cli-divergences.md @@ -14,7 +14,7 @@ These commands exist in the TS CLI today but have no direct top-level equivalent | ----------------- | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `dev` | `planned` | Reserved for a TS-native long-running local development workflow command that watches files and orchestrates subcommands. Track this as TS-only unless a direct Go equivalent emerges. | | `feedback add` | [`../src/legacy/commands/feedback/add/add.command.ts`](../src/legacy/commands/feedback/add/add.command.ts) | Quick feedback submission, legacy shell only (CLI-1946). Submits through the `submit_interfaces_feedback` RPC via supabase-js with a committed publishable key and prints the server-issued delete token once; profile-driven staging/production environments (production currently reuses staging). | -| `feedback delete` | [`../src/legacy/commands/feedback/delete/delete.command.ts`](../src/legacy/commands/feedback/delete/delete.command.ts) | Deletes previously submitted feedback using the token printed by `feedback add` (CLI-2188). Previews the feedback text, then hard-deletes via the token-gated RLS policy (`x-feedback-token` + optional `x-feedback-project-ref` headers). | +| `feedback delete` | [`../src/legacy/commands/feedback/delete/delete.command.ts`](../src/legacy/commands/feedback/delete/delete.command.ts) | Deletes previously submitted feedback using the token printed by `feedback add` (CLI-2188). Previews the feedback text, then hard-deletes via the token-gated RLS policy (`x-feedback-token` + optional `x-feedback-project-ref` / `x-feedback-user-id` headers). | | `logs` | [`../src/next/commands/logs/logs.command.ts`](../src/next/commands/logs/logs.command.ts) | Streams local stack logs. No top-level `logs` command exists in the old Go CLI reference. | | `api` | [`../src/next/commands/platform/api.command.ts`](../src/next/commands/platform/api.command.ts) | Low-level Management API client. It supersedes the old generated tree with explicit discovery via `supabase api routes` and execution via `supabase api request [--method ]`. | | `stack` | [`../src/next/cli/root.ts`](../src/next/cli/root.ts) | TS-only local runtime namespace exposing `stack start`, `stack stop`, `stack status`, `stack list`, and `stack update`. Top-level `start`, `stop`, and `status` remain aliases. | diff --git a/apps/cli/src/legacy/commands/feedback/add/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/feedback/add/SIDE_EFFECTS.md index 5c5c12b674..98f79494ed 100644 --- a/apps/cli/src/legacy/commands/feedback/add/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/feedback/add/SIDE_EFFECTS.md @@ -2,11 +2,12 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase/profile` | plain text (profile name) | when `--profile` and `SUPABASE_PROFILE` are unset (profile resolution via `legacyCliConfigLayer`) | -| `$SUPABASE_PROFILE` | YAML (`api_url:` / `gotrue_url:` …) | when `SUPABASE_PROFILE` is set to a file path instead of a built-in profile name | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `SUPABASE_PROJECT_ID` is unset — supplies the submission's `project_ref`. Absent, blank, or unreadable → `null` (never fails the submission) | +| Path | Format | When | +| ----------------------------------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/profile` | plain text (profile name) | when `--profile` and `SUPABASE_PROFILE` are unset (profile resolution via `legacyCliConfigLayer`) | +| `$SUPABASE_PROFILE` | YAML (`api_url:` / `gotrue_url:` …) | when `SUPABASE_PROFILE` is set to a file path instead of a built-in profile name | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `SUPABASE_PROJECT_ID` is unset — supplies the submission's `project_ref`. Absent, blank, or unreadable → `null` (never fails the submission) | +| `/telemetry.json` | JSON (telemetry state) | read at startup by the shared telemetry runtime — its `distinct_id` (gotrue user id stamped at login) supplies the submission's `user_id` when telemetry consent is granted. Absent, logged-out, or consent-denied → omitted | ## Files Written @@ -16,9 +17,9 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ----------------------------------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------- | -| `POST` | `/rest/v1/rpc/submit_interfaces_feedback` | `apikey` = committed publishable key | `{ feedback, user_agent, project_ref (omitted when unlinked), 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` | `/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) | `` follows the resolved profile (`feedback.layers.ts`): `supabase-staging` / `supabase-local` → the staging feedback project; @@ -100,6 +101,12 @@ terminal, a "What's on your mind?" text prompt collects it first. - Submission context: CLI version, user agent (`SupabaseCLI/` from `LegacyCliConfig`), OS/arch, agent detection, and — when the workdir has a linked project — its project ref. The resolved access token is never sent. +- The persisted gotrue user id from `/telemetry.json` (`distinct_id`, + stamped at login) is sent as `user_id` when present **and** telemetry consent + is granted; opted-out or logged-out runs omit it. The lookup is a synchronous + in-memory read — no auth or network dependency is added. A row submitted with + a `user_id` additionally requires the matching `x-feedback-user-id` header to + preview/delete it later (`feedback delete` sends it automatically). - Project-ref resolution order: `SUPABASE_PROJECT_ID` → `/supabase/.temp/project-ref` (written by `supabase link`) → `null`. This mirrors the soft-load half of `LegacyProjectRefResolver.resolveOptional` diff --git a/apps/cli/src/legacy/commands/feedback/add/add.handler.ts b/apps/cli/src/legacy/commands/feedback/add/add.handler.ts index 882aea8036..c68518df6b 100644 --- a/apps/cli/src/legacy/commands/feedback/add/add.handler.ts +++ b/apps/cli/src/legacy/commands/feedback/add/add.handler.ts @@ -61,6 +61,13 @@ export const legacyFeedbackAdd = Effect.fn("legacy.feedback.add")(function* ( .submit({ message, projectRef: Option.getOrUndefined(projectRef), + // Gotrue user UUID stamped into ~/.supabase/telemetry.json at login (ADR + // 0013). A synchronous in-memory read — best-effort attribution with no + // auth/API/network dependency, so feedback keeps working logged-out + // (undefined → user_id omitted). Gated on telemetry consent: opted-out + // users submit anonymously. + userId: + telemetryRuntime.consent === "granted" ? telemetryRuntime.identity.current() : undefined, context: { cliVersion: telemetryRuntime.cliVersion, userAgent: cliConfig.userAgent, diff --git a/apps/cli/src/legacy/commands/feedback/add/add.integration.test.ts b/apps/cli/src/legacy/commands/feedback/add/add.integration.test.ts index f6ccdecb97..2bb99e8c79 100644 --- a/apps/cli/src/legacy/commands/feedback/add/add.integration.test.ts +++ b/apps/cli/src/legacy/commands/feedback/add/add.integration.test.ts @@ -83,6 +83,9 @@ function setupLegacyFeedback( submitFailWith?: string; /** Simulates `SUPABASE_PROJECT_ID`, the only source `LegacyCliConfig` reads. */ projectIdEnv?: string; + /** Simulates the gotrue user id persisted to telemetry.json at login. */ + distinctId?: string; + consent?: "granted" | "denied"; } = {}, ) { const out = mockOutput(opts.output); @@ -94,7 +97,11 @@ function setupLegacyFeedback( submitter.layer, mockStdin(opts.stdinIsTTY ?? true, opts.pipedInput), mockRuntimeInfo({ platform: "darwin", arch: "arm64" }), - mockTelemetryRuntime({ cliVersion: "9.9.9" }), + mockTelemetryRuntime({ + cliVersion: "9.9.9", + distinctId: opts.distinctId, + consent: opts.consent, + }), mockLegacyCliConfig({ workdir: tempRoot.current, userAgent: "SupabaseCLI/9.9.9", @@ -197,6 +204,40 @@ describe("legacy feedback add", () => { }).pipe(Effect.provide(layer)); }); + it.live("attaches the persisted gotrue user id when logged in", () => { + const { layer, submitter } = setupLegacyFeedback({ + distinctId: "11111111-2222-3333-4444-555555555555", + }); + return Effect.gen(function* () { + yield* legacyFeedbackAdd({ message: ["logged in feedback"] }); + + expect(submitter.submissions[0]?.userId).toBe("11111111-2222-3333-4444-555555555555"); + }).pipe(Effect.provide(layer)); + }); + + it.live("sends no user id when not logged in", () => { + const { layer, submitter } = setupLegacyFeedback(); + return Effect.gen(function* () { + yield* legacyFeedbackAdd({ message: ["logged out feedback"] }); + + expect(submitter.submissions[0]?.userId).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("sends no user id when telemetry consent is denied", () => { + // Submit-side attribution is consent-gated: opted-out users submit + // anonymously even when a persisted gotrue id exists. + const { layer, submitter } = setupLegacyFeedback({ + distinctId: "11111111-2222-3333-4444-555555555555", + consent: "denied", + }); + return Effect.gen(function* () { + yield* legacyFeedbackAdd({ message: ["opted out feedback"] }); + + expect(submitter.submissions[0]?.userId).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + it.live("sends no project ref when the workdir is not linked", () => { const { layer, submitter } = setupLegacyFeedback(); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/feedback/delete/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/feedback/delete/SIDE_EFFECTS.md index ec77a3dece..49a102c8a8 100644 --- a/apps/cli/src/legacy/commands/feedback/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/feedback/delete/SIDE_EFFECTS.md @@ -2,11 +2,12 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase/profile` | plain text (profile name) | when `--profile` and `SUPABASE_PROFILE` are unset (profile resolution via `legacyCliConfigLayer`) | -| `$SUPABASE_PROFILE` | YAML (`api_url:` / `gotrue_url:` …) | when `SUPABASE_PROFILE` is set to a file path instead of a built-in profile name | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` and `SUPABASE_PROJECT_ID` are unset — supplies the `x-feedback-project-ref` context. Absent, blank, or unreadable → header omitted (never fails the command) | +| Path | Format | When | +| ----------------------------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/profile` | plain text (profile name) | when `--profile` and `SUPABASE_PROFILE` are unset (profile resolution via `legacyCliConfigLayer`) | +| `$SUPABASE_PROFILE` | YAML (`api_url:` / `gotrue_url:` …) | when `SUPABASE_PROFILE` is set to a file path instead of a built-in profile name | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` and `SUPABASE_PROJECT_ID` are unset — supplies the `x-feedback-project-ref` context. Absent, blank, or unreadable → header omitted (never fails the command) | +| `/telemetry.json` | JSON (telemetry state) | read at startup by the shared telemetry runtime — its `distinct_id` (gotrue user id stamped at login) supplies the `x-feedback-user-id` context. Absent or logged-out → header omitted | ## Files Written @@ -16,19 +17,22 @@ ## API Routes -| Method | Path | Auth / headers | Request body | Response (used fields) | -| -------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ------------ | ------------------------------------------------------------ | -| `GET` | `/rest/v1/interfaces_feedback?select=feedback&delete_token=eq.` | `apikey` = committed publishable key; `x-feedback-token: `; `x-feedback-project-ref: ` when resolved | — | `[{ feedback }]` or `[]` — previews the text before deletion | -| `DELETE` | `/rest/v1/interfaces_feedback?delete_token=eq.` | same headers, plus `Prefer: count=exact` | — | `Content-Range: */1` (deleted) vs `*/0` (no row matched) | +| Method | Path | Auth / headers | Request body | Response (used fields) | +| -------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ------------------------------------------------------------ | +| `GET` | `/rest/v1/interfaces_feedback?select=feedback&delete_token=eq.` | `apikey` = committed publishable key; `x-feedback-token: `; `x-feedback-project-ref: ` when resolved; `x-feedback-user-id: ` when logged in | — | `[{ feedback }]` or `[]` — previews the text before deletion | +| `DELETE` | `/rest/v1/interfaces_feedback?delete_token=eq.` | same headers, plus `Prefer: count=exact` | — | `Content-Range: */1` (deleted) vs `*/0` (no row matched) | `` follows the resolved profile exactly as `feedback add` does (`feedback.layers.ts` / `src/shared/feedback/feedback-client.layer.ts`). The `delete_token=eq.` URL filter only satisfies PostgREST's filterless-delete rejection — the `x-feedback-token` header is the security boundary enforced by -RLS. Rows submitted with a `project_ref` additionally require the matching -`x-feedback-project-ref` header on both routes; sending it against a -context-free row is ignored server-side, so the CLI always sends whatever ref -resolves. Each request times out after 10 s. +RLS. Rows submitted with a `project_ref` and/or `user_id` additionally require +the matching `x-feedback-project-ref` / `x-feedback-user-id` header on both +routes; extra context against a context-free row is ignored server-side, so +the CLI always sends whatever resolves. The user-id header is NOT gated on +telemetry consent (unlike `feedback add`'s submit-side attribution) — it is +functional auth context, and gating it would strand rows submitted before a +consent opt-out. Each request times out after 10 s. ## Environment Variables @@ -48,7 +52,7 @@ Global telemetry consent env applies as with every command. | ---- | --------------------------------------------------------------------------------------------- | | `0` | feedback deleted | | `1` | token argument is not a UUID | -| `1` | no feedback matched (wrong token, already deleted, or project-ref context mismatch) | +| `1` | no feedback matched (wrong token, already deleted, or project-ref/user-id context mismatch) | | `1` | confirmation declined, or prompt unavailable (non-interactive / machine mode without `--yes`) | | `1` | backend failure (PostgREST error, network failure, or 10 s timeout) on preview or delete | @@ -111,3 +115,11 @@ Also requires `--yes`. with that same ref presented — rerun from the linked directory or pass `--project-ref`. This mirrors `feedback add`'s resolution and works logged-out (no auth dependency). +- A row submitted while logged in (with telemetry consent) carries a `user_id` + and can only be previewed/deleted while logged in as that same user — the + persisted `distinct_id` is presented automatically as `x-feedback-user-id`. + The lookup is a synchronous in-memory read; logged-out runs simply omit the + header, which still matches all anonymous rows. `supabase logout` wipes the + persisted identity, so a delete token for an attributed row reports "not + found" until the user logs back in as the same account (login re-stamps the + same gotrue UUID, restoring delete access). diff --git a/apps/cli/src/legacy/commands/feedback/delete/delete.errors.ts b/apps/cli/src/legacy/commands/feedback/delete/delete.errors.ts index 1311da8595..a0cfd05a7a 100644 --- a/apps/cli/src/legacy/commands/feedback/delete/delete.errors.ts +++ b/apps/cli/src/legacy/commands/feedback/delete/delete.errors.ts @@ -11,8 +11,9 @@ export const LEGACY_FEEDBACK_INVALID_TOKEN_MESSAGE = export const LEGACY_FEEDBACK_NOT_FOUND_MESSAGE = "No feedback found for this token. It may already be deleted, the token may be wrong, " + - "or the feedback was submitted from a linked project directory — rerun this command " + - "from that directory or pass --project-ref ."; + "or the feedback was submitted with project/user context that isn't present — rerun " + + "from the linked project directory (or pass --project-ref ) and log in as the " + + "account that submitted it."; export const LEGACY_FEEDBACK_DELETE_CANCELLED_MESSAGE = "Deletion cancelled."; @@ -27,9 +28,9 @@ export class LegacyFeedbackInvalidTokenError extends Data.TaggedError( /** * The token matched no row: wrong token, already deleted, or the row was - * submitted with a project ref that wasn't presented (`x-feedback-project-ref` - * context gate). The backend cannot distinguish these, so the message carries - * all three remediations. + * submitted with a project ref and/or user id that wasn't presented (the + * `x-feedback-project-ref` / `x-feedback-user-id` context gates). The backend + * cannot distinguish these, so the message carries all the remediations. */ export class LegacyFeedbackNotFoundError extends Data.TaggedError("LegacyFeedbackNotFoundError")<{ readonly message: string; diff --git a/apps/cli/src/legacy/commands/feedback/delete/delete.handler.ts b/apps/cli/src/legacy/commands/feedback/delete/delete.handler.ts index 20e867411b..88a07f7fc2 100644 --- a/apps/cli/src/legacy/commands/feedback/delete/delete.handler.ts +++ b/apps/cli/src/legacy/commands/feedback/delete/delete.handler.ts @@ -2,6 +2,7 @@ import { Effect, Option } from "effect"; import { FeedbackClient } from "../../../../shared/feedback/feedback-client.service.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { legacyResolveYes } from "../../../../shared/legacy/global-flags.ts"; +import { TelemetryRuntime } from "../../../../shared/telemetry/runtime.service.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { legacyResolveFeedbackProjectRef } from "../feedback-project-ref.ts"; import type { LegacyFeedbackDeleteArgs } from "./delete.command.ts"; @@ -24,6 +25,7 @@ export const legacyFeedbackDelete = Effect.fn("legacy.feedback.delete")(function ) { const output = yield* Output; const cliConfig = yield* LegacyCliConfig; + const telemetryRuntime = yield* TelemetryRuntime; const client = yield* FeedbackClient; if (!LEGACY_UUID_PATTERN.test(args.token)) { @@ -43,9 +45,19 @@ export const legacyFeedbackDelete = Effect.fn("legacy.feedback.delete")(function Option.orElse(args.projectRef, () => cliConfig.projectId), ).pipe(Effect.map(Option.getOrUndefined)); + // User-id context gate, same shape as the project-ref one: rows submitted + // with a user_id only match when the same id arrives as a header. Unlike + // the submit-side attribution this is NOT consent-gated — it is functional + // auth context, and gating it would strand rows submitted before a consent + // opt-out. Logged out → undefined → header omitted. + const rowContext = { + projectRef, + userId: telemetryRuntime.identity.current(), + }; + const looking = yield* output.task("Looking up feedback..."); const preview = yield* client - .preview(token, projectRef) + .preview(token, rowContext) .pipe(Effect.tapError(() => looking.fail())); yield* looking.clear(); @@ -79,7 +91,7 @@ export const legacyFeedbackDelete = Effect.fn("legacy.feedback.delete")(function const deleting = yield* output.task("Deleting feedback..."); const { deleted } = yield* client - .delete(token, projectRef) + .delete(token, rowContext) .pipe(Effect.tapError(() => deleting.fail())); yield* deleting.clear(); diff --git a/apps/cli/src/legacy/commands/feedback/delete/delete.integration.test.ts b/apps/cli/src/legacy/commands/feedback/delete/delete.integration.test.ts index 3c8281cce7..1ab88a8e4e 100644 --- a/apps/cli/src/legacy/commands/feedback/delete/delete.integration.test.ts +++ b/apps/cli/src/legacy/commands/feedback/delete/delete.integration.test.ts @@ -14,6 +14,7 @@ import { mockContextualAnalytics, mockOutput, mockProcessControl, + mockTelemetryRuntime, } from "../../../../../tests/helpers/mocks.ts"; import { LEGACY_VALID_REF, @@ -59,17 +60,23 @@ interface MockClientOpts { deleteFailWith?: string; } +interface RecordedCall { + token: string; + projectRef: string | undefined; + userId: string | undefined; +} + function mockFeedbackClient(opts: MockClientOpts = {}) { - const previewCalls: Array<{ token: string; projectRef: string | undefined }> = []; - const deleteCalls: Array<{ token: string; projectRef: string | undefined }> = []; + const previewCalls: Array = []; + const deleteCalls: Array = []; return { layer: Layer.succeed( FeedbackClient, FeedbackClient.of({ submit: () => Effect.die("submit is not reachable from feedback delete"), - preview: (token, projectRef) => + preview: (token, context) => Effect.suspend(() => { - previewCalls.push({ token, projectRef }); + previewCalls.push({ token, projectRef: context?.projectRef, userId: context?.userId }); return opts.previewFailWith !== undefined ? Effect.fail( new FeedbackBackendError({ @@ -79,9 +86,9 @@ function mockFeedbackClient(opts: MockClientOpts = {}) { ) : Effect.succeed(Option.fromNullishOr(opts.previewText)); }), - delete: (token, projectRef) => + delete: (token, context) => Effect.suspend(() => { - deleteCalls.push({ token, projectRef }); + deleteCalls.push({ token, projectRef: context?.projectRef, userId: context?.userId }); return opts.deleteFailWith !== undefined ? Effect.fail( new FeedbackBackendError({ message: opts.deleteFailWith, operation: "delete" }), @@ -102,6 +109,9 @@ function setupLegacyFeedbackDelete( yes?: boolean; /** Simulates `SUPABASE_PROJECT_ID`, the only source `LegacyCliConfig` reads. */ projectIdEnv?: string; + /** Simulates the gotrue user id persisted to telemetry.json at login. */ + distinctId?: string; + consent?: "granted" | "denied"; } = {}, ) { const out = mockOutput(opts.output ?? { promptConfirmResponses: [true] }); @@ -109,6 +119,11 @@ function setupLegacyFeedbackDelete( const layer = Layer.mergeAll( out.layer, client.layer, + mockTelemetryRuntime({ + cliVersion: "9.9.9", + distinctId: opts.distinctId, + consent: opts.consent, + }), mockLegacyCliConfig({ workdir: tempRoot.current, userAgent: "SupabaseCLI/9.9.9", @@ -273,6 +288,50 @@ describe("legacy feedback delete", () => { }).pipe(Effect.provide(layer)); }); + it.live("presents the persisted gotrue user id with the preview and the delete", () => { + // Rows submitted while logged in carry a user_id, and the RLS only + // matches them when the same id arrives as the x-feedback-user-id header. + const { layer, client } = setupLegacyFeedbackDelete({ + yes: true, + distinctId: "11111111-2222-3333-4444-555555555555", + }); + return Effect.gen(function* () { + yield* legacyFeedbackDelete(deleteArgs()); + + expect(client.previewCalls).toEqual([ + { token: TOKEN, projectRef: undefined, userId: "11111111-2222-3333-4444-555555555555" }, + ]); + expect(client.deleteCalls).toEqual([ + { token: TOKEN, projectRef: undefined, userId: "11111111-2222-3333-4444-555555555555" }, + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("still presents the user id when telemetry consent is denied", () => { + // Unlike submit-side attribution, the header is functional auth context — + // gating it on consent would strand rows submitted before an opt-out. + const { layer, client } = setupLegacyFeedbackDelete({ + yes: true, + distinctId: "11111111-2222-3333-4444-555555555555", + consent: "denied", + }); + return Effect.gen(function* () { + yield* legacyFeedbackDelete(deleteArgs()); + + expect(client.deleteCalls[0]?.userId).toBe("11111111-2222-3333-4444-555555555555"); + }).pipe(Effect.provide(layer)); + }); + + it.live("sends no user id when not logged in", () => { + const { layer, client } = setupLegacyFeedbackDelete({ yes: true }); + return Effect.gen(function* () { + yield* legacyFeedbackDelete(deleteArgs()); + + expect(client.previewCalls[0]?.userId).toBeUndefined(); + expect(client.deleteCalls[0]?.userId).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + it.live("degrades to no project ref when the linked ref file cannot be read", () => { const { layer, client } = setupLegacyFeedbackDelete({ yes: true }); writeLinkedProjectRef(tempRoot.current, LEGACY_VALID_REF, { asDirectory: true }); diff --git a/apps/cli/src/shared/feedback/feedback-client.integration.test.ts b/apps/cli/src/shared/feedback/feedback-client.integration.test.ts index 925abf25d4..3544de4e17 100644 --- a/apps/cli/src/shared/feedback/feedback-client.integration.test.ts +++ b/apps/cli/src/shared/feedback/feedback-client.integration.test.ts @@ -13,10 +13,12 @@ const TEST_ENV = { const TOKEN = "123e4567-e89b-12d3-a456-426614174000"; const PROJECT_REF = "abcdefghijklmnopqrst"; +const USER_ID = "11111111-2222-3333-4444-555555555555"; const SUBMISSION: FeedbackSubmission = { message: "port conflicts when running two stacks", projectRef: PROJECT_REF, + userId: USER_ID, context: { cliVersion: "9.9.9", userAgent: "SupabaseCLI/9.9.9", @@ -76,6 +78,7 @@ describe("feedbackClientLayer", () => { feedback: "port conflicts when running two stacks", user_agent: "SupabaseCLI/9.9.9", project_ref: PROJECT_REF, + user_id: USER_ID, metadata: { cli_version: "9.9.9", source: "cli", @@ -171,7 +174,7 @@ describe("feedbackClientLayer", () => { const transport = recordingFetch(() => jsonResponse([{ feedback: "my papercut" }])); return Effect.gen(function* () { const client = yield* FeedbackClient; - const preview = yield* client.preview(TOKEN, PROJECT_REF); + const preview = yield* client.preview(TOKEN, { projectRef: PROJECT_REF, userId: USER_ID }); expect(preview).toEqual(Option.some("my papercut")); const { request } = transport.requests[0]!; @@ -183,10 +186,11 @@ describe("feedbackClientLayer", () => { expect(request.headers.get("apikey")).toBe(TEST_ENV.key); expect(request.headers.get("x-feedback-token")).toBe(TOKEN); expect(request.headers.get("x-feedback-project-ref")).toBe(PROJECT_REF); + expect(request.headers.get("x-feedback-user-id")).toBe(USER_ID); }).pipe(Effect.provide(layerWith(transport))); }); - it.live("sends no project-ref header when no ref is provided", () => { + it.live("sends no context headers when no ref or user id is provided", () => { const transport = recordingFetch(() => jsonResponse([{ feedback: "context-free" }])); return Effect.gen(function* () { const client = yield* FeedbackClient; @@ -195,6 +199,7 @@ describe("feedbackClientLayer", () => { const { request } = transport.requests[0]!; expect(request.headers.get("x-feedback-token")).toBe(TOKEN); expect(request.headers.has("x-feedback-project-ref")).toBe(false); + expect(request.headers.has("x-feedback-user-id")).toBe(false); }).pipe(Effect.provide(layerWith(transport))); }); @@ -202,7 +207,7 @@ describe("feedbackClientLayer", () => { const transport = recordingFetch(() => jsonResponse([])); return Effect.gen(function* () { const client = yield* FeedbackClient; - const preview = yield* client.preview(TOKEN, PROJECT_REF); + const preview = yield* client.preview(TOKEN, { projectRef: PROJECT_REF }); expect(Option.isNone(preview)).toBe(true); }).pipe(Effect.provide(layerWith(transport))); @@ -235,7 +240,7 @@ describe("feedbackClientLayer", () => { ); return Effect.gen(function* () { const client = yield* FeedbackClient; - const result = yield* client.delete(TOKEN, PROJECT_REF); + const result = yield* client.delete(TOKEN, { projectRef: PROJECT_REF, userId: USER_ID }); expect(result).toEqual({ deleted: true }); const { request } = transport.requests[0]!; @@ -246,12 +251,13 @@ describe("feedbackClientLayer", () => { expect(request.headers.get("prefer")).toContain("count=exact"); expect(request.headers.get("x-feedback-token")).toBe(TOKEN); expect(request.headers.get("x-feedback-project-ref")).toBe(PROJECT_REF); + expect(request.headers.get("x-feedback-user-id")).toBe(USER_ID); }).pipe(Effect.provide(layerWith(transport))); }); it.live("reports deleted: false when the delete matched zero rows", () => { - // Wrong/stale token or a project-ref context mismatch: RLS matches - // nothing and PostgREST reports an empty range. + // Wrong/stale token or a project-ref/user-id context mismatch: RLS + // matches nothing and PostgREST reports an empty range. const transport = recordingFetch( () => new Response(null, { status: 204, headers: { "content-range": "*/0" } }), ); @@ -261,6 +267,7 @@ describe("feedbackClientLayer", () => { expect(result).toEqual({ deleted: false }); expect(transport.requests[0]!.request.headers.has("x-feedback-project-ref")).toBe(false); + expect(transport.requests[0]!.request.headers.has("x-feedback-user-id")).toBe(false); }).pipe(Effect.provide(layerWith(transport))); }); diff --git a/apps/cli/src/shared/feedback/feedback-client.layer.ts b/apps/cli/src/shared/feedback/feedback-client.layer.ts index a346868c27..865f7d525a 100644 --- a/apps/cli/src/shared/feedback/feedback-client.layer.ts +++ b/apps/cli/src/shared/feedback/feedback-client.layer.ts @@ -50,14 +50,17 @@ export function legacyFeedbackEnvironment(profile: string): FeedbackEnvironment type RpcArgs = Database["public"]["Functions"]["submit_interfaces_feedback"]["Args"]; -// `user_id` is deliberately never sent: the CLI has no user-scoped identity in -// this flow, and omitting it keeps the row's delete authorization token-only. +// `user_id` is the gotrue user UUID the handler read from the persisted +// telemetry identity — best-effort attribution, omitted when logged out or +// when telemetry consent is denied. A row submitted with it additionally +// requires the matching `x-feedback-user-id` header on preview/delete (RLS). function toRpcArgs(submission: FeedbackSubmission): RpcArgs { const { context } = submission; return { feedback: submission.message, user_agent: context.userAgent, ...(submission.projectRef === undefined ? {} : { project_ref: submission.projectRef }), + ...(submission.userId === undefined ? {} : { user_id: submission.userId }), metadata: { cli_version: context.cliVersion, source: "cli", @@ -120,35 +123,43 @@ export function feedbackClientLayer(options: FeedbackClientOptions): Layer.Layer ), ), - preview: (token, projectRef) => + preview: (token, context) => run("preview", () => { - const request = client + let request = client .from("interfaces_feedback") .select("feedback") .eq("delete_token", token) .setHeader("x-feedback-token", token) .abortSignal(AbortSignal.timeout(REQUEST_TIMEOUT_MS)); - return projectRef === undefined - ? request - : request.setHeader("x-feedback-project-ref", projectRef); + if (context?.projectRef !== undefined) { + request = request.setHeader("x-feedback-project-ref", context.projectRef); + } + if (context?.userId !== undefined) { + request = request.setHeader("x-feedback-user-id", context.userId); + } + return request; }).pipe(Effect.map(({ data }) => Option.fromNullishOr(data?.[0]?.feedback))), - delete: (token, projectRef) => + delete: (token, context) => run("delete", () => { // The `delete_token=eq.` filter satisfies PostgREST's filterless-delete // rejection; the `x-feedback-token` header is the actual security // boundary (RLS matches zero rows without it). `count: "exact"` asks // for a Content-Range so the caller can tell a matched delete from a // zero-row one. - const request = client + let request = client .from("interfaces_feedback") .delete({ count: "exact" }) .eq("delete_token", token) .setHeader("x-feedback-token", token) .abortSignal(AbortSignal.timeout(REQUEST_TIMEOUT_MS)); - return projectRef === undefined - ? request - : request.setHeader("x-feedback-project-ref", projectRef); + if (context?.projectRef !== undefined) { + request = request.setHeader("x-feedback-project-ref", context.projectRef); + } + if (context?.userId !== undefined) { + request = request.setHeader("x-feedback-user-id", context.userId); + } + return request; }).pipe(Effect.map(({ count }) => ({ deleted: count === 1 }))), }); }); diff --git a/apps/cli/src/shared/feedback/feedback-client.service.ts b/apps/cli/src/shared/feedback/feedback-client.service.ts index 58c3f5d356..7d53f7fd1f 100644 --- a/apps/cli/src/shared/feedback/feedback-client.service.ts +++ b/apps/cli/src/shared/feedback/feedback-client.service.ts @@ -19,9 +19,25 @@ interface FeedbackContext { export interface FeedbackSubmission { readonly message: string; readonly projectRef?: string; + /** + * Gotrue user UUID (persisted telemetry distinct_id); absent when not + * logged in or when telemetry consent is denied. + */ + readonly userId?: string; readonly context: FeedbackContext; } +/** + * Row-context values presented as `x-feedback-*` headers on preview/delete. + * The RLS policies require each one when (and only when) the row was + * submitted with it — extra context against a context-free row is ignored, so + * sending whatever is available is always safe. + */ +interface FeedbackRowContext { + readonly projectRef?: string; + readonly userId?: string; +} + /** * Returned once per submission: the server-generated token that authorizes * deleting the row later. Never persisted by the CLI — shown to the user and @@ -49,17 +65,17 @@ interface FeedbackClientShape { ) => Effect.Effect; /** * The feedback text of the row the token unlocks, or `None` when no row - * matches (wrong token, already deleted, or a project-ref context mismatch — - * the backend cannot distinguish these). + * matches (wrong token, already deleted, or a project-ref/user-id context + * mismatch — the backend cannot distinguish these). */ readonly preview: ( token: string, - projectRef?: string, + context?: FeedbackRowContext, ) => Effect.Effect, FeedbackBackendError>; /** `deleted: false` means the delete matched zero rows (same causes as `preview` → `None`). */ readonly delete: ( token: string, - projectRef?: string, + context?: FeedbackRowContext, ) => Effect.Effect<{ readonly deleted: boolean }, FeedbackBackendError>; } From a3d67e54e562fb4d8e04feb8d315f6bf15ad2a87 Mon Sep 17 00:00:00 2001 From: Kanad Gupta Date: Fri, 14 Aug 2026 15:44:37 -0700 Subject: [PATCH 15/21] fix(cli): address feedback command review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies the accepted Codex review findings on the feedback family: - Abort in-flight feedback requests on fiber interruption: the client's run helper now threads Effect.tryPromise's interruption signal into every postgrest call via AbortSignal.any with the 10s timeout, so Ctrl-C can no longer let a submit commit after cancellation. - Gate the interactive prompt on stdin.isTTY in addition to output.interactive, so whitespace-only piped stdin with a TTY stdout fails with the documented empty-message error instead of opening a prompt against exhausted stdin. - Honor the --agent yes|no override in the submission payload via legacyResolveAgentMode (hoisted from db query to legacy/shared); --agent no also suppresses the detected agent_name. - Refresh ~/.supabase/telemetry.json on every feedback add/delete run via the standard Effect.ensuring(telemetryState.flush) finalizer. - Honor -o json on both commands (machine payload via encodeGoJson, stdout payload-only); values outside feedback's pretty|json enum are rejected pre-run like db query's restricted set. yaml/toml stay unsupported: the struct-spec encoders reproduce Go field names and no Go struct exists for this TS-only command. - Route feedback HTTP through the legacy transport: a composed fetch wires --debug request logging and --dns-resolver https DoH resolution into the supabase-js client. - Move the real-backend add→delete round trip to the gated live tier (add.live.test.ts) and keep a hermetic e2e for subcommand routing via the no-network empty-message path. Co-Authored-By: Claude Fable 5 --- apps/cli/docs/go-cli-divergences.md | 18 +- .../legacy/commands/db/query/query.format.ts | 10 -- .../db/query/query.format.unit.test.ts | 10 -- .../legacy/commands/db/query/query.handler.ts | 2 +- .../commands/feedback/add/SIDE_EFFECTS.md | 17 +- .../commands/feedback/add/add.command.ts | 10 +- .../commands/feedback/add/add.e2e.test.ts | 65 ++----- .../commands/feedback/add/add.handler.ts | 98 +++++++---- .../feedback/add/add.integration.test.ts | 114 ++++++++++++- .../commands/feedback/add/add.live.test.ts | 62 +++++++ .../commands/feedback/delete/SIDE_EFFECTS.md | 7 +- .../feedback/delete/delete.command.ts | 9 +- .../feedback/delete/delete.handler.ts | 158 ++++++++++-------- .../delete/delete.integration.test.ts | 38 ++++- .../commands/feedback/feedback-output.ts | 10 ++ .../commands/feedback/feedback.layers.ts | 42 ++++- .../feedback/feedback.layers.unit.test.ts | 54 ++++++ .../src/legacy/shared/legacy-agent-mode.ts | 11 ++ .../shared/legacy-agent-mode.unit.test.ts | 12 ++ .../feedback-client.integration.test.ts | 27 ++- .../shared/feedback/feedback-client.layer.ts | 30 ++-- 21 files changed, 589 insertions(+), 215 deletions(-) create mode 100644 apps/cli/src/legacy/commands/feedback/add/add.live.test.ts create mode 100644 apps/cli/src/legacy/commands/feedback/feedback-output.ts create mode 100644 apps/cli/src/legacy/commands/feedback/feedback.layers.unit.test.ts create mode 100644 apps/cli/src/legacy/shared/legacy-agent-mode.ts create mode 100644 apps/cli/src/legacy/shared/legacy-agent-mode.unit.test.ts diff --git a/apps/cli/docs/go-cli-divergences.md b/apps/cli/docs/go-cli-divergences.md index 28a67eb92a..ae7ce21e07 100644 --- a/apps/cli/docs/go-cli-divergences.md +++ b/apps/cli/docs/go-cli-divergences.md @@ -10,15 +10,15 @@ something the old Go CLI didn't — it is not a compatibility promise. These commands exist in the TS CLI today but have no direct top-level equivalent in the old Go CLI reference. -| TS command | TS path | Notes | -| ----------------- | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `dev` | `planned` | Reserved for a TS-native long-running local development workflow command that watches files and orchestrates subcommands. Track this as TS-only unless a direct Go equivalent emerges. | -| `feedback add` | [`../src/legacy/commands/feedback/add/add.command.ts`](../src/legacy/commands/feedback/add/add.command.ts) | Quick feedback submission, legacy shell only (CLI-1946). Submits through the `submit_interfaces_feedback` RPC via supabase-js with a committed publishable key and prints the server-issued delete token once; profile-driven staging/production environments (production currently reuses staging). | -| `feedback delete` | [`../src/legacy/commands/feedback/delete/delete.command.ts`](../src/legacy/commands/feedback/delete/delete.command.ts) | Deletes previously submitted feedback using the token printed by `feedback add` (CLI-2188). Previews the feedback text, then hard-deletes via the token-gated RLS policy (`x-feedback-token` + optional `x-feedback-project-ref` / `x-feedback-user-id` headers). | -| `logs` | [`../src/next/commands/logs/logs.command.ts`](../src/next/commands/logs/logs.command.ts) | Streams local stack logs. No top-level `logs` command exists in the old Go CLI reference. | -| `api` | [`../src/next/commands/platform/api.command.ts`](../src/next/commands/platform/api.command.ts) | Low-level Management API client. It supersedes the old generated tree with explicit discovery via `supabase api routes` and execution via `supabase api request [--method ]`. | -| `stack` | [`../src/next/cli/root.ts`](../src/next/cli/root.ts) | TS-only local runtime namespace exposing `stack start`, `stack stop`, `stack status`, `stack list`, and `stack update`. Top-level `start`, `stop`, and `status` remain aliases. | -| `branches switch` | [`../src/next/commands/branches/switch/switch.command.ts`](../src/next/commands/branches/switch/switch.command.ts) | No direct Go equivalent. Updates local active-branch state so subsequent commands target the selected branch. | +| TS command | TS path | Notes | +| ----------------- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `dev` | `planned` | Reserved for a TS-native long-running local development workflow command that watches files and orchestrates subcommands. Track this as TS-only unless a direct Go equivalent emerges. | +| `feedback add` | [`../src/legacy/commands/feedback/add/add.command.ts`](../src/legacy/commands/feedback/add/add.command.ts) | Quick feedback submission, legacy shell only (CLI-1946). Submits through the `submit_interfaces_feedback` RPC via supabase-js with a committed publishable key and prints the server-issued delete token once; profile-driven staging/production environments (production currently reuses staging). `-o` accepts `pretty\|json` only (no Go struct exists for the yaml/toml encoders). | +| `feedback delete` | [`../src/legacy/commands/feedback/delete/delete.command.ts`](../src/legacy/commands/feedback/delete/delete.command.ts) | Deletes previously submitted feedback using the token printed by `feedback add` (CLI-2188). Previews the feedback text, then hard-deletes via the token-gated RLS policy (`x-feedback-token` + optional `x-feedback-project-ref` / `x-feedback-user-id` headers). `-o` accepts `pretty\|json` only (no Go struct exists for the yaml/toml encoders). | +| `logs` | [`../src/next/commands/logs/logs.command.ts`](../src/next/commands/logs/logs.command.ts) | Streams local stack logs. No top-level `logs` command exists in the old Go CLI reference. | +| `api` | [`../src/next/commands/platform/api.command.ts`](../src/next/commands/platform/api.command.ts) | Low-level Management API client. It supersedes the old generated tree with explicit discovery via `supabase api routes` and execution via `supabase api request [--method ]`. | +| `stack` | [`../src/next/cli/root.ts`](../src/next/cli/root.ts) | TS-only local runtime namespace exposing `stack start`, `stack stop`, `stack status`, `stack list`, and `stack update`. Top-level `start`, `stop`, and `status` remain aliases. | +| `branches switch` | [`../src/next/commands/branches/switch/switch.command.ts`](../src/next/commands/branches/switch/switch.command.ts) | No direct Go equivalent. Updates local active-branch state so subsequent commands target the selected branch. | ## Flag divergences from the Go reference diff --git a/apps/cli/src/legacy/commands/db/query/query.format.ts b/apps/cli/src/legacy/commands/db/query/query.format.ts index e810cc6be9..cd4b06df8a 100644 --- a/apps/cli/src/legacy/commands/db/query/query.format.ts +++ b/apps/cli/src/legacy/commands/db/query/query.format.ts @@ -573,13 +573,3 @@ export function legacyOrderedKeys(body: string): ReadonlyArray { } return keys; } - -/** Agent-mode resolution: `yes`→true, `no`→false, `auto`→agent detected. */ -export function legacyResolveAgentMode( - agentFlag: "auto" | "yes" | "no", - aiToolName: Option.Option, -): boolean { - if (agentFlag === "yes") return true; - if (agentFlag === "no") return false; - return Option.isSome(aiToolName); -} diff --git a/apps/cli/src/legacy/commands/db/query/query.format.unit.test.ts b/apps/cli/src/legacy/commands/db/query/query.format.unit.test.ts index a433bd76a8..eb9a6d5e75 100644 --- a/apps/cli/src/legacy/commands/db/query/query.format.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/query/query.format.unit.test.ts @@ -11,7 +11,6 @@ import { legacyOrderedKeys, legacyRenderJson, legacyRenderTablewriter, - legacyResolveAgentMode, legacyToCsv, } from "./query.format.ts"; @@ -353,15 +352,6 @@ describe("legacyFindNonFiniteJsonValue", () => { }); }); -describe("legacyResolveAgentMode", () => { - it("honors the explicit flag and falls back to detection on auto", () => { - expect(legacyResolveAgentMode("yes", Option.none())).toBe(true); - expect(legacyResolveAgentMode("no", Option.some("cursor"))).toBe(false); - expect(legacyResolveAgentMode("auto", Option.some("cursor"))).toBe(true); - expect(legacyResolveAgentMode("auto", Option.none())).toBe(false); - }); -}); - describe("legacyBuildRlsAdvisory", () => { it("returns None when no tables are unprotected", () => { expect(Option.isNone(legacyBuildRlsAdvisory([]))).toBe(true); diff --git a/apps/cli/src/legacy/commands/db/query/query.handler.ts b/apps/cli/src/legacy/commands/db/query/query.handler.ts index 2b03be1af1..6d5e3ba2ba 100644 --- a/apps/cli/src/legacy/commands/db/query/query.handler.ts +++ b/apps/cli/src/legacy/commands/db/query/query.handler.ts @@ -42,9 +42,9 @@ import { legacyOrderedKeys, legacyRenderJson, legacyRenderTablewriter, - legacyResolveAgentMode, legacyToCsv, } from "./query.format.ts"; +import { legacyResolveAgentMode } from "../../../shared/legacy-agent-mode.ts"; /** The output formats `db query` selects: `json|table|csv`. */ type LegacyResolvedFormat = "json" | "table" | "csv"; diff --git a/apps/cli/src/legacy/commands/feedback/add/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/feedback/add/SIDE_EFFECTS.md index 98f79494ed..f221998955 100644 --- a/apps/cli/src/legacy/commands/feedback/add/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/feedback/add/SIDE_EFFECTS.md @@ -11,9 +11,9 @@ ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ----------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------- | +| `/telemetry.json` | JSON | every invocation, success or failure — the shared telemetry-state finalizer (device id persistence, session rotation) | ## API Routes @@ -46,11 +46,12 @@ every command. ## Exit Codes -| Code | Condition | -| ---- | ----------------------------------------------------------------- | -| `0` | success | -| `1` | no message from args, piped stdin, or an interactive prompt | -| `1` | submit failure (PostgREST error, network failure, or 10s timeout) | +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------- | +| `0` | success | +| `1` | no message from args, piped stdin, or an interactive prompt | +| `1` | submit failure (PostgREST error, network failure, or 10s timeout) | +| `1` | `-o`/`--output` value outside the command's `pretty\|json` enum (validated pre-run) | ## Telemetry Events Fired diff --git a/apps/cli/src/legacy/commands/feedback/add/add.command.ts b/apps/cli/src/legacy/commands/feedback/add/add.command.ts index 0ed066a3be..f78b194c17 100644 --- a/apps/cli/src/legacy/commands/feedback/add/add.command.ts +++ b/apps/cli/src/legacy/commands/feedback/add/add.command.ts @@ -5,7 +5,9 @@ import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime. import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; import { aiToolLayer } from "../../../../shared/telemetry/ai-tool.layer.ts"; import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; import { legacyFeedbackClientLayer, legacyFeedbackCliConfigLayer } from "../feedback.layers.ts"; +import { LEGACY_FEEDBACK_OUTPUT_FORMATS } from "../feedback-output.ts"; import { legacyFeedbackAdd } from "./add.handler.ts"; const config = { @@ -20,7 +22,12 @@ export type LegacyFeedbackAddArgs = CliCommand.Command.Config.Infer - legacyFeedbackAdd(args).pipe(withLegacyCommandInstrumentation(), withJsonErrorHandling); + legacyFeedbackAdd(args).pipe( + // Feedback's own `-o` enum is `pretty|json`, not the resource-command set + // (see feedback-output.ts). + withLegacyCommandInstrumentation({ outputFormats: LEGACY_FEEDBACK_OUTPUT_FORMATS }), + withJsonErrorHandling, + ); export const legacyFeedbackAddCommand = Command.make("add", config).pipe( Command.withDescription("Send quick feedback about the Supabase CLI to the Supabase team."), @@ -38,6 +45,7 @@ export const legacyFeedbackAddCommand = Command.make("add", config).pipe( ]), Command.withHandler(legacyFeedbackAddHandler), Command.provide(commandRuntimeLayer(["feedback", "add"])), + Command.provide(legacyTelemetryStateLayer), Command.provide(stdinLayer), Command.provide(aiToolLayer), // `Layer.provide` does not share to siblings: the handler and the feedback diff --git a/apps/cli/src/legacy/commands/feedback/add/add.e2e.test.ts b/apps/cli/src/legacy/commands/feedback/add/add.e2e.test.ts index 5370ce6021..d45a2cd4f8 100644 --- a/apps/cli/src/legacy/commands/feedback/add/add.e2e.test.ts +++ b/apps/cli/src/legacy/commands/feedback/add/add.e2e.test.ts @@ -2,62 +2,31 @@ import { describe, expect, test } from "vitest"; import { makeTempHome, runSupabase } from "../../../../../tests/helpers/cli.ts"; -const E2E_TIMEOUT_MS = 60_000; +import { LEGACY_FEEDBACK_EMPTY_MESSAGE } from "./add.errors.ts"; -const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const E2E_TIMEOUT_MS = 60_000; describe("supabase feedback (legacy)", () => { - // Golden path through the real CLI surface: `feedback add` resolves through - // the command parser's subcommand routing (unreachable from handler-level - // integration tests), submits through the real `submit_interfaces_feedback` - // RPC, and the json acknowledgement carries the server-issued delete token. - // `feedback delete` then removes the row with that token — proving the full - // add → delete round trip AND cleaning the staging row up each run. Pinned - // to --profile supabase-staging so this only ever touches the STAGING - // feedback project (staging exists for local dev/tests), never production. + // Subcommand routing through the real parser is the only surface handler + // integration tests can't reach. The empty-message path proves it with zero + // network: no args, whitespace-only piped stdin, non-TTY — the command exits + // 1 before any request could leave the process. The real-backend golden path + // (add → delete round trip) lives in add.live.test.ts, gated to the + // cli-e2e-ci runner. test( - "feedback add returns a delete token that feedback delete accepts", + "feedback add fails with the empty-message error when nothing is provided", { timeout: E2E_TIMEOUT_MS }, async () => { using home = makeTempHome(); - const message = "cli-e2e golden path (add.e2e.test.ts)"; - const added = await runSupabase( - ["feedback", "add", message, "--profile", "supabase-staging", "--output-format", "json"], - { - entrypoint: "legacy", - home: home.dir, - env: { HOME: home.dir }, - }, - ); - expect(added.exitCode).toBe(0); - const receipt = JSON.parse(added.stdout); - expect(receipt).toEqual({ - delete_token: expect.stringMatching(UUID_PATTERN), - message: "Thanks for the feedback!", - }); - - const deleted = await runSupabase( - [ - "feedback", - "delete", - receipt.delete_token, - "--yes", - "--profile", - "supabase-staging", - "--output-format", - "json", - ], - { - entrypoint: "legacy", - home: home.dir, - env: { HOME: home.dir }, - }, - ); - expect(deleted.exitCode).toBe(0); - expect(JSON.parse(deleted.stdout)).toEqual({ - feedback: message, - message: "Feedback deleted.", + const result = await runSupabase(["feedback", "add"], { + entrypoint: "legacy", + home: home.dir, + env: { HOME: home.dir }, + stdin: " \n", }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain(LEGACY_FEEDBACK_EMPTY_MESSAGE); + expect(result.stdout).toBe(""); }, ); }); diff --git a/apps/cli/src/legacy/commands/feedback/add/add.handler.ts b/apps/cli/src/legacy/commands/feedback/add/add.handler.ts index c68518df6b..9faf713361 100644 --- a/apps/cli/src/legacy/commands/feedback/add/add.handler.ts +++ b/apps/cli/src/legacy/commands/feedback/add/add.handler.ts @@ -3,9 +3,13 @@ import { FeedbackClient } from "../../../../shared/feedback/feedback-client.serv import { Output } from "../../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; import { Stdin } from "../../../../shared/runtime/stdin.service.ts"; +import { LegacyAgentFlag, LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; import { AiTool } from "../../../../shared/telemetry/ai-tool.service.ts"; import { TelemetryRuntime } from "../../../../shared/telemetry/runtime.service.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { legacyResolveAgentMode } from "../../../shared/legacy-agent-mode.ts"; +import { encodeGoJson } from "../../../shared/legacy-go-output.encoders.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { legacyResolveFeedbackProjectRef } from "../feedback-project-ref.ts"; import type { LegacyFeedbackAddArgs } from "./add.command.ts"; import { LEGACY_FEEDBACK_EMPTY_MESSAGE, LegacyFeedbackEmptyMessageError } from "./add.errors.ts"; @@ -28,7 +32,10 @@ const legacyResolveFeedbackMessage = Effect.fnUntraced(function* (args: LegacyFe } const output = yield* Output; - if (output.interactive) { + // `output.interactive` is stdout-derived; the prompt reads stdin. Both must + // be TTYs — whitespace-only piped stdin with a TTY stdout would otherwise + // open a prompt against exhausted non-TTY stdin instead of failing below. + if (stdin.isTTY && output.interactive) { const typed = yield* output.promptText("What's on your mind?", { validate: (value) => value.trim().length === 0 ? "Feedback message cannot be empty." : undefined, @@ -45,46 +52,71 @@ export const legacyFeedbackAdd = Effect.fn("legacy.feedback.add")(function* ( args: LegacyFeedbackAddArgs, ) { const output = yield* Output; + const goOutputFlag = yield* LegacyOutputFlag; const cliConfig = yield* LegacyCliConfig; const runtimeInfo = yield* RuntimeInfo; const telemetryRuntime = yield* TelemetryRuntime; const aiTool = yield* AiTool; const client = yield* FeedbackClient; + const telemetryState = yield* LegacyTelemetryState; - const message = yield* legacyResolveFeedbackMessage(args); - const agentName = Option.getOrUndefined(aiTool.name); - const projectRef = yield* legacyResolveFeedbackProjectRef(cliConfig.workdir, cliConfig.projectId); + // Persist the telemetry state file (`~/.supabase/telemetry.json`) whether + // the submission succeeds or fails — the same PersistentPostRun-shaped + // finalizer every legacy command runs. + yield* Effect.gen(function* () { + const message = yield* legacyResolveFeedbackMessage(args); + // `--agent yes|no` overrides detection (`auto`), same as root's output + // selection and `db query`. When the override says "not an agent", the + // detected tool name is suppressed too so the payload cannot contradict + // itself; `--agent yes` without a detected tool sends no name. + const agentFlag = yield* LegacyAgentFlag; + const isAgent = legacyResolveAgentMode(agentFlag, aiTool.name); + const agentName = isAgent ? Option.getOrUndefined(aiTool.name) : undefined; + const projectRef = yield* legacyResolveFeedbackProjectRef( + cliConfig.workdir, + cliConfig.projectId, + ); - const sending = yield* output.task("Sending feedback..."); + const sending = yield* output.task("Sending feedback..."); - const { deleteToken } = yield* client - .submit({ - message, - projectRef: Option.getOrUndefined(projectRef), - // Gotrue user UUID stamped into ~/.supabase/telemetry.json at login (ADR - // 0013). A synchronous in-memory read — best-effort attribution with no - // auth/API/network dependency, so feedback keeps working logged-out - // (undefined → user_id omitted). Gated on telemetry consent: opted-out - // users submit anonymously. - userId: - telemetryRuntime.consent === "granted" ? telemetryRuntime.identity.current() : undefined, - context: { - cliVersion: telemetryRuntime.cliVersion, - userAgent: cliConfig.userAgent, - os: runtimeInfo.platform, - arch: runtimeInfo.arch, - isAgent: agentName !== undefined, - agentName, - }, - }) - .pipe(Effect.tapError(() => sending.fail())); + const { deleteToken } = yield* client + .submit({ + message, + projectRef: Option.getOrUndefined(projectRef), + // Gotrue user UUID stamped into ~/.supabase/telemetry.json at login (ADR + // 0013). A synchronous in-memory read — best-effort attribution with no + // auth/API/network dependency, so feedback keeps working logged-out + // (undefined → user_id omitted). Gated on telemetry consent: opted-out + // users submit anonymously. + userId: + telemetryRuntime.consent === "granted" ? telemetryRuntime.identity.current() : undefined, + context: { + cliVersion: telemetryRuntime.cliVersion, + userAgent: cliConfig.userAgent, + os: runtimeInfo.platform, + arch: runtimeInfo.arch, + isAgent, + agentName, + }, + }) + .pipe(Effect.tapError(() => sending.fail())); - yield* sending.clear(); + yield* sending.clear(); - if (output.format !== "text") { - yield* output.success("Thanks for the feedback!", { delete_token: deleteToken }); - return; - } - yield* output.success("Thanks for the feedback!"); - yield* output.info(`To delete this feedback later, run: supabase feedback delete ${deleteToken}`); + // Go-compat `-o json` wins over `--output-format` (legacy CLAUDE.md item 6): + // stdout carries the machine payload only. `pretty` (or unset) falls through. + if (Option.getOrUndefined(goOutputFlag) === "json") { + yield* output.raw(encodeGoJson({ delete_token: deleteToken })); + return; + } + + if (output.format !== "text") { + yield* output.success("Thanks for the feedback!", { delete_token: deleteToken }); + return; + } + yield* output.success("Thanks for the feedback!"); + yield* output.info( + `To delete this feedback later, run: supabase feedback delete ${deleteToken}`, + ); + }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/feedback/add/add.integration.test.ts b/apps/cli/src/legacy/commands/feedback/add/add.integration.test.ts index 2bb99e8c79..5715d110cb 100644 --- a/apps/cli/src/legacy/commands/feedback/add/add.integration.test.ts +++ b/apps/cli/src/legacy/commands/feedback/add/add.integration.test.ts @@ -8,6 +8,7 @@ import { FeedbackBackendError, FeedbackClient, } from "../../../../shared/feedback/feedback-client.service.ts"; +import { LegacyAgentFlag, LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; import { AiTool } from "../../../../shared/telemetry/ai-tool.service.ts"; import { @@ -21,8 +22,11 @@ import { import { LEGACY_VALID_REF, mockLegacyCliConfig, + mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; +import { legacyInvalidOutputFormatMessage } from "../../../shared/legacy-go-output-flag.ts"; +import { LEGACY_FEEDBACK_OUTPUT_FORMATS } from "../feedback-output.ts"; import { legacyFeedbackAddHandler } from "./add.command.ts"; import { LEGACY_FEEDBACK_EMPTY_MESSAGE } from "./add.errors.ts"; import { legacyFeedbackAdd } from "./add.handler.ts"; @@ -80,6 +84,9 @@ function setupLegacyFeedback( stdinIsTTY?: boolean; pipedInput?: string; agentName?: string; + agentFlag?: "auto" | "yes" | "no"; + /** Simulates the Go-compat `-o`/`--output` global flag. */ + goOutput?: "env" | "pretty" | "json" | "toml" | "yaml" | "table" | "csv"; submitFailWith?: string; /** Simulates `SUPABASE_PROJECT_ID`, the only source `LegacyCliConfig` reads. */ projectIdEnv?: string; @@ -92,9 +99,11 @@ function setupLegacyFeedback( const submitter = mockFeedbackClient( opts.submitFailWith === undefined ? {} : { failWith: opts.submitFailWith }, ); + const telemetryState = mockLegacyTelemetryStateTracked(); const layer = Layer.mergeAll( out.layer, submitter.layer, + telemetryState.layer, mockStdin(opts.stdinIsTTY ?? true, opts.pipedInput), mockRuntimeInfo({ platform: "darwin", arch: "arm64" }), mockTelemetryRuntime({ @@ -108,11 +117,13 @@ function setupLegacyFeedback( projectId: opts.projectIdEnv === undefined ? Option.none() : Option.some(opts.projectIdEnv), }), mockAiTool(opts.agentName), + Layer.succeed(LegacyAgentFlag, opts.agentFlag ?? "auto"), + Layer.succeed(LegacyOutputFlag, Option.fromNullishOr(opts.goOutput)), // Real filesystem: the handler reads `supabase/.temp/project-ref` from the // temp workdir, so this must not be stubbed out. BunServices.layer, ); - return { layer, out, submitter }; + return { layer, out, submitter, telemetryState }; } // Extra layers required by the wrapped `legacyFeedbackAddHandler` (the exact @@ -135,7 +146,7 @@ function setupLegacyFeedbackHandler( describe("legacy feedback add", () => { it.live("submits a quoted message with CLI version, os, and arch attached", () => { - const { layer, out, submitter } = setupLegacyFeedback(); + const { layer, out, submitter, telemetryState } = setupLegacyFeedback(); return Effect.gen(function* () { yield* legacyFeedbackAdd({ message: ["port conflicts when running two stacks"] }); @@ -162,6 +173,9 @@ describe("legacy feedback add", () => { message: `To delete this feedback later, run: supabase feedback delete ${MOCK_DELETE_TOKEN}`, }), ); + // telemetry.json is refreshed on every invocation, like every other + // legacy command's PersistentPostRun-shaped finalizer. + expect(telemetryState.flushCount).toBe(1); }).pipe(Effect.provide(layer)); }); @@ -184,6 +198,31 @@ describe("legacy feedback add", () => { }).pipe(Effect.provide(layer)); }); + it.live("suppresses agent metadata when --agent no overrides a detected tool", () => { + const { layer, submitter } = setupLegacyFeedback({ + agentName: "claude_code", + agentFlag: "no", + }); + return Effect.gen(function* () { + yield* legacyFeedbackAdd({ message: ["human at the keyboard"] }); + + expect(submitter.submissions[0]?.context.isAgent).toBe(false); + // The name is suppressed too — an `is_agent: false` payload must not + // carry a contradictory `agent_name`. + expect(submitter.submissions[0]?.context.agentName).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("marks the submission as agent feedback when --agent yes forces it", () => { + const { layer, submitter } = setupLegacyFeedback({ agentFlag: "yes" }); + return Effect.gen(function* () { + yield* legacyFeedbackAdd({ message: ["undetected agent"] }); + + expect(submitter.submissions[0]?.context.isAgent).toBe(true); + expect(submitter.submissions[0]?.context.agentName).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + it.live("attaches the linked project ref written by supabase link", () => { const { layer, submitter } = setupLegacyFeedback(); writeLinkedProjectRef(tempRoot.current, LEGACY_VALID_REF); @@ -284,6 +323,40 @@ describe("legacy feedback add", () => { }).pipe(Effect.provide(layer)); }); + it.live("does not prompt when piped stdin is exhausted, even with a TTY stdout", () => { + // `printf ' ' | supabase feedback add` in a terminal: stdout is a TTY (so + // `output.interactive` is true) but stdin is a drained pipe the prompt + // cannot read from. This must fail like the non-interactive case instead + // of opening a prompt against exhausted stdin. + const { layer, submitter } = setupLegacyFeedback({ + output: { interactive: true, promptTextResponses: ["never read"] }, + stdinIsTTY: false, + pipedInput: " \n", + }); + return Effect.gen(function* () { + const error = yield* legacyFeedbackAdd({ message: [] }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "LegacyFeedbackEmptyMessageError", + message: LEGACY_FEEDBACK_EMPTY_MESSAGE, + }); + expect(submitter.submissions).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails without prompting when stdout is not interactive, even on a TTY stdin", () => { + // `supabase feedback add > out.txt` in a terminal: stdin is a TTY but + // stdout is redirected, so the text layer reports interactive: false and + // there is nowhere to render a prompt. + const { layer, submitter } = setupLegacyFeedback({ output: { interactive: false } }); + return Effect.gen(function* () { + const error = yield* legacyFeedbackAdd({ message: [] }).pipe(Effect.flip); + + expect(error).toMatchObject({ _tag: "LegacyFeedbackEmptyMessageError" }); + expect(submitter.submissions).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + it.live("fails with a helpful error when there is no message anywhere", () => { // Whitespace-only args and whitespace-only pipe both fall through; a // non-interactive terminal leaves nothing left to ask. @@ -319,6 +392,37 @@ describe("legacy feedback add", () => { }).pipe(Effect.provide(layer)); }); + it.live("emits only the machine payload on stdout with -o json", () => { + const { layer, out, submitter } = setupLegacyFeedback({ goOutput: "json" }); + return Effect.gen(function* () { + yield* legacyFeedbackAdd({ message: ["go machine format feedback"] }); + + expect(submitter.submissions).toHaveLength(1); + expect(out.rawChunks).toHaveLength(1); + expect(out.rawChunks[0]?.stream).toBe("stdout"); + expect(JSON.parse(out.rawChunks[0]!.text)).toEqual({ delete_token: MOCK_DELETE_TOKEN }); + // No human-readable acknowledgement — stdout is payload-only. + expect(out.messages).not.toContainEqual(expect.objectContaining({ type: "success" })); + expect(out.messages).not.toContainEqual(expect.objectContaining({ type: "info" })); + }).pipe(Effect.provide(layer)); + }); + + it.live("rejects an -o value outside feedback's pretty|json enum", () => { + const { layer, submitter } = setupLegacyFeedbackHandler({ + goOutput: "yaml", + args: ["feedback", "add", "doomed", "--output", "yaml"], + }); + return Effect.gen(function* () { + const error = yield* legacyFeedbackAddHandler({ message: ["doomed"] }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "LegacyInvalidOutputFormatError", + message: legacyInvalidOutputFormatMessage("yaml", LEGACY_FEEDBACK_OUTPUT_FORMATS), + }); + expect(submitter.submissions).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + it.live("reports a json error and exit code 1 when the message is missing in json mode", () => { const { layer, out, submitter, processControl } = setupLegacyFeedbackHandler({ output: { format: "json" }, @@ -336,7 +440,9 @@ describe("legacy feedback add", () => { }); it.live("surfaces a submitter failure", () => { - const { layer, out } = setupLegacyFeedback({ submitFailWith: "backend unavailable" }); + const { layer, out, telemetryState } = setupLegacyFeedback({ + submitFailWith: "backend unavailable", + }); return Effect.gen(function* () { const error = yield* legacyFeedbackAdd({ message: ["doomed message"] }).pipe(Effect.flip); @@ -345,6 +451,8 @@ describe("legacy feedback add", () => { message: "backend unavailable", }); expect(out.messages).not.toContainEqual(expect.objectContaining({ type: "success" })); + // The finalizer runs on failure too, matching Go's PersistentPostRun. + expect(telemetryState.flushCount).toBe(1); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/feedback/add/add.live.test.ts b/apps/cli/src/legacy/commands/feedback/add/add.live.test.ts new file mode 100644 index 0000000000..9aa0b27247 --- /dev/null +++ b/apps/cli/src/legacy/commands/feedback/add/add.live.test.ts @@ -0,0 +1,62 @@ +import { expect, test } from "vitest"; + +import { makeTempHome } from "../../../../../tests/helpers/cli.ts"; +import { describeLive, runSupabaseLive } from "../../../../../tests/helpers/live.ts"; + +const LIVE_TIMEOUT_MS = 60_000; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +describeLive("supabase feedback (legacy, live)", () => { + // Golden path against the real feedback backend: `feedback add` resolves + // through the command parser's subcommand routing, submits through the real + // `submit_interfaces_feedback` RPC, and the json acknowledgement carries the + // server-issued delete token. `feedback delete` then removes the row with + // that token — proving the full add → delete round trip AND cleaning the + // staging row up each run. Pinned to --profile supabase-staging so this only + // ever touches the STAGING feedback project (staging exists for local + // dev/tests), never production. + test( + "feedback add returns a delete token that feedback delete accepts", + { timeout: LIVE_TIMEOUT_MS }, + async () => { + using home = makeTempHome(); + const message = "cli-e2e golden path (add.live.test.ts)"; + const added = await runSupabaseLive( + ["feedback", "add", message, "--profile", "supabase-staging", "--output-format", "json"], + { + home: home.dir, + env: { HOME: home.dir }, + }, + ); + expect(added.exitCode).toBe(0); + const receipt = JSON.parse(added.stdout); + expect(receipt).toEqual({ + delete_token: expect.stringMatching(UUID_PATTERN), + message: "Thanks for the feedback!", + }); + + const deleted = await runSupabaseLive( + [ + "feedback", + "delete", + receipt.delete_token, + "--yes", + "--profile", + "supabase-staging", + "--output-format", + "json", + ], + { + home: home.dir, + env: { HOME: home.dir }, + }, + ); + expect(deleted.exitCode).toBe(0); + expect(JSON.parse(deleted.stdout)).toEqual({ + feedback: message, + message: "Feedback deleted.", + }); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/feedback/delete/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/feedback/delete/SIDE_EFFECTS.md index 49a102c8a8..56c3972f01 100644 --- a/apps/cli/src/legacy/commands/feedback/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/feedback/delete/SIDE_EFFECTS.md @@ -11,9 +11,9 @@ ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ----------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------- | +| `/telemetry.json` | JSON | every invocation, success or failure — the shared telemetry-state finalizer (device id persistence, session rotation) | ## API Routes @@ -55,6 +55,7 @@ Global telemetry consent env applies as with every command. | `1` | no feedback matched (wrong token, already deleted, or project-ref/user-id context mismatch) | | `1` | confirmation declined, or prompt unavailable (non-interactive / machine mode without `--yes`) | | `1` | backend failure (PostgREST error, network failure, or 10 s timeout) on preview or delete | +| `1` | `-o`/`--output` value outside the command's `pretty\|json` enum (validated pre-run) | ## Telemetry Events Fired diff --git a/apps/cli/src/legacy/commands/feedback/delete/delete.command.ts b/apps/cli/src/legacy/commands/feedback/delete/delete.command.ts index 91c67f65fb..63196b2685 100644 --- a/apps/cli/src/legacy/commands/feedback/delete/delete.command.ts +++ b/apps/cli/src/legacy/commands/feedback/delete/delete.command.ts @@ -3,7 +3,9 @@ import type * as CliCommand from "effect/unstable/cli/Command"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; import { legacyFeedbackClientLayer, legacyFeedbackCliConfigLayer } from "../feedback.layers.ts"; +import { LEGACY_FEEDBACK_OUTPUT_FORMATS } from "../feedback-output.ts"; import { legacyFeedbackDelete } from "./delete.handler.ts"; const config = { @@ -24,7 +26,11 @@ export const legacyFeedbackDeleteHandler = (args: LegacyFeedbackDeleteArgs) => legacyFeedbackDelete(args).pipe( // The token is a positional (structurally excluded from telemetry) and // `--project-ref` is a plain string flag, so its value is redacted. - withLegacyCommandInstrumentation({ flags: args }), + // Feedback's own `-o` enum is `pretty|json` (see feedback-output.ts). + withLegacyCommandInstrumentation({ + flags: args, + outputFormats: LEGACY_FEEDBACK_OUTPUT_FORMATS, + }), withJsonErrorHandling, ); @@ -39,6 +45,7 @@ export const legacyFeedbackDeleteCommand = Command.make("delete", config).pipe( ]), Command.withHandler(legacyFeedbackDeleteHandler), Command.provide(commandRuntimeLayer(["feedback", "delete"])), + Command.provide(legacyTelemetryStateLayer), // `Layer.provide` does not share to siblings: the handler and the feedback // client each get their own cli-config provision (legacy CLAUDE.md item 5). Command.provide(legacyFeedbackClientLayer), diff --git a/apps/cli/src/legacy/commands/feedback/delete/delete.handler.ts b/apps/cli/src/legacy/commands/feedback/delete/delete.handler.ts index 88a07f7fc2..2a6d0639e0 100644 --- a/apps/cli/src/legacy/commands/feedback/delete/delete.handler.ts +++ b/apps/cli/src/legacy/commands/feedback/delete/delete.handler.ts @@ -1,9 +1,11 @@ import { Effect, Option } from "effect"; import { FeedbackClient } from "../../../../shared/feedback/feedback-client.service.ts"; import { Output } from "../../../../shared/output/output.service.ts"; -import { legacyResolveYes } from "../../../../shared/legacy/global-flags.ts"; +import { LegacyOutputFlag, legacyResolveYes } from "../../../../shared/legacy/global-flags.ts"; import { TelemetryRuntime } from "../../../../shared/telemetry/runtime.service.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { encodeGoJson } from "../../../shared/legacy-go-output.encoders.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { legacyResolveFeedbackProjectRef } from "../feedback-project-ref.ts"; import type { LegacyFeedbackDeleteArgs } from "./delete.command.ts"; import { @@ -24,87 +26,105 @@ export const legacyFeedbackDelete = Effect.fn("legacy.feedback.delete")(function args: LegacyFeedbackDeleteArgs, ) { const output = yield* Output; + const goOutputFlag = yield* LegacyOutputFlag; const cliConfig = yield* LegacyCliConfig; const telemetryRuntime = yield* TelemetryRuntime; const client = yield* FeedbackClient; + const telemetryState = yield* LegacyTelemetryState; - if (!LEGACY_UUID_PATTERN.test(args.token)) { - return yield* Effect.fail( - new LegacyFeedbackInvalidTokenError({ message: LEGACY_FEEDBACK_INVALID_TOKEN_MESSAGE }), - ); - } - // RLS compares the header lowercased; normalize so an uppercase paste works. - const token = args.token.toLowerCase(); + const goFmt = Option.getOrUndefined(goOutputFlag); - // Project-ref context gate: rows submitted with a project ref only match - // when the same ref is presented. Flag → SUPABASE_PROJECT_ID → the linked - // ref file; extra context against a context-free row is ignored server-side, - // so sending whatever resolves is always safe. - const projectRef = yield* legacyResolveFeedbackProjectRef( - cliConfig.workdir, - Option.orElse(args.projectRef, () => cliConfig.projectId), - ).pipe(Effect.map(Option.getOrUndefined)); - - // User-id context gate, same shape as the project-ref one: rows submitted - // with a user_id only match when the same id arrives as a header. Unlike - // the submit-side attribution this is NOT consent-gated — it is functional - // auth context, and gating it would strand rows submitted before a consent - // opt-out. Logged out → undefined → header omitted. - const rowContext = { - projectRef, - userId: telemetryRuntime.identity.current(), - }; + // Persist the telemetry state file (`~/.supabase/telemetry.json`) whether + // the delete succeeds or fails — the same PersistentPostRun-shaped + // finalizer every legacy command runs. + yield* Effect.gen(function* () { + if (!LEGACY_UUID_PATTERN.test(args.token)) { + return yield* Effect.fail( + new LegacyFeedbackInvalidTokenError({ message: LEGACY_FEEDBACK_INVALID_TOKEN_MESSAGE }), + ); + } + // RLS compares the header lowercased; normalize so an uppercase paste works. + const token = args.token.toLowerCase(); - const looking = yield* output.task("Looking up feedback..."); - const preview = yield* client - .preview(token, rowContext) - .pipe(Effect.tapError(() => looking.fail())); - yield* looking.clear(); + // Project-ref context gate: rows submitted with a project ref only match + // when the same ref is presented. Flag → SUPABASE_PROJECT_ID → the linked + // ref file; extra context against a context-free row is ignored server-side, + // so sending whatever resolves is always safe. + const projectRef = yield* legacyResolveFeedbackProjectRef( + cliConfig.workdir, + Option.orElse(args.projectRef, () => cliConfig.projectId), + ).pipe(Effect.map(Option.getOrUndefined)); - if (Option.isNone(preview)) { - return yield* Effect.fail( - new LegacyFeedbackNotFoundError({ message: LEGACY_FEEDBACK_NOT_FOUND_MESSAGE }), - ); - } - const feedbackText = preview.value; + // User-id context gate, same shape as the project-ref one: rows submitted + // with a user_id only match when the same id arrives as a header. Unlike + // the submit-side attribution this is NOT consent-gated — it is functional + // auth context, and gating it would strand rows submitted before a consent + // opt-out. Logged out → undefined → header omitted. + const rowContext = { + projectRef, + userId: telemetryRuntime.identity.current(), + }; - if (output.format === "text") { - yield* output.info(`Found feedback: "${feedbackText}"`); - } + const looking = yield* output.task("Looking up feedback..."); + const preview = yield* client + .preview(token, rowContext) + .pipe(Effect.tapError(() => looking.fail())); + yield* looking.clear(); - // `--yes`/`SUPABASE_YES` auto-confirms; otherwise prompt. In non-interactive - // contexts (json/stream-json, or piped text mode) the prompt fails loudly - // with NonInteractiveError rather than silently deleting — pass --yes there. - const yes = yield* legacyResolveYes; - if (!yes) { - const confirmed = yield* output.promptConfirm("Permanently delete this feedback?", { - defaultValue: false, - }); - if (!confirmed) { + if (Option.isNone(preview)) { return yield* Effect.fail( - new LegacyFeedbackDeleteCancelledError({ - message: LEGACY_FEEDBACK_DELETE_CANCELLED_MESSAGE, - }), + new LegacyFeedbackNotFoundError({ message: LEGACY_FEEDBACK_NOT_FOUND_MESSAGE }), ); } - } + const feedbackText = preview.value; + + // Suppressed under `-o json` as well: stdout must stay payload-only, and + // the payload already carries the feedback text. + if (goFmt !== "json" && output.format === "text") { + yield* output.info(`Found feedback: "${feedbackText}"`); + } + + // `--yes`/`SUPABASE_YES` auto-confirms; otherwise prompt. In non-interactive + // contexts (json/stream-json, or piped text mode) the prompt fails loudly + // with NonInteractiveError rather than silently deleting — pass --yes there. + const yes = yield* legacyResolveYes; + if (!yes) { + const confirmed = yield* output.promptConfirm("Permanently delete this feedback?", { + defaultValue: false, + }); + if (!confirmed) { + return yield* Effect.fail( + new LegacyFeedbackDeleteCancelledError({ + message: LEGACY_FEEDBACK_DELETE_CANCELLED_MESSAGE, + }), + ); + } + } - const deleting = yield* output.task("Deleting feedback..."); - const { deleted } = yield* client - .delete(token, rowContext) - .pipe(Effect.tapError(() => deleting.fail())); - yield* deleting.clear(); + const deleting = yield* output.task("Deleting feedback..."); + const { deleted } = yield* client + .delete(token, rowContext) + .pipe(Effect.tapError(() => deleting.fail())); + yield* deleting.clear(); - // The preview matched but the delete didn't: the row disappeared in between. - if (!deleted) { - return yield* Effect.fail( - new LegacyFeedbackNotFoundError({ message: LEGACY_FEEDBACK_NOT_FOUND_MESSAGE }), - ); - } + // The preview matched but the delete didn't: the row disappeared in between. + if (!deleted) { + return yield* Effect.fail( + new LegacyFeedbackNotFoundError({ message: LEGACY_FEEDBACK_NOT_FOUND_MESSAGE }), + ); + } - if (output.format !== "text") { - yield* output.success("Feedback deleted.", { feedback: feedbackText }); - return; - } - yield* output.success("Feedback deleted."); + // Go-compat `-o json` wins over `--output-format` (legacy CLAUDE.md item 6): + // stdout carries the machine payload only. `pretty` (or unset) falls through. + if (goFmt === "json") { + yield* output.raw(encodeGoJson({ feedback: feedbackText })); + return; + } + + if (output.format !== "text") { + yield* output.success("Feedback deleted.", { feedback: feedbackText }); + return; + } + yield* output.success("Feedback deleted."); + }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/feedback/delete/delete.integration.test.ts b/apps/cli/src/legacy/commands/feedback/delete/delete.integration.test.ts index 1ab88a8e4e..a08b391b2f 100644 --- a/apps/cli/src/legacy/commands/feedback/delete/delete.integration.test.ts +++ b/apps/cli/src/legacy/commands/feedback/delete/delete.integration.test.ts @@ -8,7 +8,7 @@ import { FeedbackBackendError, FeedbackClient, } from "../../../../shared/feedback/feedback-client.service.ts"; -import { LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; +import { LegacyOutputFlag, LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; import { mockContextualAnalytics, @@ -19,6 +19,7 @@ import { import { LEGACY_VALID_REF, mockLegacyCliConfig, + mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; import type { LegacyFeedbackDeleteArgs } from "./delete.command.ts"; @@ -107,6 +108,8 @@ function setupLegacyFeedbackDelete( output?: Parameters[0]; client?: MockClientOpts; yes?: boolean; + /** Simulates the Go-compat `-o`/`--output` global flag. */ + goOutput?: "env" | "pretty" | "json" | "toml" | "yaml" | "table" | "csv"; /** Simulates `SUPABASE_PROJECT_ID`, the only source `LegacyCliConfig` reads. */ projectIdEnv?: string; /** Simulates the gotrue user id persisted to telemetry.json at login. */ @@ -116,9 +119,11 @@ function setupLegacyFeedbackDelete( ) { const out = mockOutput(opts.output ?? { promptConfirmResponses: [true] }); const client = mockFeedbackClient(opts.client ?? { previewText: "my papercut" }); + const telemetryState = mockLegacyTelemetryStateTracked(); const layer = Layer.mergeAll( out.layer, client.layer, + telemetryState.layer, mockTelemetryRuntime({ cliVersion: "9.9.9", distinctId: opts.distinctId, @@ -130,12 +135,13 @@ function setupLegacyFeedbackDelete( projectId: opts.projectIdEnv === undefined ? Option.none() : Option.some(opts.projectIdEnv), }), Layer.succeed(LegacyYesFlag, opts.yes ?? false), + Layer.succeed(LegacyOutputFlag, Option.fromNullishOr(opts.goOutput)), Layer.succeed(CliArgs, { args: [] }), // Real filesystem: the handler reads `supabase/.temp/project-ref` from the // temp workdir, so this must not be stubbed out. BunServices.layer, ); - return { layer, out, client }; + return { layer, out, client, telemetryState }; } // Extra layers required by the wrapped `legacyFeedbackDeleteHandler` (the exact @@ -158,7 +164,7 @@ function setupLegacyFeedbackDeleteHandler( describe("legacy feedback delete", () => { it.live("previews the feedback, confirms, and deletes it", () => { - const { layer, out, client } = setupLegacyFeedbackDelete(); + const { layer, out, client, telemetryState } = setupLegacyFeedbackDelete(); return Effect.gen(function* () { yield* legacyFeedbackDelete(deleteArgs()); @@ -173,6 +179,9 @@ describe("legacy feedback delete", () => { expect(out.messages).toContainEqual( expect.objectContaining({ type: "success", message: "Feedback deleted." }), ); + // telemetry.json is refreshed on every invocation, like every other + // legacy command's PersistentPostRun-shaped finalizer. + expect(telemetryState.flushCount).toBe(1); }).pipe(Effect.provide(layer)); }); @@ -364,6 +373,25 @@ describe("legacy feedback delete", () => { }).pipe(Effect.provide(layer)); }); + it.live("emits only the machine payload on stdout with -o json", () => { + const { layer, out } = setupLegacyFeedbackDelete({ + goOutput: "json", + client: { previewText: "go machine feedback" }, + yes: true, + }); + return Effect.gen(function* () { + yield* legacyFeedbackDelete(deleteArgs()); + + expect(out.rawChunks).toHaveLength(1); + expect(out.rawChunks[0]?.stream).toBe("stdout"); + expect(JSON.parse(out.rawChunks[0]!.text)).toEqual({ feedback: "go machine feedback" }); + // The payload carries the feedback text; no "Found feedback" info line + // and no human-readable acknowledgement — stdout is payload-only. + expect(out.messages).not.toContainEqual(expect.objectContaining({ type: "info" })); + expect(out.messages).not.toContainEqual(expect.objectContaining({ type: "success" })); + }).pipe(Effect.provide(layer)); + }); + it.live("fails loudly in json mode without --yes instead of silently deleting", () => { const { layer, out, client, processControl } = setupLegacyFeedbackDeleteHandler({ output: { format: "json", promptConfirmFail: true }, @@ -391,7 +419,7 @@ describe("legacy feedback delete", () => { }); it.live("surfaces a backend failure during the delete", () => { - const { layer, out } = setupLegacyFeedbackDelete({ + const { layer, out, telemetryState } = setupLegacyFeedbackDelete({ client: { previewText: "doomed", deleteFailWith: "backend unavailable" }, yes: true, }); @@ -400,6 +428,8 @@ describe("legacy feedback delete", () => { expect(error).toMatchObject({ _tag: "FeedbackBackendError", operation: "delete" }); expect(out.messages).not.toContainEqual(expect.objectContaining({ type: "success" })); + // The finalizer runs on failure too, matching Go's PersistentPostRun. + expect(telemetryState.flushCount).toBe(1); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/feedback/feedback-output.ts b/apps/cli/src/legacy/commands/feedback/feedback-output.ts new file mode 100644 index 0000000000..c64ed15891 --- /dev/null +++ b/apps/cli/src/legacy/commands/feedback/feedback-output.ts @@ -0,0 +1,10 @@ +/** + * The `-o`/`--output` values the feedback family accepts, validated by + * `withLegacyCommandInstrumentation` exactly like `db query`'s restricted + * `json|table|csv` enum. Feedback is TS-only: there is no Go struct behind its + * acknowledgement payload, so the struct-spec-driven `-o yaml|toml` encoders + * (which reproduce Go field names byte-for-byte) don't apply, and `env` has no + * plausible consumer for a one-field receipt. `pretty` keeps the human text; + * `json` emits the machine payload through `encodeGoJson`. + */ +export const LEGACY_FEEDBACK_OUTPUT_FORMATS = ["pretty", "json"] as const; diff --git a/apps/cli/src/legacy/commands/feedback/feedback.layers.ts b/apps/cli/src/legacy/commands/feedback/feedback.layers.ts index ef0658a7bd..265bfc0ec5 100644 --- a/apps/cli/src/legacy/commands/feedback/feedback.layers.ts +++ b/apps/cli/src/legacy/commands/feedback/feedback.layers.ts @@ -3,17 +3,55 @@ import { feedbackClientLayer, legacyFeedbackEnvironment, } from "../../../shared/feedback/feedback-client.layer.ts"; +import { LegacyDnsResolverFlag } from "../../../shared/legacy/global-flags.ts"; import { legacyCliConfigLayer } from "../../config/legacy-cli-config.layer.ts"; import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; import { legacyDebugLoggerLayer } from "../../shared/legacy-debug-logger.layer.ts"; +import type { LegacyDebugLoggerShape } from "../../shared/legacy-debug-logger.service.ts"; +import { LegacyDebugLogger } from "../../shared/legacy-debug-logger.service.ts"; +import { legacyDohFetch } from "../../shared/legacy-http-dns.ts"; export const legacyFeedbackCliConfigLayer = legacyCliConfigLayer.pipe( Layer.provide(legacyDebugLoggerLayer), ); +interface LegacyFeedbackFetchOptions { + readonly dnsResolver: "native" | "https"; + readonly logger: LegacyDebugLoggerShape; + /** Injectable inner transport for hermetic tests; defaults to `globalThis.fetch`. */ + readonly innerFetch?: typeof globalThis.fetch; +} + +/** + * The feedback client speaks `fetch` (supabase-js), not Effect's `HttpClient`, + * so the two legacy transport behaviors every command promises compose at the + * fetch boundary instead of through `legacyHttpClientLayer`: `--debug` request + * logging on stderr, wrapping the `--dns-resolver https` DoH resolution. + */ +export function legacyFeedbackFetch(options: LegacyFeedbackFetchOptions): typeof globalThis.fetch { + const { dnsResolver, logger } = options; + const dohFetch = legacyDohFetch({ dnsResolver, innerFetch: options.innerFetch }); + return Object.assign( + (input: string | URL | Request, init?: RequestInit): Promise => { + const method = init?.method ?? (input instanceof Request ? input.method : "GET"); + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + // The logger's write is synchronous (a gated stderr write); running it + // at this plain-fetch boundary keeps the wrapper a `typeof fetch`. + Effect.runSync(logger.http(method, url)); + return dohFetch(input, init); + }, + { preconnect: globalThis.fetch.preconnect }, + ); +} + export const legacyFeedbackClientLayer = Layer.unwrap( Effect.gen(function* () { const config = yield* LegacyCliConfig; - return feedbackClientLayer({ environment: legacyFeedbackEnvironment(config.profile) }); + const dnsResolver = yield* LegacyDnsResolverFlag; + const logger = yield* LegacyDebugLogger; + return feedbackClientLayer({ + environment: legacyFeedbackEnvironment(config.profile), + fetch: legacyFeedbackFetch({ dnsResolver, logger }), + }); }), -).pipe(Layer.provide(legacyFeedbackCliConfigLayer)); +).pipe(Layer.provide(legacyFeedbackCliConfigLayer), Layer.provide(legacyDebugLoggerLayer)); diff --git a/apps/cli/src/legacy/commands/feedback/feedback.layers.unit.test.ts b/apps/cli/src/legacy/commands/feedback/feedback.layers.unit.test.ts new file mode 100644 index 0000000000..ccd27f99f4 --- /dev/null +++ b/apps/cli/src/legacy/commands/feedback/feedback.layers.unit.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { Effect } from "effect"; +import type { LegacyDebugLoggerShape } from "../../shared/legacy-debug-logger.service.ts"; +import { legacyFeedbackFetch } from "./feedback.layers.ts"; + +function recordingLogger() { + const httpLines: Array = []; + const logger: LegacyDebugLoggerShape = { + debug: () => Effect.void, + http: (method, url) => + Effect.sync(() => { + httpLines.push(`${method} ${url}`); + }), + }; + return { logger, httpLines }; +} + +function recordingInnerFetch() { + const requests: Array<{ url: string; method: string | undefined }> = []; + const fetch: typeof globalThis.fetch = Object.assign( + async (input: string | URL | Request, init?: RequestInit) => { + requests.push({ url: String(input), method: init?.method }); + return new Response("ok"); + }, + { preconnect: () => Promise.resolve() }, + ); + return { fetch, requests }; +} + +describe("legacyFeedbackFetch", () => { + it("logs every request through the debug logger and delegates to the inner fetch", async () => { + const { logger, httpLines } = recordingLogger(); + const inner = recordingInnerFetch(); + const fetch = legacyFeedbackFetch({ dnsResolver: "native", logger, innerFetch: inner.fetch }); + + const response = await fetch("https://feedback.supabase.co/rest/v1/rpc/x", { method: "POST" }); + + expect(await response.text()).toBe("ok"); + expect(httpLines).toEqual(["POST https://feedback.supabase.co/rest/v1/rpc/x"]); + expect(inner.requests).toEqual([ + { url: "https://feedback.supabase.co/rest/v1/rpc/x", method: "POST" }, + ]); + }); + + it("defaults the logged method to GET when the request carries none", async () => { + const { logger, httpLines } = recordingLogger(); + const inner = recordingInnerFetch(); + const fetch = legacyFeedbackFetch({ dnsResolver: "native", logger, innerFetch: inner.fetch }); + + await fetch("https://feedback.supabase.co/rest/v1/interfaces_feedback"); + + expect(httpLines).toEqual(["GET https://feedback.supabase.co/rest/v1/interfaces_feedback"]); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-agent-mode.ts b/apps/cli/src/legacy/shared/legacy-agent-mode.ts new file mode 100644 index 0000000000..1b68bdd32d --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-agent-mode.ts @@ -0,0 +1,11 @@ +import { Option } from "effect"; + +/** Agent-mode resolution: `yes`→true, `no`→false, `auto`→agent detected. */ +export function legacyResolveAgentMode( + agentFlag: "auto" | "yes" | "no", + aiToolName: Option.Option, +): boolean { + if (agentFlag === "yes") return true; + if (agentFlag === "no") return false; + return Option.isSome(aiToolName); +} diff --git a/apps/cli/src/legacy/shared/legacy-agent-mode.unit.test.ts b/apps/cli/src/legacy/shared/legacy-agent-mode.unit.test.ts new file mode 100644 index 0000000000..a4ff49aa01 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-agent-mode.unit.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { Option } from "effect"; +import { legacyResolveAgentMode } from "./legacy-agent-mode.ts"; + +describe("legacyResolveAgentMode", () => { + it("honors the explicit flag and falls back to detection on auto", () => { + expect(legacyResolveAgentMode("yes", Option.none())).toBe(true); + expect(legacyResolveAgentMode("no", Option.some("cursor"))).toBe(false); + expect(legacyResolveAgentMode("auto", Option.some("cursor"))).toBe(true); + expect(legacyResolveAgentMode("auto", Option.none())).toBe(false); + }); +}); diff --git a/apps/cli/src/shared/feedback/feedback-client.integration.test.ts b/apps/cli/src/shared/feedback/feedback-client.integration.test.ts index 3544de4e17..527aacd282 100644 --- a/apps/cli/src/shared/feedback/feedback-client.integration.test.ts +++ b/apps/cli/src/shared/feedback/feedback-client.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Option } from "effect"; +import { Effect, Fiber, Option } from "effect"; import { feedbackClientLayer } from "./feedback-client.layer.ts"; import type { FeedbackSubmission } from "./feedback-client.service.ts"; import { FeedbackClient } from "./feedback-client.service.ts"; @@ -157,6 +157,31 @@ describe("feedbackClientLayer", () => { }).pipe(Effect.provide(layerWith(transport))); }); + it.live("aborts the in-flight request when the fiber is interrupted", () => { + // Ctrl-C during submission must abort the HTTP request, not let the + // insert commit after the command was cancelled. The fake fetch behaves + // like a real one: it settles only when its abort signal fires. + let capturedSignal: AbortSignal | undefined; + const inFlight = Promise.withResolvers(); + const transport = recordingFetch((request) => { + capturedSignal = request.signal; + inFlight.resolve(); + return new Promise((_, reject) => { + request.signal.addEventListener("abort", () => reject(request.signal.reason)); + }); + }); + return Effect.gen(function* () { + const client = yield* FeedbackClient; + const fiber = yield* client + .submit(SUBMISSION) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => inFlight.promise); + yield* Fiber.interrupt(fiber); + + expect(capturedSignal?.aborted).toBe(true); + }).pipe(Effect.provide(layerWith(transport))); + }); + it.live("fails when the backend returns no delete token", () => { const transport = recordingFetch(() => jsonResponse(null)); return Effect.gen(function* () { diff --git a/apps/cli/src/shared/feedback/feedback-client.layer.ts b/apps/cli/src/shared/feedback/feedback-client.layer.ts index 865f7d525a..174ce648be 100644 --- a/apps/cli/src/shared/feedback/feedback-client.layer.ts +++ b/apps/cli/src/shared/feedback/feedback-client.layer.ts @@ -31,7 +31,11 @@ const REQUEST_TIMEOUT_MS = 10_000; interface FeedbackClientOptions { readonly environment: FeedbackEnvironment; - /** Injectable transport for hermetic tests, like `legacyDohFetch`'s `innerFetch`. */ + /** + * Injectable transport. Production wires the legacy debug/DoH fetch + * (`legacyFeedbackFetch` in `feedback.layers.ts`) so `--debug` and + * `--dns-resolver https` apply; hermetic tests inject a recording fake. + */ readonly fetch?: typeof globalThis.fetch; } @@ -80,17 +84,21 @@ export function feedbackClientLayer(options: FeedbackClientOptions): Layer.Layer }); // PostgREST reports failures as a returned `error`, not a rejection; a - // thrown/timed-out fetch rejects. Both map to `FeedbackBackendError`. + // thrown/timed-out/aborted fetch rejects. Both map to `FeedbackBackendError`. + // Each request aborts on whichever fires first: fiber interruption (the + // signal `Effect.tryPromise` hands us — Ctrl-C must not let an in-flight + // submit commit after the command is cancelled) or the 10s timeout. const run = ( operation: FeedbackBackendError["operation"], - request: () => PromiseLike<{ + request: (signal: AbortSignal) => PromiseLike<{ data: A; error: { message: string } | null; count?: number | null; }>, ) => Effect.tryPromise({ - try: request, + try: (signal) => + request(AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)])), catch: (cause) => new FeedbackBackendError({ message: cause instanceof Error ? cause.message : String(cause), @@ -106,10 +114,8 @@ export function feedbackClientLayer(options: FeedbackClientOptions): Layer.Layer return FeedbackClient.of({ submit: (submission) => - run("submit", () => - client - .rpc("submit_interfaces_feedback", toRpcArgs(submission)) - .abortSignal(AbortSignal.timeout(REQUEST_TIMEOUT_MS)), + run("submit", (signal) => + client.rpc("submit_interfaces_feedback", toRpcArgs(submission)).abortSignal(signal), ).pipe( Effect.flatMap(({ data }) => typeof data === "string" && data.length > 0 @@ -124,13 +130,13 @@ export function feedbackClientLayer(options: FeedbackClientOptions): Layer.Layer ), preview: (token, context) => - run("preview", () => { + run("preview", (signal) => { let request = client .from("interfaces_feedback") .select("feedback") .eq("delete_token", token) .setHeader("x-feedback-token", token) - .abortSignal(AbortSignal.timeout(REQUEST_TIMEOUT_MS)); + .abortSignal(signal); if (context?.projectRef !== undefined) { request = request.setHeader("x-feedback-project-ref", context.projectRef); } @@ -141,7 +147,7 @@ export function feedbackClientLayer(options: FeedbackClientOptions): Layer.Layer }).pipe(Effect.map(({ data }) => Option.fromNullishOr(data?.[0]?.feedback))), delete: (token, context) => - run("delete", () => { + run("delete", (signal) => { // The `delete_token=eq.` filter satisfies PostgREST's filterless-delete // rejection; the `x-feedback-token` header is the actual security // boundary (RLS matches zero rows without it). `count: "exact"` asks @@ -152,7 +158,7 @@ export function feedbackClientLayer(options: FeedbackClientOptions): Layer.Layer .delete({ count: "exact" }) .eq("delete_token", token) .setHeader("x-feedback-token", token) - .abortSignal(AbortSignal.timeout(REQUEST_TIMEOUT_MS)); + .abortSignal(signal); if (context?.projectRef !== undefined) { request = request.setHeader("x-feedback-project-ref", context.projectRef); } From 9760ee7be4ec8acf639ad36ecb6ddc2eb0f579ae Mon Sep 17 00:00:00 2001 From: Kanad Gupta Date: Tue, 18 Aug 2026 11:56:33 -0700 Subject: [PATCH 16/21] chore: add script for regenerating types --- apps/cli/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/cli/package.json b/apps/cli/package.json index 68feb5a91f..d404f58cdb 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -32,6 +32,7 @@ "docs:spec": "bun scripts/generate-docs-spec.ts", "dev:next": "pnpm exec bun src/next/main.ts", "dev:legacy": "pnpm exec bun src/legacy/main.ts", + "gen:feedback-types": "pnpm dev:legacy -- gen types typescript > src/shared/feedback/database.types.ts", "test": "nx run-many -t test:core test:e2e --projects=$npm_package_name", "test:core": "nx run-many -t test:unit test:integration --projects=$npm_package_name --coverage.enabled", "test:smoke": "bun run tests/smoke-test.ts", From 8b17e913beca4e2a52334e0d58be860d90f77b4d Mon Sep 17 00:00:00 2001 From: Kanad Gupta Date: Tue, 18 Aug 2026 11:56:44 -0700 Subject: [PATCH 17/21] chore: add note specifying 1000 character limit --- apps/cli/src/legacy/commands/feedback/add/add.command.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/legacy/commands/feedback/add/add.command.ts b/apps/cli/src/legacy/commands/feedback/add/add.command.ts index f78b194c17..c44aad10be 100644 --- a/apps/cli/src/legacy/commands/feedback/add/add.command.ts +++ b/apps/cli/src/legacy/commands/feedback/add/add.command.ts @@ -12,7 +12,9 @@ import { legacyFeedbackAdd } from "./add.handler.ts"; const config = { message: Argument.string("message").pipe( - Argument.withDescription("Freeform feedback. Bare words are joined with spaces."), + Argument.withDescription( + "Freeform feedback. Bare words are joined with spaces. 1000 character limit.", + ), Argument.variadic, ), } as const; From ac97fbae29d98fee80edab067893e388202a6964 Mon Sep 17 00:00:00 2001 From: Kanad Gupta Date: Tue, 18 Aug 2026 13:33:14 -0700 Subject: [PATCH 18/21] chore: more script changes, remove non-public schemas --- apps/cli/package.json | 2 +- .../cli/src/shared/feedback/database.types.ts | 28 ------------------- 2 files changed, 1 insertion(+), 29 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index d404f58cdb..de54a18052 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -32,7 +32,7 @@ "docs:spec": "bun scripts/generate-docs-spec.ts", "dev:next": "pnpm exec bun src/next/main.ts", "dev:legacy": "pnpm exec bun src/legacy/main.ts", - "gen:feedback-types": "pnpm dev:legacy -- gen types typescript > src/shared/feedback/database.types.ts", + "gen:feedback-types": "pnpm dev:legacy -- gen types typescript --project-id imrwaufzgcaczqmpnxyr --schema public > src/shared/feedback/database.types.ts", "test": "nx run-many -t test:core test:e2e --projects=$npm_package_name", "test:core": "nx run-many -t test:unit test:integration --projects=$npm_package_name --coverage.enabled", "test:smoke": "bun run tests/smoke-test.ts", diff --git a/apps/cli/src/shared/feedback/database.types.ts b/apps/cli/src/shared/feedback/database.types.ts index 9050d4c3af..7aea2dfbd4 100644 --- a/apps/cli/src/shared/feedback/database.types.ts +++ b/apps/cli/src/shared/feedback/database.types.ts @@ -12,31 +12,6 @@ export type Database = { __InternalSupabase: { PostgrestVersion: "14.5" } - graphql_public: { - Tables: { - [_ in never]: never - } - Views: { - [_ in never]: never - } - Functions: { - graphql: { - Args: { - extensions?: Json - operationName?: string - query?: string - variables?: Json - } - Returns: Json - } - } - Enums: { - [_ in never]: never - } - CompositeTypes: { - [_ in never]: never - } - } public: { Tables: { feedback: { @@ -980,9 +955,6 @@ export type CompositeTypes< : never export const Constants = { - graphql_public: { - Enums: {}, - }, public: { Enums: { feedback_vote: ["yes", "no"], From 85479ec60580fce8c9805da09f847088f1a4ba36 Mon Sep 17 00:00:00 2001 From: Kanad Gupta Date: Tue, 18 Aug 2026 14:43:54 -0700 Subject: [PATCH 19/21] chore: lockfile --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21e9aac6a6..d05b60efcb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -168,7 +168,7 @@ importers: version: link:../../packages/stack '@supabase/supabase-js': specifier: ^2.110.7 - version: 2.111.0 + version: 2.112.2 '@tsconfig/bun': specifier: 'catalog:' version: 1.0.10 From ded7f433efb236d73627b7ace24572e4384de115 Mon Sep 17 00:00:00 2001 From: Kanad Gupta Date: Tue, 18 Aug 2026 14:47:29 -0700 Subject: [PATCH 20/21] chore: use this config instead --- .oxfmtrc.jsonc | 8 ++++++++ apps/cli/.prettierignore | 3 --- 2 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 .oxfmtrc.jsonc delete mode 100644 apps/cli/.prettierignore diff --git a/.oxfmtrc.jsonc b/.oxfmtrc.jsonc new file mode 100644 index 0000000000..131b911cb8 --- /dev/null +++ b/.oxfmtrc.jsonc @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "ignorePatterns": [ + ".repos/**", + // Autogenerated files that must stay byte-identical to their generator's output (never reformat) + "apps/cli/src/shared/feedback/database.types.ts", + ], +} diff --git a/apps/cli/.prettierignore b/apps/cli/.prettierignore deleted file mode 100644 index 99bbedf86f..0000000000 --- a/apps/cli/.prettierignore +++ /dev/null @@ -1,3 +0,0 @@ -# oxfmt reads .prettierignore by default. Autogenerated files that must stay -# byte-identical to their generator's output (never reformat): -src/shared/feedback/database.types.ts From d8a427eb17f081911ac3246cae480174b44ba5ab Mon Sep 17 00:00:00 2001 From: Kanad Gupta Date: Tue, 18 Aug 2026 16:01:12 -0700 Subject: [PATCH 21/21] chore: use production endpoint --- apps/cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index de54a18052..77760a3938 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -32,7 +32,7 @@ "docs:spec": "bun scripts/generate-docs-spec.ts", "dev:next": "pnpm exec bun src/next/main.ts", "dev:legacy": "pnpm exec bun src/legacy/main.ts", - "gen:feedback-types": "pnpm dev:legacy -- gen types typescript --project-id imrwaufzgcaczqmpnxyr --schema public > src/shared/feedback/database.types.ts", + "gen:feedback-types": "pnpm dev:legacy -- gen types typescript --project-id xguihxuzqibwxjnimxev --schema public > src/shared/feedback/database.types.ts", "test": "nx run-many -t test:core test:e2e --projects=$npm_package_name", "test:core": "nx run-many -t test:unit test:integration --projects=$npm_package_name --coverage.enabled", "test:smoke": "bun run tests/smoke-test.ts",