diff --git a/apps/cli/src/command-internal/command-facts/command-catalog.unit.test.ts b/apps/cli/src/command-internal/command-facts/command-catalog.unit.test.ts index 8e3d1c0640..8c3c86b015 100644 --- a/apps/cli/src/command-internal/command-facts/command-catalog.unit.test.ts +++ b/apps/cli/src/command-internal/command-facts/command-catalog.unit.test.ts @@ -43,6 +43,23 @@ describe("commandCatalog", () => { }), ); + it.effect("reports a command's declared output schema as test-checked", () => + Effect.gen(function* () { + const entry = yield* entryAt("branches delete"); + + expect(entry?.facts["supabase/CommandOutput"]).toMatchObject({ + checked: ["test"], + value: { + schema: { + type: "object", + properties: { project_ref: { type: "string" } }, + required: expect.arrayContaining(["project_ref", "message"]), + }, + }, + }); + }), + ); + it.effect("keeps a permission entry's conditions and request details", () => Effect.gen(function* () { const entry = yield* entryAt("projects api-keys"); diff --git a/apps/cli/src/command-internal/command-facts/command-fact-adoption.unit.test.ts b/apps/cli/src/command-internal/command-facts/command-fact-adoption.unit.test.ts new file mode 100644 index 0000000000..16ba095229 --- /dev/null +++ b/apps/cli/src/command-internal/command-facts/command-fact-adoption.unit.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Context, Effect } from "effect"; +import { Command } from "effect/cli"; + +import { rootCommand } from "../../cli/root.ts"; +import { flattenSubcommands } from "../../docs/docs-introspection.ts"; +import { hasOptionalSubcommand } from "../../shared/output/optional-subcommand-usage.ts"; +import { CommandPermissionsAnnotation } from "../command-permissions/command-permissions.annotation.ts"; +import { TargetPolicyAnnotation } from "../target/target-policy.ts"; +import { CommandOutputAnnotation } from "./command-output.ts"; +import { OUTPUT_EXCEPTIONS } from "./command-output.exceptions.ts"; + +const REQUIRED_FACTS = [ + CommandOutputAnnotation.key, + TargetPolicyAnnotation.key, + CommandPermissionsAnnotation.key, +] as const; + +type Exceptions = Readonly>>; + +const executableCommands = (root: Command.Command.Any) => { + const found: Array<{ readonly path: string; readonly command: Command.Command.Any }> = []; + const visit = (command: Command.Command.Any, path: ReadonlyArray) => { + const children = flattenSubcommands(command); + if (path.length > 0 && (children.length === 0 || hasOptionalSubcommand(command))) { + found.push({ path: path.join(" "), command }); + } + children.forEach((child) => visit(child, [...path, child.name])); + }; + visit(root, []); + return found; +}; + +/** Missing facts without an exception, and exceptions for facts the command now declares. */ +const adoptionGaps = (root: Command.Command.Any, exceptions: Exceptions) => { + const missing: Array = []; + const stale: Array = []; + const executable = executableCommands(root); + for (const { path, command } of executable) { + for (const fact of REQUIRED_FACTS) { + const present = command.annotations.mapUnsafe.has(fact); + const excepted = exceptions[fact]?.has(path) ?? false; + if (!present && !excepted) missing.push(`${path}: ${fact}`); + if (present && excepted) stale.push(`${path}: ${fact}`); + } + } + const paths = new Set(executable.map((entry) => entry.path)); + for (const [fact, entries] of Object.entries(exceptions)) { + for (const path of entries.keys()) { + if (!paths.has(path)) stale.push(`${path}: ${fact}`); + } + } + return { missing, stale }; +}; + +const PROJECT_EXCEPTIONS: Exceptions = { [CommandOutputAnnotation.key]: OUTPUT_EXCEPTIONS }; + +const withFacts = ( + command: Command.Command.Any, + facts: ReadonlyArray<(typeof REQUIRED_FACTS)[number]>, +): Command.Command.Any => + facts.reduce( + (current, fact) => Command.annotate(Context.Service()(fact), true)(current), + command, + ); + +const fixture = (facts: ReadonlyArray<(typeof REQUIRED_FACTS)[number]>) => + Command.make("supabase").pipe( + Command.withSubcommands([ + withFacts( + Command.make("leaf", {}, () => Effect.void), + facts, + ), + ]), + ); + +describe("command fact adoption", () => { + it("requires output, target policy and permissions on every executable command", () => { + expect(adoptionGaps(rootCommand, PROJECT_EXCEPTIONS)).toEqual({ missing: [], stale: [] }); + }); + + it("only excepts streaming commands for a reason other than pending adoption", () => { + const streaming = [...OUTPUT_EXCEPTIONS].filter(([, reason]) => reason === "streaming"); + expect(streaming.map(([path]) => path).toSorted()).toEqual(["functions serve", "logs"]); + }); + + it("does not except branches delete", () => { + expect(OUTPUT_EXCEPTIONS.has("branches delete")).toBe(false); + }); + + it.each(REQUIRED_FACTS)("fails a command that drops %s", (dropped) => { + const root = fixture(REQUIRED_FACTS.filter((fact) => fact !== dropped)); + expect(adoptionGaps(root, {}).missing).toEqual([`leaf: ${dropped}`]); + }); + + it("does not let an output exception excuse a missing target policy", () => { + const root = fixture([CommandPermissionsAnnotation.key]); + const exceptions = { [CommandOutputAnnotation.key]: new Map([["leaf", "streaming"]]) }; + expect(adoptionGaps(root, exceptions).missing).toEqual([`leaf: ${TargetPolicyAnnotation.key}`]); + }); + + it("fails an exception for a command that now declares the fact", () => { + const root = fixture(REQUIRED_FACTS); + const exceptions = { [CommandOutputAnnotation.key]: new Map([["leaf", "not-yet-declared"]]) }; + expect(adoptionGaps(root, exceptions).stale).toEqual([`leaf: ${CommandOutputAnnotation.key}`]); + }); + + it("fails an exception for a command that no longer exists", () => { + const root = fixture(REQUIRED_FACTS); + const exceptions = { [CommandOutputAnnotation.key]: new Map([["gone", "not-yet-declared"]]) }; + expect(adoptionGaps(root, exceptions).stale).toEqual([`gone: ${CommandOutputAnnotation.key}`]); + }); +}); diff --git a/apps/cli/src/command-internal/command-facts/command-fact.ts b/apps/cli/src/command-internal/command-facts/command-fact.ts index 346f166965..1fd9a1f45e 100644 --- a/apps/cli/src/command-internal/command-facts/command-fact.ts +++ b/apps/cli/src/command-internal/command-facts/command-fact.ts @@ -1,8 +1,11 @@ import { Context, Effect, Option, Schema } from "effect"; import type { Command } from "effect/cli"; -/** How a fact is enforced: by the compiler, by a render-time check, by a runtime gate, or not at all. */ -export type FactCheck = "compiler" | "render" | "runtime" | "none"; +/** + * How a fact is checked: by the compiler, a render-time check, a runtime gate, opted-in tests, + * a production observer that reports without enforcing, or not at all. + */ +export type FactCheck = "compiler" | "render" | "runtime" | "test" | "report-only" | "none"; /** A command fact stored under one `Context` key on `Command.annotations`, serialized for the catalog. */ export interface CommandFact { @@ -26,3 +29,19 @@ export const registerFact = ( ), }; }; + +/** Registers an annotation key whose catalog value `serialize` derives, such as a schema's JSON Schema. */ +export const registerDerivedFact = ( + key: Context.Key, + options: { + readonly serialize: (value: A) => unknown; + readonly checked: ReadonlyArray; + }, +): CommandFact => ({ + id: key.key, + checked: options.checked, + read: (command) => + Option.map(Context.getOption(command.annotations, key), (value) => + Effect.sync(() => options.serialize(value)), + ), +}); diff --git a/apps/cli/src/command-internal/command-facts/command-facts.registry.ts b/apps/cli/src/command-internal/command-facts/command-facts.registry.ts index feb533742c..d7f29dc1ea 100644 --- a/apps/cli/src/command-internal/command-facts/command-facts.registry.ts +++ b/apps/cli/src/command-internal/command-facts/command-facts.registry.ts @@ -7,7 +7,8 @@ import type * as Permissions from "../command-permissions/model.ts"; import { CommandFlagsAnnotation } from "../command-permissions/permission-flags.ts"; import { TargetPolicyAnnotation } from "../target/target-policy.ts"; import type { TargetPolicyShape } from "../target/target-policy.ts"; -import { registerFact } from "./command-fact.ts"; +import { registerDerivedFact, registerFact } from "./command-fact.ts"; +import { CommandOutputAnnotation } from "./command-output.ts"; type AllKeys = T extends unknown ? keyof T : never; type SameKeys = [AllKeys] extends [AllKeys] @@ -91,6 +92,10 @@ const TargetPolicy = covering()( export const COMMAND_FACTS = [ registerFact(CommandPermissionsAnnotation, { schema: CommandPermissions, checked: ["none"] }), registerFact(TargetPolicyAnnotation, { schema: TargetPolicy, checked: ["runtime", "compiler"] }), + registerDerivedFact(CommandOutputAnnotation, { + serialize: (contract) => Schema.toJsonSchemaDocument(contract.schema), + checked: ["test"], + }), ] as const; /** diff --git a/apps/cli/src/command-internal/command-facts/command-output.exceptions.ts b/apps/cli/src/command-internal/command-facts/command-output.exceptions.ts new file mode 100644 index 0000000000..f9f3a29240 --- /dev/null +++ b/apps/cli/src/command-internal/command-facts/command-output.exceptions.ts @@ -0,0 +1,158 @@ +/** Why a command may lack an output declaration. */ +export type OutputException = "streaming" | "not-yet-declared"; + +/** + * Executable commands allowed to omit `withOutput`. Entries may only be removed: a new command + * declares its output, and the adoption test fails on entries that no longer apply. + */ +export const OUTPUT_EXCEPTIONS: ReadonlyMap = new Map([ + ["backups list", "not-yet-declared"], + ["backups restore", "not-yet-declared"], + ["bootstrap", "not-yet-declared"], + ["branches list", "not-yet-declared"], + ["branches create", "not-yet-declared"], + ["branches get", "not-yet-declared"], + ["branches update", "not-yet-declared"], + ["branches pause", "not-yet-declared"], + ["branches unpause", "not-yet-declared"], + ["branches disable", "not-yet-declared"], + ["completion bash", "not-yet-declared"], + ["completion fish", "not-yet-declared"], + ["completion powershell", "not-yet-declared"], + ["completion zsh", "not-yet-declared"], + ["config convert", "not-yet-declared"], + ["config diff", "not-yet-declared"], + ["config pull", "not-yet-declared"], + ["config push", "not-yet-declared"], + ["db diff", "not-yet-declared"], + ["db dump", "not-yet-declared"], + ["db push", "not-yet-declared"], + ["db pull", "not-yet-declared"], + ["db reset", "not-yet-declared"], + ["db lint", "not-yet-declared"], + ["db start", "not-yet-declared"], + ["db query", "not-yet-declared"], + ["db advisors", "not-yet-declared"], + ["db test", "not-yet-declared"], + ["db branch create", "not-yet-declared"], + ["db branch delete", "not-yet-declared"], + ["db branch list", "not-yet-declared"], + ["db branch switch", "not-yet-declared"], + ["db remote changes", "not-yet-declared"], + ["db remote commit", "not-yet-declared"], + ["db schema declarative sync", "not-yet-declared"], + ["db schema declarative generate", "not-yet-declared"], + ["domains create", "not-yet-declared"], + ["domains get", "not-yet-declared"], + ["domains reverify", "not-yet-declared"], + ["domains activate", "not-yet-declared"], + ["domains delete", "not-yet-declared"], + ["encryption get-root-key", "not-yet-declared"], + ["encryption update-root-key", "not-yet-declared"], + ["feedback add", "not-yet-declared"], + ["feedback delete", "not-yet-declared"], + ["functions list", "not-yet-declared"], + ["functions delete", "not-yet-declared"], + ["functions download", "not-yet-declared"], + ["functions deploy", "not-yet-declared"], + ["functions new", "not-yet-declared"], + ["functions serve", "streaming"], + ["gen types", "not-yet-declared"], + ["gen signing-key", "not-yet-declared"], + ["gen bearer-jwt", "not-yet-declared"], + ["gen keys", "not-yet-declared"], + ["init", "not-yet-declared"], + ["inspect report", "not-yet-declared"], + ["inspect db db-stats", "not-yet-declared"], + ["inspect db replication-slots", "not-yet-declared"], + ["inspect db locks", "not-yet-declared"], + ["inspect db blocking", "not-yet-declared"], + ["inspect db outliers", "not-yet-declared"], + ["inspect db calls", "not-yet-declared"], + ["inspect db index-stats", "not-yet-declared"], + ["inspect db collation-drift", "not-yet-declared"], + ["inspect db long-running-queries", "not-yet-declared"], + ["inspect db bloat", "not-yet-declared"], + ["inspect db role-stats", "not-yet-declared"], + ["inspect db vacuum-stats", "not-yet-declared"], + ["inspect db table-stats", "not-yet-declared"], + ["inspect db traffic-profile", "not-yet-declared"], + ["inspect db toast-sizes", "not-yet-declared"], + ["inspect db xid-age", "not-yet-declared"], + ["inspect db cache-hit", "not-yet-declared"], + ["inspect db index-usage", "not-yet-declared"], + ["inspect db total-index-size", "not-yet-declared"], + ["inspect db index-sizes", "not-yet-declared"], + ["inspect db table-sizes", "not-yet-declared"], + ["inspect db table-index-sizes", "not-yet-declared"], + ["inspect db total-table-sizes", "not-yet-declared"], + ["inspect db unused-indexes", "not-yet-declared"], + ["inspect db table-record-counts", "not-yet-declared"], + ["inspect db seq-scans", "not-yet-declared"], + ["inspect db role-configs", "not-yet-declared"], + ["inspect db role-connections", "not-yet-declared"], + ["issue bug", "not-yet-declared"], + ["issue feature", "not-yet-declared"], + ["issue docs", "not-yet-declared"], + ["link", "not-yet-declared"], + ["login", "not-yet-declared"], + ["logout", "not-yet-declared"], + ["logs", "streaming"], + ["logs sources", "not-yet-declared"], + ["migration list", "not-yet-declared"], + ["migration new", "not-yet-declared"], + ["migration repair", "not-yet-declared"], + ["migration squash", "not-yet-declared"], + ["migration up", "not-yet-declared"], + ["migration down", "not-yet-declared"], + ["migration fetch", "not-yet-declared"], + ["network-bans get", "not-yet-declared"], + ["network-bans remove", "not-yet-declared"], + ["network-restrictions get", "not-yet-declared"], + ["network-restrictions update", "not-yet-declared"], + ["notebooks push", "not-yet-declared"], + ["notebooks pull", "not-yet-declared"], + ["orgs list", "not-yet-declared"], + ["orgs create", "not-yet-declared"], + ["postgres-config get", "not-yet-declared"], + ["postgres-config update", "not-yet-declared"], + ["postgres-config delete", "not-yet-declared"], + ["projects list", "not-yet-declared"], + ["projects create", "not-yet-declared"], + ["projects api-keys", "not-yet-declared"], + ["projects delete", "not-yet-declared"], + ["pull", "not-yet-declared"], + ["secrets list", "not-yet-declared"], + ["secrets set", "not-yet-declared"], + ["secrets unset", "not-yet-declared"], + ["seed buckets", "not-yet-declared"], + ["services", "not-yet-declared"], + ["snippets list", "not-yet-declared"], + ["snippets download", "not-yet-declared"], + ["ssl-enforcement get", "not-yet-declared"], + ["ssl-enforcement update", "not-yet-declared"], + ["sso list", "not-yet-declared"], + ["sso add", "not-yet-declared"], + ["sso remove", "not-yet-declared"], + ["sso update", "not-yet-declared"], + ["sso show", "not-yet-declared"], + ["sso info", "not-yet-declared"], + ["start", "not-yet-declared"], + ["status", "not-yet-declared"], + ["stop", "not-yet-declared"], + ["storage ls", "not-yet-declared"], + ["storage cp", "not-yet-declared"], + ["storage mv", "not-yet-declared"], + ["storage rm", "not-yet-declared"], + ["telemetry enable", "not-yet-declared"], + ["telemetry disable", "not-yet-declared"], + ["telemetry status", "not-yet-declared"], + ["test db", "not-yet-declared"], + ["test new", "not-yet-declared"], + ["unlink", "not-yet-declared"], + ["vanity-subdomains get", "not-yet-declared"], + ["vanity-subdomains check-availability", "not-yet-declared"], + ["vanity-subdomains activate", "not-yet-declared"], + ["vanity-subdomains delete", "not-yet-declared"], + ["whoami", "not-yet-declared"], +]); diff --git a/apps/cli/src/command-internal/command-facts/command-output.ts b/apps/cli/src/command-internal/command-facts/command-output.ts new file mode 100644 index 0000000000..3bde09c51b --- /dev/null +++ b/apps/cli/src/command-internal/command-facts/command-output.ts @@ -0,0 +1,34 @@ +import { Context, Exit, Option, Schema } from "effect"; +import { Command } from "effect/cli"; + +/** A command's declared machine result: the JSON value its `Output.success` or `Output.result` writes. */ +export interface CommandOutputContract { + readonly schema: Schema.Decoder; +} + +/** Context key under which a command's output contract is stored in `Command.annotations`. */ +export class CommandOutputAnnotation extends Context.Service< + CommandOutputAnnotation, + CommandOutputContract +>()("supabase/CommandOutput") {} + +/** Declares the JSON result shape described by `schema`. */ +export const make = (schema: Schema.Decoder): CommandOutputContract => ({ schema }); + +/** Annotates a command with its output contract; the handler and its writes are unchanged. */ +export const withOutput = (contract: CommandOutputContract) => + Command.annotate(CommandOutputAnnotation, contract); + +/** + * Returns why a serialized result doesn't match the contract, or `None` when it does. Keys the + * schema doesn't declare count as a mismatch. + */ +export const resultIssue = ( + contract: CommandOutputContract, + json: string, +): Option.Option => { + const exit = Schema.decodeExit(Schema.fromJsonString(contract.schema), { + onExcessProperty: "error", + })(json); + return Exit.isSuccess(exit) ? Option.none() : Option.some(String(exit.cause)); +}; diff --git a/apps/cli/src/commands/branches/delete/delete.command.ts b/apps/cli/src/commands/branches/delete/delete.command.ts index 4985220e98..988e1a951e 100644 --- a/apps/cli/src/commands/branches/delete/delete.command.ts +++ b/apps/cli/src/commands/branches/delete/delete.command.ts @@ -11,6 +11,8 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { branchesDelete } from "./delete.handler.ts"; +import { branchesDeleteOutput } from "./delete.output.ts"; +import { withOutput } from "../../../command-internal/command-facts/command-output.ts"; import { withTargetPolicy } from "../../../command-internal/target/target-policy.ts"; import { projectTargetFlags } from "../../../command-internal/target/target-flags.ts"; import { branchesParentTarget } from "../branches.target.ts"; @@ -27,6 +29,7 @@ export type BranchesDeleteFlags = CliCommand.Command.Config.Infer export const branchesDeleteCommand = Command.make("delete", config).pipe( Command.withDescription("Delete a preview branch by its name or ID."), Command.withShortDescription("Delete a preview branch"), + withOutput(branchesDeleteOutput), withPermissions( compose( { operations: [{ operationId: "v1-delete-a-branch", kind: "required" }] }, diff --git a/apps/cli/src/commands/branches/delete/delete.integration.test.ts b/apps/cli/src/commands/branches/delete/delete.integration.test.ts index 2905df7bc6..827aae2e3c 100644 --- a/apps/cli/src/commands/branches/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/branches/delete/delete.integration.test.ts @@ -23,6 +23,7 @@ import { realLinkedProjectCache } from "../../../../tests/helpers/permission-rea import { branchPrompt } from "../branches.prompt.ts"; import type { BranchesDeleteFlags } from "./delete.command.ts"; import { branchesDelete } from "./delete.handler.ts"; +import { branchesDeleteOutput } from "./delete.output.ts"; import { YesFlag } from "../../../command-internal/global-flags.ts"; import { TargetPolicy } from "../../../command-internal/target/target-policy.ts"; @@ -343,6 +344,86 @@ describe("branches delete integration", () => { ); }); +describe("branches delete JSON output contract", () => { + const setupJson = (opts: SetupOpts = {}, format: "json" | "stream-json" = "json") => { + const out = mockOutput({ format, contract: branchesDeleteOutput }); + const api = buildApi(opts); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + return { out, layer: buildRuntime({ out, api, cliSettings }) }; + }; + + it.effect("writes one result that matches the declared schema", () => { + const { out, layer } = setupJson(); + return Effect.gen(function* () { + yield* out.verifyCompletion( + branchesDelete({ + ...baseFlags, + projectRef: Option.some(VALID_REF), + name: Option.some(BRANCH_UUID), + }), + ); + expect(out.messages).toEqual([ + { + type: "success", + message: "Deleted preview branch", + data: { project_ref: BRANCH_UUID }, + }, + ]); + }).pipe(Effect.provide(layer)); + }); + + it.effect.each(["json", "stream-json"] as const)( + "includes the linked parent target in the declared %s result without target flags", + (format) => { + const { out, layer } = setupJson({}, format); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = `${tempRoot.current}/supabase/.temp`; + yield* fs.makeDirectory(tempDir, { recursive: true }); + yield* fs.writeFileString(`${tempDir}/project-ref`, VALID_REF); + + yield* out.verifyCompletion( + branchesDelete({ ...baseFlags, name: Option.some(BRANCH_UUID) }), + ); + + const payload = + format === "json" + ? yield* decodeJson(out.stdoutText) + : out.events.find((event) => event.type === "result")?.data; + expect(payload).toEqual({ + project_ref: BRANCH_UUID, + message: "Deleted preview branch", + target: { kind: "linked", project_ref: VALID_REF, environment: "unverified" }, + }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect("reports a failed delete as its own error, not a missing result", () => { + const { out, layer } = setupJson({ deleteStatus: 500 }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + out.verifyCompletion( + branchesDelete({ + ...baseFlags, + projectRef: Option.some(VALID_REF), + name: Option.some(BRANCH_UUID), + }), + ), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const causeText = Cause.pretty(exit.cause); + expect(causeText).toContain("BranchesDeleteUnexpectedStatusError"); + expect(causeText).not.toContain("OutputContractViolation"); + } + }).pipe(Effect.provide(layer)); + }); +}); + describeHostedConsent("branches delete", { api: apiOptions({}), invoke: () => ({ run: branchesDelete({ ...baseFlags, name: Option.some(BRANCH_UUID) }) }), diff --git a/apps/cli/src/commands/branches/delete/delete.output.ts b/apps/cli/src/commands/branches/delete/delete.output.ts new file mode 100644 index 0000000000..58e9b0ed21 --- /dev/null +++ b/apps/cli/src/commands/branches/delete/delete.output.ts @@ -0,0 +1,13 @@ +import { Schema } from "effect"; + +import { make } from "../../../command-internal/command-facts/command-output.ts"; +import { CommandTargetJson } from "../../../shared/output/command-target.service.ts"; + +/** The JSON result of `branches delete`. */ +export const branchesDeleteOutput = make( + Schema.Struct({ + project_ref: Schema.String.annotate({ description: "Project ref of the deleted branch." }), + message: Schema.Literal("Deleted preview branch"), + target: Schema.optionalKey(CommandTargetJson), + }), +); diff --git a/apps/cli/src/docs/docs-spec.ts b/apps/cli/src/docs/docs-spec.ts index d318d91ac0..c095895828 100644 --- a/apps/cli/src/docs/docs-spec.ts +++ b/apps/cli/src/docs/docs-spec.ts @@ -1,5 +1,5 @@ import { hasOptionalSubcommand } from "../shared/output/optional-subcommand-usage.ts"; -import { Option } from "effect"; +import { Context, JsonSchema, Option, Schema } from "effect"; import { stringify } from "yaml"; import { GlobalFlag } from "effect/cli"; import type { Command, Param } from "effect/cli"; @@ -10,6 +10,7 @@ import { userGlobalFlagParams, } from "./docs-introspection.ts"; import { unwrapParam } from "../command-internal/param-introspection.ts"; +import { CommandOutputAnnotation } from "../command-internal/command-facts/command-output.ts"; import { isRemoteFlag } from "../command-internal/target/target-flags.ts"; import { remoteFlagName } from "../shared/output/target-help.ts"; import { @@ -70,6 +71,8 @@ export interface DocsCommand { readonly usage?: string; readonly subcommands: ReadonlyArray; readonly flags: ReadonlyArray; + /** Draft 2020-12 JSON Schema of the command's `--output-format json` stdout. */ + readonly json_output_schema?: JsonSchema.JsonSchema; } interface DocsInfo { @@ -280,6 +283,16 @@ function docsExperimentalFlag(rootFlags: ReadonlyArray): DocsFlag { }; } +/** A standalone draft 2020-12 JSON Schema for a command's declared output schema. */ +export function docsJsonOutputSchema(schema: Schema.Top): JsonSchema.JsonSchema { + const document = Schema.toJsonSchemaDocument(schema); + return { + $schema: JsonSchema.META_SCHEMA_URI_DRAFT_2020_12, + ...document.schema, + ...(Object.keys(document.definitions).length === 0 ? {} : { $defs: document.definitions }), + }; +} + export function buildDocsSpec(input: DocsSpecInput): DocsSpec { const rootPath = ["supabase"]; const rootFlagParams: ReadonlyArray = [ @@ -432,6 +445,10 @@ export function buildDocsSpec(input: DocsSpecInput): DocsSpec { .map((child) => [...commandPath, child.name].join("-")) .filter((childId) => !DOCS_EXCLUDED.has(childId)), flags, + ...Option.match(Context.getOption(command.annotations, CommandOutputAnnotation), { + onNone: () => ({}), + onSome: (contract) => ({ json_output_schema: docsJsonOutputSchema(contract.schema) }), + }), }); // A non-root command's own global flags (e.g. `seed`'s `--remote`) behave like diff --git a/apps/cli/src/docs/docs-spec.unit.test.ts b/apps/cli/src/docs/docs-spec.unit.test.ts index fec9900be1..bc6c52611d 100644 --- a/apps/cli/src/docs/docs-spec.unit.test.ts +++ b/apps/cli/src/docs/docs-spec.unit.test.ts @@ -288,6 +288,23 @@ describe("buildDocsSpec", () => { expect(snippetsExamples?.[0]?.response).toBeUndefined(); }); + it("publishes a declared output schema as json_output_schema", () => { + const { byId } = builtSpec(); + expect(byId.get("supabase-branches-delete")?.json_output_schema).toMatchObject({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { project_ref: { type: "string" } }, + required: expect.arrayContaining(["project_ref", "message"]), + }); + }); + + it("omits json_output_schema for a command without an output declaration", () => { + const { byId } = builtSpec(); + const command = byId.get("supabase-branches-list"); + expect(command).toBeDefined(); + expect(command).not.toHaveProperty("json_output_schema"); + }); + it("documents db query's output formats via the injected flag", () => { const { byId } = builtSpec(); const output = byId.get("supabase-db-query")?.flags.find((flag) => flag.id === "output"); diff --git a/apps/cli/src/shared/output/command-target.service.ts b/apps/cli/src/shared/output/command-target.service.ts index f7eb247d58..6af06f2e67 100644 --- a/apps/cli/src/shared/output/command-target.service.ts +++ b/apps/cli/src/shared/output/command-target.service.ts @@ -56,6 +56,18 @@ export class CommandTarget extends Context.Service CommandTarget.use((cell) => cell.announce(rendered)); +/** Adds the announced target to a plain-object result; arrays, scalars and an own `target` stay. */ +export const withCommandTarget = Effect.fnUntraced(function* (data: unknown) { + if (typeof data !== "object" || data === null || Array.isArray(data)) return data; + const prototype = Object.getPrototypeOf(data); + if (prototype !== Object.prototype && prototype !== null) return data; + if ("target" in data && data.target !== undefined) return data; + const cell = yield* Effect.serviceOption(CommandTarget); + if (Option.isNone(cell)) return data; + const target = yield* cell.value.json; + return Option.isSome(target) ? { ...data, target: target.value } : data; +}); + const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); diff --git a/apps/cli/src/shared/output/output.layer.ts b/apps/cli/src/shared/output/output.layer.ts index 0932006209..8ba49434e0 100644 --- a/apps/cli/src/shared/output/output.layer.ts +++ b/apps/cli/src/shared/output/output.layer.ts @@ -29,7 +29,7 @@ import { } from "effect"; import { Tty } from "../runtime/tty.service.ts"; -import { CommandTarget } from "./command-target.service.ts"; +import { withCommandTarget } from "./command-target.service.ts"; import { CONTEXT_CANCELED_MESSAGE, NonInteractiveError, StdoutClosedError } from "./errors.ts"; import { MachineErrorContext } from "./machine-error-context.service.ts"; import { type PlanGateEntitlement, upgradeHint } from "./normalize-error.ts"; @@ -52,21 +52,6 @@ const readMachineErrorContext = Effect.fnUntraced(function* () { return Option.isSome(context) ? yield* context.value.get : {}; }); -const isPlainObject = (value: unknown): value is Record => { - if (typeof value !== "object" || value === null || Array.isArray(value)) return false; - const prototype = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; -}; - -/** Adds the announced target to a plain-object result; arrays, scalars and an own `target` stay. */ -const withCommandTarget = Effect.fnUntraced(function* (data: unknown) { - if (!isPlainObject(data) || data.target !== undefined) return data; - const cell = yield* Effect.serviceOption(CommandTarget); - if (Option.isNone(cell)) return data; - const target = yield* cell.value.json; - return Option.isSome(target) ? { ...data, target: target.value } : data; -}); - function formatTaskMessage(message: string | undefined): string | undefined { if (message === undefined || !message.includes("\n")) { return message; diff --git a/apps/cli/tests/helpers/mock-output-contract.unit.test.ts b/apps/cli/tests/helpers/mock-output-contract.unit.test.ts new file mode 100644 index 0000000000..c6a16b21c1 --- /dev/null +++ b/apps/cli/tests/helpers/mock-output-contract.unit.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, Schema } from "effect"; + +import { make } from "../../src/command-internal/command-facts/command-output.ts"; +import { Output } from "../../src/shared/output/output.service.ts"; +import { mockOutput, OutputContractViolation } from "./mocks.ts"; + +const contract = make(Schema.Struct({ id: Schema.String, message: Schema.String })); + +const run = ( + format: "json" | "stream-json", + handler: Effect.Effect, +): Effect.Effect> => { + const out = mockOutput({ format, contract }); + return Effect.exit(out.verifyCompletion(handler).pipe(Effect.provide(out.layer))); +}; + +const violation = (exit: Exit.Exit) => { + if (Exit.isSuccess(exit)) return "succeeded"; + const error = Cause.squash(exit.cause); + return Schema.is(OutputContractViolation)(error) ? error.reason : String(error); +}; + +const success = (data: Record) => + Effect.gen(function* () { + const output = yield* Output; + yield* output.success("done", data); + }); + +describe("mockOutput contract verification", () => { + it.effect.each(["json", "stream-json"] as const)("accepts one valid result in %s", (format) => + Effect.gen(function* () { + const exit = yield* run(format, success({ id: "a" })); + expect(Exit.isSuccess(exit)).toBe(true); + }), + ); + + it.effect("rejects a result that doesn't match the schema", () => + Effect.gen(function* () { + const exit = yield* run("json", success({ id: 1 })); + expect(violation(exit)).toBe("invalid-result"); + }), + ); + + it.effect("rejects a result with undeclared keys", () => + Effect.gen(function* () { + const exit = yield* run("json", success({ id: "a", extra: true })); + expect(violation(exit)).toBe("invalid-result"); + }), + ); + + it.effect("rejects a second result write", () => + Effect.gen(function* () { + const exit = yield* run( + "json", + Effect.gen(function* () { + const output = yield* Output; + yield* output.success("done", { id: "a" }); + yield* output.result({ id: "b" }); + }), + ); + expect(violation(exit)).toBe("duplicate-result"); + }), + ); + + it.effect("rejects a successful handler that writes no result", () => + Effect.gen(function* () { + const exit = yield* run("json", Effect.void); + expect(violation(exit)).toBe("missing-result"); + }), + ); + + it.effect("keeps a failing handler's own error", () => + Effect.gen(function* () { + const exit = yield* run("json", Effect.fail("boom")); + expect(violation(exit)).toBe("boom"); + }), + ); +}); diff --git a/apps/cli/tests/helpers/mocks.ts b/apps/cli/tests/helpers/mocks.ts index 911c045fe8..1ea54c2560 100644 --- a/apps/cli/tests/helpers/mocks.ts +++ b/apps/cli/tests/helpers/mocks.ts @@ -25,8 +25,13 @@ import { } from "../../src/shared/config/project-link-state.service.ts"; import { legacyProjectStateLayer } from "../../src/shared/config/legacy-project-state.layer.ts"; import { CliProjectContext } from "../../src/shared/config/cli-project-context.service.ts"; +import { + type CommandOutputContract, + resultIssue, +} from "../../src/command-internal/command-facts/command-output.ts"; import { NonInteractiveError } from "../../src/shared/output/errors.ts"; import { Output } from "../../src/shared/output/output.service.ts"; +import { withCommandTarget } from "../../src/shared/output/command-target.service.ts"; import type { OutputFormat } from "../../src/shared/output/types.ts"; import { Browser } from "../../src/shared/runtime/browser.service.ts"; import { @@ -228,9 +233,23 @@ export function mockProcessControl( }; } +/** A result write that breaks the output contract passed to `mockOutput`. */ +export class OutputContractViolation extends Schema.TaggedError()( + "OutputContractViolation", + { + reason: Schema.Literals(["invalid-result", "duplicate-result", "missing-result"]), + detail: Schema.String, + }, +) {} + +/** + * With `contract` set in json or stream-json format, an invalid or duplicate result write dies with + * `OutputContractViolation`, and `verifyCompletion` also requires a result after success. + */ export function mockOutput( opts: { format?: OutputFormat; + contract?: CommandOutputContract; interactive?: boolean; confirmRelogin?: boolean; confirmLogout?: boolean; @@ -275,7 +294,61 @@ export function mockOutput( const promptSelectResponses = [...(opts.promptSelectResponses ?? [])]; const promptPasswordResponses = [...(opts.promptPasswordResponses ?? [])]; const promptConfirmResponses = [...(opts.promptConfirmResponses ?? [])]; + const contract = + opts.format === "json" || opts.format === "stream-json" ? opts.contract : undefined; + let resultWrites = 0; + const checkResult = (payload: unknown) => + Effect.gen(function* () { + if (contract === undefined) return; + resultWrites++; + if (resultWrites > 1) { + return yield* Effect.die( + new OutputContractViolation({ + reason: "duplicate-result", + detail: `result written ${resultWrites} times`, + }), + ); + } + const issue = resultIssue(contract, yield* jsonText(payload)); + if (Option.isSome(issue)) { + return yield* Effect.die( + new OutputContractViolation({ reason: "invalid-result", detail: issue.value }), + ); + } + }); + const writeResult = (data: unknown) => + Effect.gen(function* () { + const payload = yield* withCommandTarget(data); + if (opts.format === "json") { + rawChunks.push({ text: `${yield* jsonText(payload)}\n`, stream: "stdout" }); + } else if (opts.format === "stream-json") { + events.push({ + type: "result", + data: payload, + timestamp: DateTime.formatIso(DateTime.nowUnsafe()), + }); + } + yield* checkResult(payload); + }); return { + verifyCompletion: (handler: Effect.Effect): Effect.Effect => + contract === undefined + ? Effect.die(new Error("verifyCompletion needs a contract and json or stream-json format")) + : Effect.suspend(() => { + const before = resultWrites; + return handler.pipe( + Effect.tap(() => + resultWrites === before + ? Effect.die( + new OutputContractViolation({ + reason: "missing-result", + detail: "handler succeeded without writing a result", + }), + ) + : Effect.void, + ), + ); + }), layer: Layer.succeed(Output, { format: opts.format ?? "text", interactive: opts.interactive ?? (opts.format ?? "text") === "text", @@ -345,21 +418,13 @@ export function mockOutput( : yield* jsonText(event), }); }), - result: (data: unknown) => - Effect.gen(function* () { - if (opts.format === "json") { - rawChunks.push({ text: `${yield* jsonText(data)}\n`, stream: "stdout" }); - } else if (opts.format === "stream-json") { - events.push({ - type: "result", - data, - timestamp: DateTime.formatIso(DateTime.nowUnsafe()), - }); - } - }), + result: writeResult, success: (message: string, data?: Record) => - Effect.sync(() => { + Effect.gen(function* () { messages.push({ type: "success", message, data }); + if (opts.format === "json" || opts.format === "stream-json") { + yield* writeResult({ ...data, message }); + } }), fail: (err: { code: string; message: string; detail?: string; suggestion?: string }) => Effect.sync(() => {