diff --git a/apps/cli/docs/supabase/db/advisors.md b/apps/cli/docs/supabase/db/advisors.md index 1c2e295433..2e17438731 100644 --- a/apps/cli/docs/supabase/db/advisors.md +++ b/apps/cli/docs/supabase/db/advisors.md @@ -1,5 +1,7 @@ ## supabase-db-advisors +`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. 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..9c1a87514c --- /dev/null +++ b/apps/cli/docs/supabase/issues.md @@ -0,0 +1,15 @@ +## 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. + +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. + +`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/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.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/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/db/advisors/SIDE_EFFECTS.md b/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md index 5b511ee4bc..5b924f2bf5 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 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 Management API. diff --git a/apps/cli/src/commands/db/advisors/advisors.handler.ts b/apps/cli/src/commands/db/advisors/advisors.handler.ts index c640450a99..282cb6f132 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,119 +11,31 @@ 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. */ -const runLocal = Effect.fn("db.advisors.runLocal")(function* ( - target: Exclude, - dnsResolver: "native" | "https", - advisorType: string, - level: string, -) { - 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 filterAdvisorLints(lints, advisorType, level); -}); - /** 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 +136,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 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; const telemetryState = yield* TelemetryState; // Flush telemetry on success and failure. Command-level instrumentation / 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..70ca2e605a 100644 --- a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts +++ b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts @@ -38,8 +38,9 @@ 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, 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"; @@ -60,10 +61,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 { splitLintsSql } from "./advisors.lints-sql.ts"; +import { issues } from "../../issues/issues.handler.ts"; +import type { IssuesFlags } from "../../issues/issues.command.ts"; +import { splitLintsSql } from "../../../command-internal/advisors/advisors.lints-sql.ts"; import type { DbAdvisorsFlags } from "./advisors.command.ts"; const LOCAL_CONN: PgConnInput = { @@ -234,6 +240,8 @@ interface SetupOpts { loggedIn?: boolean; invalidToken?: boolean; ipv6Error?: boolean; + healthLints?: ReadonlyArray>; + healthStatus?: number; securityStatus?: number; securityRawBody?: string; securityLints?: ReadonlyArray>; @@ -263,6 +271,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 +339,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 +531,9 @@ describe("db advisors — local", () => { expect.objectContaining({ type: "success", message: "db advisors" }), ); expect(out.stdoutText).toBe(""); + expect(out.stderrText).toContain( + "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)); }); @@ -896,3 +914,328 @@ describeDbTargetSelection("db advisors", { }; }, }); + +const issueFlags = (over: Partial = {}): IssuesFlags => ({ + ...flags(), + type: Option.none(), + severity: Option.none(), + failOn: Option.none(), + ...over, +}); +describe("issues", () => { + 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.", + }, + ], + }, + }); + 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)); + }); +}); + +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/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/SIDE_EFFECTS.md b/apps/cli/src/commands/issues/SIDE_EFFECTS.md new file mode 100644 index 0000000000..2f56a9e80f --- /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 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. 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..44c7906ab2 --- /dev/null +++ b/apps/cli/src/commands/issues/issues.command.ts @@ -0,0 +1,105 @@ +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"; +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 { 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"; +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({ + outputFormats: GLOBAL_OUTPUT_FORMATS, + flags: { + type: flags.type, + severity: flags.severity, + "fail-on": flags.failOn, + }, + config, + }), + withJsonErrorHandling, + ), + ), + Command.provide(commandRuntimeLayer(["issues"])), + Command.provide(advisorChecksRuntimeLayer), + 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..ec8b667756 --- /dev/null +++ b/apps/cli/src/commands/issues/issues.format.ts @@ -0,0 +1,145 @@ +import { Effect, Option, Schema } from "effect"; +import type { AdvisorLint } from "../../command-internal/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 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]; +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 || + encodeAffectedIdentity([ + 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.format.unit.test.ts b/apps/cli/src/commands/issues/issues.format.unit.test.ts new file mode 100644 index 0000000000..c0285188e1 --- /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 "../../command-internal/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"]); + }); +}); 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..56399d57dd --- /dev/null +++ b/apps/cli/src/commands/issues/issues.handler.ts @@ -0,0 +1,135 @@ +import { Effect, Option } from "effect"; +import { resolveTarget } from "../../command-internal/target/resolve-target.ts"; +import { DnsResolverFlag, OutputFlag } from "../../command-internal/global-flags.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"; +import { LinkedProjectCache } from "../../telemetry/linked-project-cache.service.ts"; +import { CommandPlatformApiFactory } from "../../auth/command-platform-api-factory.service.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, + 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()] }))); + } + }); + 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* 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") { + 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 = + 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/commands/issues/issues.text.ts b/apps/cli/src/commands/issues/issues.text.ts new file mode 100644 index 0000000000..24895722f5 --- /dev/null +++ b/apps/cli/src/commands/issues/issues.text.ts @@ -0,0 +1,74 @@ +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"; + +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 >= 20 ? 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 findings = + issues.length === 0 + ? "" + : 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") + ? `${wrap("Some checks are unavailable; results are incomplete.")}\n` + : ""; + const hint = wrap( + "Use --output-format json for affected resources, evidence, and remediation links.", + ); + 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 new file mode 100644 index 0000000000..1c4160e86b --- /dev/null +++ b/apps/cli/src/commands/issues/issues.text.unit.test.ts @@ -0,0 +1,62 @@ +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([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"; + const output = renderIssuesText( + [issue], + [{ type: "performance", status: "unavailable", reason }], + "info", + width, + ); + expect(output.split("\n").every((line) => stringWidth(line) <= width)).toBe(true); + 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, ""), + ); + expect(output).not.toContain("\u001b"); + expect(output.replaceAll(/\s/gu, "")).toContain( + reason.replaceAll("\u001b", "\\u001b").replaceAll(/\s/gu, ""), + ); + expect(output.replaceAll(/\s+/gu, " ")).toContain( + "Some checks are unavailable; results are incomplete.", + ); + }, + ); +}); 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/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", 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", 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"); +} 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 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,