From 25e2b2ff453891452103183f33405bb287991b48 Mon Sep 17 00:00:00 2001 From: anthony lio Date: Fri, 23 Oct 2026 17:43:55 +0300 Subject: [PATCH 1/2] feat(cli): send docs targets in feedback metadata --- .../feedback-client.integration.test.ts | 40 +++++++++++++++++++ .../shared/feedback/feedback-client.layer.ts | 3 ++ .../feedback/feedback-client.service.ts | 6 +++ 3 files changed, 49 insertions(+) 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 eb9bbe3508..e15b7a8279 100644 --- a/apps/cli/src/shared/feedback/feedback-client.integration.test.ts +++ b/apps/cli/src/shared/feedback/feedback-client.integration.test.ts @@ -131,6 +131,46 @@ describe("feedbackClientLayer", () => { }).pipe(Effect.provide(layerWith(transport))); }); + it.effect("sends docs targets inside metadata", () => { + const transport = recordingFetch(() => jsonResponse(TOKEN)); + return Effect.gen(function* () { + const client = yield* FeedbackClient; + yield* client.submit({ + message: "the guide does not say who refreshes the session", + context: { + cliVersion: "9.9.9", + userAgent: "SupabaseCLI/9.9.9", + os: "linux", + arch: "x64", + isAgent: false, + }, + targets: [ + { + url: "https://supabase.com/docs/guides/auth/server-side", + quote: "Create a server client.", + heading: "Creating a client", + }, + ], + }); + + const body = yield* decodeJsonBody(transport.requests[0]!.bodyText); + expect(body["metadata"]).toEqual({ + cli_version: "9.9.9", + source: "cli", + os: "linux", + arch: "x64", + is_agent: false, + targets: [ + { + url: "https://supabase.com/docs/guides/auth/server-side", + quote: "Create a server client.", + heading: "Creating a client", + }, + ], + }); + }).pipe(Effect.provide(layerWith(transport))); + }); + it.effect("maps a PostgREST error response to FeedbackBackendError", () => { // Shape PostgREST returns when execute is denied on the RPC. const transport = recordingFetch(() => diff --git a/apps/cli/src/shared/feedback/feedback-client.layer.ts b/apps/cli/src/shared/feedback/feedback-client.layer.ts index a22c675391..fc64b14155 100644 --- a/apps/cli/src/shared/feedback/feedback-client.layer.ts +++ b/apps/cli/src/shared/feedback/feedback-client.layer.ts @@ -75,6 +75,9 @@ function toRpcArgs(submission: FeedbackSubmission): RpcArgs { arch: context.arch, is_agent: context.isAgent, ...(context.agentName === undefined ? {} : { agent_name: context.agentName }), + ...(submission.targets === undefined || submission.targets.length === 0 + ? {} + : { targets: submission.targets }), }, }; } diff --git a/apps/cli/src/shared/feedback/feedback-client.service.ts b/apps/cli/src/shared/feedback/feedback-client.service.ts index c91b4bf90a..7fdc8c526b 100644 --- a/apps/cli/src/shared/feedback/feedback-client.service.ts +++ b/apps/cli/src/shared/feedback/feedback-client.service.ts @@ -25,6 +25,12 @@ export interface FeedbackSubmission { */ readonly userId?: string; readonly context: FeedbackContext; + /** An inline object type, not an interface, so it is assignable to the `Json` metadata type. */ + readonly targets?: Array<{ + readonly url: string; + readonly quote: string; + readonly heading?: string; + }>; } /** From 15972337b1bf191daa3635c0ab723ad3467abc13 Mon Sep 17 00:00:00 2001 From: anthony lio Date: Fri, 23 Oct 2026 17:43:56 +0300 Subject: [PATCH 2/2] feat(cli): add --target to feedback add --- .../src/command-internal/db-target-flags.ts | 1 + .../src/commands/feedback/add/SIDE_EFFECTS.md | 13 +- .../src/commands/feedback/add/add.command.ts | 23 ++- .../src/commands/feedback/add/add.e2e.test.ts | 23 +++ .../src/commands/feedback/add/add.errors.ts | 20 +++ .../src/commands/feedback/add/add.handler.ts | 49 +++++- .../feedback/add/add.integration.test.ts | 142 +++++++++++++++++- .../telemetry/__fixtures__/error-tags.txt | 1 + 8 files changed, 260 insertions(+), 12 deletions(-) diff --git a/apps/cli/src/command-internal/db-target-flags.ts b/apps/cli/src/command-internal/db-target-flags.ts index 8622d8e393..d1ca65300e 100644 --- a/apps/cli/src/command-internal/db-target-flags.ts +++ b/apps/cli/src/command-internal/db-target-flags.ts @@ -112,6 +112,7 @@ export const VALUE_CONSUMING_LONG_FLAGS = new Set([ "sub", ...GEN_TYPES_LANGUAGE_VALUE_FLAG_NAMES, "tail", + "target", "template", "timestamp", "to", diff --git a/apps/cli/src/commands/feedback/add/SIDE_EFFECTS.md b/apps/cli/src/commands/feedback/add/SIDE_EFFECTS.md index b51f331a51..49bd3ff9fa 100644 --- a/apps/cli/src/commands/feedback/add/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/feedback/add/SIDE_EFFECTS.md @@ -1,4 +1,4 @@ -# `supabase feedback add [message...] [--project-ref ]` +# `supabase feedback add [message...] [--project-ref ] [--target ]...` ## Files Read @@ -17,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), user_id (omitted when logged out or consent-denied), metadata: { cli_version, source: "cli", os, arch, is_agent, agent_name? } }` | uuid delete token (issued exactly once, shown to the user) | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ----------------------------------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | +| `POST` | `/rest/v1/rpc/submit_interfaces_feedback` | `apikey` = committed publishable key | `{ feedback, user_agent, project_ref (omitted when unlinked), user_id (omitted when logged out or consent-denied), metadata: { cli_version, source: "cli", os, arch, is_agent, agent_name?, targets?: [{ url, quote, heading? }] } }` | uuid delete token (issued exactly once, shown to the user) | `` follows the resolved profile (`feedback.layers.ts`): `supabase-staging` / `supabase-local` → the persistent staging branch of the @@ -50,6 +50,8 @@ every command. | `0` | success | | `1` | no message from args, piped stdin, or an interactive prompt (`-o json` never prompts) | | `1` | message over the 1000-character limit (checked client-side, no request sent) | +| `1` | invalid `--target` (not a JSON object, or a bad `url`, `quote` or `heading`; no request sent) | +| `1` | more than 3 `--target` flags (validated pre-run) | | `1` | `--project-ref` or `SUPABASE_PROJECT_ID` is set but not a well-formed project ref (no request sent) | | `1` | submit failure (PostgREST error, network failure, or 10s timeout) | | `1` | `-o`/`--output` value outside the command's `pretty\|json` enum (validated pre-run) | @@ -64,7 +66,8 @@ The feedback message content is NEVER included in any telemetry event: the message is a positional argument, which `extractChangedFlagNames` structurally excludes from the `flags` property (it only scans `-`-prefixed argv tokens). `--project-ref` is recorded by name only; its value is redacted (no -telemetry-safe marking, same as `feedback delete`). Regression-tested in +telemetry-safe marking, same as `feedback delete`). `--target` values are +redacted the same way. Regression-tested in `add.integration.test.ts`. The message goes only to the `interfaces_feedback` table via the API route above. diff --git a/apps/cli/src/commands/feedback/add/add.command.ts b/apps/cli/src/commands/feedback/add/add.command.ts index 283e44b881..fc26559e84 100644 --- a/apps/cli/src/commands/feedback/add/add.command.ts +++ b/apps/cli/src/commands/feedback/add/add.command.ts @@ -24,6 +24,12 @@ const config = { ), Flag.optional, ), + target: Flag.String("target").pipe( + Flag.withDescription( + 'Docs section the feedback is about, as JSON: {"url":"https://supabase.com/docs/...","quote":"...","heading":"..."}. Repeat up to 3 times. URL is https://supabase.com/ followed by docs, kb, llms/, llms.txt or llms-full.txt; quote 1-400 characters, copied verbatim; heading at most 200.', + ), + Flag.atMost(3), + ), } as const; export type FeedbackAddArgs = CliCommand.Command.Config.Infer; @@ -33,15 +39,17 @@ export type FeedbackAddArgs = CliCommand.Command.Config.Infer; export const feedbackAddHandler = (args: FeedbackAddArgs) => feedbackAdd(args).pipe( // The message is a positional (structurally excluded from telemetry) and - // `--project-ref` is a plain string flag, so its value is redacted — the - // same treatment as `feedback delete`. Feedback's own `-o` enum is - // `pretty|json` (see feedback-output.ts). + // `--project-ref` and `--target` are plain string flags, so their values + // are redacted — the same treatment as `feedback delete`. Feedback's own + // `-o` enum is `pretty|json` (see feedback-output.ts). withCommandTelemetry({ flags: args, outputFormats: FEEDBACK_OUTPUT_FORMATS }), withJsonErrorHandling, ); export const feedbackAddCommand = Command.make("add", config).pipe( - Command.withDescription("Send quick feedback about the Supabase CLI to the Supabase team."), + Command.withDescription( + "Send quick feedback about the Supabase CLI or docs to the Supabase team.", + ), Command.withShortDescription("Send feedback to the Supabase team"), Command.withExamples([ { @@ -57,8 +65,13 @@ export const feedbackAddCommand = Command.make("add", config).pipe( command: 'supabase feedback add --project-ref abcdefghijklmnopqrst "branching is slow here"', description: "Attribute the feedback to a project from an unlinked directory", }, + { + command: + 'supabase feedback add --target \'{"url":"https://supabase.com/docs/guides/auth/server-side","quote":"Create a server client."}\' "the guide does not say who refreshes the session"', + description: "Report a docs problem with the section it is about", + }, ]), - withPermissions({ operations: [], noApiEffectFlags: ["project-ref"] }), + withPermissions({ operations: [], noApiEffectFlags: ["project-ref", "target"] }), Command.withHandler(feedbackAddHandler), Command.provide(commandRuntimeLayer(["feedback", "add"])), Command.provide(telemetryStateLayer), diff --git a/apps/cli/src/commands/feedback/add/add.e2e.test.ts b/apps/cli/src/commands/feedback/add/add.e2e.test.ts index 1781cc4b47..cd69e170f1 100644 --- a/apps/cli/src/commands/feedback/add/add.e2e.test.ts +++ b/apps/cli/src/commands/feedback/add/add.e2e.test.ts @@ -31,4 +31,27 @@ describe("supabase feedback", () => { ), E2E_TIMEOUT_MS, ); + + it.live( + "feedback add rejects more than 3 --target flags before running", + () => + withTempHome((home) => + Effect.gen(function* () { + const target = '{"url":"https://supabase.com/docs/guides/auth","quote":"a"}'; + const result = yield* runSupabaseEffect( + [ + "feedback", + "add", + ...Array.from({ length: 4 }, () => ["--target", target]).flat(), + "docs gap", + ], + { home: home.dir, env: { HOME: home.dir } }, + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("at most 3"); + expect(result.stdout).toBe(""); + }), + ), + E2E_TIMEOUT_MS, + ); }); diff --git a/apps/cli/src/commands/feedback/add/add.errors.ts b/apps/cli/src/commands/feedback/add/add.errors.ts index 593c97fd67..c8d13bf39a 100644 --- a/apps/cli/src/commands/feedback/add/add.errors.ts +++ b/apps/cli/src/commands/feedback/add/add.errors.ts @@ -55,3 +55,23 @@ export class FeedbackMessageTooLongError extends Data.TaggedError("FeedbackMessa return actionability.invalidInput; } } + +export function feedbackTargetInvalidMessage(position: number, field?: string): string { + return ( + (field === undefined + ? `--target ${position} is not a JSON object. ` + : `--target ${position} has a missing or invalid "${field}". `) + + `Pass JSON like {"url":"https://supabase.com/docs/guides/auth","quote":"..."}. ` + + `The URL must be https://supabase.com/ followed by docs, kb, llms/, llms.txt or llms-full.txt, with spaces percent-encoded and no ".." segments. ` + + `The quote must be 1-400 characters, and the optional heading at most 200 characters.` + ); +} + +/** A `--target` value that is not a valid docs target. */ +export class FeedbackTargetInvalidError extends Data.TaggedError("FeedbackTargetInvalidError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} diff --git a/apps/cli/src/commands/feedback/add/add.handler.ts b/apps/cli/src/commands/feedback/add/add.handler.ts index 51f7a803ba..15c2d0aac2 100644 --- a/apps/cli/src/commands/feedback/add/add.handler.ts +++ b/apps/cli/src/commands/feedback/add/add.handler.ts @@ -1,4 +1,4 @@ -import { Effect, Option, Stream } from "effect"; +import { Effect, Option, Schema, SchemaIssue, Stream } from "effect"; import type { PlatformError } from "effect/PlatformError"; import { FeedbackClient } from "../../../shared/feedback/feedback-client.service.ts"; import { Output } from "../../../shared/output/output.service.ts"; @@ -21,9 +21,32 @@ import { FEEDBACK_PIPE_TOO_LONG_MESSAGE, FeedbackEmptyMessageError, FeedbackMessageTooLongError, + FeedbackTargetInvalidError, + feedbackTargetInvalidMessage, feedbackTooLongMessage, } from "./add.errors.ts"; +const decodeDocsTarget = Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.Struct({ + url: Schema.String.check( + Schema.isMaxLength(500), + Schema.makeFilter((value: string) => { + const url = URL.parse(value); + // The parser resolves dot segments and encodes unsafe characters, so a changed href means unsafe input. + return ( + url?.href === value && + value.startsWith("https://supabase.com/") && + /^\/(docs|kb)(\/|$)|^\/llms(-full)?\.txt$|^\/llms\//.test(url.pathname) + ); + }), + ), + quote: Schema.String.check(Schema.isPattern(/\S/), Schema.isMaxCodePoints(400)), + heading: Schema.optionalKey(Schema.String.check(Schema.isMaxCodePoints(200))), + }), + ), +); + // Collects piped stdin in constant memory, bailing out as over-limit once the // byte cap is crossed — the documented character limit makes anything past the // cap over-limit without buffering the rest of the pipe. Read errors degrade @@ -106,6 +129,29 @@ export const feedbackAdd = Effect.fn("feedback.add")(function* (args: FeedbackAd // the submission succeeds or fails — the telemetry-state finalizer every // command runs. yield* Effect.gen(function* () { + // Check the targets first, so a bad target does not discard a typed or piped message. + const targets = yield* Effect.forEach(args.target, (raw, index) => + decodeDocsTarget(raw).pipe( + Effect.map(({ url, quote, heading }) => ({ + url, + quote, + ...(heading?.trim() ? { heading } : {}), + })), + Effect.mapError((cause) => { + // The error names the field but not its value, because the value can be private docs text. + const segment = SchemaIssue.makeFormatterStandardSchemaV1()(cause.issue).issues[0] + ?.path?.[0]; + return new FeedbackTargetInvalidError({ + message: feedbackTargetInvalidMessage( + index + 1, + segment === undefined + ? undefined + : String(typeof segment === "object" ? segment.key : segment), + ), + }); + }), + ), + ); const message = yield* resolveFeedbackMessage(args); // The backend enforces the 1000-character limit; mirroring it client-side // keeps a user mistake from surfacing as a cryptic PostgREST error @@ -158,6 +204,7 @@ export const feedbackAdd = Effect.fn("feedback.add")(function* (args: FeedbackAd isAgent, agentName, }, + targets, }) .pipe(settleFeedbackTask(sending)); diff --git a/apps/cli/src/commands/feedback/add/add.integration.test.ts b/apps/cli/src/commands/feedback/add/add.integration.test.ts index c4c78f245d..ac0b6a26ba 100644 --- a/apps/cli/src/commands/feedback/add/add.integration.test.ts +++ b/apps/cli/src/commands/feedback/add/add.integration.test.ts @@ -36,6 +36,7 @@ import { FEEDBACK_EMPTY_MESSAGE, FEEDBACK_PIPE_CAP_BYTES, FEEDBACK_PIPE_TOO_LONG_MESSAGE, + feedbackTargetInvalidMessage, feedbackTooLongMessage, } from "./add.errors.ts"; import { feedbackAdd } from "./add.handler.ts"; @@ -72,7 +73,7 @@ function addArgs( message: ReadonlyArray, overrides: Partial = {}, ): FeedbackAddArgs { - return { message, projectRef: Option.none(), ...overrides }; + return { message, projectRef: Option.none(), target: [], ...overrides }; } function mockFeedbackClient(opts: { failWith?: string } = {}) { @@ -193,6 +194,7 @@ describe("feedback add", () => { arch: "arm64", isAgent: false, }, + targets: [], }, ]); expect(out.messages).toContainEqual( @@ -585,6 +587,126 @@ describe("feedback add", () => { }).pipe(Effect.provide(layer)); }); + it.effect("submits docs targets with the message", () => { + const { layer, submitter } = setupFeedback(); + return Effect.gen(function* () { + yield* feedbackAdd( + addArgs(["the guide does not say who refreshes the session"], { + target: [ + '{"url":"https://supabase.com/docs/guides/auth/server-side#creating-a-client","heading":"Creating a client","quote":"Create a server client.","extra":"dropped"}', + '{"url":"https://supabase.com/kb/troubleshooting/auth-session-missing","heading":" ","quote":"Refresh the session."}', + ], + }), + ); + + expect(submitter.submissions[0]?.targets).toEqual([ + { + url: "https://supabase.com/docs/guides/auth/server-side#creating-a-client", + heading: "Creating a client", + quote: "Create a server client.", + }, + { + url: "https://supabase.com/kb/troubleshooting/auth-session-missing", + quote: "Refresh the session.", + }, + ]); + }).pipe(Effect.provide(layer)); + }); + + it.effect.each([ + "https://supabase.com/llms.txt", + "https://supabase.com/llms-full.txt", + "https://supabase.com/llms/guides.txt", + ])("accepts the llms target %s", (url) => { + const { layer, submitter } = setupFeedback(); + return Effect.gen(function* () { + yield* feedbackAdd(addArgs(["docs gap"], { target: [`{"url":"${url}","quote":"a"}`] })); + + expect(submitter.submissions[0]?.targets).toEqual([{ url, quote: "a" }]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("accepts a target quote and heading at the limit, counted in code points", () => { + const quote = "🦆".repeat(400); + const heading = "🦆".repeat(200); + const { layer, submitter } = setupFeedback(); + return Effect.gen(function* () { + yield* feedbackAdd( + addArgs(["docs gap"], { + target: [ + `{"url":"https://supabase.com/docs/x","quote":"${quote}","heading":"${heading}"}`, + ], + }), + ); + + expect(submitter.submissions[0]?.targets).toEqual([ + { url: "https://supabase.com/docs/x", quote, heading }, + ]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("rejects an invalid --target before any request, naming the field", () => { + const { layer, submitter } = setupFeedback(); + const invalidTargets: ReadonlyArray = [ + ["not json", undefined], + ["[]", undefined], + ['{"url":"https://github.com/supabase/ssr","quote":"a"}', "url"], + ['{"url":"https://supabase.com/blog/x","quote":"a"}', "url"], + ['{"url":"https://supabase.com/docs/../blog/x","quote":"a"}', "url"], + ['{"url":"https://supabase.com/docs/%2E%2e/blog","quote":"a"}', "url"], + ['{"url":"https://supabase.com/docs/a b","quote":"a"}', "url"], + ['{"url":"https://evil@supabase.com/docs/x","quote":"a"}', "url"], + ['{"url":"https://supabase.com/docsfoo","quote":"a"}', "url"], + ['{"url":"https://supabase.com/llmsfoo","quote":"a"}', "url"], + ['{"url":"https://supabase.com/llms.txt.evil","quote":"a"}', "url"], + [`{"url":"https://supabase.com/docs/${"a".repeat(475)}","quote":"a"}`, "url"], + ['{"url":"https://supabase.com/docs/x","quote":" "}', "quote"], + [`{"url":"https://supabase.com/docs/x","quote":"${"a".repeat(401)}"}`, "quote"], + ['{"url":"https://supabase.com/docs/x"}', "quote"], + [ + `{"url":"https://supabase.com/docs/x","quote":"a","heading":"${"h".repeat(201)}"}`, + "heading", + ], + ['{"url":"https://supabase.com/docs/x","quote":"a","heading":null}', "heading"], + ]; + return Effect.gen(function* () { + for (const [target, field] of invalidTargets) { + const error = yield* feedbackAdd(addArgs(["docs gap"], { target: [target] })).pipe( + Effect.flip, + ); + expect(error, target).toMatchObject({ + _tag: "FeedbackTargetInvalidError", + message: feedbackTargetInvalidMessage(1, field), + }); + } + expect(submitter.submissions).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.effect("reports the position of an invalid --target after a valid one", () => { + const { layer, submitter } = setupFeedback(); + return Effect.gen(function* () { + const error = yield* feedbackAdd( + addArgs(["docs gap"], { + target: ['{"url":"https://supabase.com/docs/x","quote":"a"}', "not json"], + }), + ).pipe(Effect.flip); + + expect(error).toMatchObject({ message: feedbackTargetInvalidMessage(2) }); + expect(submitter.submissions).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.effect("rejects an invalid --target before it reads piped stdin", () => { + const { layer, submitter } = setupFeedback({ stdinIsTTY: false }); + return Effect.gen(function* () { + const error = yield* feedbackAdd(addArgs([], { target: ["not json"] })).pipe(Effect.flip); + + expect(error).toMatchObject({ _tag: "FeedbackTargetInvalidError" }); + expect(submitter.submissions).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + it.effect("emits the delete token in the json acknowledgement", () => { const { layer, out, submitter } = setupFeedback({ output: { format: "json" } }); return Effect.gen(function* () { @@ -707,6 +829,24 @@ describe("feedback add", () => { }).pipe(Effect.provide(layer)); }); + it.effect("records --target by name only, never its value, in PostHog", () => { + const target = '{"url":"https://supabase.com/docs/guides/private-page","quote":"secret quote"}'; + const { layer, analytics, submitter } = setupFeedbackHandler({ + args: ["feedback", "add", "docs gap", "--target", target], + }); + return Effect.gen(function* () { + yield* feedbackAddHandler(addArgs(["docs gap"], { target: [target] })); + + expect(submitter.submissions[0]?.targets).toHaveLength(1); + const events = analytics.captured.filter((c) => c.event === "cli_command_executed"); + expect(events).toHaveLength(1); + const scanned = yield* jsonText(events[0]); + expect(scanned).not.toContain("private-page"); + expect(scanned).not.toContain("secret quote"); + expect(Object.keys(events[0]?.properties.flags ?? {})).toEqual(["target"]); + }).pipe(Effect.provide(layer)); + }); + it.effect("never sends the feedback message content to PostHog", () => { const { layer, analytics, submitter } = setupFeedbackHandler({ args: ["feedback", "add", "my", "secret", "papercut", "--debug"], diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index c53504bd7a..9164fcee3e 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -256,6 +256,7 @@ FeedbackEmptyMessageError FeedbackInvalidTokenError FeedbackMessageTooLongError FeedbackNotFoundError +FeedbackTargetInvalidError FileWatcherError FunctionDeployCancelledError FunctionDownloadNotFoundError