From cdb38050a7156220f238d2131ef2e35b7ba25396 Mon Sep 17 00:00:00 2001 From: Jordi Enric Date: Wed, 11 Nov 2026 10:09:47 +0100 Subject: [PATCH 01/14] feat(cli): add grouped project issues command --- apps/cli/src/cli/root.ts | 2 + .../commands/db/advisors/advisors.handler.ts | 20 ++- .../cli/src/commands/issues/issues.command.ts | 103 +++++++++++++ apps/cli/src/commands/issues/issues.errors.ts | 19 +++ apps/cli/src/commands/issues/issues.format.ts | 136 ++++++++++++++++++ .../cli/src/commands/issues/issues.handler.ts | 120 ++++++++++++++++ .../telemetry/__fixtures__/error-tags.txt | 2 + 7 files changed, 395 insertions(+), 7 deletions(-) create mode 100644 apps/cli/src/commands/issues/issues.command.ts create mode 100644 apps/cli/src/commands/issues/issues.errors.ts create mode 100644 apps/cli/src/commands/issues/issues.format.ts create mode 100644 apps/cli/src/commands/issues/issues.handler.ts diff --git a/apps/cli/src/cli/root.ts b/apps/cli/src/cli/root.ts index 9e41bc8ff5..32ab672a62 100644 --- a/apps/cli/src/cli/root.ts +++ b/apps/cli/src/cli/root.ts @@ -29,6 +29,7 @@ import { functionsCommand } from "../commands/functions/functions.command.ts"; import { genCommand } from "../commands/gen/gen.command.ts"; import { initCommand } from "../commands/init/init.command.ts"; import { inspectCommand } from "../commands/inspect/inspect.command.ts"; +import { issuesCommand } from "../commands/issues/issues.command.ts"; import { issueCommand } from "../commands/issue/issue.command.ts"; import { linkCommand } from "../commands/link/link.command.ts"; import { logsCommandFor } from "../commands/logs/logs.command.ts"; @@ -177,6 +178,7 @@ export const rootCommandForFeatures = (options: RootCommandFeatures = {}) => initCommand, inspectCommand, issueCommand, + issuesCommand, linkCommand, loginCommand, logoutCommand, diff --git a/apps/cli/src/commands/db/advisors/advisors.handler.ts b/apps/cli/src/commands/db/advisors/advisors.handler.ts index c640450a99..fe40769e76 100644 --- a/apps/cli/src/commands/db/advisors/advisors.handler.ts +++ b/apps/cli/src/commands/db/advisors/advisors.handler.ts @@ -77,11 +77,9 @@ const setExposedSchemas = Effect.fn("DbAdvisors.setExposedSchemas")(function* ( }); /** Gathers lints from a local (or `--db-url`) database connection. */ -const runLocal = Effect.fn("db.advisors.runLocal")(function* ( +export const collectLocalAdvisorLints = Effect.fn("db.advisors.runLocal")(function* ( target: Exclude, dnsResolver: "native" | "https", - advisorType: string, - level: string, ) { const output = yield* Output; const resolver = yield* DbConfigResolver; @@ -120,11 +118,11 @@ const runLocal = Effect.fn("db.advisors.runLocal")(function* ( }), ); - return filterAdvisorLints(lints, advisorType, level); + return lints; }); /** Gathers the linked project's lints from the Management API, never from its database. */ -const runLinked = Effect.fn("db.advisors.runLinked")(function* ( +const collectLinkedAdvisorLints = Effect.fn("db.advisors.runLinked")(function* ( target: Extract, advisorType: string, level: string, @@ -225,14 +223,22 @@ const runAdvisors = Effect.fnUntraced(function* ( // Linked goes through the Management API; local and `--db-url` read the database directly. const filtered = target.kind === "linked" - ? yield* runLinked(target, advisorType, level) - : yield* runLocal(target, dnsResolver, advisorType, level); + ? yield* collectLinkedAdvisorLints(target, advisorType, level) + : filterAdvisorLints( + yield* collectLocalAdvisorLints(target, dnsResolver), + advisorType, + level, + ); yield* Effect.annotateCurrentSpan("advisor.reported_count", filtered.length); yield* outputAndCheck(filtered, failOn); }); export const dbAdvisors = Effect.fn("db.advisors")(function* (flags: DbAdvisorsFlags) { + yield* (yield* Output).raw( + "supabase db advisors is deprecated; use supabase issues instead.\n", + "stderr", + ); const dnsResolver = yield* DnsResolverFlag; const telemetryState = yield* TelemetryState; // Flush telemetry on success and failure. Command-level instrumentation / diff --git a/apps/cli/src/commands/issues/issues.command.ts b/apps/cli/src/commands/issues/issues.command.ts new file mode 100644 index 0000000000..dedec986d9 --- /dev/null +++ b/apps/cli/src/commands/issues/issues.command.ts @@ -0,0 +1,103 @@ +import { Command, Flag } from "effect/cli"; +import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose, withCondition } from "../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../telemetry/linked-project-cache.layer.ts"; +import { withJsonErrorHandling } from "../../shared/output/json-error-handling.ts"; +import { withCommandTelemetry } from "../../telemetry/command-telemetry.ts"; +import { issues } from "./issues.handler.ts"; +import { dbAdvisorsRuntimeLayer } from "../db/advisors/advisors.layers.ts"; +import { commandRuntimeLayer } from "../../shared/runtime/command-runtime.layer.ts"; +import { cliConfigValuesLayer } from "../../config/cli-config-values.layer.ts"; +import { withCliConfigFlags } from "../../config/cli-config-flags.ts"; +import { branchResolution } from "../../command-internal/target/branch-resolution.ts"; +import { targetFlags } from "../../command-internal/target/target-flags.ts"; +import { withTargetPolicy } from "../../command-internal/target/target-policy.ts"; + +const config = { + ...targetFlags({ subject: "database", dbUrl: true }), + type: Flag.Literals("type", ["all", "security", "performance", "health"] as const).pipe( + Flag.withDescription("Type of issues to check: all, security, performance, health."), + Flag.optional, + ), + severity: Flag.Literals("severity", ["info", "warning", "critical"] as const).pipe( + Flag.withDescription("Minimum issue severity to display: info, warning, critical."), + Flag.optional, + ), + failOn: Flag.Literals("fail-on", ["none", "info", "warning", "critical"] as const).pipe( + Flag.withDescription("Minimum displayed issue severity to exit with non-zero status."), + Flag.optional, + ), +} as const; + +export type IssuesFlags = CliCommand.Command.Config.Infer; + +export const issuesCommand = Command.make("issues", config).pipe( + Command.withDescription("Checks security, performance, and health issues."), + Command.withShortDescription("Checks security, performance, and health issues"), + withPermissions( + compose( + { + operations: [ + { + operationId: "v2-run-project-advisors", + kind: "required", + when: [{ dbTarget: ["linked"] }, { flag: "type", oneOf: ["all", "health"] }], + }, + { + operationId: "v2-run-project-advisors", + kind: "required", + when: [{ dbTarget: ["linked"] }, { flag: "type", present: false }], + }, + { + operationId: "v1-get-security-advisors", + kind: "required", + when: [{ dbTarget: ["linked"] }, { flag: "type", oneOf: ["all", "security"] }], + }, + { + operationId: "v1-get-security-advisors", + kind: "required", + when: [{ dbTarget: ["linked"] }, { flag: "type", present: false }], + }, + { + operationId: "v1-get-performance-advisors", + kind: "required", + when: [{ dbTarget: ["linked"] }, { flag: "type", oneOf: ["all", "performance"] }], + }, + { + operationId: "v1-get-performance-advisors", + kind: "required", + when: [{ dbTarget: ["linked"] }, { flag: "type", present: false }], + }, + ], + noApiEffectFlags: ["severity", "fail-on"], + }, + withCondition(projectRefPicker, { dbTarget: ["linked"] }), + withCondition(linkedProjectCache, { dbTarget: ["linked"] }), + branchResolution, + ), + ), + Command.withHandler((flags) => + issues(flags).pipe( + withCommandTelemetry({ + flags: { + type: flags.type, + severity: flags.severity, + "fail-on": flags.failOn, + }, + config, + }), + withJsonErrorHandling, + ), + ), + Command.provide(commandRuntimeLayer(["issues"])), + Command.provide(dbAdvisorsRuntimeLayer), + Command.provide(cliConfigValuesLayer), + withCliConfigFlags(config), + withTargetPolicy({ + group: "selectable", + consent: "none", + consentExemptReason: "its transaction is always rolled back", + }), +); diff --git a/apps/cli/src/commands/issues/issues.errors.ts b/apps/cli/src/commands/issues/issues.errors.ts new file mode 100644 index 0000000000..ae0821156a --- /dev/null +++ b/apps/cli/src/commands/issues/issues.errors.ts @@ -0,0 +1,19 @@ +import { Schema } from "effect"; +import { actionability, ErrorActionabilityId } from "../../shared/telemetry/error-actionability.ts"; +export class IssuesHealthUnsupportedError extends Schema.TaggedError()( + "IssuesHealthUnsupportedError", + { message: Schema.String }, +) { + get [ErrorActionabilityId]() { + return actionability.invalidInput; + } +} + +export class IssuesOutputFlagUnsupportedError extends Schema.TaggedError()( + "IssuesOutputFlagUnsupportedError", + { message: Schema.String }, +) { + get [ErrorActionabilityId]() { + return actionability.invalidInput; + } +} diff --git a/apps/cli/src/commands/issues/issues.format.ts b/apps/cli/src/commands/issues/issues.format.ts new file mode 100644 index 0000000000..3ecbb2728b --- /dev/null +++ b/apps/cli/src/commands/issues/issues.format.ts @@ -0,0 +1,136 @@ +import { Effect, Option, Schema } from "effect"; +import type { AdvisorLint } from "../db/advisors/advisors.format.ts"; + +export const IssueSeverity = Schema.Literals(["info", "warning", "critical"]); +export type IssueSeverity = typeof IssueSeverity.Type; +export const IssueCategory = Schema.Literals(["security", "performance", "health"]); +export type IssueCategory = typeof IssueCategory.Type; +const Resource = Schema.Struct({ + type: Schema.String, + name: Schema.String, + schema: Schema.NullOr(Schema.String), +}); +const Affected = Schema.Struct({ + id: Schema.String, + resource: Resource, + detail: Schema.String, + severity: IssueSeverity, + docs_url: Schema.String, +}); +export const Issue = Schema.Struct({ + id: Schema.String, + title: Schema.String, + description: Schema.String, + severity: IssueSeverity, + category: IssueCategory, + source: Schema.Literal("lint"), + docs_url: Schema.String, + affected: Schema.Array(Affected), +}); +export interface Issue extends Schema.Schema.Type {} +export interface CheckCoverage { + readonly type: IssueCategory; + readonly status: "complete" | "unavailable"; + readonly reason?: string; +} +const Metadata = Schema.Struct({ + name: Schema.optionalKey(Schema.String), + entity: Schema.optionalKey(Schema.String), + type: Schema.optionalKey(Schema.String), + schema: Schema.optionalKey(Schema.NullOr(Schema.String)), + arguments: Schema.optionalKey(Schema.String), +}); +const resourceTypes = new Map([ + ["table", "table"], + ["view", "view"], + ["materialized view", "materialized_view"], + ["foreign table", "foreign_table"], + ["function", "function"], + ["auth", "auth"], + ["extension", "extension"], + ["bucket", "bucket"], + ["compliance", "project"], + ["health", "project"], +]); +export const unescapeIssueText = (text: string): string => text.replaceAll("\\`", "`"); +export const isUnavailableCheck = (lint: AdvisorLint): boolean => + lint.name === "advisor_check_unavailable" || lint.name === "project_not_active"; +const severityRank = { info: 0, warning: 1, critical: 2 }; +export const severityAtLeast = (severity: IssueSeverity, minimum: IssueSeverity): boolean => + severityRank[severity] >= severityRank[minimum]; +const Level = Schema.Literals(["INFO", "WARN", "ERROR"]); +const severityByLevel = { INFO: "info", WARN: "warning", ERROR: "critical" } as const; +export const groupIssues = Effect.fnUntraced(function* ( + lints: ReadonlyArray, + minimum: IssueSeverity, +) { + const grouped = new Map(); + for (const lint of lints) { + if (isUnavailableCheck(lint)) continue; + const severity = severityByLevel[yield* Schema.decodeUnknownEffect(Level)(lint.level)]; + const metadata = Option.getOrElse(Schema.decodeUnknownOption(Metadata)(lint.metadata), () => + Metadata.make({}), + ); + const resource = + metadata.entity !== undefined + ? { + type: + resourceTypes.get(metadata.type ?? "") ?? + (metadata.arguments === undefined ? "project" : "function"), + name: metadata.entity, + schema: null, + } + : metadata.name === undefined + ? { type: "project", name: "Project", schema: null } + : { + type: + resourceTypes.get(metadata.type ?? "") ?? + (metadata.arguments === undefined ? "project" : "function"), + name: + metadata.arguments === undefined + ? metadata.name + : `${metadata.name}(${metadata.arguments})`, + schema: metadata.schema ?? null, + }; + const affected = { + id: + lint.cacheKey || + JSON.stringify([lint.name, resource.type, resource.schema, resource.name, lint.detail]), + resource, + detail: unescapeIssueText(lint.detail), + severity, + docs_url: lint.remediation, + }; + const category: IssueCategory = + lint.categories?.includes("HEALTH") === true + ? "health" + : lint.categories?.includes("SECURITY") === true + ? "security" + : "performance"; + const existing = grouped.get(lint.name); + grouped.set( + lint.name, + existing === undefined + ? { + id: lint.name, + title: lint.title, + description: unescapeIssueText(lint.description), + severity, + category, + source: "lint", + docs_url: lint.remediation, + affected: [affected], + } + : { + ...existing, + severity: severityAtLeast(severity, existing.severity) ? severity : existing.severity, + affected: [...existing.affected, affected], + }, + ); + } + return [...grouped.values()] + .filter((issue) => severityAtLeast(issue.severity, minimum)) + .sort( + (a, b) => severityRank[b.severity] - severityRank[a.severity] || a.id.localeCompare(b.id), + ); +}); diff --git a/apps/cli/src/commands/issues/issues.handler.ts b/apps/cli/src/commands/issues/issues.handler.ts new file mode 100644 index 0000000000..0860918e10 --- /dev/null +++ b/apps/cli/src/commands/issues/issues.handler.ts @@ -0,0 +1,120 @@ +import { Effect, Option } from "effect"; +import { resolveTarget } from "../../command-internal/target/resolve-target.ts"; +import { DnsResolverFlag, OutputFlag } from "../../command-internal/global-flags.ts"; +import { encodeHtmlSafeJsonIndented } from "../../command-internal/html-safe-json.ts"; +import { Output } from "../../shared/output/output.service.ts"; +import { ProcessControl } from "../../shared/runtime/process-control.service.ts"; +import { TelemetryState } from "../../telemetry/telemetry-state.service.ts"; +import { LinkedProjectCache } from "../../telemetry/linked-project-cache.service.ts"; +import { CommandPlatformApiFactory } from "../../auth/command-platform-api-factory.service.ts"; +import { collectLocalAdvisorLints } from "../db/advisors/advisors.handler.ts"; +import { fetchPerformanceAdvisors, fetchSecurityAdvisors } from "../db/advisors/advisors.linked.ts"; +import { apiLintsToAdvisorLints, type AdvisorLint } from "../db/advisors/advisors.format.ts"; +import { + groupIssues, + isUnavailableCheck, + unescapeIssueText, + severityAtLeast, + type CheckCoverage, + type IssueCategory, +} from "./issues.format.ts"; +import { unsupportedOutputFlagMessage } from "../../command-internal/output-formats.ts"; +import { IssuesOutputFlagUnsupportedError, IssuesHealthUnsupportedError } from "./issues.errors.ts"; +import type { IssuesFlags } from "./issues.command.ts"; + +const healthChecks = [ + "log_data_api_error_rate_high", + "log_auth_error_rate_high", + "log_storage_error_rate_high", + "log_edge_function_error_rate_high", +] as const; +const fetchHealth = Effect.fn("issues.fetchHealth")(function* (ref: string) { + const api = yield* (yield* CommandPlatformApiFactory).make; + const result = yield* api.v2.runProjectAdvisors({ + ref, + data: { + type: "project_advisors", + attributes: { lints: healthChecks.map((name) => ({ name })) }, + }, + }); + return apiLintsToAdvisorLints(result.data.attributes.lints); +}); +const fetchRemoteIssues = Effect.fn("issues.fetchRemote")(function* ( + ref: string, + category: IssueCategory, +) { + if (category === "health") return yield* fetchHealth(ref); + if (category === "security") return yield* fetchSecurityAdvisors(ref); + return yield* fetchPerformanceAdvisors(ref); +}); +const runIssues = Effect.fn("issues.run")(function* (flags: IssuesFlags) { + if (Option.isSome(yield* OutputFlag)) + return yield* new IssuesOutputFlagUnsupportedError({ + message: unsupportedOutputFlagMessage("issues"), + }); + const target = yield* resolveTarget(flags); + const dns = yield* DnsResolverFlag; + const output = yield* Output; + const process = yield* ProcessControl; + const type = Option.getOrElse(flags.type, () => "all" as const); + const minimum = Option.getOrElse(flags.severity, () => "info" as const); + const failOn = Option.getOrElse(flags.failOn, () => "none" as const); + if (type === "health" && target.kind !== "linked") { + return yield* new IssuesHealthUnsupportedError({ + message: + "Health checks require a remote project. Use supabase issues --remote --type health.", + }); + } + const categories: ReadonlyArray = + type === "all" ? ["security", "performance", "health"] : [type]; + const lints: AdvisorLint[] = []; + const checks: CheckCoverage[] = []; + const collect = Effect.gen(function* () { + const local = + target.kind !== "linked" && type !== "health" + ? yield* collectLocalAdvisorLints(target, dns) + : []; + for (const category of categories) { + if (category === "health" && target.kind !== "linked") { + checks.push({ + type: "health", + status: "unavailable", + reason: "Health checks require a remote project.", + }); + continue; + } + const found = + target.kind === "linked" + ? yield* fetchRemoteIssues(target.ref, category) + : local.filter((lint) => lint.categories?.includes(category.toUpperCase())); + const unavailable = found.filter(isUnavailableCheck); + checks.push( + unavailable.length === 0 + ? { type: category, status: "complete" } + : { + type: category, + status: "unavailable", + reason: unavailable + .map((lint) => `${lint.name}: ${unescapeIssueText(lint.detail)}`) + .join("; "), + }, + ); + lints.push(...found.map((lint) => ({ ...lint, categories: [category.toUpperCase()] }))); + } + }); + if (target.kind === "linked") + yield* collect.pipe(Effect.ensuring((yield* LinkedProjectCache).cache(target.ref))); + else yield* collect; + const issues = yield* groupIssues(lints, minimum); + yield* Effect.annotateCurrentSpan("issues.count", issues.length); + if (output.format === "text") yield* output.raw(encodeHtmlSafeJsonIndented({ issues, checks })); + else yield* output.success("issues", { issues, checks }); + const unavailableRemote = + target.kind === "linked" && checks.some((check) => check.status === "unavailable"); + const thresholdReached = + failOn !== "none" && issues.some((issue) => severityAtLeast(issue.severity, failOn)); + if (unavailableRemote || thresholdReached) yield* process.setExitCode(1); +}); +export const issues = Effect.fn("issues")(function* (flags: IssuesFlags) { + yield* runIssues(flags).pipe(Effect.ensuring((yield* TelemetryState).flush)); +}); diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index 6e1d61ac18..3ffd7bc64c 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -303,6 +303,8 @@ InvalidProjectRefError InvalidRemoteProjectIdError InvalidSecretPairError InvalidServiceVersionTagError +IssuesHealthUnsupportedError +IssuesOutputFlagUnsupportedError KongReloadError LinkApiKeysNetworkError LinkAuthTokenError From 9901f169b4694f9ebf316b68d63e474fd1f32dc5 Mon Sep 17 00:00:00 2001 From: Jordi Enric Date: Wed, 11 Nov 2026 10:13:33 +0100 Subject: [PATCH 02/14] fix(cli): encode issue identities and report check progress --- apps/cli/src/commands/issues/issues.format.ts | 11 ++++++++++- apps/cli/src/commands/issues/issues.handler.ts | 9 +++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/commands/issues/issues.format.ts b/apps/cli/src/commands/issues/issues.format.ts index 3ecbb2728b..1f778404a8 100644 --- a/apps/cli/src/commands/issues/issues.format.ts +++ b/apps/cli/src/commands/issues/issues.format.ts @@ -55,6 +55,9 @@ const resourceTypes = new Map([ export const unescapeIssueText = (text: string): string => text.replaceAll("\\`", "`"); export const isUnavailableCheck = (lint: AdvisorLint): boolean => lint.name === "advisor_check_unavailable" || lint.name === "project_not_active"; +const encodeAffectedIdentity = Schema.encodeSync( + Schema.fromJsonString(Schema.Array(Schema.NullOr(Schema.String))), +); const severityRank = { info: 0, warning: 1, critical: 2 }; export const severityAtLeast = (severity: IssueSeverity, minimum: IssueSeverity): boolean => severityRank[severity] >= severityRank[minimum]; @@ -95,7 +98,13 @@ export const groupIssues = Effect.fnUntraced(function* ( const affected = { id: lint.cacheKey || - JSON.stringify([lint.name, resource.type, resource.schema, resource.name, lint.detail]), + encodeAffectedIdentity([ + lint.name, + resource.type, + resource.schema, + resource.name, + lint.detail, + ]), resource, detail: unescapeIssueText(lint.detail), severity, diff --git a/apps/cli/src/commands/issues/issues.handler.ts b/apps/cli/src/commands/issues/issues.handler.ts index 0860918e10..34ef0e382d 100644 --- a/apps/cli/src/commands/issues/issues.handler.ts +++ b/apps/cli/src/commands/issues/issues.handler.ts @@ -102,9 +102,14 @@ const runIssues = Effect.fn("issues.run")(function* (flags: IssuesFlags) { lints.push(...found.map((lint) => ({ ...lint, categories: [category.toUpperCase()] }))); } }); + const task = yield* output.task("Checking project issues"); + const checked = collect.pipe( + Effect.tap(() => task.succeed()), + Effect.tapError(() => task.fail()), + ); if (target.kind === "linked") - yield* collect.pipe(Effect.ensuring((yield* LinkedProjectCache).cache(target.ref))); - else yield* collect; + yield* checked.pipe(Effect.ensuring((yield* LinkedProjectCache).cache(target.ref))); + else yield* checked; const issues = yield* groupIssues(lints, minimum); yield* Effect.annotateCurrentSpan("issues.count", issues.length); if (output.format === "text") yield* output.raw(encodeHtmlSafeJsonIndented({ issues, checks })); From 6ca3f0720339ab462969ffbc87887c6610705b5e Mon Sep 17 00:00:00 2001 From: Jordi Enric Date: Wed, 11 Nov 2026 10:14:59 +0100 Subject: [PATCH 03/14] fix(cli): register issue docs metadata and read-only checks --- .../src/command-internal/target/target-policy.unit.test.ts | 1 + apps/cli/src/commands/issues/issues.command.ts | 2 ++ apps/cli/src/docs/docs-spec.tables.ts | 4 ++++ 3 files changed, 7 insertions(+) diff --git a/apps/cli/src/command-internal/target/target-policy.unit.test.ts b/apps/cli/src/command-internal/target/target-policy.unit.test.ts index dbe7ef8626..72f709f705 100644 --- a/apps/cli/src/command-internal/target/target-policy.unit.test.ts +++ b/apps/cli/src/command-internal/target/target-policy.unit.test.ts @@ -25,6 +25,7 @@ const VARIANTS: ReadonlyArray<{ readonly name: string; readonly variant: Permiss /** Non-GET operations a leaf may call without hosted-mutation consent, with the reason. */ const CONSENT_FREE_OPERATIONS: ReadonlyMap = new Map([ + ["v2-run-project-advisors", "runs read-only checks without changing project configuration"], [ "v1-create-login-role", "mints a short-lived login role for the connection; revisit when connection setup asks for consent", diff --git a/apps/cli/src/commands/issues/issues.command.ts b/apps/cli/src/commands/issues/issues.command.ts index dedec986d9..37a9253ae1 100644 --- a/apps/cli/src/commands/issues/issues.command.ts +++ b/apps/cli/src/commands/issues/issues.command.ts @@ -1,3 +1,4 @@ +import { GLOBAL_OUTPUT_FORMATS } from "../../command-internal/global-flags.ts"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { withPermissions } from "../../command-internal/command-permissions/command-permissions.annotation.ts"; @@ -81,6 +82,7 @@ export const issuesCommand = Command.make("issues", config).pipe( Command.withHandler((flags) => issues(flags).pipe( withCommandTelemetry({ + outputFormats: GLOBAL_OUTPUT_FORMATS, flags: { type: flags.type, severity: flags.severity, diff --git a/apps/cli/src/docs/docs-spec.tables.ts b/apps/cli/src/docs/docs-spec.tables.ts index 905ca8f7d3..0a137ab9cc 100644 --- a/apps/cli/src/docs/docs-spec.tables.ts +++ b/apps/cli/src/docs/docs-spec.tables.ts @@ -35,6 +35,7 @@ export const DOCS_TAGS: Readonly>> = { "supabase-init": ["local-dev"], "supabase-inspect": ["local-dev"], "supabase-issue": ["other-commands"], + "supabase-issues": ["management-api", "local-dev"], "supabase-link": ["local-dev"], "supabase-login": ["local-dev"], "supabase-logout": ["local-dev"], @@ -123,6 +124,9 @@ export const DOCS_DEFAULT_OVERRIDES: Readonly> = { "supabase output": "pretty", "supabase output-format": "text", "supabase profile": "supabase", + "supabase-issues fail-on": "none", + "supabase-issues severity": "info", + "supabase-issues type": "all", "supabase-db-advisors fail-on": "none", "supabase-db-advisors level": "warn", "supabase-db-advisors type": "all", From 26b7d7f7b86104a089d49b4942c94053176e3479 Mon Sep 17 00:00:00 2001 From: Jordi Enric Date: Wed, 11 Nov 2026 10:15:16 +0100 Subject: [PATCH 04/14] test(cli): cover issues evidence coverage and exit behavior --- .../db/advisors/advisors.integration.test.ts | 260 +++++++++++++++++- .../issues/issues.format.unit.test.ts | 97 +++++++ 2 files changed, 356 insertions(+), 1 deletion(-) create mode 100644 apps/cli/src/commands/issues/issues.format.unit.test.ts diff --git a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts index c7ccda85a8..a860febf2d 100644 --- a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts +++ b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts @@ -39,7 +39,7 @@ import { import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; -import { DnsResolverFlag, YesFlag } from "../../../command-internal/global-flags.ts"; +import { DnsResolverFlag, OutputFlag, YesFlag } from "../../../command-internal/global-flags.ts"; import { CommandCredentials } from "../../../auth/command-credentials.service.ts"; import { InvalidAccessTokenError } from "../../../auth/errors.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; @@ -63,6 +63,8 @@ import { } from "./advisors.errors.ts"; import { encodeAdvisorLints, scanAdvisorLintRow } from "./advisors.format.ts"; import { dbAdvisors } from "./advisors.handler.ts"; +import { issues } from "../../issues/issues.handler.ts"; +import type { IssuesFlags } from "../../issues/issues.command.ts"; import { splitLintsSql } from "./advisors.lints-sql.ts"; import type { DbAdvisorsFlags } from "./advisors.command.ts"; @@ -234,6 +236,8 @@ interface SetupOpts { loggedIn?: boolean; invalidToken?: boolean; ipv6Error?: boolean; + healthLints?: ReadonlyArray>; + healthStatus?: number; securityStatus?: number; securityRawBody?: string; securityLints?: ReadonlyArray>; @@ -263,6 +267,12 @@ function setup(opts: SetupOpts = {}) { const api = mockCommandPlatformApi({ handler: (request) => { const url = request.url; + if (url.includes("/advisors/run")) + return Effect.succeed( + jsonResponse(request, opts.healthStatus ?? 200, { + data: { type: "project_advisors", attributes: { lints: opts.healthLints ?? [] } }, + }), + ); if (url.includes("/advisors/security")) { const status = opts.securityStatus ?? 200; if (status !== 200) { @@ -325,6 +335,7 @@ function setup(opts: SetupOpts = {}) { Layer.succeed(TargetPolicy, { group: "selectable", consent: "none" }), Layer.succeed(YesFlag, false), Layer.succeed(DnsResolverFlag, "native"), + Layer.succeed(OutputFlag, Option.none()), Layer.succeed(CliArgs, { args: [] }), BunServices.layer, ); @@ -516,6 +527,9 @@ describe("db advisors — local", () => { expect.objectContaining({ type: "success", message: "db advisors" }), ); expect(out.stdoutText).toBe(""); + expect(out.stderrText).toContain( + "supabase db advisors is deprecated; use supabase issues instead.", + ); }).pipe(Effect.provide(layer)); }); @@ -896,3 +910,247 @@ describeDbTargetSelection("db advisors", { }; }, }); + +const issueFlags = (over: Partial = {}): IssuesFlags => ({ + ...flags(), + type: Option.none(), + severity: Option.none(), + failOn: Option.none(), + ...over, +}); +describe("issues", () => { + it.effect.each([false, true])( + "groups local and direct database findings and reports unavailable health (%s)", + (direct) => { + const { layer, out, connection } = setup({ + format: "json", + rows: [ + lintRow(), + lintRow({ + level: "INFO", + cache_key: "second", + metadata: { + name: "search", + type: "function", + schema: "app", + arguments: "text, integer", + }, + }), + ], + }); + return Effect.gen(function* () { + yield* issues( + issueFlags( + direct + ? { dbUrl: Option.some("postgresql://postgres:postgres@localhost:54322/postgres") } + : {}, + ), + ); + expect(out.messages).toContainEqual({ + type: "success", + message: "issues", + data: { + issues: [ + expect.objectContaining({ + id: "rls_disabled_in_public", + severity: "critical", + affected: [ + expect.objectContaining({ id: "rls_disabled_in_public_public_users" }), + expect.objectContaining({ + id: "second", + severity: "info", + resource: { name: "search(text, integer)", type: "function", schema: "app" }, + }), + ], + }), + ], + checks: [ + { type: "security", status: "complete" }, + { type: "performance", status: "complete" }, + { + type: "health", + status: "unavailable", + reason: "Health checks require a remote project.", + }, + ], + }, + }); + expect(connection.execs.at(-1)).toBe("rollback"); + expect(out.stdoutText).toBe(""); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect("rejects local health before opening a database connection", () => { + const { layer, connection } = setup(); + return Effect.gen(function* () { + const error = yield* Effect.flip(issues(issueFlags({ type: Option.some("health") }))); + expect(error._tag).toBe("IssuesHealthUnsupportedError"); + expect(connection.execs).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + it.effect( + "filters groups after maximum severity and applies fail-on only to displayed issues", + () => { + const { layer, out, processControl } = setup({ + format: "json", + rows: [lintRow({ level: "WARN" })], + }); + return Effect.gen(function* () { + yield* issues( + issueFlags({ severity: Option.some("critical"), failOn: Option.some("info") }), + ); + expect(processControl.exitCode).toBeUndefined(); + expect(out.messages).toContainEqual( + expect.objectContaining({ data: expect.objectContaining({ issues: [] }) }), + ); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect("runs remote health with all four checks and returns critical issues", () => { + const { layer, out, api, processControl, cache } = setup({ + format: "json", + healthLints: [lintRow({ name: "log_auth_error_rate_high", categories: ["HEALTH"] })], + }); + return Effect.gen(function* () { + yield* issues( + issueFlags({ + remote: REMOTE_LINKED, + type: Option.some("health"), + failOn: Option.some("warning"), + }), + ); + expect(out.messages).toContainEqual( + expect.objectContaining({ + data: { + issues: [ + expect.objectContaining({ + id: "log_auth_error_rate_high", + category: "health", + severity: "critical", + }), + ], + checks: [{ type: "health", status: "complete" }], + }, + }), + ); + expect(processControl.exitCode).toBe(1); + expect(api.requests.find((request) => request.url.includes("/advisors/run"))?.body).toEqual({ + data: { + type: "project_advisors", + attributes: { + lints: [ + { name: "log_data_api_error_rate_high" }, + { name: "log_auth_error_rate_high" }, + { name: "log_storage_error_rate_high" }, + { name: "log_edge_function_error_rate_high" }, + ], + }, + }, + }); + expect(cache.cachedRef).toBe(VALID_REF); + }).pipe(Effect.provide(layer)); + }); + it.effect.each(["advisor_check_unavailable", "project_not_active"])( + "never reports %s as clean remote health", + (name) => { + const { layer, out, processControl } = setup({ + format: "json", + healthLints: [lintRow({ name, categories: ["HEALTH"] })], + }); + return Effect.gen(function* () { + yield* issues(issueFlags({ remote: REMOTE_LINKED, type: Option.some("health") })); + expect(out.messages).toContainEqual( + expect.objectContaining({ + data: expect.objectContaining({ + checks: [ + { + type: "health", + status: "unavailable", + reason: `${name}: Table public.users has RLS disabled`, + }, + ], + }), + }), + ); + expect(processControl.exitCode).toBe(1); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect("propagates remote health HTTP failures and still caches the linked target", () => { + const { layer, out, cache } = setup({ format: "json", healthStatus: 503 }); + return Effect.gen(function* () { + const result = yield* Effect.exit( + issues(issueFlags({ remote: REMOTE_LINKED, type: Option.some("health") })), + ); + expect(Exit.isFailure(result)).toBe(true); + expect(out.messages.some((message) => message.type === "success")).toBe(false); + expect(cache.cachedRef).toBe(VALID_REF); + }).pipe(Effect.provide(layer)); + }); +}); + +describe("issues boundary failures", () => { + it.effect("attributes security endpoint findings and unavailable diagnostics to security", () => { + const { layer, out, processControl } = setup({ + format: "json", + securityLints: [ + lintRow({ categories: [] }), + lintRow({ name: "advisor_check_unavailable", categories: ["HEALTH"] }), + ], + }); + return Effect.gen(function* () { + yield* issues(issueFlags({ remote: REMOTE_LINKED, type: Option.some("security") })); + expect(out.messages).toContainEqual( + expect.objectContaining({ + data: { + issues: [expect.objectContaining({ category: "security" })], + checks: [ + { + type: "security", + status: "unavailable", + reason: "advisor_check_unavailable: Table public.users has RLS disabled", + }, + ], + }, + }), + ); + expect(processControl.exitCode).toBe(1); + }).pipe(Effect.provide(layer)); + }); + it.effect.each([false, true])( + "rejects future severity values without reporting a clean success (remote %s)", + (remote) => { + const { layer, out } = setup({ + format: "json", + rows: [lintRow({ level: "FUTURE" })], + securityLints: [lintRow({ level: "FUTURE" })], + }); + return Effect.gen(function* () { + const error = yield* Effect.flip( + issues( + issueFlags({ + remote: remote ? REMOTE_LINKED : Option.none(), + type: Option.some("security"), + }), + ), + ); + expect(error._tag).toBe("SchemaError"); + expect(out.messages.some((message) => message.type === "success")).toBe(false); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect("rejects legacy output formats before any database connection", () => { + const { layer, connection } = setup(); + return Effect.gen(function* () { + const error = yield* Effect.flip( + issues(issueFlags()).pipe(Effect.provideService(OutputFlag, Option.some("yaml"))), + ); + expect(error).toMatchObject({ + _tag: "IssuesOutputFlagUnsupportedError", + message: + "the -o/--output flag is not supported by issues; use --output-format json|stream-json instead.", + }); + expect(connection.execs).toEqual([]); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/commands/issues/issues.format.unit.test.ts b/apps/cli/src/commands/issues/issues.format.unit.test.ts new file mode 100644 index 0000000000..554d7d8190 --- /dev/null +++ b/apps/cli/src/commands/issues/issues.format.unit.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { scanAdvisorLintRow } from "../db/advisors/advisors.format.ts"; +import { Effect } from "effect"; +import { groupIssues as group } from "./issues.format.ts"; + +const groupIssues = (...args: Parameters) => Effect.runSync(group(...args)); +const lint = (values: Readonly> = {}) => + scanAdvisorLintRow({ + name: "check", + title: "Check", + description: "Check \\`resources\\`", + detail: "Affected \\`resource\\`", + categories: ["SECURITY"], + level: "WARN", + remediation: "https://example.com/check", + cache_key: "one", + ...values, + }); +describe("issue grouping", () => { + it("filters on maximum group severity while retaining lower severity evidence and individual documentation", () => { + const issues = groupIssues( + [ + lint({ level: "INFO" }), + lint({ level: "ERROR", cache_key: "two", remediation: "https://example.com/other" }), + ], + "critical", + ); + expect(issues).toEqual([ + { + id: "check", + title: "Check", + description: "Check `resources`", + severity: "critical", + category: "security", + source: "lint", + docs_url: "https://example.com/check", + affected: [ + { + id: "one", + resource: { type: "project", name: "Project", schema: null }, + detail: "Affected `resource`", + severity: "info", + docs_url: "https://example.com/check", + }, + { + id: "two", + resource: { type: "project", name: "Project", schema: null }, + detail: "Affected `resource`", + severity: "critical", + docs_url: "https://example.com/other", + }, + ], + }, + ]); + }); + it.each([ + [ + { name: "calculate", arguments: "" }, + { type: "function", name: "calculate()", schema: null }, + ], + [ + { name: "calculate", arguments: "text, integer", type: "function", schema: "api" }, + { type: "function", name: "calculate(text, integer)", schema: "api" }, + ], + [ + { entity: "auth.users", type: "table", schema: "ignored" }, + { type: "table", name: "auth.users", schema: null }, + ], + [ + { name: "settings", type: "compliance" }, + { type: "project", name: "settings", schema: null }, + ], + [ + { name: "x", type: "future" }, + { type: "project", name: "x", schema: null }, + ], + [ + { name: "reports", type: "materialized view", schema: "public" }, + { type: "materialized_view", name: "reports", schema: "public" }, + ], + ])("preserves resource identities from metadata %j", (metadata, resource) => { + expect(groupIssues([lint({ metadata })], "info")[0]?.affected[0]?.resource).toEqual(resource); + }); + it("sorts by severity then stable check id and excludes coverage diagnostics", () => { + expect( + groupIssues( + [ + lint({ name: "z", level: "WARN" }), + lint({ name: "b", level: "ERROR" }), + lint({ name: "a", level: "ERROR" }), + lint({ name: "advisor_check_unavailable" }), + ], + "info", + ).map((issue) => issue.id), + ).toEqual(["a", "b", "z"]); + }); +}); From c5fcf6f5d415424962dd7ed3435e49db640491e7 Mon Sep 17 00:00:00 2001 From: Jordi Enric Date: Wed, 11 Nov 2026 10:15:54 +0100 Subject: [PATCH 05/14] docs(cli): explain issues checks and advisor compatibility --- apps/cli/docs/supabase/db/advisors.md | 2 ++ apps/cli/docs/supabase/issues.md | 13 +++++++++++++ .../src/commands/db/advisors/SIDE_EFFECTS.md | 2 ++ apps/cli/src/commands/issues/SIDE_EFFECTS.md | 17 +++++++++++++++++ 4 files changed, 34 insertions(+) create mode 100644 apps/cli/docs/supabase/issues.md create mode 100644 apps/cli/src/commands/issues/SIDE_EFFECTS.md diff --git a/apps/cli/docs/supabase/db/advisors.md b/apps/cli/docs/supabase/db/advisors.md index 1c2e295433..52d7314f81 100644 --- a/apps/cli/docs/supabase/db/advisors.md +++ b/apps/cli/docs/supabase/db/advisors.md @@ -1,5 +1,7 @@ ## supabase-db-advisors +Deprecated: use `supabase issues`. Existing flags, defaults, output, and exit behavior remain supported. A deprecation notice is written to stderr. + Inspects the database for common security and performance issues such as missing RLS policies, unindexed foreign keys, exposed auth.users, and more. Inspects the local database by default. Pass `--remote` for the linked project, or `--remote=` for another project, a branch of the linked project, or a database URL. The command prints a `Target:` line on stderr naming the database it resolved, and never asks for confirmation. diff --git a/apps/cli/docs/supabase/issues.md b/apps/cli/docs/supabase/issues.md new file mode 100644 index 0000000000..6ba7c0e73f --- /dev/null +++ b/apps/cli/docs/supabase/issues.md @@ -0,0 +1,13 @@ +## supabase-issues + +Checks a project for security, performance, and health issues. Findings are grouped by check, with every affected resource and its evidence preserved. Severities are `info`, `warning`, and `critical`, corresponding to the underlying INFO, WARN, and ERROR findings. + +The default `--type all --severity info` displays every issue. Use `--fail-on warning` to exit with status 1 when a displayed issue is warning or critical. The severity filter applies before the exit threshold. + +Inspects the local database by default. Use `--remote` for the linked project, or `--remote=` for another target. Security and performance checks use a transaction that is always rolled back for local databases and direct database URLs. Health checks require a remote project: `--type health` rejects local and direct database targets, while `--type all` reports health coverage as unavailable for those targets. + +The result contains `issues` and `checks`. Each issue includes its stable check ID, severity, category, documentation URL, and affected resources. Coverage reports each requested category as complete or unavailable. A remote check that cannot run is reported as unavailable and exits with status 1; it is never reported as a clean check. + +`supabase db advisors` is deprecated. Its existing flags, defaults, output, and exit behavior remain supported. + +Use `--output-format json` or `--output-format stream-json` for structured output. The legacy `-o` / `--output` flag is not supported by this command. Check IDs identify check types within a project; they are not persistent incident IDs. diff --git a/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md b/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md index 5b511ee4bc..53ae1b264a 100644 --- a/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md @@ -2,6 +2,8 @@ Target: selectable, no consent — see [Command target](../../../../docs/command-target.md) +Writes a deprecation notice to stderr naming `supabase issues`. Existing flags, defaults, payloads, and exit behavior are preserved. + Checks a database for security and performance issues. Two backends: the local stack and `--remote=` query the database directly; a project fetches from the Management API. diff --git a/apps/cli/src/commands/issues/SIDE_EFFECTS.md b/apps/cli/src/commands/issues/SIDE_EFFECTS.md new file mode 100644 index 0000000000..761d1affc6 --- /dev/null +++ b/apps/cli/src/commands/issues/SIDE_EFFECTS.md @@ -0,0 +1,17 @@ +# `supabase issues` + +Target: selectable, no consent. Local database lint transactions always roll back. + +## Files and environment + +Uses the same configuration, connection, credentials, target resolution, linked-project cache, and telemetry files and environment as [db advisors](../db/advisors/SIDE_EFFECTS.md). No issue lifecycle state is persisted. + +## API routes + +Remote security and performance use `GET /v1/projects/{ref}/advisors/security` and `GET /v1/projects/{ref}/advisors/performance`. Health uses `POST /v2/projects/{ref}/advisors/run` with `data.type=project_advisors` and `data.attributes.lints` containing the four `log_{data_api,auth,storage,edge_function}_error_rate_high` names. Target resolution may fetch project and branch metadata. Linked cache and telemetry finalizers run on success and failure. + +## Output and exits + +Text prints a JSON object; machine modes put `{ issues, checks }` in the standard success result. Issues group by lint name, retain affected evidence, and sort by decreasing severity then ID. Severity defaults to info. Type defaults to all. Fail-on defaults to none and applies only to displayed issues. Exit 1 indicates an error, a matching fail-on threshold, or unavailable remote check coverage. Local all reports unavailable health coverage without failing. Explicit local or direct-database health is an unsupported-target error. Authentication, transport, and API failures remain errors. + +`-o` / `--output` is rejected with guidance to use `--output-format json|stream-json`. Check IDs are project-scoped check identifiers, not persisted incidents. From 6afd1d9bb913867f4ca8c160382581094f2f6a0d Mon Sep 17 00:00:00 2001 From: Jordi Enric Date: Wed, 11 Nov 2026 10:26:15 +0100 Subject: [PATCH 06/14] fix(cli): register severity as a value-consuming flag --- apps/cli/src/shared/cli/value-consuming-flags.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/cli/src/shared/cli/value-consuming-flags.ts b/apps/cli/src/shared/cli/value-consuming-flags.ts index 98524174dc..c3a8c82bdb 100644 --- a/apps/cli/src/shared/cli/value-consuming-flags.ts +++ b/apps/cli/src/shared/cli/value-consuming-flags.ts @@ -78,6 +78,7 @@ export const VALUE_CONSUMING_LONG_FLAGS = new Set([ "source", "sql", "status", + "severity", "sub", ...GEN_TYPES_LANGUAGE_VALUE_FLAG_NAMES, "tail", From 8337ade38239880a9df7521530cdeadab37c0ee4 Mon Sep 17 00:00:00 2001 From: Jordi Enric Date: Wed, 11 Nov 2026 10:26:40 +0100 Subject: [PATCH 07/14] refactor(cli): share advisor checks outside command internals --- .../advisors/advisors.errors.ts | 2 +- .../advisors/advisors.errors.unit.test.ts | 2 +- .../advisors/advisors.format.ts | 4 +- .../advisors/advisors.format.unit.test.ts | 0 .../advisors/advisors.layers.ts | 59 ++++++++++ .../advisors/advisors.linked.ts | 4 +- .../advisors/advisors.lints-sql.ts | 0 .../advisors/advisors.local.ts | 93 ++++++++++++++++ .../commands/db/advisors/advisors.handler.ts | 101 ++---------------- .../db/advisors/advisors.integration.test.ts | 9 +- .../commands/db/advisors/advisors.layers.ts | 81 +------------- .../cli/src/commands/issues/issues.command.ts | 4 +- apps/cli/src/commands/issues/issues.format.ts | 2 +- .../issues/issues.format.unit.test.ts | 2 +- .../cli/src/commands/issues/issues.handler.ts | 12 ++- apps/cli/src/telemetry/command-telemetry.ts | 2 +- 16 files changed, 188 insertions(+), 189 deletions(-) rename apps/cli/src/{commands/db => command-internal}/advisors/advisors.errors.ts (98%) rename apps/cli/src/{commands/db => command-internal}/advisors/advisors.errors.unit.test.ts (93%) rename apps/cli/src/{commands/db => command-internal}/advisors/advisors.format.ts (97%) rename apps/cli/src/{commands/db => command-internal}/advisors/advisors.format.unit.test.ts (100%) create mode 100644 apps/cli/src/command-internal/advisors/advisors.layers.ts rename apps/cli/src/{commands/db => command-internal}/advisors/advisors.linked.ts (91%) rename apps/cli/src/{commands/db => command-internal}/advisors/advisors.lints-sql.ts (100%) create mode 100644 apps/cli/src/command-internal/advisors/advisors.local.ts diff --git a/apps/cli/src/commands/db/advisors/advisors.errors.ts b/apps/cli/src/command-internal/advisors/advisors.errors.ts similarity index 98% rename from apps/cli/src/commands/db/advisors/advisors.errors.ts rename to apps/cli/src/command-internal/advisors/advisors.errors.ts index a9598f2721..871a70f938 100644 --- a/apps/cli/src/commands/db/advisors/advisors.errors.ts +++ b/apps/cli/src/command-internal/advisors/advisors.errors.ts @@ -4,7 +4,7 @@ import { type CliErrorActionabilityDeclaration, ErrorActionabilityId, statusCodeActionability, -} from "../../../shared/telemetry/error-actionability.ts"; +} from "../../shared/telemetry/error-actionability.ts"; /** * Tagged errors for `db advisors`, one per failure path. Message text is an diff --git a/apps/cli/src/commands/db/advisors/advisors.errors.unit.test.ts b/apps/cli/src/command-internal/advisors/advisors.errors.unit.test.ts similarity index 93% rename from apps/cli/src/commands/db/advisors/advisors.errors.unit.test.ts rename to apps/cli/src/command-internal/advisors/advisors.errors.unit.test.ts index 0265ca22ab..2cccb0eb81 100644 --- a/apps/cli/src/commands/db/advisors/advisors.errors.unit.test.ts +++ b/apps/cli/src/command-internal/advisors/advisors.errors.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { classifyCliErrorActionability } from "../../../shared/telemetry/error-actionability.ts"; +import { classifyCliErrorActionability } from "../../shared/telemetry/error-actionability.ts"; import { DbAdvisorsInvalidTokenError } from "./advisors.errors.ts"; describe("DbAdvisorsInvalidTokenError actionability", () => { diff --git a/apps/cli/src/commands/db/advisors/advisors.format.ts b/apps/cli/src/command-internal/advisors/advisors.format.ts similarity index 97% rename from apps/cli/src/commands/db/advisors/advisors.format.ts rename to apps/cli/src/command-internal/advisors/advisors.format.ts index 283195458b..e811d2345c 100644 --- a/apps/cli/src/commands/db/advisors/advisors.format.ts +++ b/apps/cli/src/command-internal/advisors/advisors.format.ts @@ -6,8 +6,8 @@ * byte-for-byte. The only `omitempty` field is `metadata`. */ -import { encodeHtmlSafeJsonIndented } from "../../../command-internal/html-safe-json.ts"; -import { makeLevelEnum } from "../../../command-internal/fail-on.ts"; +import { encodeHtmlSafeJsonIndented } from "../html-safe-json.ts"; +import { makeLevelEnum } from "../fail-on.ts"; /** Lowest severity first. */ const ADVISORS_ALLOWED_LEVELS = ["info", "warn", "error"] as const; diff --git a/apps/cli/src/commands/db/advisors/advisors.format.unit.test.ts b/apps/cli/src/command-internal/advisors/advisors.format.unit.test.ts similarity index 100% rename from apps/cli/src/commands/db/advisors/advisors.format.unit.test.ts rename to apps/cli/src/command-internal/advisors/advisors.format.unit.test.ts diff --git a/apps/cli/src/command-internal/advisors/advisors.layers.ts b/apps/cli/src/command-internal/advisors/advisors.layers.ts new file mode 100644 index 0000000000..333ecc4f0c --- /dev/null +++ b/apps/cli/src/command-internal/advisors/advisors.layers.ts @@ -0,0 +1,59 @@ +import { Layer } from "effect"; + +import { commandCredentialsLayer } from "../../auth/command-credentials.layer.ts"; +import { httpClientLayer } from "../../auth/http-debug.layer.ts"; +import { commandPlatformApiFactoryLayer } from "../../auth/command-platform-api-factory.layer.ts"; +import { commandSettingsLayer } from "../../config/command-settings.layer.ts"; +import { projectRefLayer } from "../../config/project-ref.layer.ts"; +import { dbConfigLayer } from "../db-config.layer.ts"; +import { dbConnectionLayer } from "../db-connection.sql-pg.layer.ts"; +import { debugLoggerLayer } from "../debug-logger.layer.ts"; +import { identityStitchLayer } from "../identity-stitch.ts"; +import { linkedProjectCacheLayer } from "../../telemetry/linked-project-cache.layer.ts"; +import { telemetryStateLayer } from "../../telemetry/telemetry-state.layer.ts"; + +const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); +const httpClient = httpClientLayer.pipe(Layer.provide(debugLoggerLayer)); +const credentials = commandCredentialsLayer.pipe( + Layer.provide(cliSettings), + Layer.provide(debugLoggerLayer), +); + +const platformApiFactory = commandPlatformApiFactoryLayer.pipe( + Layer.provide(credentials), + Layer.provide(cliSettings), + Layer.provide(debugLoggerLayer), + Layer.provide(identityStitchLayer), +); + +const projectRef = projectRefLayer.pipe( + Layer.provide(platformApiFactory), + Layer.provide(cliSettings), +); + +const linkedProjectCache = linkedProjectCacheLayer.pipe( + Layer.provide(credentials), + Layer.provide(cliSettings), + Layer.provide(httpClient), + Layer.provide(identityStitchLayer), +); + +const dbConfig = dbConfigLayer.pipe( + Layer.provide(cliSettings), + Layer.provide(dbConnectionLayer), + Layer.provide(debugLoggerLayer), + Layer.provide(identityStitchLayer), +); + +export const advisorChecksRuntimeLayer = Layer.mergeAll( + dbConfig, + dbConnectionLayer, + cliSettings, + httpClient, + credentials, + projectRef, + linkedProjectCache, + platformApiFactory, + identityStitchLayer, + telemetryStateLayer, +); diff --git a/apps/cli/src/commands/db/advisors/advisors.linked.ts b/apps/cli/src/command-internal/advisors/advisors.linked.ts similarity index 91% rename from apps/cli/src/commands/db/advisors/advisors.linked.ts rename to apps/cli/src/command-internal/advisors/advisors.linked.ts index 3d3b8025ff..35bca43908 100644 --- a/apps/cli/src/commands/db/advisors/advisors.linked.ts +++ b/apps/cli/src/command-internal/advisors/advisors.linked.ts @@ -1,7 +1,7 @@ import { Effect } from "effect"; -import { CommandPlatformApiFactory } from "../../../auth/command-platform-api-factory.service.ts"; -import { mapHttpError } from "../../../command-internal/http-errors.ts"; +import { CommandPlatformApiFactory } from "../../auth/command-platform-api-factory.service.ts"; +import { mapHttpError } from "../http-errors.ts"; import { DbAdvisorsPerformanceNetworkError, DbAdvisorsPerformanceStatusError, diff --git a/apps/cli/src/commands/db/advisors/advisors.lints-sql.ts b/apps/cli/src/command-internal/advisors/advisors.lints-sql.ts similarity index 100% rename from apps/cli/src/commands/db/advisors/advisors.lints-sql.ts rename to apps/cli/src/command-internal/advisors/advisors.lints-sql.ts diff --git a/apps/cli/src/command-internal/advisors/advisors.local.ts b/apps/cli/src/command-internal/advisors/advisors.local.ts new file mode 100644 index 0000000000..87eaedb2ba --- /dev/null +++ b/apps/cli/src/command-internal/advisors/advisors.local.ts @@ -0,0 +1,93 @@ +import { Effect, Option } from "effect"; +import { hostedMutationPreapproval } from "../hosted-consent/hosted-consent.ts"; +import type { ResolvedTarget } from "../target/resolve-target.ts"; +import { Output } from "../../shared/output/output.service.ts"; +import { DbConfigResolver } from "../db-config.service.ts"; +import { DbConnection, type DbSession } from "../db-connection.service.ts"; +import { + DbAdvisorsBeginTxError, + DbAdvisorsQueryError, + DbAdvisorsSetupError, +} from "./advisors.errors.ts"; +import { scanAdvisorLintRow } from "./advisors.format.ts"; +import { splitLintsSql } from "./advisors.lints-sql.ts"; + +const queryLints = Effect.fn("DbAdvisors.queryLints")(function* (session: DbSession) { + const [setupSql, querySql] = splitLintsSql(); + yield* session.exec(setupSql).pipe( + Effect.mapError( + (cause) => + new DbAdvisorsSetupError({ + message: `failed to prepare lint session: ${cause.message}`, + }), + ), + ); + const rows = yield* session + .query(querySql) + .pipe( + Effect.mapError( + (cause) => new DbAdvisorsQueryError({ message: `failed to query lints: ${cause.message}` }), + ), + ); + yield* Effect.annotateCurrentSpan("advisor.count", rows.length); + return rows.map(scanAdvisorLintRow); +}); + +const setExposedSchemas = Effect.fn("DbAdvisors.setExposedSchemas")(function* ( + session: DbSession, + schemas: ReadonlyArray, +) { + yield* Effect.annotateCurrentSpan("advisor.exposed_schema_count", schemas.length); + yield* session + .query("select set_config('pgrst.db_schemas', $1, true)", [schemas.join(",")]) + .pipe( + Effect.mapError( + (cause) => + new DbAdvisorsSetupError({ message: `failed to prepare lint session: ${cause.message}` }), + ), + ); +}); + +export const collectLocalAdvisorLints = Effect.fn("db.advisors.runLocal")(function* ( + target: Exclude, + dnsResolver: "native" | "https", +) { + const output = yield* Output; + const resolver = yield* DbConfigResolver; + const dbConn = yield* DbConnection; + + const cfg = yield* resolver.resolveConnection({ + target, + dnsResolver, + password: Option.none(), + networkBanRemoval: yield* hostedMutationPreapproval, + }); + + const lints = yield* Effect.scoped( + Effect.gen(function* () { + const session = yield* dbConn.connect(cfg.conn, { isLocal: cfg.isLocal, dnsResolver }); + yield* session.exec("begin").pipe( + Effect.mapError( + (cause) => + new DbAdvisorsBeginTxError({ + message: `failed to begin transaction: ${cause.message}`, + }), + ), + ); + return yield* Effect.gen(function* () { + if (cfg.exposedSchemas !== undefined) { + yield* setExposedSchemas(session, cfg.exposedSchemas); + } + return yield* queryLints(session); + }).pipe( + Effect.ensuring( + session + .exec("rollback") + .pipe(Effect.catch((cause) => output.raw(`${cause.message}\n`, "stderr"))), + ), + ); + }), + ); + + return lints; +}); diff --git a/apps/cli/src/commands/db/advisors/advisors.handler.ts b/apps/cli/src/commands/db/advisors/advisors.handler.ts index fe40769e76..ba123486e3 100644 --- a/apps/cli/src/commands/db/advisors/advisors.handler.ts +++ b/apps/cli/src/commands/db/advisors/advisors.handler.ts @@ -1,7 +1,6 @@ import { Effect, Option } from "effect"; import { DnsResolverFlag } from "../../../command-internal/global-flags.ts"; -import { hostedMutationPreapproval } from "../../../command-internal/hosted-consent/hosted-consent.ts"; import { resolveTarget, type ResolvedTarget, @@ -12,115 +11,29 @@ import { CommandCredentials } from "../../../auth/command-credentials.service.ts import { aqua } from "../../../command-internal/colors.ts"; import { missingAccessTokenMessage } from "../../../auth/access-token.ts"; import { failsOn } from "../../../command-internal/fail-on.ts"; -import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; -import { DbConnection } from "../../../command-internal/db-connection.service.ts"; -import type { DbSession } from "../../../command-internal/db-connection.service.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; +import { collectLocalAdvisorLints } from "../../../command-internal/advisors/advisors.local.ts"; import type { DbAdvisorsFlags } from "./advisors.command.ts"; import { - DbAdvisorsBeginTxError, DbAdvisorsFailOnError, DbAdvisorsInvalidTokenError, DbAdvisorsNotLoggedInError, - DbAdvisorsQueryError, - DbAdvisorsSetupError, -} from "./advisors.errors.ts"; +} from "../../../command-internal/advisors/advisors.errors.ts"; import { encodeAdvisorLints, filterAdvisorLints, ADVISORS_LEVEL_ENUM, type AdvisorLint, - scanAdvisorLintRow, -} from "./advisors.format.ts"; -import { fetchPerformanceAdvisors, fetchSecurityAdvisors } from "./advisors.linked.ts"; -import { splitLintsSql } from "./advisors.lints-sql.ts"; +} from "../../../command-internal/advisors/advisors.format.ts"; +import { + fetchPerformanceAdvisors, + fetchSecurityAdvisors, +} from "../../../command-internal/advisors/advisors.linked.ts"; /** Established output-contract suggestion for a missing/invalid access token. */ const loginSuggestion = (): string => `Run ${aqua("supabase login")} first.`; -/** Queries and scans the lints, minus the transaction the caller owns. */ -const queryLints = Effect.fn("DbAdvisors.queryLints")(function* (session: DbSession) { - const [setupSql, querySql] = splitLintsSql(); - yield* session.exec(setupSql).pipe( - Effect.mapError( - (cause) => - new DbAdvisorsSetupError({ - message: `failed to prepare lint session: ${cause.message}`, - }), - ), - ); - const rows = yield* session - .query(querySql) - .pipe( - Effect.mapError( - (cause) => new DbAdvisorsQueryError({ message: `failed to query lints: ${cause.message}` }), - ), - ); - yield* Effect.annotateCurrentSpan("advisor.count", rows.length); - return rows.map(scanAdvisorLintRow); -}); - -const setExposedSchemas = Effect.fn("DbAdvisors.setExposedSchemas")(function* ( - session: DbSession, - schemas: ReadonlyArray, -) { - yield* Effect.annotateCurrentSpan("advisor.exposed_schema_count", schemas.length); - yield* session - .query("select set_config('pgrst.db_schemas', $1, true)", [schemas.join(",")]) - .pipe( - Effect.mapError( - (cause) => - new DbAdvisorsSetupError({ message: `failed to prepare lint session: ${cause.message}` }), - ), - ); -}); - -/** Gathers lints from a local (or `--db-url`) database connection. */ -export const collectLocalAdvisorLints = Effect.fn("db.advisors.runLocal")(function* ( - target: Exclude, - dnsResolver: "native" | "https", -) { - const output = yield* Output; - const resolver = yield* DbConfigResolver; - const dbConn = yield* DbConnection; - - const cfg = yield* resolver.resolveConnection({ - target, - dnsResolver, - password: Option.none(), - networkBanRemoval: yield* hostedMutationPreapproval, - }); - - const lints = yield* Effect.scoped( - Effect.gen(function* () { - const session = yield* dbConn.connect(cfg.conn, { isLocal: cfg.isLocal, dnsResolver }); - yield* session.exec("begin").pipe( - Effect.mapError( - (cause) => - new DbAdvisorsBeginTxError({ - message: `failed to begin transaction: ${cause.message}`, - }), - ), - ); - return yield* Effect.gen(function* () { - if (cfg.exposedSchemas !== undefined) { - yield* setExposedSchemas(session, cfg.exposedSchemas); - } - return yield* queryLints(session); - }).pipe( - Effect.ensuring( - session - .exec("rollback") - .pipe(Effect.catch((cause) => output.raw(`${cause.message}\n`, "stderr"))), - ), - ); - }), - ); - - return lints; -}); - /** Gathers the linked project's lints from the Management API, never from its database. */ const collectLinkedAdvisorLints = Effect.fn("db.advisors.runLinked")(function* ( target: Extract, diff --git a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts index a860febf2d..bf1ac87974 100644 --- a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts +++ b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts @@ -60,12 +60,15 @@ import { DbAdvisorsFailOnError, DbAdvisorsInvalidTokenError, DbAdvisorsNotLoggedInError, -} from "./advisors.errors.ts"; -import { encodeAdvisorLints, scanAdvisorLintRow } from "./advisors.format.ts"; +} from "../../../command-internal/advisors/advisors.errors.ts"; +import { + encodeAdvisorLints, + scanAdvisorLintRow, +} from "../../../command-internal/advisors/advisors.format.ts"; import { dbAdvisors } from "./advisors.handler.ts"; import { issues } from "../../issues/issues.handler.ts"; import type { IssuesFlags } from "../../issues/issues.command.ts"; -import { splitLintsSql } from "./advisors.lints-sql.ts"; +import { splitLintsSql } from "../../../command-internal/advisors/advisors.lints-sql.ts"; import type { DbAdvisorsFlags } from "./advisors.command.ts"; const LOCAL_CONN: PgConnInput = { diff --git a/apps/cli/src/commands/db/advisors/advisors.layers.ts b/apps/cli/src/commands/db/advisors/advisors.layers.ts index 436ce0e7c1..9a4c5006c9 100644 --- a/apps/cli/src/commands/db/advisors/advisors.layers.ts +++ b/apps/cli/src/commands/db/advisors/advisors.layers.ts @@ -1,83 +1,8 @@ import { Layer } from "effect"; - +import { advisorChecksRuntimeLayer } from "../../../command-internal/advisors/advisors.layers.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; -import { commandCredentialsLayer } from "../../../auth/command-credentials.layer.ts"; -import { httpClientLayer } from "../../../auth/http-debug.layer.ts"; -import { commandPlatformApiFactoryLayer } from "../../../auth/command-platform-api-factory.layer.ts"; -import { commandSettingsLayer } from "../../../config/command-settings.layer.ts"; -import { projectRefLayer } from "../../../config/project-ref.layer.ts"; -import { dbConfigLayer } from "../../../command-internal/db-config.layer.ts"; -import { dbConnectionLayer } from "../../../command-internal/db-connection.sql-pg.layer.ts"; -import { debugLoggerLayer } from "../../../command-internal/debug-logger.layer.ts"; -import { identityStitchLayer } from "../../../command-internal/identity-stitch.ts"; -import { linkedProjectCacheLayer } from "../../../telemetry/linked-project-cache.layer.ts"; -import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts"; - -/** - * Runtime layer for `supabase db advisors`, which spans two backends: - * - * - **`--local` / `--db-url`** — the Postgres connection + db-config resolver. - * - **`--linked`** — typed advisor GETs via the lazy platform-API factory, project-ref - * resolution, and the linked-project cache. - * - * Does not use `managementApiRuntimeLayer`: that layer eagerly builds `CommandPlatformApi`, - * resolving an access token at layer construction, which would make the auth-free `--local` path - * fail with a "token not provided" error before the handler runs. - * - * Instead the project-ref resolver is given the lazy `commandPlatformApiFactoryLayer`, whose - * `make` is only forced by an interactive project-ref prompt. The linked path resolves the ref - * through target resolution, which never forces the factory; the local path never resolves a - * project ref at all, so no token is resolved there either. - * - * `commandSettingsLayer` is provided to each consumer that needs it, since `Layer.provide` does - * not share to merge siblings; layers are memoised by reference so the config / credentials / - * HTTP instances are reused. - */ -const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); -const httpClient = httpClientLayer.pipe(Layer.provide(debugLoggerLayer)); -const credentials = commandCredentialsLayer.pipe( - Layer.provide(cliSettings), - Layer.provide(debugLoggerLayer), -); - -const platformApiFactory = commandPlatformApiFactoryLayer.pipe( - Layer.provide(credentials), - Layer.provide(cliSettings), - Layer.provide(debugLoggerLayer), - Layer.provide(identityStitchLayer), -); - -const projectRef = projectRefLayer.pipe( - Layer.provide(platformApiFactory), - Layer.provide(cliSettings), -); - -const linkedProjectCache = linkedProjectCacheLayer.pipe( - Layer.provide(credentials), - Layer.provide(cliSettings), - Layer.provide(httpClient), - Layer.provide(identityStitchLayer), -); - -const dbConfig = dbConfigLayer.pipe( - Layer.provide(cliSettings), - Layer.provide(dbConnectionLayer), - Layer.provide(debugLoggerLayer), - Layer.provide(identityStitchLayer), -); -export const dbAdvisorsRuntimeLayer = Layer.mergeAll( - dbConfig, - dbConnectionLayer, - cliSettings, - httpClient, - credentials, - projectRef, - linkedProjectCache, - platformApiFactory, - // Exposed at top level so `withCommandTelemetry` can read `stitchedDistinctId()`. Memoised by - // reference, so it is the same instance the factory's typed client stitches through. - identityStitchLayer, - telemetryStateLayer, +export const dbAdvisorsRuntimeLayer = Layer.merge( + advisorChecksRuntimeLayer, commandRuntimeLayer(["db", "advisors"]), ); diff --git a/apps/cli/src/commands/issues/issues.command.ts b/apps/cli/src/commands/issues/issues.command.ts index 37a9253ae1..44c7906ab2 100644 --- a/apps/cli/src/commands/issues/issues.command.ts +++ b/apps/cli/src/commands/issues/issues.command.ts @@ -8,7 +8,7 @@ import { linkedProjectCache } from "../../telemetry/linked-project-cache.layer.t import { withJsonErrorHandling } from "../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../telemetry/command-telemetry.ts"; import { issues } from "./issues.handler.ts"; -import { dbAdvisorsRuntimeLayer } from "../db/advisors/advisors.layers.ts"; +import { advisorChecksRuntimeLayer } from "../../command-internal/advisors/advisors.layers.ts"; import { commandRuntimeLayer } from "../../shared/runtime/command-runtime.layer.ts"; import { cliConfigValuesLayer } from "../../config/cli-config-values.layer.ts"; import { withCliConfigFlags } from "../../config/cli-config-flags.ts"; @@ -94,7 +94,7 @@ export const issuesCommand = Command.make("issues", config).pipe( ), ), Command.provide(commandRuntimeLayer(["issues"])), - Command.provide(dbAdvisorsRuntimeLayer), + Command.provide(advisorChecksRuntimeLayer), Command.provide(cliConfigValuesLayer), withCliConfigFlags(config), withTargetPolicy({ diff --git a/apps/cli/src/commands/issues/issues.format.ts b/apps/cli/src/commands/issues/issues.format.ts index 1f778404a8..ec8b667756 100644 --- a/apps/cli/src/commands/issues/issues.format.ts +++ b/apps/cli/src/commands/issues/issues.format.ts @@ -1,5 +1,5 @@ import { Effect, Option, Schema } from "effect"; -import type { AdvisorLint } from "../db/advisors/advisors.format.ts"; +import type { AdvisorLint } from "../../command-internal/advisors/advisors.format.ts"; export const IssueSeverity = Schema.Literals(["info", "warning", "critical"]); export type IssueSeverity = typeof IssueSeverity.Type; diff --git a/apps/cli/src/commands/issues/issues.format.unit.test.ts b/apps/cli/src/commands/issues/issues.format.unit.test.ts index 554d7d8190..c0285188e1 100644 --- a/apps/cli/src/commands/issues/issues.format.unit.test.ts +++ b/apps/cli/src/commands/issues/issues.format.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { scanAdvisorLintRow } from "../db/advisors/advisors.format.ts"; +import { scanAdvisorLintRow } from "../../command-internal/advisors/advisors.format.ts"; import { Effect } from "effect"; import { groupIssues as group } from "./issues.format.ts"; diff --git a/apps/cli/src/commands/issues/issues.handler.ts b/apps/cli/src/commands/issues/issues.handler.ts index 34ef0e382d..95fce3b67f 100644 --- a/apps/cli/src/commands/issues/issues.handler.ts +++ b/apps/cli/src/commands/issues/issues.handler.ts @@ -7,9 +7,15 @@ import { ProcessControl } from "../../shared/runtime/process-control.service.ts" import { TelemetryState } from "../../telemetry/telemetry-state.service.ts"; import { LinkedProjectCache } from "../../telemetry/linked-project-cache.service.ts"; import { CommandPlatformApiFactory } from "../../auth/command-platform-api-factory.service.ts"; -import { collectLocalAdvisorLints } from "../db/advisors/advisors.handler.ts"; -import { fetchPerformanceAdvisors, fetchSecurityAdvisors } from "../db/advisors/advisors.linked.ts"; -import { apiLintsToAdvisorLints, type AdvisorLint } from "../db/advisors/advisors.format.ts"; +import { collectLocalAdvisorLints } from "../../command-internal/advisors/advisors.local.ts"; +import { + fetchPerformanceAdvisors, + fetchSecurityAdvisors, +} from "../../command-internal/advisors/advisors.linked.ts"; +import { + apiLintsToAdvisorLints, + type AdvisorLint, +} from "../../command-internal/advisors/advisors.format.ts"; import { groupIssues, isUnavailableCheck, diff --git a/apps/cli/src/telemetry/command-telemetry.ts b/apps/cli/src/telemetry/command-telemetry.ts index 7f31681a60..a098eeb287 100644 --- a/apps/cli/src/telemetry/command-telemetry.ts +++ b/apps/cli/src/telemetry/command-telemetry.ts @@ -28,7 +28,7 @@ import { classifyCliErrorActionability, unknownProcessControlledFailureActionability, } from "../shared/telemetry/error-actionability.ts"; -import { DbAdvisorsFailOnError } from "../commands/db/advisors/advisors.errors.ts"; +import { DbAdvisorsFailOnError } from "../command-internal/advisors/advisors.errors.ts"; import { DbLintFailOnError } from "../commands/db/lint/lint.errors.ts"; import { EventCommandExecuted, From e41f7177d2ca3ccf9f06d4793885dab7474ed231 Mon Sep 17 00:00:00 2001 From: Jordi Enric Date: Thu, 12 Nov 2026 12:01:08 +0100 Subject: [PATCH 08/14] refactor(cli): share terminal text wrapping --- .../src/commands/logs/logs.browser.format.ts | 3 +- apps/cli/src/commands/logs/logs.display.ts | 31 +---------------- .../commands/logs/logs.display.unit.test.ts | 3 +- apps/cli/src/commands/logs/logs.handler.ts | 3 +- .../src/shared/output/wrap-terminal-text.ts | 33 +++++++++++++++++++ 5 files changed, 40 insertions(+), 33 deletions(-) create mode 100644 apps/cli/src/shared/output/wrap-terminal-text.ts diff --git a/apps/cli/src/commands/logs/logs.browser.format.ts b/apps/cli/src/commands/logs/logs.browser.format.ts index 31fe6e0a34..845046fde4 100644 --- a/apps/cli/src/commands/logs/logs.browser.format.ts +++ b/apps/cli/src/commands/logs/logs.browser.format.ts @@ -1,8 +1,9 @@ +import { wrapTerminalText as wrapLogsText } from "../../shared/output/wrap-terminal-text.ts"; import { dim, inverse, type ColorStream } from "../../command-internal/colors.ts"; import { DateTime, type Schema } from "effect"; import { stripControlSequences } from "../../shared/output/strip-control-sequences.ts"; import { stringWidth } from "../../command-internal/string-width.ts"; -import { renderLogsRows, wrapLogsText } from "./logs.display.ts"; +import { renderLogsRows } from "./logs.display.ts"; const browserHeaderRows = (state: BrowserState) => state.context === undefined && state.filters === undefined ? 2 : 4; diff --git a/apps/cli/src/commands/logs/logs.display.ts b/apps/cli/src/commands/logs/logs.display.ts index 54aa8dec49..5541166210 100644 --- a/apps/cli/src/commands/logs/logs.display.ts +++ b/apps/cli/src/commands/logs/logs.display.ts @@ -1,3 +1,4 @@ +import { wrapTerminalText as wrapLogsText } from "../../shared/output/wrap-terminal-text.ts"; import type { Schema } from "effect"; import { renderTablewriter } from "../../command-internal/tabular-output.ts"; import { stringWidth } from "../../command-internal/string-width.ts"; @@ -5,36 +6,6 @@ import { stripControlSequences } from "../../shared/output/strip-control-sequenc const clean = (text: string) => stripControlSequences(text).replaceAll("\t", " "); -export function wrapLogsText(text: string, width: number): string { - const limit = Math.max(2, Math.floor(width)); - return clean(text) - .split("\n") - .map((line) => { - const lines: string[] = []; - let current = ""; - let used = 0; - for (const token of line.match(/\S+\s*|\s+/gu) ?? []) { - if (used > 0 && used + stringWidth(token) > limit) { - lines.push(current); - current = ""; - used = 0; - } - for (const character of token) { - const size = stringWidth(character); - if (used > 0 && used + size > limit) { - lines.push(current); - current = ""; - used = 0; - } - current += character; - used += size; - } - } - return [...lines, current].join("\n"); - }) - .join("\n"); -} - const queryValue = (value: Schema.Json | undefined): string => value === undefined ? "" : typeof value === "string" ? clean(value) : JSON.stringify(value); diff --git a/apps/cli/src/commands/logs/logs.display.unit.test.ts b/apps/cli/src/commands/logs/logs.display.unit.test.ts index ac41c79e37..4707d528ae 100644 --- a/apps/cli/src/commands/logs/logs.display.unit.test.ts +++ b/apps/cli/src/commands/logs/logs.display.unit.test.ts @@ -1,6 +1,7 @@ +import { wrapTerminalText as wrapLogsText } from "../../shared/output/wrap-terminal-text.ts"; import { describe, expect, it } from "vitest"; import { stringWidth } from "../../command-internal/string-width.ts"; -import { renderLogsRows, renderLogsSources, wrapLogsText } from "./logs.display.ts"; +import { renderLogsRows, renderLogsSources } from "./logs.display.ts"; describe("readable logs output", () => { for (const width of [80, 120]) { diff --git a/apps/cli/src/commands/logs/logs.handler.ts b/apps/cli/src/commands/logs/logs.handler.ts index fd2a9c9292..8e4d2cc539 100644 --- a/apps/cli/src/commands/logs/logs.handler.ts +++ b/apps/cli/src/commands/logs/logs.handler.ts @@ -1,3 +1,4 @@ +import { wrapTerminalText as wrapLogsText } from "../../shared/output/wrap-terminal-text.ts"; import { hasGatewayLogs } from "../../command-internal/logs/local-sources.ts"; import { logEvent, markerText } from "../../command-internal/stack-log-events.ts"; import { Clock, Effect, FileSystem, Option, Path, Result, Schema, Stream } from "effect"; @@ -21,7 +22,7 @@ import { Output } from "../../shared/output/output.service.ts"; import { Stdin } from "../../shared/runtime/stdin.service.ts"; import { Tty } from "../../shared/runtime/tty.service.ts"; import { encodeHtmlSafeJsonIndented } from "../../command-internal/html-safe-json.ts"; -import { renderLogsRows, renderLogsSources, wrapLogsText } from "./logs.display.ts"; +import { renderLogsRows, renderLogsSources } from "./logs.display.ts"; import { hostedSources, severityServices } from "./logs.catalog.ts"; import { logsWindow } from "./logs.format.ts"; import { LogsInputError } from "./logs.errors.ts"; diff --git a/apps/cli/src/shared/output/wrap-terminal-text.ts b/apps/cli/src/shared/output/wrap-terminal-text.ts new file mode 100644 index 0000000000..60cef04c90 --- /dev/null +++ b/apps/cli/src/shared/output/wrap-terminal-text.ts @@ -0,0 +1,33 @@ +import { stringWidth } from "../../command-internal/string-width.ts"; +import { stripControlSequences } from "./strip-control-sequences.ts"; + +export function wrapTerminalText(text: string, width: number): string { + const limit = Math.max(2, Math.floor(width)); + return stripControlSequences(text) + .replaceAll("\t", " ") + .split("\n") + .map((line) => { + const lines: string[] = []; + let current = ""; + let used = 0; + for (const token of line.match(/\S+\s*|\s+/gu) ?? []) { + if (used > 0 && used + stringWidth(token) > limit) { + lines.push(current); + current = ""; + used = 0; + } + for (const character of token) { + const size = stringWidth(character); + if (used > 0 && used + size > limit) { + lines.push(current); + current = ""; + used = 0; + } + current += character; + used += size; + } + } + return [...lines, current].join("\n"); + }) + .join("\n"); +} From 70e0eef512f595e3ccbad0e63b99678a6b3b6275 Mon Sep 17 00:00:00 2001 From: Jordi Enric Date: Thu, 12 Nov 2026 12:03:21 +0100 Subject: [PATCH 09/14] feat(cli): show issues in a readable terminal table --- .../cli/src/commands/issues/issues.handler.ts | 10 ++-- apps/cli/src/commands/issues/issues.text.ts | 54 +++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 apps/cli/src/commands/issues/issues.text.ts diff --git a/apps/cli/src/commands/issues/issues.handler.ts b/apps/cli/src/commands/issues/issues.handler.ts index 95fce3b67f..56399d57dd 100644 --- a/apps/cli/src/commands/issues/issues.handler.ts +++ b/apps/cli/src/commands/issues/issues.handler.ts @@ -1,7 +1,8 @@ import { Effect, Option } from "effect"; import { resolveTarget } from "../../command-internal/target/resolve-target.ts"; import { DnsResolverFlag, OutputFlag } from "../../command-internal/global-flags.ts"; -import { encodeHtmlSafeJsonIndented } from "../../command-internal/html-safe-json.ts"; +import { Tty } from "../../shared/runtime/tty.service.ts"; +import { renderIssuesText } from "./issues.text.ts"; import { Output } from "../../shared/output/output.service.ts"; import { ProcessControl } from "../../shared/runtime/process-control.service.ts"; import { TelemetryState } from "../../telemetry/telemetry-state.service.ts"; @@ -118,8 +119,11 @@ const runIssues = Effect.fn("issues.run")(function* (flags: IssuesFlags) { else yield* checked; const issues = yield* groupIssues(lints, minimum); yield* Effect.annotateCurrentSpan("issues.count", issues.length); - if (output.format === "text") yield* output.raw(encodeHtmlSafeJsonIndented({ issues, checks })); - else yield* output.success("issues", { issues, checks }); + if (output.format === "text") { + const tty = yield* Effect.serviceOption(Tty); + const width = Option.isSome(tty) ? (tty.value.stdoutColumns ?? 80) : 80; + yield* output.raw(renderIssuesText(issues, checks, minimum, width)); + } else yield* output.success("issues", { issues, checks }); const unavailableRemote = target.kind === "linked" && checks.some((check) => check.status === "unavailable"); const thresholdReached = diff --git a/apps/cli/src/commands/issues/issues.text.ts b/apps/cli/src/commands/issues/issues.text.ts new file mode 100644 index 0000000000..809e03787b --- /dev/null +++ b/apps/cli/src/commands/issues/issues.text.ts @@ -0,0 +1,54 @@ +import { renderTablewriter } from "../../command-internal/tabular-output.ts"; +import { escapeControlCharacters } from "../../shared/output/escape-control-characters.ts"; +import { wrapTerminalText } from "../../shared/output/wrap-terminal-text.ts"; +import type { CheckCoverage, Issue, IssueSeverity } from "./issues.format.ts"; + +export function renderIssuesText( + issues: ReadonlyArray, + checks: ReadonlyArray, + minimum: IssueSeverity, + columns: number, +): string { + const width = Number.isFinite(columns) && columns >= 45 ? Math.floor(columns) : 80; + const severityWidth = Math.max( + "Severity".length, + ...issues.map((issue) => issue.severity.length), + ); + const categoryWidth = Math.max( + "Category".length, + ...issues.map((issue) => issue.category.length), + ); + const affectedWidth = Math.max( + "Affected".length, + ...issues.map((issue) => String(issue.affected.length).length), + ); + const titleWidth = width - severityWidth - categoryWidth - affectedWidth - 13; + const wrap = (text: string) => wrapTerminalText(escapeControlCharacters(text), width); + const summary = + issues.length === 0 + ? `No issues match --severity ${minimum}.` + : `${issues.length} ${issues.length === 1 ? "issue" : "issues"} (${minimum} or higher).`; + const table = + issues.length === 0 + ? "" + : renderTablewriter( + ["Severity", "Category", "Issue", "Affected"], + issues.map((issue) => [ + issue.severity, + issue.category, + wrapTerminalText(escapeControlCharacters(issue.title || issue.id), titleWidth), + String(issue.affected.length), + ]), + (value) => String(value), + ); + const coverage = checks.map((check) => + wrap(`${check.type}: ${check.status}${check.reason === undefined ? "" : ` — ${check.reason}`}`), + ); + const incomplete = checks.some((check) => check.status === "unavailable") + ? "Some checks are unavailable; results are incomplete.\n" + : ""; + const hint = wrap( + "Use --output-format json for affected resources, evidence, and remediation links.", + ); + return `${summary}\n${table}\nChecks\n${coverage.join("\n")}\n${incomplete}\n${hint}\n`; +} From 21ba2edef4b1b76aaa2b8f6638a29ae046dc29d8 Mon Sep 17 00:00:00 2001 From: Jordi Enric Date: Thu, 12 Nov 2026 12:03:54 +0100 Subject: [PATCH 10/14] test(cli): verify issue tables and machine output routing --- .../db/advisors/advisors.integration.test.ts | 200 ++++++++++++------ .../commands/issues/issues.text.unit.test.ts | 55 +++++ 2 files changed, 196 insertions(+), 59 deletions(-) create mode 100644 apps/cli/src/commands/issues/issues.text.unit.test.ts diff --git a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts index bf1ac87974..9b4bac4f65 100644 --- a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts +++ b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts @@ -38,6 +38,7 @@ import { } from "../../../../tests/helpers/permission-real-layers.ts"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import { Tty } from "../../../shared/runtime/tty.service.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { DnsResolverFlag, OutputFlag, YesFlag } from "../../../command-internal/global-flags.ts"; import { CommandCredentials } from "../../../auth/command-credentials.service.ts"; @@ -922,67 +923,67 @@ const issueFlags = (over: Partial = {}): IssuesFlags => ({ ...over, }); describe("issues", () => { - it.effect.each([false, true])( - "groups local and direct database findings and reports unavailable health (%s)", - (direct) => { - const { layer, out, connection } = setup({ - format: "json", - rows: [ - lintRow(), - lintRow({ - level: "INFO", - cache_key: "second", - metadata: { - name: "search", - type: "function", - schema: "app", - arguments: "text, integer", + it.effect.each([ + { direct: false, format: "json" as const }, + { direct: true, format: "stream-json" as const }, + ])("groups local and direct database findings without text output (%j)", ({ direct, format }) => { + const { layer, out, connection } = setup({ + format, + rows: [ + lintRow(), + lintRow({ + level: "INFO", + cache_key: "second", + metadata: { + name: "search", + type: "function", + schema: "app", + arguments: "text, integer", + }, + }), + ], + }); + return Effect.gen(function* () { + yield* issues( + issueFlags( + direct + ? { dbUrl: Option.some("postgresql://postgres:postgres@localhost:54322/postgres") } + : {}, + ), + ); + expect(out.messages).toContainEqual({ + type: "success", + message: "issues", + data: { + issues: [ + expect.objectContaining({ + id: "rls_disabled_in_public", + severity: "critical", + affected: [ + expect.objectContaining({ id: "rls_disabled_in_public_public_users" }), + expect.objectContaining({ + id: "second", + severity: "info", + resource: { name: "search(text, integer)", type: "function", schema: "app" }, + }), + ], + }), + ], + checks: [ + { type: "security", status: "complete" }, + { type: "performance", status: "complete" }, + { + type: "health", + status: "unavailable", + reason: "Health checks require a remote project.", }, - }), - ], + ], + }, }); - return Effect.gen(function* () { - yield* issues( - issueFlags( - direct - ? { dbUrl: Option.some("postgresql://postgres:postgres@localhost:54322/postgres") } - : {}, - ), - ); - expect(out.messages).toContainEqual({ - type: "success", - message: "issues", - data: { - issues: [ - expect.objectContaining({ - id: "rls_disabled_in_public", - severity: "critical", - affected: [ - expect.objectContaining({ id: "rls_disabled_in_public_public_users" }), - expect.objectContaining({ - id: "second", - severity: "info", - resource: { name: "search(text, integer)", type: "function", schema: "app" }, - }), - ], - }), - ], - checks: [ - { type: "security", status: "complete" }, - { type: "performance", status: "complete" }, - { - type: "health", - status: "unavailable", - reason: "Health checks require a remote project.", - }, - ], - }, - }); - expect(connection.execs.at(-1)).toBe("rollback"); - expect(out.stdoutText).toBe(""); - }).pipe(Effect.provide(layer)); - }, - ); + expect(connection.execs.at(-1)).toBe("rollback"); + expect(out.stdoutText).toBe(""); + }).pipe(Effect.provide(layer)); + }); it.effect("rejects local health before opening a database connection", () => { const { layer, connection } = setup(); return Effect.gen(function* () { @@ -1157,3 +1158,84 @@ describe("issues boundary failures", () => { }).pipe(Effect.provide(layer)); }); }); + +describe("issues readable text", () => { + it.effect( + "renders grouped findings as a table with coverage and a complete-evidence hint", + () => { + const { layer, out, processControl } = setup({ + rows: [lintRow(), lintRow({ cache_key: "second" })], + }); + return Effect.gen(function* () { + yield* issues(issueFlags({ failOn: Option.some("warning") })); + expect(out.stdoutText).toContain("1 issue (info or higher)."); + expect(out.stdoutText).toMatch(/│ Severity\s+│ Category\s+│ Issue\s+│ Affected\s+│/u); + expect(out.stdoutText).toMatch( + /│ critical\s+│ security\s+│ RLS disabled in public\s+│ 2\s+│/u, + ); + expect(out.stdoutText).toContain("security: complete"); + expect(out.stdoutText).toContain( + "health: unavailable — Health checks require a remote project.", + ); + expect(out.stdoutText).toContain("Some checks are unavailable; results are incomplete."); + expect(out.stdoutText.replaceAll(/\s+/gu, " ")).toContain( + "Use --output-format json for affected resources, evidence, and remediation links.", + ); + expect(processControl.exitCode).toBe(1); + expect(out.messages.some((message) => message.type === "success")).toBe(false); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect.each([false, true])( + "describes empty filtered findings without hiding incomplete coverage (unavailable %s)", + (unavailable) => { + const { layer, out, processControl } = setup({ + securityLints: [lintRow({ level: "WARN" })], + healthLints: unavailable + ? [ + lintRow({ + name: "advisor_check_unavailable", + detail: "Health logs are temporarily unavailable", + }), + ] + : [], + }); + return Effect.gen(function* () { + yield* issues( + issueFlags({ + remote: REMOTE_LINKED, + severity: Option.some("critical"), + failOn: Option.some("info"), + }), + ); + expect(out.stdoutText).toContain("No issues match --severity critical."); + expect(out.stdoutText).toContain("security: complete"); + expect(out.stdoutText).toContain( + unavailable + ? "health: unavailable — advisor_check_unavailable: Health logs are temporarily" + : "health: complete", + ); + if (unavailable) + expect(out.stdoutText).toContain("Some checks are unavailable; results are incomplete."); + expect(processControl.exitCode).toBe(unavailable ? 1 : undefined); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect.each([80, 120])("uses the terminal's %s-column width for long titles", (columns) => { + const { layer, out } = setup({ + rows: [lintRow({ title: "Long database check title ".repeat(8) })], + }); + return Effect.gen(function* () { + yield* issues(issueFlags({ type: Option.some("security") })).pipe( + Effect.provideService(Tty, { + stdinIsTty: false, + stdoutIsTty: true, + stdoutIsPipe: false, + stdoutColumns: columns, + }), + ); + const separator = out.stdoutText.split("\n").find((line) => line.startsWith("┌")); + expect(separator?.length).toBe(columns); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/commands/issues/issues.text.unit.test.ts b/apps/cli/src/commands/issues/issues.text.unit.test.ts new file mode 100644 index 0000000000..f26a93443f --- /dev/null +++ b/apps/cli/src/commands/issues/issues.text.unit.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { stringWidth } from "../../command-internal/string-width.ts"; +import type { Issue } from "./issues.format.ts"; +import { renderIssuesText } from "./issues.text.ts"; + +const title = "Long table check 界🙂e\u0301 ".repeat(12) + "\u001b[31m\nInjected line"; +const issue: Issue = { + id: "long_check", + title, + description: "", + severity: "warning", + category: "performance", + source: "lint", + docs_url: "https://example.com/check", + affected: [ + { + id: "one", + resource: { type: "project", name: "Project", schema: null }, + detail: "", + severity: "warning", + docs_url: "https://example.com/check", + }, + ], +}; + +describe("readable issue tables", () => { + it.each([80, 120])( + "preserves long Unicode titles and unavailable reasons within %s columns", + (width) => { + const reason = "Backend check unavailable 界🙂 ".repeat(12) + "\u001b[2J"; + const output = renderIssuesText( + [issue], + [{ type: "performance", status: "unavailable", reason }], + "info", + width, + ); + expect(output.split("\n").every((line) => stringWidth(line) <= width)).toBe(true); + const renderedTitle = output + .split("\n") + .filter((line) => line.startsWith("│")) + .slice(1) + .map((line) => line.split("│")[3]?.trim() ?? "") + .join("") + .replaceAll(/\s/gu, ""); + expect(renderedTitle).toBe( + title.replaceAll("\u001b", "\\u001b").replaceAll("\n", "\\u000a").replaceAll(/\s/gu, ""), + ); + expect(output).not.toContain("\u001b"); + expect(output.replaceAll(/\s/gu, "")).toContain( + reason.replaceAll("\u001b", "\\u001b").replaceAll(/\s/gu, ""), + ); + expect(output).toContain("Some checks are unavailable; results are incomplete."); + }, + ); +}); From 681ec2df744bac001867514e2d07c94af981eab2 Mon Sep 17 00:00:00 2001 From: Jordi Enric Date: Thu, 12 Nov 2026 12:04:09 +0100 Subject: [PATCH 11/14] docs(cli): describe readable issue summaries and coverage --- apps/cli/docs/supabase/issues.md | 4 +++- apps/cli/src/commands/issues/SIDE_EFFECTS.md | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/cli/docs/supabase/issues.md b/apps/cli/docs/supabase/issues.md index 6ba7c0e73f..a21a1d277c 100644 --- a/apps/cli/docs/supabase/issues.md +++ b/apps/cli/docs/supabase/issues.md @@ -6,7 +6,9 @@ The default `--type all --severity info` displays every issue. Use `--fail-on wa Inspects the local database by default. Use `--remote` for the linked project, or `--remote=` for another target. Security and performance checks use a transaction that is always rolled back for local databases and direct database URLs. Health checks require a remote project: `--type health` rejects local and direct database targets, while `--type all` reports health coverage as unavailable for those targets. -The result contains `issues` and `checks`. Each issue includes its stable check ID, severity, category, documentation URL, and affected resources. Coverage reports each requested category as complete or unavailable. A remote check that cannot run is reported as unavailable and exits with status 1; it is never reported as a clean check. +By default, the command prints a table with severity, category, issue title, and the number of affected resources, followed by check coverage. Long titles wrap to fit the terminal. Unavailable checks include their reason and an incomplete-results warning. An empty result means no issues match the selected severity; it does not establish that the project is healthy. + +Structured output contains `issues` and `checks`. Each issue includes its stable check ID, severity, category, documentation URL, and affected resources. Coverage reports each requested category as complete or unavailable. A remote check that cannot run is reported as unavailable and exits with status 1; it is never reported as a clean check. `supabase db advisors` is deprecated. Its existing flags, defaults, output, and exit behavior remain supported. diff --git a/apps/cli/src/commands/issues/SIDE_EFFECTS.md b/apps/cli/src/commands/issues/SIDE_EFFECTS.md index 761d1affc6..35932eae57 100644 --- a/apps/cli/src/commands/issues/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/issues/SIDE_EFFECTS.md @@ -12,6 +12,6 @@ Remote security and performance use `GET /v1/projects/{ref}/advisors/security` a ## Output and exits -Text prints a JSON object; machine modes put `{ issues, checks }` in the standard success result. Issues group by lint name, retain affected evidence, and sort by decreasing severity then ID. Severity defaults to info. Type defaults to all. Fail-on defaults to none and applies only to displayed issues. Exit 1 indicates an error, a matching fail-on threshold, or unavailable remote check coverage. Local all reports unavailable health coverage without failing. Explicit local or direct-database health is an unsupported-target error. Authentication, transport, and API failures remain errors. +Text prints a terminal-width table of severity, category, issue title, and affected-resource count. Titles wrap without truncation. Check coverage includes unavailable reasons and an incomplete-results warning; empty results describe the severity filter rather than declaring the project healthy. A hint directs users to JSON for full affected evidence and remediation links. Machine modes put `{ issues, checks }` in the standard success result. Issues group by lint name, retain affected evidence, and sort by decreasing severity then ID. Severity defaults to info. Type defaults to all. Fail-on defaults to none and applies only to displayed issues. Exit 1 indicates an error, a matching fail-on threshold, or unavailable remote check coverage. Local all reports unavailable health coverage without failing. Explicit local or direct-database health is an unsupported-target error. Authentication, transport, and API failures remain errors. `-o` / `--output` is rejected with guidance to use `--output-format json|stream-json`. Check IDs are project-scoped check identifiers, not persisted incidents. From 803dcd2d10573401f33a6bd7e6ed885899b38ced Mon Sep 17 00:00:00 2001 From: Jordi Enric Date: Thu, 12 Nov 2026 12:23:20 +0100 Subject: [PATCH 12/14] fix(cli): fit issue summaries in narrow terminals --- apps/cli/src/commands/issues/issues.text.ts | 48 +++++++++++++------ .../commands/issues/issues.text.unit.test.ts | 25 ++++++---- 2 files changed, 50 insertions(+), 23 deletions(-) diff --git a/apps/cli/src/commands/issues/issues.text.ts b/apps/cli/src/commands/issues/issues.text.ts index 809e03787b..24895722f5 100644 --- a/apps/cli/src/commands/issues/issues.text.ts +++ b/apps/cli/src/commands/issues/issues.text.ts @@ -3,13 +3,31 @@ import { escapeControlCharacters } from "../../shared/output/escape-control-char import { wrapTerminalText } from "../../shared/output/wrap-terminal-text.ts"; import type { CheckCoverage, Issue, IssueSeverity } from "./issues.format.ts"; +function renderIssueFields(issue: Issue, width: number): string { + const fields: ReadonlyArray = [ + ["Severity", issue.severity], + ["Category", issue.category], + ["Issue", issue.title || issue.id], + ["Affected", String(issue.affected.length)], + ]; + return fields + .map(([label, value]) => { + const content = wrapTerminalText(escapeControlCharacters(value), width - 2) + .split("\n") + .map((line) => ` ${line}`) + .join("\n"); + return `${label}:\n${content}`; + }) + .join("\n"); +} + export function renderIssuesText( issues: ReadonlyArray, checks: ReadonlyArray, minimum: IssueSeverity, columns: number, ): string { - const width = Number.isFinite(columns) && columns >= 45 ? Math.floor(columns) : 80; + const width = Number.isFinite(columns) && columns >= 20 ? Math.floor(columns) : 80; const severityWidth = Math.max( "Severity".length, ...issues.map((issue) => issue.severity.length), @@ -28,27 +46,29 @@ export function renderIssuesText( issues.length === 0 ? `No issues match --severity ${minimum}.` : `${issues.length} ${issues.length === 1 ? "issue" : "issues"} (${minimum} or higher).`; - const table = + const findings = issues.length === 0 ? "" - : renderTablewriter( - ["Severity", "Category", "Issue", "Affected"], - issues.map((issue) => [ - issue.severity, - issue.category, - wrapTerminalText(escapeControlCharacters(issue.title || issue.id), titleWidth), - String(issue.affected.length), - ]), - (value) => String(value), - ); + : titleWidth < "Issue".length + ? issues.map((issue) => renderIssueFields(issue, width)).join("\n\n") + "\n" + : renderTablewriter( + ["Severity", "Category", "Issue", "Affected"], + issues.map((issue) => [ + issue.severity, + issue.category, + wrapTerminalText(escapeControlCharacters(issue.title || issue.id), titleWidth), + String(issue.affected.length), + ]), + (value) => String(value), + ); const coverage = checks.map((check) => wrap(`${check.type}: ${check.status}${check.reason === undefined ? "" : ` — ${check.reason}`}`), ); const incomplete = checks.some((check) => check.status === "unavailable") - ? "Some checks are unavailable; results are incomplete.\n" + ? `${wrap("Some checks are unavailable; results are incomplete.")}\n` : ""; const hint = wrap( "Use --output-format json for affected resources, evidence, and remediation links.", ); - return `${summary}\n${table}\nChecks\n${coverage.join("\n")}\n${incomplete}\n${hint}\n`; + return `${wrap(summary)}\n${findings}\nChecks\n${coverage.join("\n")}\n${incomplete}\n${hint}\n`; } diff --git a/apps/cli/src/commands/issues/issues.text.unit.test.ts b/apps/cli/src/commands/issues/issues.text.unit.test.ts index f26a93443f..1c4160e86b 100644 --- a/apps/cli/src/commands/issues/issues.text.unit.test.ts +++ b/apps/cli/src/commands/issues/issues.text.unit.test.ts @@ -24,7 +24,7 @@ const issue: Issue = { }; describe("readable issue tables", () => { - it.each([80, 120])( + it.each([20, 40, 45, 49, 80, 120])( "preserves long Unicode titles and unavailable reasons within %s columns", (width) => { const reason = "Backend check unavailable 界🙂 ".repeat(12) + "\u001b[2J"; @@ -35,13 +35,18 @@ describe("readable issue tables", () => { width, ); expect(output.split("\n").every((line) => stringWidth(line) <= width)).toBe(true); - const renderedTitle = output - .split("\n") - .filter((line) => line.startsWith("│")) - .slice(1) - .map((line) => line.split("│")[3]?.trim() ?? "") - .join("") - .replaceAll(/\s/gu, ""); + const renderedTitle = ( + width < 45 + ? (output.split("Issue:\n")[1]?.split("\nAffected:")[0] ?? "") + : output + .split("\n") + .filter((line) => line.startsWith("│")) + .slice(1) + .map((line) => line.split("│")[3]?.trim() ?? "") + .join("") + ).replaceAll(/\s/gu, ""); + if (width < 45) + expect(output).toContain("Severity:\n warning\nCategory:\n performance\nIssue:\n"); expect(renderedTitle).toBe( title.replaceAll("\u001b", "\\u001b").replaceAll("\n", "\\u000a").replaceAll(/\s/gu, ""), ); @@ -49,7 +54,9 @@ describe("readable issue tables", () => { expect(output.replaceAll(/\s/gu, "")).toContain( reason.replaceAll("\u001b", "\\u001b").replaceAll(/\s/gu, ""), ); - expect(output).toContain("Some checks are unavailable; results are incomplete."); + expect(output.replaceAll(/\s+/gu, " ")).toContain( + "Some checks are unavailable; results are incomplete.", + ); }, ); }); From b2c21a6b7dbe0401a84a3a320d5f0d796430381e Mon Sep 17 00:00:00 2001 From: Jordi Enric Date: Thu, 12 Nov 2026 12:23:34 +0100 Subject: [PATCH 13/14] docs(cli): note stacked issue fields on narrow terminals --- apps/cli/docs/supabase/issues.md | 2 +- apps/cli/src/commands/issues/SIDE_EFFECTS.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/cli/docs/supabase/issues.md b/apps/cli/docs/supabase/issues.md index a21a1d277c..d645bca55c 100644 --- a/apps/cli/docs/supabase/issues.md +++ b/apps/cli/docs/supabase/issues.md @@ -6,7 +6,7 @@ The default `--type all --severity info` displays every issue. Use `--fail-on wa Inspects the local database by default. Use `--remote` for the linked project, or `--remote=` for another target. Security and performance checks use a transaction that is always rolled back for local databases and direct database URLs. Health checks require a remote project: `--type health` rejects local and direct database targets, while `--type all` reports health coverage as unavailable for those targets. -By default, the command prints a table with severity, category, issue title, and the number of affected resources, followed by check coverage. Long titles wrap to fit the terminal. Unavailable checks include their reason and an incomplete-results warning. An empty result means no issues match the selected severity; it does not establish that the project is healthy. +By default, the command prints a table with severity, category, issue title, and the number of affected resources, followed by check coverage. Long titles wrap to fit the terminal. Narrow terminals show stacked fields when the table cannot fit. Unavailable checks include their reason and an incomplete-results warning. An empty result means no issues match the selected severity; it does not establish that the project is healthy. Structured output contains `issues` and `checks`. Each issue includes its stable check ID, severity, category, documentation URL, and affected resources. Coverage reports each requested category as complete or unavailable. A remote check that cannot run is reported as unavailable and exits with status 1; it is never reported as a clean check. diff --git a/apps/cli/src/commands/issues/SIDE_EFFECTS.md b/apps/cli/src/commands/issues/SIDE_EFFECTS.md index 35932eae57..2f56a9e80f 100644 --- a/apps/cli/src/commands/issues/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/issues/SIDE_EFFECTS.md @@ -12,6 +12,6 @@ Remote security and performance use `GET /v1/projects/{ref}/advisors/security` a ## Output and exits -Text prints a terminal-width table of severity, category, issue title, and affected-resource count. Titles wrap without truncation. Check coverage includes unavailable reasons and an incomplete-results warning; empty results describe the severity filter rather than declaring the project healthy. A hint directs users to JSON for full affected evidence and remediation links. Machine modes put `{ issues, checks }` in the standard success result. Issues group by lint name, retain affected evidence, and sort by decreasing severity then ID. Severity defaults to info. Type defaults to all. Fail-on defaults to none and applies only to displayed issues. Exit 1 indicates an error, a matching fail-on threshold, or unavailable remote check coverage. Local all reports unavailable health coverage without failing. Explicit local or direct-database health is an unsupported-target error. Authentication, transport, and API failures remain errors. +Text prints a terminal-width table of severity, category, issue title, and affected-resource count. Titles wrap without truncation. Narrow terminals use stacked label/value fields when the table cannot fit; summaries and coverage warnings also wrap. Check coverage includes unavailable reasons and an incomplete-results warning; empty results describe the severity filter rather than declaring the project healthy. A hint directs users to JSON for full affected evidence and remediation links. Machine modes put `{ issues, checks }` in the standard success result. Issues group by lint name, retain affected evidence, and sort by decreasing severity then ID. Severity defaults to info. Type defaults to all. Fail-on defaults to none and applies only to displayed issues. Exit 1 indicates an error, a matching fail-on threshold, or unavailable remote check coverage. Local all reports unavailable health coverage without failing. Explicit local or direct-database health is an unsupported-target error. Authentication, transport, and API failures remain errors. `-o` / `--output` is rejected with guidance to use `--output-format json|stream-json`. Check IDs are project-scoped check identifiers, not persisted incidents. From 089e9b262edd3e0b030b5654dd927e7acd705520 Mon Sep 17 00:00:00 2001 From: Jordi Enric Date: Thu, 12 Nov 2026 16:50:34 +0100 Subject: [PATCH 14/14] fix(cli): keep advisors supported alongside issues --- apps/cli/docs/supabase/db/advisors.md | 2 +- apps/cli/docs/supabase/issues.md | 2 +- apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/db/advisors/advisors.handler.ts | 2 +- apps/cli/src/commands/db/advisors/advisors.integration.test.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/cli/docs/supabase/db/advisors.md b/apps/cli/docs/supabase/db/advisors.md index 52d7314f81..2e17438731 100644 --- a/apps/cli/docs/supabase/db/advisors.md +++ b/apps/cli/docs/supabase/db/advisors.md @@ -1,6 +1,6 @@ ## supabase-db-advisors -Deprecated: use `supabase issues`. Existing flags, defaults, output, and exit behavior remain supported. A deprecation notice is written to stderr. +`supabase db advisors` is planned for deprecation in a future release. It remains available alongside `supabase issues`. Existing flags, defaults, output, and exit behavior remain supported. An informational migration notice is written to stderr. Inspects the database for common security and performance issues such as missing RLS policies, unindexed foreign keys, exposed auth.users, and more. diff --git a/apps/cli/docs/supabase/issues.md b/apps/cli/docs/supabase/issues.md index d645bca55c..9c1a87514c 100644 --- a/apps/cli/docs/supabase/issues.md +++ b/apps/cli/docs/supabase/issues.md @@ -10,6 +10,6 @@ By default, the command prints a table with severity, category, issue title, and Structured output contains `issues` and `checks`. Each issue includes its stable check ID, severity, category, documentation URL, and affected resources. Coverage reports each requested category as complete or unavailable. A remote check that cannot run is reported as unavailable and exits with status 1; it is never reported as a clean check. -`supabase db advisors` is deprecated. Its existing flags, defaults, output, and exit behavior remain supported. +`supabase db advisors` is planned for deprecation in a future release and remains available alongside `supabase issues`. Its existing flags, defaults, output, and exit behavior remain supported. Use `--output-format json` or `--output-format stream-json` for structured output. The legacy `-o` / `--output` flag is not supported by this command. Check IDs identify check types within a project; they are not persistent incident IDs. diff --git a/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md b/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md index 53ae1b264a..5b924f2bf5 100644 --- a/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md @@ -2,7 +2,7 @@ Target: selectable, no consent — see [Command target](../../../../docs/command-target.md) -Writes a deprecation notice to stderr naming `supabase issues`. Existing flags, defaults, payloads, and exit behavior are preserved. +Writes an informational migration notice to stderr explaining the planned future deprecation and naming `supabase issues`. Existing flags, defaults, payloads, and exit behavior are preserved. Checks a database for security and performance issues. Two backends: the local stack and `--remote=` query the database directly; a project fetches from the diff --git a/apps/cli/src/commands/db/advisors/advisors.handler.ts b/apps/cli/src/commands/db/advisors/advisors.handler.ts index ba123486e3..282cb6f132 100644 --- a/apps/cli/src/commands/db/advisors/advisors.handler.ts +++ b/apps/cli/src/commands/db/advisors/advisors.handler.ts @@ -149,7 +149,7 @@ const runAdvisors = Effect.fnUntraced(function* ( export const dbAdvisors = Effect.fn("db.advisors")(function* (flags: DbAdvisorsFlags) { yield* (yield* Output).raw( - "supabase db advisors is deprecated; use supabase issues instead.\n", + "supabase db advisors is planned for deprecation in a future release. It remains supported; consider supabase issues for security, performance, and health checks.\n", "stderr", ); const dnsResolver = yield* DnsResolverFlag; diff --git a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts index 9b4bac4f65..70ca2e605a 100644 --- a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts +++ b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts @@ -532,7 +532,7 @@ describe("db advisors — local", () => { ); expect(out.stdoutText).toBe(""); expect(out.stderrText).toContain( - "supabase db advisors is deprecated; use supabase issues instead.", + "supabase db advisors is planned for deprecation in a future release. It remains supported; consider supabase issues for security, performance, and health checks.", ); }).pipe(Effect.provide(layer)); });