diff --git a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts index ffddf8b017..31d81f8845 100644 --- a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts +++ b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts @@ -437,7 +437,10 @@ const makeLegacyCredentials = Effect.gen(function* () { ); if (ok) return; } - yield* fs.makeDirectory(fallbackDir, { recursive: true, mode: 0o700 }).pipe(Effect.orDie); + // Go's `fallbackSaveToken` creates the dir via `MkdirIfNotExistFS` → + // `MkdirAll(path, 0755)` (`access_token.go:91`, `misc.go:273`); only + // the token FILE itself is private (0600, `access_token.go:94`). + yield* fs.makeDirectory(fallbackDir, { recursive: true, mode: 0o755 }).pipe(Effect.orDie); yield* fs.writeFileString(fallbackPath, token, { mode: 0o600 }).pipe(Effect.orDie); }), diff --git a/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts b/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts index f5320dba28..a0d58c9b99 100644 --- a/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts +++ b/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts @@ -1,4 +1,12 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -357,12 +365,24 @@ describe("legacyCredentialsLayer.saveAccessToken", () => { it.effect("falls back to the filesystem when the keyring write throws", () => { throwOnSetPassword = true; + // Deterministic mode assertions require a permissive umask: Go pins the + // fallback dir to 0755 (`access_token.go:91` → `MkdirIfNotExistFS`, + // `misc.go:273`, changed from the prior 0700) and the token file itself to + // 0600 (`access_token.go:94`). + const prevUmask = process.umask(0); return Effect.gen(function* () { const { saveAccessToken } = yield* LegacyCredentials; yield* saveAccessToken(VALID_TOKEN); - const content = readFileSync(join(tempHome, ".supabase", "access-token"), "utf-8"); + const fallbackDir = join(tempHome, ".supabase"); + const fallbackPath = join(fallbackDir, "access-token"); + const content = readFileSync(fallbackPath, "utf-8"); expect(content).toBe(VALID_TOKEN); - }).pipe(Effect.provide(makeLayer())); + expect(statSync(fallbackDir).mode & 0o777).toBe(0o755); + expect(statSync(fallbackPath).mode & 0o777).toBe(0o600); + }).pipe( + Effect.provide(makeLayer()), + Effect.ensuring(Effect.sync(() => process.umask(prevUmask))), + ); }); it.effect("filesystem fallback honors SUPABASE_HOME when configured", () => { diff --git a/apps/cli/src/legacy/commands/db/dump/dump.handler.ts b/apps/cli/src/legacy/commands/db/dump/dump.handler.ts index 0a51f63b36..d7021d20ca 100644 --- a/apps/cli/src/legacy/commands/db/dump/dump.handler.ts +++ b/apps/cli/src/legacy/commands/db/dump/dump.handler.ts @@ -224,6 +224,12 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy } as const); const modeEnv = mode.buildEnv(conn, opt); + // Go keys every `--file` branch off `len(path) > 0`, not flag presence + // (`internal/db/dump/dump.go:20-32`; `cmd/db.go:152-159` for the PostRun + // print): an explicit `--file ""` means stdout, with no file open and no + // `Dumped schema to …` line. + const fileFlag = Option.filter(flags.file, (file) => file.length > 0); + // 5. Dry-run: print the env-expanded script to stdout (no container). if (flags.dryRun) { yield* output.raw("DRY RUN: *only* printing the pg_dump script to console.\n", "stderr"); @@ -235,8 +241,8 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy // stderr line here WITHOUT creating/truncating the file — Go never touches it on // a dry-run (`internal/db/dump/dump.go:23-32`). Resolve the path like the real // path (Go's `filepath.Abs` after the PreRun chdir into the workdir). - if (Option.isSome(flags.file)) { - const dryRunFile = path.resolve(cliConfig.workdir, flags.file.value); + if (Option.isSome(fileFlag)) { + const dryRunFile = path.resolve(cliConfig.workdir, fileFlag.value); yield* output.raw(`Dumped schema to ${legacyBold(dryRunFile)}.\n`, "stderr"); } return; @@ -258,7 +264,7 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy // in PersistentPreRunE before opening the file (`cmd/root.go:104` → // `internal/utils/misc.go`), so `--workdir /repo db dump -f out.sql` writes // `/repo/out.sql`. `path.resolve` leaves absolute paths unchanged. - const resolvedFile = Option.map(flags.file, (file) => path.resolve(cliConfig.workdir, file)); + const resolvedFile = Option.map(fileFlag, (file) => path.resolve(cliConfig.workdir, file)); // Open (create + truncate) the output file up front so an unwritable `--file` // path fails before the dump runs, matching Go's `OpenFile(O_WRONLY|O_CREATE| diff --git a/apps/cli/src/legacy/commands/db/dump/dump.integration.test.ts b/apps/cli/src/legacy/commands/db/dump/dump.integration.test.ts index 3c16d07f08..52226baada 100644 --- a/apps/cli/src/legacy/commands/db/dump/dump.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/dump/dump.integration.test.ts @@ -396,6 +396,19 @@ describe("legacy db dump integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("treats an explicit --file '' as stdout on --dry-run (Go: len(path) > 0)", () => { + // Go keys every --file branch off len(path) > 0, not flag presence + // (internal/db/dump/dump.go:20-32); an explicit empty --file means stdout, with + // no "Dumped schema to …" line and no file ever touched. + const { layer, out, docker } = setup({ isLocal: true }); + return Effect.gen(function* () { + yield* legacyDbDump(flags({ dryRun: true, local: Option.some(true), file: Option.some("") })); + expect(out.stderrText).toContain("DRY RUN: *only* printing the pg_dump script to console."); + expect(out.stderrText).not.toContain("Dumped schema to"); + expect(docker.lastOpts).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + it.live("validates the merged config before the --dry-run print (Go root PreRun order)", () => { // Go runs ParseDatabaseConfig (→ config.Load → Validate) in the root PreRunE // before dump.Run, even for --dry-run, so an invalid config fails without printing. diff --git a/apps/cli/src/legacy/commands/db/query/query.format.ts b/apps/cli/src/legacy/commands/db/query/query.format.ts index dd7a39b411..0c49986960 100644 --- a/apps/cli/src/legacy/commands/db/query/query.format.ts +++ b/apps/cli/src/legacy/commands/db/query/query.format.ts @@ -1,5 +1,6 @@ import { Option } from "effect"; +import { legacyGoFormatFloat } from "../../../shared/legacy-go-float.ts"; import { legacyStringWidth } from "../../../shared/legacy-rune-width.ts"; // `JSON.rawJSON` (ES2025, present in Bun) wraps a string so `JSON.stringify` emits it @@ -19,35 +20,6 @@ declare global { * Go-parity rules (NULL rendering, key sort order, HTML escaping) are explicit. */ -/** - * Render a number the way Go's `fmt.Sprintf("%v", float64)` does — JSON numbers - * decode to `float64`, so Go uses shortest `%g`: exponent form when the decimal - * exponent is `< -4` or `>= 6` (e.g. `1000000` → `1e+06`, `1.5e8` → `1.5e+08`, - * `1e-5` → `1e-05`), fixed notation otherwise. The exponent is signed and at least - * two digits. JS fixed notation matches Go for the `[-4, 6)` range, so only the - * exponent cases need reformatting. - */ -function goFormatFloat(n: number): string { - if (Number.isNaN(n)) return "NaN"; - if (!Number.isFinite(n)) return n > 0 ? "+Inf" : "-Inf"; - // Go's `%v` preserves the sign of negative zero (`-0`); `n === 0` is true for - // both `+0` and `-0`, so distinguish them with `Object.is` before the shortcut. - if (Object.is(n, -0)) return "-0"; - if (n === 0) return "0"; - const neg = n < 0; - const abs = Math.abs(n); - const [mantissa, eRaw] = abs.toExponential().split("e"); - const exp = Number.parseInt(eRaw!, 10); - let out: string; - if (exp < -4 || exp >= 6) { - const mag = Math.abs(exp).toString().padStart(2, "0"); - out = `${mantissa}e${exp < 0 ? "-" : "+"}${mag}`; - } else { - out = abs.toString(); - } - return neg ? `-${out}` : out; -} - /** * Reproduce Go's `fmt.Sprintf("%v", v)` for JSON-decoded (`interface{}`) values: * objects → `map[k:v ...]` with byte-sorted keys, arrays → `[a b ...]` @@ -58,7 +30,7 @@ function goFormatValue(value: unknown): string { if (value === null || value === undefined) return ""; if (typeof value === "string") return value; if (typeof value === "boolean") return value ? "true" : "false"; - if (typeof value === "number") return goFormatFloat(value); + if (typeof value === "number") return legacyGoFormatFloat(value); // `bytea` columns: pgx scans them into a Go `[]byte`, so `fmt.Sprintf("%v")` // prints the decimal byte values space-separated in brackets (`[222 173]`). // node-postgres returns a `Buffer` (a `Uint8Array`), which would otherwise hit @@ -231,7 +203,7 @@ export function legacyMakeLocalCellFormatter( // Defensive: native rows may still carry a `Date`; render it like Go's `%v`. if (value instanceof Date) return formatGoTime(value); if (typeof value === "number" && (oid === PG_FLOAT4_OID || oid === PG_FLOAT8_OID)) { - return goFormatFloat(value); + return legacyGoFormatFloat(value); } return legacyFormatValue(value); }; diff --git a/apps/cli/src/legacy/commands/encryption/update-root-key/update-root-key.handler.ts b/apps/cli/src/legacy/commands/encryption/update-root-key/update-root-key.handler.ts index 2119f0ea52..5a8fe47613 100644 --- a/apps/cli/src/legacy/commands/encryption/update-root-key/update-root-key.handler.ts +++ b/apps/cli/src/legacy/commands/encryption/update-root-key/update-root-key.handler.ts @@ -1,6 +1,7 @@ import { Effect, Option } from "effect"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { legacyAqua } from "../../../shared/legacy-colors.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { Stdin } from "../../../../shared/runtime/stdin.service.ts"; @@ -59,9 +60,10 @@ export const legacyEncryptionUpdateRootKey = Effect.fn("legacy.encryption.update return; } - // text — Go prints a plain finished notice to stderr (`fmt.Fprintln`, - // `utils.Aqua` rendered as plain text per the legacy-port convention). - yield* output.raw("Finished supabase root-key update.\n", "stderr"); + // text — Go: `fmt.Fprintln(os.Stderr, "Finished "+utils.Aqua("supabase + // root-key update")+".")` (`internal/encryption/update/update.go:26`). + // `legacyAqua` renders cyan on a TTY and plain when piped, like lipgloss. + yield* output.raw(`Finished ${legacyAqua("supabase root-key update")}.\n`, "stderr"); }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); }, ); diff --git a/apps/cli/src/legacy/commands/encryption/update-root-key/update-root-key.integration.test.ts b/apps/cli/src/legacy/commands/encryption/update-root-key/update-root-key.integration.test.ts index ab312812d7..0943fffeb3 100644 --- a/apps/cli/src/legacy/commands/encryption/update-root-key/update-root-key.integration.test.ts +++ b/apps/cli/src/legacy/commands/encryption/update-root-key/update-root-key.integration.test.ts @@ -69,7 +69,11 @@ describe("legacy encryption update-root-key integration", () => { // Go parity: prompt to stderr, trailing newline to stdout (defer Println), // finished notice to stderr. expect(out.stderrText).toContain("Enter a new root key: "); - expect(out.stderrText).toContain("Finished supabase root-key update."); + // The command path is wrapped in ANSI (legacyAqua) in colour-capable + // environments, so assert on the tokens around it — same convention as + // `db/reset/reset.integration.test.ts`'s aqua'd branch name. + expect(out.stderrText).toContain("Finished "); + expect(out.stderrText).toContain("supabase root-key update"); expect(out.stdoutText).toBe("\n"); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/functions/delete/delete.handler.ts b/apps/cli/src/legacy/commands/functions/delete/delete.handler.ts index 2895f1c7d3..4d74d06ad6 100644 --- a/apps/cli/src/legacy/commands/functions/delete/delete.handler.ts +++ b/apps/cli/src/legacy/commands/functions/delete/delete.handler.ts @@ -1,5 +1,6 @@ import { Effect, Option } from "effect"; import { deleteFunction } from "../../../../shared/functions/delete.ts"; +import { legacyAqua } from "../../../shared/legacy-colors.ts"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; @@ -27,6 +28,10 @@ export const legacyFunctionsDelete = Effect.fn("legacy.functions.delete")(functi }), ), ), + // Go: `fmt.Printf("Deleted Function %s from project %s.\n", utils.Aqua(slug), + // utils.Aqua(projectRef))` (`internal/functions/delete/delete.go:20`) — + // stdout-bound, so the TTY gate must check stdout. + styleIdentifier: (text) => legacyAqua(text, process.stdout), }, ).pipe( Effect.ensuring( diff --git a/apps/cli/src/legacy/commands/functions/delete/delete.integration.test.ts b/apps/cli/src/legacy/commands/functions/delete/delete.integration.test.ts index 6c897b64db..e40b1d228a 100644 --- a/apps/cli/src/legacy/commands/functions/delete/delete.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/delete/delete.integration.test.ts @@ -41,6 +41,11 @@ function mockContextualAnalytics() { return { layer, captured }; } +// Strip ANSI SGR (aqua slug/ref via `legacyAqua`) so byte-assertions are +// stable whether or not the test stdout supports color. +// eslint-disable-next-line no-control-regex +const stripSgr = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); + describe("legacy functions delete", () => { it.live("deletes a function natively through the Management API", () => { const out = mockOutput({ format: "text" }); @@ -66,7 +71,9 @@ describe("legacy functions delete", () => { expect(api.requests[0]?.url).toBe( "https://api.supabase.com/v1/projects/abcdefghijklmnopqrst/functions/hello-world", ); - expect(out.stdoutText).toBe( + // The slug and ref are wrapped in ANSI (legacyAqua) in colour-capable + // environments — strip SGR so the byte assertion stays stable. + expect(stripSgr(out.stdoutText)).toBe( "Deleted Function hello-world from project abcdefghijklmnopqrst.\n", ); expect(linkedProjectCache.cached).toBe(true); diff --git a/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts b/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts index db3a446954..00bb20b7ba 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts +++ b/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts @@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { Effect, Option, Stdio } from "effect"; import { deployFunctions } from "../../../../shared/functions/deploy.ts"; +import { legacyAqua, legacyBold } from "../../../shared/legacy-colors.ts"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; @@ -56,6 +57,14 @@ export const legacyFunctionsDeploy = Effect.fn("legacy.functions.deploy")(functi }), ), ), + // Go: `fmt.Printf("Deployed Functions on project %s: %s\n", + // utils.Aqua(flags.ProjectRef), …)` (`internal/functions/deploy/deploy.go:70`) + // — stdout-bound, so the TTY gate must check stdout. + styleIdentifier: (text) => legacyAqua(text, process.stdout), + // Go: `utils.Bold` on the `Bundling Function:` slug (`bundle.go:30`, stderr) + // and the no-functions error dir (`deploy.go:35`, rendered on stderr) — + // both stderr-bound, matching `legacyBold`'s default TTY gate. + styleEmphasis: (text) => legacyBold(text), }).pipe( Effect.ensuring( Effect.suspend(() => diff --git a/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts b/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts index 428845f62c..fa8a498367 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts @@ -15,7 +15,13 @@ import { } from "../../../../../tests/helpers/legacy-mocks.ts"; import { mockOutput, mockRuntimeInfo } from "../../../../../tests/helpers/mocks.ts"; import { mockChildProcessSpawner } from "../../../../../../../packages/process-compose/tests/helpers/mocks.ts"; -import { ConflictingFunctionDeployFlagsError } from "../../../../shared/functions/deploy.errors.ts"; +import { deployFunctions } from "../../../../shared/functions/deploy.ts"; +import { + ConflictingFunctionDeployFlagsError, + NoFunctionsToDeployError, +} from "../../../../shared/functions/deploy.errors.ts"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { legacyFunctionsDeploy } from "./deploy.handler.ts"; import type { LegacyFunctionsDeployFlags } from "./deploy.command.ts"; @@ -113,7 +119,7 @@ describe("legacy functions deploy", () => { "https://api.supabase.com/v1/projects/abcdefghijklmnopqrst/functions/deploy", ); expect(deployRequest?.urlParams).toContain("slug=hello-world"); - expect(out.stdoutText).toContain( + expect(stripSgr(out.stdoutText)).toContain( "Deployed Functions on project abcdefghijklmnopqrst: hello-world\n", ); expect(linkedProjectCache.cached).toBe(true); @@ -253,7 +259,7 @@ describe("legacy functions deploy", () => { }); expect(api.requests).toHaveLength(2); - expect(out.stdoutText).toContain( + expect(stripSgr(out.stdoutText)).toContain( "Deployed Functions on project abcdefghijklmnopqrst: hello-world\n", ); }).pipe( @@ -402,7 +408,7 @@ describe("legacy functions deploy", () => { (request) => request.method === "POST" && request.url.endsWith("/functions/deploy"), ); expect(deployRequest?.urlParams).toContain("slug=custom-entry"); - expect(out.stdoutText).toContain( + expect(stripSgr(out.stdoutText)).toContain( "Deployed Functions on project abcdefghijklmnopqrst: custom-entry\n", ); }).pipe( @@ -805,7 +811,7 @@ describe("legacy functions deploy", () => { jobs: Option.some(2), }); - expect(out.stdoutText).toContain( + expect(stripSgr(out.stdoutText)).toContain( "Deployed Functions on project abcdefghijklmnopqrst: hello-world\n", ); }).pipe( @@ -863,7 +869,7 @@ describe("legacy functions deploy", () => { jobs: Option.some(0), }); - expect(out.stdoutText).toContain( + expect(stripSgr(out.stdoutText)).toContain( "Deployed Functions on project abcdefghijklmnopqrst: hello-world\n", ); }).pipe( @@ -934,7 +940,7 @@ describe("legacy functions deploy", () => { // it wasn't running, so the command fell back to the API and still succeeded. expect(child.spawned).toEqual([{ command: "docker", args: ["info"] }]); expect(out.stderrText).toContain("WARNING: Docker is not running\n"); - expect(out.stdoutText).toContain( + expect(stripSgr(out.stdoutText)).toContain( "Deployed Functions on project abcdefghijklmnopqrst: hello-world\n", ); }).pipe( @@ -945,4 +951,87 @@ describe("legacy functions deploy", () => { ); }); }); + + describe("no-functions error styling (Go parity: deploy.go:35; structured output stays plain)", () => { + // Calls the shared `deployFunctions` with a marker `styleEmphasis` instead of + // going through `legacyFunctionsDeploy`: the real hook (`legacyBold`) is + // TTY-gated and therefore inert under vitest, so only an injected marker can + // deterministically observe which output formats apply the styling. + function setupNoFunctionsTest(format: "text" | "json") { + const out = mockOutput({ format }); + const api = mockLegacyPlatformApi(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(LegacyYesFlag, false), + Stdio.layerTest({ args: Effect.succeed(["functions", "deploy"]) }), + ); + const deployNoFunctions = Effect.gen(function* () { + const platformApi = yield* LegacyPlatformApi; + return yield* deployFunctions( + { ...baseFlags, functionNames: [] }, + { + api: platformApi, + cwd: tempRoot.current, + flagCwd: tempRoot.current, + projectRoot: tempRoot.current, + supabaseDir: join(tempRoot.current, "supabase"), + dashboardUrl: "https://supabase.com/dashboard", + goViperCompat: true, + yes: false, + rawArgs: ["functions", "deploy"], + edgeRuntimeVersion: "1.69.12", + resolveProjectRef: () => Effect.succeed("abcdefghijklmnopqrst"), + styleEmphasis: (text) => `${text}`, + }, + ); + }); + return { out, layer, deployNoFunctions }; + } + + it.live("keeps the injected styling out of the json error payload", () => { + const { out, layer, deployNoFunctions } = setupNoFunctionsTest("json"); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current)); + + yield* deployNoFunctions.pipe(withJsonErrorHandling); + + expect(out.messages).toContainEqual({ + type: "fail", + message: "No Functions specified or found in supabase/functions", + }); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), + ), + ); + }); + + it.live("still emphasizes the functions dir in the text-mode error", () => { + const { layer, deployNoFunctions } = setupNoFunctionsTest("text"); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current)); + + const error = yield* deployNoFunctions.pipe(Effect.flip); + + expect(error).toBeInstanceOf(NoFunctionsToDeployError); + if (!(error instanceof NoFunctionsToDeployError)) { + throw new Error(`unexpected error: ${String(error)}`); + } + expect(error.message).toBe( + "No Functions specified or found in supabase/functions", + ); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), + ), + ); + }); + }); }); diff --git a/apps/cli/src/legacy/commands/inspect/db/blocking/blocking.query.ts b/apps/cli/src/legacy/commands/inspect/db/blocking/blocking.query.ts index e5656096e6..56824a6e34 100644 --- a/apps/cli/src/legacy/commands/inspect/db/blocking/blocking.query.ts +++ b/apps/cli/src/legacy/commands/inspect/db/blocking/blocking.query.ts @@ -1,4 +1,5 @@ import { + legacyInspectBacktickStmt, legacyInspectInt, legacyInspectStmt, legacyInspectText, @@ -25,7 +26,10 @@ WHERE NOT bl.granted`; /** * `inspect db blocking` — queries holding locks and the queries waiting on them. * Port of `apps/cli-go/internal/inspect/blocking/blocking.go`. Both statement - * columns are whitespace-collapsed. + * columns are whitespace-collapsed; Go's row format (`blocking.go:56`, + * `` |`%d`|`%s`|`%s`|`%d`|%s|`%s`|\n ``) backtick-wraps every column EXCEPT + * `blocked_statement` (col 5), so `blocking_statement` (col 2) uses the + * backtick variant and col 5 stays bare. */ export const legacyBlockingSpec: LegacyInspectQuerySpec = { name: "blocking", @@ -41,7 +45,7 @@ export const legacyBlockingSpec: LegacyInspectQuerySpec = { ], project: (row) => [ legacyInspectInt(row["blocked_pid"]), - legacyInspectStmt(row["blocking_statement"]), + legacyInspectBacktickStmt(row["blocking_statement"]), legacyInspectText(row["blocking_duration"]), legacyInspectInt(row["blocking_pid"]), legacyInspectStmt(row["blocked_statement"]), diff --git a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.ts b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.ts index 345ad9185a..fddd4a3d9d 100644 --- a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.ts +++ b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.ts @@ -148,9 +148,11 @@ export function legacyInspectStmt(value: unknown): string { /** * A whitespace-collapsed statement cell that Go ALSO wraps in backticks - * (`calls.go:52` / `outliers.go:50` write the query as `` `%s` ``, unlike - * `locks`/`blocking` which leave it bare). Same empty-code-span rule as - * `legacyInspectText`: an empty value surfaces as the two literal backticks. + * (`calls.go:52` / `outliers.go:50` write the query as `` `%s` ``, and + * `blocking.go:56` does the same for `blocking_statement` — unlike `locks` + * and `blocking`'s `blocked_statement`, which stay bare). Same + * empty-code-span rule as `legacyInspectText`: an empty value surfaces as the + * two literal backticks. */ export function legacyInspectBacktickStmt(value: unknown): string { const stmt = legacyInspectStmt(value); diff --git a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-specs.integration.test.ts b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-specs.integration.test.ts index 1cdf7465e4..983b9dcc41 100644 --- a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-specs.integration.test.ts +++ b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-specs.integration.test.ts @@ -291,4 +291,25 @@ describe("legacy inspect db specs (per-subcommand correctness)", () => { }).pipe(Effect.provide(ctx.layer)); }); } + + // Cell-level truth for `blocking.go:56`'s per-column backtick-wrapping: col 2 + // (`blocking_statement`) is backtick-wrapped, so a null/empty value renders as + // the two literal backticks (glamour's empty-code-span rule), while col 5 + // (`blocked_statement`) stays bare, so an empty value renders as "". + it("projects a null blocking_statement to the two-backtick empty code span, keeping blocked_statement bare", () => { + const row: Record = { + blocked_pid: 1, + blocking_statement: null, + blocking_duration: "00:02", + blocking_pid: 2, + blocked_statement: "", + blocked_duration: "00:03", + }; + const cells = legacyBlockingSpec.project(row, { + conn: LOCAL_CONN, + isLocal: true, + } satisfies LegacyResolvedDbConfig); + expect(cells[1]).toBe("``"); + expect(cells[4]).toBe(""); + }); }); diff --git a/apps/cli/src/legacy/commands/inspect/report/report.handler.ts b/apps/cli/src/legacy/commands/inspect/report/report.handler.ts index 3fcff06e56..53280d800d 100644 --- a/apps/cli/src/legacy/commands/inspect/report/report.handler.ts +++ b/apps/cli/src/legacy/commands/inspect/report/report.handler.ts @@ -109,8 +109,10 @@ const legacyRunInspectReport = Effect.fnUntraced(function* ( if (!path.isAbsolute(outDir)) { outDir = path.join(runtimeInfo.cwd, outDir); } + // Go pins the output dir to 0755 (`MkdirIfNotExistFS`, `report.go:32`, + // `misc.go:273`) and each CSV to 0644 (`report.go:66`). yield* fs - .makeDirectory(outDir, { recursive: true }) + .makeDirectory(outDir, { recursive: true, mode: 0o755 }) .pipe( Effect.mapError( (error) => new LegacyInspectReportMkdirError({ message: `failed to mkdir: ${error}` }), @@ -136,7 +138,7 @@ const legacyRunInspectReport = Effect.fnUntraced(function* ( legacyWrapReportQuery(sql, ignoreSchemas, dbLiteral), ); const filePath = path.join(outDir, `${fileName}.csv`); - yield* fs.writeFile(filePath, bytes).pipe( + yield* fs.writeFile(filePath, bytes, { mode: 0o644 }).pipe( Effect.mapError( (error) => new LegacyInspectReportWriteError({ diff --git a/apps/cli/src/legacy/commands/inspect/report/report.integration.test.ts b/apps/cli/src/legacy/commands/inspect/report/report.integration.test.ts index 58d4763d6c..cb24a9d9f8 100644 --- a/apps/cli/src/legacy/commands/inspect/report/report.integration.test.ts +++ b/apps/cli/src/legacy/commands/inspect/report/report.integration.test.ts @@ -1,7 +1,7 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Layer, Option } from "effect"; -import { mkdirSync, mkdtempSync, readdirSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readdirSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -197,9 +197,10 @@ describe("legacy inspect report", () => { it.live("writes one CSV per inspect query for the linked project", () => { const base = tempDir("supabase-report-out-"); const { layer, connection } = setupLegacyReport({ csvs: DEFAULT_RULE_CSVS }); + const prevUmask = process.umask(0); return Effect.gen(function* () { yield* legacyInspectReport(flags({ outputDir: base })); - const { files } = dateFolderContents(base); + const { dir, files } = dateFolderContents(base); expect(files.length).toBe(14); expect(files).toContain("db_stats.csv"); expect(files).toContain("unused_indexes.csv"); @@ -211,7 +212,11 @@ describe("legacy inspect report", () => { (s) => s.startsWith("COPY (") && s.endsWith("TO STDOUT WITH CSV HEADER"), ), ).toBe(true); - }).pipe(Effect.provide(layer)); + // Go pins the date folder to 0755 and each CSV to 0644 (report.handler.ts + // mirrors `internal/utils/misc.go:273,281-284`). + expect(statSync(dir).mode & 0o777).toBe(0o755); + expect(statSync(join(dir, "db_stats.csv")).mode & 0o777).toBe(0o644); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(() => process.umask(prevUmask)))); }); it.live("inspects the local database with --local", () => { diff --git a/apps/cli/src/legacy/commands/migration/new/new.handler.ts b/apps/cli/src/legacy/commands/migration/new/new.handler.ts index de50e1d0d9..bbb96caddd 100644 --- a/apps/cli/src/legacy/commands/migration/new/new.handler.ts +++ b/apps/cli/src/legacy/commands/migration/new/new.handler.ts @@ -58,6 +58,22 @@ export const legacyMigrationNew = Effect.fn("legacy.migration.new")(function* ( Effect.mapError((cause) => new LegacyMigrationNewWriteError({ message: cause.message })), ); + // Go prints the RELATIVE path: `utils.MigrationsDir` is `supabase/migrations` + // and Go chdir's into `--workdir` in its persistent pre-run, so the printed + // path is workdir-independent. Reproduce that exactly while still writing to + // the absolute `migrationPath`. + const relativePath = path.join( + "supabase", + "migrations", + `${timestamp}_${flags.migrationName}.sql`, + ); + // stdout-bound line, so the colour TTY gate must check stdout (see + // `legacy-colors.ts`'s doc comment — the CLI-1546 bug class). + const printCreated = + output.format === "text" + ? output.raw(`Created new migration at ${legacyBold(relativePath, process.stdout)}\n`) + : Effect.void; + // Go's `CopyStdinIfExists` opens the migration file first, then streams stdin into it // with `io.Copy` (`internal/migration/new/new.go:19,28,41`) — a fixed-size buffer, so a // large `pg_dump | supabase migration new` runs in constant memory. Mirror that: create @@ -66,7 +82,8 @@ export const legacyMigrationNew = Effect.fn("legacy.migration.new")(function* ( // file; an empty pipe streams nothing → empty file, both matching Go. yield* Effect.scoped( Effect.gen(function* () { - // Go fails with "failed to open migration file" if the open fails (`new.go:21`)... + // Go fails with "failed to open migration file" if the open fails (`new.go:21`) — + // BEFORE its deferred Created-line print is registered, so no line on open failure... const handle = yield* fs.open(migrationPath, { flag: "w", mode: 0o644 }).pipe( Effect.mapError( (cause) => @@ -76,7 +93,10 @@ export const legacyMigrationNew = Effect.fn("legacy.migration.new")(function* ( ), ); // ...and with "failed to copy from stdin" if the copy fails (`new.go:42`). A piped - // stdin read error must abort here, not silently leave a truncated/empty file. + // stdin read error must abort here, not silently leave a truncated/empty file — but + // Go's `defer fmt.Println("Created new migration at …")` (`new.go:24-27`) is already + // registered by then, so the Created line still prints (stdout) BEFORE the copy + // error surfaces (stderr, exit 1). if (!stdin.isTTY) { yield* stdin.pipedBytesStream.pipe( Stream.runForEach((chunk) => handle.writeAll(chunk)), @@ -86,22 +106,14 @@ export const legacyMigrationNew = Effect.fn("legacy.migration.new")(function* ( message: `failed to copy from stdin: ${cause.message}`, }), ), + Effect.tapError(() => printCreated), ); } }), ); - // Go prints the RELATIVE path: `utils.MigrationsDir` is `supabase/migrations` - // and Go chdir's into `--workdir` in its persistent pre-run, so the printed - // path is workdir-independent. Reproduce that exactly while still writing to - // the absolute `migrationPath`. - const relativePath = path.join( - "supabase", - "migrations", - `${timestamp}_${flags.migrationName}.sql`, - ); if (output.format === "text") { - yield* output.raw(`Created new migration at ${legacyBold(relativePath)}\n`); + yield* printCreated; } else { yield* output.success("Migration created", { path: migrationPath }); } diff --git a/apps/cli/src/legacy/commands/migration/new/new.integration.test.ts b/apps/cli/src/legacy/commands/migration/new/new.integration.test.ts index 2d8c4b02cb..ad3eea5ffa 100644 --- a/apps/cli/src/legacy/commands/migration/new/new.integration.test.ts +++ b/apps/cli/src/legacy/commands/migration/new/new.integration.test.ts @@ -158,6 +158,9 @@ describe("legacy migration new", () => { () => { // Go's io.Copy returns "failed to copy from stdin" and exits non-zero on a stdin read // error (new.go:42); the streaming copy must surface that, not leave a truncated file. + // Go's deferred `fmt.Println("Created new migration at …")` (new.go:24-27) is already + // registered by the time the copy fails, so the Created line still prints to stdout + // BEFORE the copy error propagates to stderr / exit 1 — assert BOTH here. const failingStdin = Layer.succeed(Stdin, { isTTY: false, readPipedBytes: Effect.succeed(Option.none()), @@ -187,6 +190,10 @@ describe("legacy migration new", () => { } } } + const file = onlyMigration(tmp.current); + expect(stripAnsi(out.stdoutText)).toBe( + `Created new migration at supabase/migrations/${file}\n`, + ); expect(telemetry.flushed).toBe(true); }).pipe(Effect.provide(layer)); }, diff --git a/apps/cli/src/legacy/commands/postgres-config/postgres-config.integration.test.ts b/apps/cli/src/legacy/commands/postgres-config/postgres-config.integration.test.ts index 60536380f6..dba5877ec2 100644 --- a/apps/cli/src/legacy/commands/postgres-config/postgres-config.integration.test.ts +++ b/apps/cli/src/legacy/commands/postgres-config/postgres-config.integration.test.ts @@ -62,6 +62,23 @@ describe("legacy postgres-config get", () => { }).pipe(Effect.provide(layer)); }); + it.live("renders a large integral config value with Go's float64 %g in the pretty table", () => { + // Go decodes the API response with `json.Unmarshal` into `map[string]any`, + // so every JSON number is a `float64`; `get.go:32-35`'s `%+v` then prints + // it with shortest `%g` — 1000000 renders as `1e+06`, never `1000000`. + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi({ + response: { status: 200, body: { max_connections: 1000000 } }, + }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + yield* legacyPostgresConfigGet({ projectRef: Option.none() }); + expect(out.stdoutText).toContain("1e+06"); + expect(out.stdoutText).not.toContain("1000000"); + }).pipe(Effect.provide(layer)); + }); + it.live("emits TOML bytes for --output toml", () => { const out = mockOutput({ format: "text" }); const api = mockLegacyPlatformApi({ diff --git a/apps/cli/src/legacy/commands/postgres-config/postgres-config.shared.ts b/apps/cli/src/legacy/commands/postgres-config/postgres-config.shared.ts index 0bbd54b263..860f77b7f2 100644 --- a/apps/cli/src/legacy/commands/postgres-config/postgres-config.shared.ts +++ b/apps/cli/src/legacy/commands/postgres-config/postgres-config.shared.ts @@ -13,6 +13,7 @@ import { encodeGoStructJsonBody, encodeYaml, } from "../../shared/legacy-go-output.encoders.ts"; +import { legacyGoFormatFloat } from "../../shared/legacy-go-float.ts"; import { sanitizeLegacyErrorBody } from "../../shared/legacy-http-errors.ts"; import { requestWithAuth } from "../../shared/legacy-raw-http.ts"; import { resolveLegacyAccessToken } from "../../shared/legacy-resolve-token.ts"; @@ -30,7 +31,11 @@ function sortConfigEntries(config: LegacyPostgresConfigMap): Array<[string, unkn function formatPrettyValue(value: unknown): string { if (typeof value === "string") return value; - if (typeof value === "number" || typeof value === "boolean") return String(value); + // Go renders each cell with `%+v` (`get.go:32-35`) on values from + // `json.Unmarshal` into `map[string]any` — every JSON number is a `float64`, + // so e.g. `1000000` prints as `1e+06`, not `1000000`. + if (typeof value === "number") return legacyGoFormatFloat(value); + if (typeof value === "boolean") return String(value); if (value === null) return ""; return JSON.stringify(value); } @@ -66,11 +71,39 @@ function encodePostgresConfigToml(config: LegacyPostgresConfigMap): string { return lines.length === 0 ? "" : lines.join("\n") + "\n"; } +// Go's `strconv.Atoi` parses into a 64-bit int (`update.go:43`). +const INT64_MIN = -(2n ** 63n); +const INT64_MAX = 2n ** 63n - 1n; + +// Exactly `strconv.ParseBool`'s accepted sets. `1`/`0` are also in them, but +// the integer branch below wins first — in Go too, where `Atoi` runs before +// `ParseBool` (`update.go:43-48`). +const GO_TRUE_LITERALS = new Set(["1", "t", "T", "TRUE", "true", "True"]); +const GO_FALSE_LITERALS = new Set(["0", "f", "F", "FALSE", "false", "False"]); + +/** + * Go's coercion chain for `--config key=value` (`update.go:41-49`): + * `strconv.Atoi` → `strconv.ParseBool` → keep as string. `Atoi` fails with + * `ErrRange` on digits beyond int64, and a pure digit string is not a + * `ParseBool` literal (only bare `1`/`0` are, and those fit in int64), so an + * overflowing integer falls through to the verbatim string. `ParseBool` is + * case-SENSITIVE over a fixed set — `tRuE` stays a string in Go. + * + * Residual divergence: digits in `(2^53, 2^63)` fit int64, so Go sends an + * exact JSON integer, while JS `Number` loses precision there — + * `encodeGoStructJsonBody` is `JSON.stringify`, which cannot emit exact + * int64 tokens beyond `Number.MAX_SAFE_INTEGER`. + */ export function parseConfigValue(value: string): string | number | boolean { - if (/^[+-]?\d+$/.test(value)) return Number.parseInt(value, 10); - const lower = value.toLowerCase(); - if (["1", "t", "true"].includes(lower)) return true; - if (["0", "f", "false"].includes(lower)) return false; + if (/^[+-]?\d+$/.test(value)) { + const asBigInt = BigInt(value.replace(/^\+/, "")); + if (asBigInt >= INT64_MIN && asBigInt <= INT64_MAX) { + return Number.parseInt(value, 10); + } + return value; + } + if (GO_TRUE_LITERALS.has(value)) return true; + if (GO_FALSE_LITERALS.has(value)) return false; return value; } diff --git a/apps/cli/src/legacy/commands/postgres-config/postgres-config.shared.unit.test.ts b/apps/cli/src/legacy/commands/postgres-config/postgres-config.shared.unit.test.ts new file mode 100644 index 0000000000..241691e69c --- /dev/null +++ b/apps/cli/src/legacy/commands/postgres-config/postgres-config.shared.unit.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { parseConfigValue } from "./postgres-config.shared.ts"; + +describe("parseConfigValue", () => { + it("parses signed and zero-padded digit strings as int64-range numbers", () => { + expect(parseConfigValue("100")).toBe(100); + expect(parseConfigValue("+7")).toBe(7); + expect(parseConfigValue("-3")).toBe(-3); + expect(parseConfigValue("007")).toBe(7); + }); + + it("keeps a digit string that overflows int64 as the verbatim string", () => { + expect(parseConfigValue("99999999999999999999999999")).toBe("99999999999999999999999999"); + }); + + it("straddles the int64 boundary: max fits as a number, one past it stays a string", () => { + expect(parseConfigValue("9223372036854775807")).toBe( + Number.parseInt("9223372036854775807", 10), + ); + expect(parseConfigValue("9223372036854775808")).toBe("9223372036854775808"); + }); + + it("matches Go's strconv.ParseBool accepted literals, case-sensitively", () => { + for (const literal of ["t", "T", "TRUE", "true", "True"]) { + expect(parseConfigValue(literal)).toBe(true); + } + for (const literal of ["f", "F", "FALSE", "false", "False"]) { + expect(parseConfigValue(literal)).toBe(false); + } + }); + + it("keeps case-mismatched or non-canonical bool-ish strings as plain strings", () => { + expect(parseConfigValue("tRuE")).toBe("tRuE"); + expect(parseConfigValue("YES")).toBe("YES"); + expect(parseConfigValue("on")).toBe("on"); + }); + + it("prefers the int branch over ParseBool for bare 1/0", () => { + expect(parseConfigValue("1")).toBe(1); + expect(parseConfigValue("0")).toBe(0); + }); +}); diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.e2e.test.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.e2e.test.ts index c0f0d50c5a..b21fa02eeb 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.e2e.test.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.e2e.test.ts @@ -47,7 +47,7 @@ describe("supabase seed buckets (legacy)", () => { ); expect(exitCode).toBe(1); expect(`${stdout}${stderr}`).toContain( - "if any flags in the group [linked local] are set none of the others can be", + "if any flags in the group [local linked] are set none of the others can be", ); }); @@ -76,7 +76,7 @@ describe("supabase seed buckets (legacy)", () => { ); expect(exitCode).toBe(1); expect(`${stdout}${stderr}`).toContain( - "if any flags in the group [linked local] are set none of the others can be", + "if any flags in the group [local linked] are set none of the others can be", ); }); }); diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.flags.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.flags.ts index 3f4bb5fe46..baff389f3b 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.flags.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.flags.ts @@ -20,6 +20,12 @@ export function legacySeedChangedTargetFlags(args: ReadonlyArray): Reado * (`apps/cli-go/cmd/seed.go:32`). Go rejects this at flag validation — before * `RunE`/`PersistentPostRun` — so it must NOT emit `cli_command_executed`; the * command calls this BEFORE `withLegacyCommandInstrumentation`. + * + * The first bracket keeps seed's REGISTRATION order `[local linked]` — cobra + * joins the group names unsorted (`flag_groups.go:73`) and only sorts the + * "were all set" list (`flag_groups.go:203-204`). `storage` registers the same + * pair in the opposite order (`cmd/storage.go:99`), so the two commands' + * first brackets legitimately differ. */ export const legacyAssertSeedTargetsExclusive = Effect.fnUntraced(function* ( args: ReadonlyArray, @@ -27,7 +33,7 @@ export const legacyAssertSeedTargetsExclusive = Effect.fnUntraced(function* ( const setFlags = legacySeedChangedTargetFlags(args); if (setFlags.length > 1) { return yield* new LegacySeedMutuallyExclusiveFlagsError({ - message: `if any flags in the group [linked local] are set none of the others can be; [${setFlags.join(" ")}] were all set`, + message: `if any flags in the group [local linked] are set none of the others can be; [${setFlags.join(" ")}] were all set`, }); } }); diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.flags.unit.test.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.flags.unit.test.ts index 8fdabb4497..889c356c8b 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.flags.unit.test.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.flags.unit.test.ts @@ -58,7 +58,7 @@ describe("legacyAssertSeedTargetsExclusive", () => { ); expect(Exit.isFailure(exit)).toBe(true); expect(JSON.stringify(exit)).toContain( - "if any flags in the group [linked local] are set none of the others can be; [linked local] were all set", + "if any flags in the group [local linked] are set none of the others can be; [linked local] were all set", ); }); diff --git a/apps/cli/src/legacy/commands/services/services.handler.ts b/apps/cli/src/legacy/commands/services/services.handler.ts index 7fa9a9e81b..877ac24120 100644 --- a/apps/cli/src/legacy/commands/services/services.handler.ts +++ b/apps/cli/src/legacy/commands/services/services.handler.ts @@ -48,7 +48,25 @@ export const legacyServices = Effect.fn("legacy.services")(function* (_flags: Le return Option.none(); } - const content = yield* fs.readFileString(projectRefPath).pipe(Effect.orElseSucceed(() => "")); + // Go's `Run` warns on a ref-file READ error (as opposed to the file simply + // not existing) and keeps going as unlinked (`internal/services/ + // services.go:18-20`: `fmt.Fprintln(os.Stderr, err)` with `LoadProjectRef`'s + // `failed to load project ref: %w`, `project_ref.go:71-72`). A NotFound + // between the exists() check above and this read (TOCTOU) maps to Go's + // `os.ErrNotExist` → `ErrNotLinked` branch: silent, no warning. The + // warning's error suffix is Effect's description, not Go's `*PathError` + // text — the prefix is the parity-bearing part. + const content = yield* fs + .readFileString(projectRefPath) + .pipe( + Effect.catch((cause) => + cause._tag === "PlatformError" && cause.reason._tag === "NotFound" + ? Effect.succeed("") + : output + .raw(`failed to load project ref: ${String(cause)}\n`, "stderr") + .pipe(Effect.as("")), + ), + ); const trimmed = content.trim(); return trimmed.length === 0 ? Option.none() : Option.some(trimmed); }); diff --git a/apps/cli/src/legacy/commands/services/services.integration.test.ts b/apps/cli/src/legacy/commands/services/services.integration.test.ts index 86c2ceb807..75e48c6f5e 100644 --- a/apps/cli/src/legacy/commands/services/services.integration.test.ts +++ b/apps/cli/src/legacy/commands/services/services.integration.test.ts @@ -558,6 +558,21 @@ major_version = 15 }); }); + it.live("warns to stderr when the project-ref file exists but cannot be read", () => { + // A directory at the ref path makes `exists()` true but `readFileString()` fail + // (EISDIR), exercising the READ-error branch distinct from "file absent". + const workdir = mkdtempSync(join(tmpdir(), "supabase-services-")); + mkdirSync(join(workdir, "supabase", ".temp", "project-ref"), { recursive: true }); + const { layer, out } = setup({ workdir }); + + return Effect.gen(function* () { + yield* legacyServices({}).pipe(Effect.provide(layer)); + + expect(out.stderrText).toContain("failed to load project ref: "); + expect(out.stdoutText).toContain("supabase/postgres"); + }).pipe(Effect.ensuring(Effect.sync(() => rmSync(workdir, { recursive: true, force: true })))); + }); + it.live("flushes telemetry state after the command finishes", () => { const { layer, telemetry } = setup(); diff --git a/apps/cli/src/legacy/commands/snippets/download/download.handler.ts b/apps/cli/src/legacy/commands/snippets/download/download.handler.ts index 086709b41e..26510a13d2 100644 --- a/apps/cli/src/legacy/commands/snippets/download/download.handler.ts +++ b/apps/cli/src/legacy/commands/snippets/download/download.handler.ts @@ -7,6 +7,7 @@ import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.ser import { Output } from "../../../../shared/output/output.service.ts"; import { resolveLegacyAccessToken } from "../../../shared/legacy-resolve-token.ts"; import { sanitizeLegacyErrorBody } from "../../../shared/legacy-http-errors.ts"; +import { legacyGoQuote } from "../../../shared/legacy-go-quote.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { @@ -16,25 +17,116 @@ import { } from "../snippets.errors.ts"; import type { LegacySnippetsDownloadFlags } from "./download.command.ts"; -// Load-bearing for error-message parity. The generated `V1GetASnippetInput` -// schema (contracts.ts:1539-1545) already pattern-checks UUIDs, so if this -// pre-check is removed, a non-UUID input would surface as a `SchemaError` -// routed through `mapDownloadError` to `LegacySnippetsDownloadNetworkError` -// with a `failed to download snippet:` prefix — losing the Go-canonical -// `invalid snippet ID:` prefix from `apps/cli-go/internal/snippets/download/download.go:17`. -const UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; - -// Mirrors Go's `uuid.Parse` (google/uuid v1.6.0) error surface: -// - len(s) not in {32, 36, 38, 41} → `invalid UUID length: N` -// - len(s) == 36 but dashes/hex chars wrong → `invalid UUID format` -// We accept only the canonical 36-char form (`8-4-4-4-12`), so the two -// branches collapse to length-vs-format. The outer wrap mirrors -// `fmt.Errorf("invalid snippet ID: %w", err)` from download.go:17. -function uuidErrorMessage(value: string): string { - if (value.length !== 36) { - return `invalid snippet ID: invalid UUID length: ${value.length}`; +const DASH_BYTE = 0x2d; + +function canonicalFromHex(hex32: string): string { + return `${hex32.slice(0, 8)}-${hex32.slice(8, 12)}-${hex32.slice(12, 16)}-${hex32.slice(16, 20)}-${hex32.slice(20)}`; +} + +/** + * Reads `[start, end)` of `s` as lowercase hex, or `undefined` on the first + * non-hex byte — the byte-wise equivalent of Go's `xtob` loop. + */ +function readHexRange(s: Uint8Array, start: number, end: number): string | undefined { + let out = ""; + for (let i = start; i < end; i++) { + const b = s[i] ?? 0; + if (b >= 0x30 && b <= 0x39) { + out += String.fromCharCode(b); + } else { + const lower = b | 0x20; + if (lower < 0x61 || lower > 0x66) return undefined; + out += String.fromCharCode(lower); + } + } + return out; +} + +/** + * `strings.EqualFold(s[:9], "urn:uuid:")` equivalent. ASCII-only folding over + * the raw bytes is exact here: no non-ASCII rune case-folds to any rune of + * `"urn:uuid:"` (Unicode's only ASCII-target simple folds are `ſ`→`s` and + * `K`→`k`, neither of which appears), and multibyte runes can never + * byte-match an ASCII target. + */ +function isUrnUuidPrefix(bytes: Uint8Array): boolean { + const expected = "urn:uuid:"; + for (let i = 0; i < expected.length; i++) { + const b = bytes[i] ?? 0; + const lower = b >= 0x41 && b <= 0x5a ? b + 0x20 : b; + if (lower !== expected.charCodeAt(i)) return false; + } + return true; +} + +/** + * Faithful port of Go's `uuid.Parse` (google/uuid v1.6.0, `uuid.go:68-117`), + * which `download.Run` uses to validate the snippet id + * (`apps/cli-go/internal/snippets/download/download.go:15-17`). Accepts the + * same 4 forms Go does — hyphenated (36), `urn:uuid:`-prefixed (45), braced + * `{…}` (38, where only the middle 36 bytes are examined — the trailing byte + * is never validated, mirroring `s = s[1:]`), and raw 32-hex — and returns + * the CANONICAL lowercase hyphenated form (Go interpolates the parsed + * `uuid.UUID`, whose `String()` is always lowercase, into the request URL — + * never the raw arg). Error strings reproduce Go's three branches verbatim; + * the caller wraps them like `fmt.Errorf("invalid snippet ID: %w", err)`. + * + * Everything operates on the argument's UTF-8 BYTES: Go's `switch len(s)` + * counts bytes where a JS string's `length` counts UTF-16 code units, so a + * non-ASCII argument like `é}` (JS length 38, 39 bytes) must + * take Go's default branch and report `invalid UUID length: 39` — not slip + * into the braced branch and issue a request (verified against go1.26 + + * google/uuid v1.6.0). + * + * This pre-check is load-bearing for error-message parity: the generated + * `V1GetASnippetInput` schema already pattern-checks UUIDs, so without it a + * non-UUID input would surface as a `SchemaError` with a `failed to download + * snippet:` prefix instead of Go's `invalid snippet ID:`. + */ +export function legacyParseSnippetUuid( + input: string, +): { readonly canonical: string } | { readonly error: string } { + let s = new TextEncoder().encode(input); + switch (s.length) { + // xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + case 36: + break; + // urn:uuid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + case 45: { + if (!isUrnUuidPrefix(s)) { + return { error: `invalid urn prefix: ${legacyGoQuote(s.subarray(0, 9))}` }; + } + s = s.subarray(9); + break; + } + // {xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx} + case 38: + s = s.subarray(1); + break; + // xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + case 32: { + const hex = readHexRange(s, 0, 32); + if (hex === undefined) return { error: "invalid UUID format" }; + return { canonical: canonicalFromHex(hex) }; + } + default: + return { error: `invalid UUID length: ${s.length}` }; + } + // s is now at least 36 bytes and must be xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx. + if (s[8] !== DASH_BYTE || s[13] !== DASH_BYTE || s[18] !== DASH_BYTE || s[23] !== DASH_BYTE) { + return { error: "invalid UUID format" }; + } + const segments = [ + readHexRange(s, 0, 8), + readHexRange(s, 9, 13), + readHexRange(s, 14, 18), + readHexRange(s, 19, 23), + readHexRange(s, 24, 36), + ]; + if (segments.some((segment) => segment === undefined)) { + return { error: "invalid UUID format" }; } - return "invalid snippet ID: invalid UUID format"; + return { canonical: canonicalFromHex(segments.join("")) }; } // Tolerant body parse — see `list.handler.ts` for the rationale. The real @@ -66,9 +158,10 @@ export const legacySnippetsDownload = Effect.fn("legacy.snippets.download")(func const ref = yield* resolver.resolve(flags.projectRef); yield* Effect.gen(function* () { - if (!UUID_RE.test(flags.snippetId)) { + const parsed = legacyParseSnippetUuid(flags.snippetId); + if ("error" in parsed) { return yield* new LegacySnippetsInvalidIdError({ - message: uuidErrorMessage(flags.snippetId), + message: `invalid snippet ID: ${parsed.error}`, }); } @@ -79,7 +172,7 @@ export const legacySnippetsDownload = Effect.fn("legacy.snippets.download")(func ? HttpClientRequest.bearerToken(tokenOpt.value) : (req) => req; const request = HttpClientRequest.get( - `${cliConfig.apiUrl}/v1/snippets/${flags.snippetId}`, + `${cliConfig.apiUrl}/v1/snippets/${parsed.canonical}`, ).pipe(authHeader, HttpClientRequest.setHeader("User-Agent", cliConfig.userAgent)); const fetching = diff --git a/apps/cli/src/legacy/commands/snippets/download/download.integration.test.ts b/apps/cli/src/legacy/commands/snippets/download/download.integration.test.ts index 05db086767..8f21d68c06 100644 --- a/apps/cli/src/legacy/commands/snippets/download/download.integration.test.ts +++ b/apps/cli/src/legacy/commands/snippets/download/download.integration.test.ts @@ -19,6 +19,9 @@ import { legacySnippetsDownload } from "./download.handler.ts"; // --------------------------------------------------------------------------- const VALID_ID = "0b0d48f6-878b-4190-88d7-2ca33ed800bc"; +// Raw 32-hex form of VALID_ID, uppercase — a form Go's `uuid.Parse` accepts +// (google/uuid v1.6.0) that the old handler rejected before this fix. +const UPPER_HEX32_ID = "0B0D48F6878B419088D72CA33ED800BC"; const INVALID_ID = "not-a-uuid"; // length 10 → "invalid UUID length: 10" const TOO_LONG_ID = "0b0d48f6-878b-4190-88d7-2ca33ed800bc-extra"; // length 42 (3 ungrouped: 32, 36, 38, 41) const WRONG_FORMAT_ID = "0b0d48f6.878b.4190.88d7.2ca33ed800bc"; // length 36, no dashes in canonical positions @@ -203,6 +206,18 @@ describe("legacy snippets download integration", () => { }).pipe(Effect.provide(layer)); }); + it.live( + "a 32-hex UPPERCASE snippet id resolves to the canonical lowercase hyphenated URL (Go parity)", + () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacySnippetsDownload({ snippetId: UPPER_HEX32_ID, projectRef: Option.none() }); + expect(api.requests).toHaveLength(1); + expect(api.requests[0]?.url).toContain(`/v1/snippets/${VALID_ID}`); + }).pipe(Effect.provide(layer)); + }, + ); + it.live("uses --project-ref flag value when resolving the linked-project cache", () => { const flagRef = "zzzzzzzzzzzzzzzzzzzz"; const { layer, cache } = setup(); diff --git a/apps/cli/src/legacy/commands/snippets/download/download.uuid.unit.test.ts b/apps/cli/src/legacy/commands/snippets/download/download.uuid.unit.test.ts new file mode 100644 index 0000000000..189fe17892 --- /dev/null +++ b/apps/cli/src/legacy/commands/snippets/download/download.uuid.unit.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; + +import { legacyParseSnippetUuid } from "./download.handler.ts"; + +describe("legacyParseSnippetUuid", () => { + it("accepts the canonical 36-char hyphenated form, lowercasing uppercase hex", () => { + expect(legacyParseSnippetUuid("0b0d48f6-878b-4190-88d7-2ca33ed800bc")).toEqual({ + canonical: "0b0d48f6-878b-4190-88d7-2ca33ed800bc", + }); + expect(legacyParseSnippetUuid("0B0D48F6-878B-4190-88D7-2CA33ED800BC")).toEqual({ + canonical: "0b0d48f6-878b-4190-88d7-2ca33ed800bc", + }); + }); + + it("accepts the raw 32-hex form and returns the canonical hyphenated lowercase form", () => { + expect(legacyParseSnippetUuid("0b0d48f6878b419088d72ca33ed800bc")).toEqual({ + canonical: "0b0d48f6-878b-4190-88d7-2ca33ed800bc", + }); + expect(legacyParseSnippetUuid("0B0D48F6878B419088D72CA33ED800BC")).toEqual({ + canonical: "0b0d48f6-878b-4190-88d7-2ca33ed800bc", + }); + }); + + it("accepts a urn:uuid: prefix, case-insensitively", () => { + expect(legacyParseSnippetUuid("urn:uuid:0b0d48f6-878b-4190-88d7-2ca33ed800bc")).toEqual({ + canonical: "0b0d48f6-878b-4190-88d7-2ca33ed800bc", + }); + expect(legacyParseSnippetUuid("URN:UUID:0b0d48f6-878b-4190-88d7-2ca33ed800bc")).toEqual({ + canonical: "0b0d48f6-878b-4190-88d7-2ca33ed800bc", + }); + }); + + it("accepts the braced form, never validating the trailing 38th char (s = s[1:] quirk)", () => { + expect(legacyParseSnippetUuid("{0b0d48f6-878b-4190-88d7-2ca33ed800bc}")).toEqual({ + canonical: "0b0d48f6-878b-4190-88d7-2ca33ed800bc", + }); + // The 38th (trailing) character is `!`, not `}` — still parses, because Go's + // `s = s[1:]` only strips the leading brace and never inspects the last byte. + expect(legacyParseSnippetUuid("{0b0d48f6-878b-4190-88d7-2ca33ed800bc!")).toEqual({ + canonical: "0b0d48f6-878b-4190-88d7-2ca33ed800bc", + }); + }); + + it("rejects the wrong length with `invalid UUID length: `", () => { + expect(legacyParseSnippetUuid("not-a-uuid")).toEqual({ + error: "invalid UUID length: 10", + }); + expect(legacyParseSnippetUuid("")).toEqual({ + error: "invalid UUID length: 0", + }); + }); + + it('rejects 45 chars with a bad prefix with `invalid urn prefix: ""`', () => { + expect(legacyParseSnippetUuid("xrn:uuid:0b0d48f6-878b-4190-88d7-2ca33ed800bc")).toEqual({ + error: 'invalid urn prefix: "xrn:uuid:"', + }); + }); + + it("rejects 36 chars with misplaced hyphens or non-hex with `invalid UUID format`", () => { + // Dots instead of hyphens at the canonical dash positions. + expect(legacyParseSnippetUuid("0b0d48f6.878b.4190.88d7.2ca33ed800bc")).toEqual({ + error: "invalid UUID format", + }); + // Correct dash positions, but a non-hex character in the payload. + expect(legacyParseSnippetUuid("0b0d48f6-878b-4190-88d7-2ca33ed800bg")).toEqual({ + error: "invalid UUID format", + }); + }); + + it("rejects 32 non-hex chars with `invalid UUID format`", () => { + expect(legacyParseSnippetUuid("0b0d48f6878b419088d72ca33ed800bg")).toEqual({ + error: "invalid UUID format", + }); + }); + + // Go's `uuid.Parse` dispatches on `len(s)` — UTF-8 BYTES — where a JS + // string's `length` counts UTF-16 code units. Non-ASCII arguments must take + // Go's branch and report Go's byte count. Every expectation below is ground + // truth from go1.26 + google/uuid v1.6.0. + describe("UTF-8 byte-length dispatch (non-ASCII arguments)", () => { + const canonical = "0b0d48f6-878b-4190-88d7-2ca33ed800bc"; + + it("counts a multibyte char as its byte width, never slipping into the braced branch", () => { + // JS length 38 (would hit the braced branch and issue a request for the + // embedded canonical UUID); Go sees 39 bytes → length error. + expect(legacyParseSnippetUuid(`é${canonical}}`)).toEqual({ + error: "invalid UUID length: 39", + }); + expect(legacyParseSnippetUuid(`{${canonical}é`)).toEqual({ + error: "invalid UUID length: 39", + }); + // JS length 36; Go sees 37 bytes. + expect(legacyParseSnippetUuid(`é${canonical.slice(1)}`)).toEqual({ + error: "invalid UUID length: 37", + }); + }); + + it("slices the urn prefix by byte and %q-quotes it (printable rune prints literally)", () => { + // 2 (é) + 7 + 36 = 45 bytes → urn branch; first 9 BYTES are "érn:uuid". + expect(legacyParseSnippetUuid(`érn:uuid${canonical}`)).toEqual({ + error: 'invalid urn prefix: "érn:uuid"', + }); + }); + + it("renders a rune split by the 9-byte prefix slice as Go's lone \\xNN escape", () => { + // 8 ASCII + é(2 bytes) + 35 = 45 bytes; byte 9 cuts é in half, so Go's + // `%q` shows its orphaned lead byte: `"12345678\xc3"`. + expect(legacyParseSnippetUuid(`12345678é${canonical.slice(0, 35)}`)).toEqual({ + error: 'invalid urn prefix: "12345678\\xc3"', + }); + }); + }); +}); diff --git a/apps/cli/src/legacy/commands/start/start.handler.ts b/apps/cli/src/legacy/commands/start/start.handler.ts index 0e087161cf..5902a8dac9 100644 --- a/apps/cli/src/legacy/commands/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/start/start.handler.ts @@ -709,6 +709,10 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta const path = yield* Path.Path; const dbConnection = yield* LegacyDbConnection; const runtimeInfo = yield* RuntimeInfo; + // Threaded into every `legacyDockerRemoveAll` teardown below — Go's + // `--debug` gates that function's `Pruned …:` stderr reports + // (`docker.go:123-143`, `viper.GetBool("DEBUG")`). + const debug = yield* LegacyDebugFlag; yield* Effect.gen(function* () { // 0. Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go:231-250`) — @@ -2197,7 +2201,6 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta // real relative position (between ImgProxy and pg-meta). if (entry.service === "edgeRuntime") { if (!gates.edgeRuntime || edgeRuntimeDefaultImage === undefined) continue; - const debug = yield* LegacyDebugFlag; // `config.edge_runtime.secrets` is still schema-decoded plain // strings here — `toPlainEdgeRuntimeConfig` only emits entries // whose values are `Redacted` (`shared/functions/serve.ts`), and a @@ -2355,7 +2358,7 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta // `onError` fires on any failure outcome (including interruption) and its // cleanup effect runs uninterruptibly, matching Go's unconditional check. Effect.onError(() => - legacyRollbackStart(spawner, filterValue, isFreshVolume, cliConfig.workdir), + legacyRollbackStart(spawner, filterValue, isFreshVolume, cliConfig.workdir, debug), ), ); @@ -2374,12 +2377,19 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta ); } let removedContainers: ReadonlyArray = []; - yield* legacyDockerRemoveAll(spawner, filterValue, false, (containers) => { - // Recovery only trusts its own workdir; empty labels use the existing fallback. - removedContainers = containers.filter( - (container) => container.workdir.length === 0 || container.workdir === cliConfig.workdir, - ); - }).pipe( + yield* legacyDockerRemoveAll( + spawner, + filterValue, + false, + (containers) => { + // Recovery only trusts its own workdir; empty labels use the existing fallback. + removedContainers = containers.filter( + (container) => + container.workdir.length === 0 || container.workdir === cliConfig.workdir, + ); + }, + debug, + ).pipe( Effect.ensuring( Effect.suspend(() => legacyCleanupStartSecrets(removedContainers, cliConfig.workdir)), ), @@ -2604,7 +2614,7 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta } }).pipe( Effect.onError(() => - legacyRollbackStart(spawner, filterValue, isFreshVolume, cliConfig.workdir), + legacyRollbackStart(spawner, filterValue, isFreshVolume, cliConfig.workdir, debug), ), ); } diff --git a/apps/cli/src/legacy/commands/start/start.rollback.ts b/apps/cli/src/legacy/commands/start/start.rollback.ts index b46956059c..8038ace6bf 100644 --- a/apps/cli/src/legacy/commands/start/start.rollback.ts +++ b/apps/cli/src/legacy/commands/start/start.rollback.ts @@ -66,12 +66,27 @@ export const legacyRollbackStart = ( filterValue: string, deleteVolumes: boolean, workdir: string, + debug: boolean, ): Effect.Effect => Effect.gen(function* () { + // Go's `DockerRemoveAll` prints "Stopping containers..." to the writer its + // caller passes (`internal/utils/docker.go:97`); the start-failure path + // passes `os.Stderr` (`internal/start/start.go:77`). The TS port moved that + // line out of `legacyDockerRemoveAll` and into each caller (`stop` prints + // it to its own status writer), so the rollback path prints it here. + yield* Effect.sync(() => { + globalThis.process.stderr.write("Stopping containers...\n"); + }); let removedContainers: ReadonlyArray = []; - yield* legacyDockerRemoveAll(spawner, filterValue, deleteVolumes, (containers) => { - removedContainers = containers; - }).pipe( + yield* legacyDockerRemoveAll( + spawner, + filterValue, + deleteVolumes, + (containers) => { + removedContainers = containers; + }, + debug, + ).pipe( Effect.catch((error) => Effect.sync(() => { globalThis.process.stderr.write(`${error.message}\n`); diff --git a/apps/cli/src/legacy/commands/start/start.rollback.unit.test.ts b/apps/cli/src/legacy/commands/start/start.rollback.unit.test.ts index bff777ba6f..a9d3f8788c 100644 --- a/apps/cli/src/legacy/commands/start/start.rollback.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/start.rollback.unit.test.ts @@ -73,8 +73,13 @@ describe("legacyRollbackStart", () => { "com.supabase.cli.project=my-app", false, "/tmp/legacy-rollback-unit-test-workdir", + false, ); - expect(stderr).not.toHaveBeenCalled(); + // Go's start-failure path prints "Stopping containers..." to stderr + // before tearing down (`docker.go:97` with `w == os.Stderr`, + // `start.go:77`); no other stderr output on success without --debug. + expect(stderr).toHaveBeenCalledTimes(1); + expect(stderr).toHaveBeenCalledWith("Stopping containers...\n"); // legacyDockerRemoveAll's own list (its `onContainersListed` hook feeds // legacyCleanupStartSecrets the same container names, no second `ps` // call) -> container prune -> network prune; no stop calls (empty list) @@ -91,6 +96,7 @@ describe("legacyRollbackStart", () => { "com.supabase.cli.project=my-app", true, "/tmp/legacy-rollback-unit-test-workdir", + false, ); expect(mock.spawned.map((args) => args[0])).toEqual([ "ps", @@ -113,9 +119,11 @@ describe("legacyRollbackStart", () => { "com.supabase.cli.project=my-app", false, "/tmp/legacy-rollback-unit-test-workdir", + false, ); - expect(stderr).toHaveBeenCalledTimes(1); - expect(stderr).toHaveBeenCalledWith("failed to list containers: permission denied\n"); + expect(stderr).toHaveBeenCalledTimes(2); + expect(stderr).toHaveBeenNthCalledWith(1, "Stopping containers...\n"); + expect(stderr).toHaveBeenNthCalledWith(2, "failed to list containers: permission denied\n"); }); }); @@ -128,9 +136,11 @@ describe("legacyRollbackStart", () => { "com.supabase.cli.project=my-app", false, "/tmp/legacy-rollback-unit-test-workdir", + false, ); - expect(stderr).toHaveBeenCalledTimes(1); - expect(stderr).toHaveBeenCalledWith("failed to list containers\n"); + expect(stderr).toHaveBeenCalledTimes(2); + expect(stderr).toHaveBeenNthCalledWith(1, "Stopping containers...\n"); + expect(stderr).toHaveBeenNthCalledWith(2, "failed to list containers\n"); }); }); }); diff --git a/apps/cli/src/legacy/commands/stop/stop.handler.ts b/apps/cli/src/legacy/commands/stop/stop.handler.ts index ff8e8e5bfc..7131e83023 100644 --- a/apps/cli/src/legacy/commands/stop/stop.handler.ts +++ b/apps/cli/src/legacy/commands/stop/stop.handler.ts @@ -5,6 +5,7 @@ import { Output } from "../../../shared/output/output.service.ts"; import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; import { LegacyTelemetryState } from "../../telemetry/legacy-telemetry-state.service.ts"; import { legacyAqua } from "../../shared/legacy-colors.ts"; +import { LegacyDebugFlag } from "../../../shared/legacy/global-flags.ts"; import { legacyCliProjectFilterValue } from "../../shared/legacy-docker-ids.ts"; import { legacyListVolumesByLabel, @@ -122,6 +123,9 @@ export const legacyStop = Effect.fn("legacy.stop")(function* (flags: LegacyStopF const telemetryState = yield* LegacyTelemetryState; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fs = yield* FileSystem.FileSystem; + // Threaded into `legacyDockerRemoveAll` below — Go's `--debug` gates that + // function's `Pruned …:` stderr reports (`docker.go:123-143`). + const debug = yield* LegacyDebugFlag; yield* Effect.gen(function* () { // Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go:231-250`) @@ -204,9 +208,15 @@ export const legacyStop = Effect.fn("legacy.stop")(function* (flags: LegacyStopF // `onContainersRemoved` has fired) — same pattern as // `storage/ls/ls.handler.ts`/`storage/rm/rm.handler.ts`. let removedContainers: ReadonlyArray = []; - yield* legacyDockerRemoveAll(spawner, filterValue, deleteVolumes, (containers) => { - removedContainers = containers; - }).pipe( + yield* legacyDockerRemoveAll( + spawner, + filterValue, + deleteVolumes, + (containers) => { + removedContainers = containers; + }, + debug, + ).pipe( Effect.catchTags({ LegacyDockerRemoveAllListError: (error) => Effect.fail(new LegacyStopListError({ message: error.message })), diff --git a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts index 04fe4fa074..865a9c932e 100644 --- a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts @@ -13,6 +13,7 @@ import { mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../tests/helpers/legacy-mocks.ts"; +import { LegacyDebugFlag } from "../../../shared/legacy/global-flags.ts"; import { legacyStop } from "./stop.handler.ts"; import type { LegacyStopFlags } from "./stop.command.ts"; @@ -184,6 +185,8 @@ interface SetupOpts { readonly skipConfig?: boolean; /** Defaults to `tempRoot.current` — override for `--workdir`-resolution tests. */ readonly workdir?: string; + /** `--debug` — gates `legacyDockerRemoveAll`'s `Pruned …:` stderr reports. */ + readonly debug?: boolean; } function setup(opts: SetupOpts = {}) { @@ -208,6 +211,7 @@ function setup(opts: SetupOpts = {}) { cliConfig, telemetry.layer, child.layer, + Layer.succeed(LegacyDebugFlag, opts.debug ?? false), ); return { workdir, out, telemetry, child, layer }; @@ -1255,4 +1259,56 @@ enabled = true expect(out.stderrText).not.toContain("Local data are backed up"); }).pipe(Effect.provide(layer)); }); + + // `legacyDockerRemoveAll`'s `--debug` prune reports (`legacy-docker-remove-all.ts`'s + // `reportPruned`) write straight to `process.stderr`, bypassing the mocked `Output` + // service entirely — a raw `vi.spyOn` on `process.stderr.write` is the only way to + // observe them, same boundary the file already spies at for `console.error` above. + const pruneReportRoutes = (args: ReadonlyArray): RouteResult => { + if (args[0] === "container" && args[1] === "prune") { + return { stdout: ["Deleted Containers:", "abc123", "", "Total reclaimed space: 42B"] }; + } + if (args[0] === "volume" && args[1] === "prune") { + return { stdout: ["vol1"] }; + } + if (args[0] === "network" && args[1] === "prune") { + return { stdout: ["Deleted Networks:", "net1"] }; + } + return defaultRoute()(args); + }; + + it.live("reports Go's --debug Pruned lines to stderr, in stage order", () => { + const { layer } = setup({ + debug: true, + configuredProjectId: "demo", + route: pruneReportRoutes, + }); + const writeSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + return Effect.gen(function* () { + yield* legacyStop(flags({ noBackup: true })); + const prunedWrites = writeSpy.mock.calls + .map((call) => call[0]) + .filter((chunk): chunk is string => typeof chunk === "string" && chunk.includes("Pruned")); + expect(prunedWrites).toEqual([ + "Pruned containers: [abc123]\n", + "Pruned volumes: [vol1]\n", + "Pruned network: [net1]\n", + ]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(() => writeSpy.mockRestore()))); + }); + + it.live("never writes Go's Pruned lines to stderr without --debug", () => { + const { layer } = setup({ + configuredProjectId: "demo", + route: pruneReportRoutes, + }); + const writeSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + return Effect.gen(function* () { + yield* legacyStop(flags({ noBackup: true })); + const prunedWrites = writeSpy.mock.calls.filter( + (call) => typeof call[0] === "string" && call[0].includes("Pruned"), + ); + expect(prunedWrites).toEqual([]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(() => writeSpy.mockRestore()))); + }); }); diff --git a/apps/cli/src/legacy/commands/stop/stop.live.test.ts b/apps/cli/src/legacy/commands/stop/stop.live.test.ts index 95452c78c7..a9b62c5050 100644 --- a/apps/cli/src/legacy/commands/stop/stop.live.test.ts +++ b/apps/cli/src/legacy/commands/stop/stop.live.test.ts @@ -6,6 +6,7 @@ import { promisify } from "node:util"; import { afterEach, expect, test } from "vitest"; import { describeLive, runSupabaseLive } from "../../../../tests/helpers/live.ts"; +import { legacySanitizeProjectId } from "../../shared/legacy-docker-ids.ts"; const execFileAsync = promisify(execFile); @@ -75,4 +76,59 @@ describeLive("supabase stop (live)", () => { expect(remaining.trim()).toBe(""); }, ); + + test( + "stop --no-backup --debug reports real pruned containers, volumes, and network", + { timeout: START_TIMEOUT_MS }, + async () => { + projectDir = await mkdtemp(path.join(tmpdir(), "sb-stop-live-")); + // Sanitizing is a no-op for a `mkdtemp`-generated basename (already + // alphanumeric/`-`), but mirrors the port's actual resolution rather + // than assuming that stays true (same note as `start.live.test.ts`). + projectId = legacySanitizeProjectId(path.basename(projectDir)); + + const init = await runSupabaseLive(["init"], { cwd: projectDir }); + expect(init.exitCode, `stdout:\n${init.stdout}\nstderr:\n${init.stderr}`).toBe(0); + + const start = await runSupabaseLive( + ["start", "--exclude", "studio", "--exclude", "analytics", "--exclude", "vector"], + { cwd: projectDir, exitTimeoutMs: START_TIMEOUT_MS }, + ); + expect(start.exitCode, `stdout:\n${start.stdout}\nstderr:\n${start.stderr}`).toBe(0); + + // `--no-backup` exercises the volume-prune branch; `--debug` turns on + // Go's `Pruned …:` stderr reports (`docker.go:123-143`), which are + // backed by parsing REAL `docker`/`podman` prune stdout — the format + // assumption (`Deleted …:` headers, `Total reclaimed space:` trailer) + // that mocked integration fixtures cannot validate by construction. + const stop = await runSupabaseLive(["stop", "--no-backup", "--debug"], { cwd: projectDir }); + expect(stop.exitCode, `stdout:\n${stop.stdout}\nstderr:\n${stop.stderr}`).toBe(0); + expect(stop.stdout).toContain("Stopped"); + + // Containers: real Docker reports full hex IDs — the list must be + // non-empty, since the started stack's containers were just removed. + expect(stop.stderr).toMatch(/^Pruned containers: \[[0-9a-f][^\]]*\]$/mu); + // Volumes: the db volume always exists (db is never excluded), so the + // report must name it. Other project volumes may also appear. + const volumesLine = stop.stderr + .split("\n") + .find((line) => line.startsWith("Pruned volumes: [")); + expect(volumesLine, `stderr:\n${stop.stderr}`).toContain(`supabase_db_${projectId}`); + // Network: exactly the project network; Go's label is singular + // "network" (`docker.go:143`), unlike the other two reports. + expect(stop.stderr).toContain(`Pruned network: [supabase_network_${projectId}]`); + + // The real Docker daemon must agree with the report: nothing carrying + // this project's label survives. + const { stdout: remaining } = await execFileAsync("docker", [ + "ps", + "-a", + "--filter", + `label=com.supabase.cli.project=${projectId}`, + "--format", + "{{.ID}}", + ]); + expect(remaining.trim()).toBe(""); + }, + ); }); diff --git a/apps/cli/src/legacy/commands/storage/cp/cp.command.integration.test.ts b/apps/cli/src/legacy/commands/storage/cp/cp.command.integration.test.ts new file mode 100644 index 0000000000..47ff9454b1 --- /dev/null +++ b/apps/cli/src/legacy/commands/storage/cp/cp.command.integration.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { CliOutput, Command } from "effect/unstable/cli"; + +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { textCliOutputFormatter } from "../../../../shared/output/text-formatter.ts"; +import { normalizeCause } from "../../../../shared/output/normalize-error.ts"; +import { LEGACY_GLOBAL_FLAGS } from "../../../../shared/legacy/global-flags.ts"; +import { + mockAnalytics, + mockOutput, + mockProcessControl, + mockRuntimeInfo, + mockTty, + processEnvLayer, +} from "../../../../../tests/helpers/mocks.ts"; +import { makeTelemetryIdentity } from "../../../../shared/telemetry/identity.ts"; +import { TelemetryRuntime } from "../../../../shared/telemetry/runtime.service.ts"; +import { legacyStorageCommand } from "../storage.command.ts"; + +// Go's `--jobs` is a pflag uint (`UintVarP`, `cmd/storage.go:107`): a negative +// value fails `strconv.ParseUint` at cobra flag-parse time — before the +// `--experimental` gate in `PersistentPreRunE` (`cmd/root.go:93-96`), before +// cobra's mutual-exclusivity check, and before RunE. `cp.command.ts` +// reproduces that ordering by rejecting inside the flag's own +// `Flag.mapTryCatch`, which Effect CLI runs while parsing the command tree — +// strictly ahead of the handler (where the experimental gate and the +// `--linked`/`--local` mutex check live). This suite proves the rejection is +// wired into the real command tree — not just reachable by calling +// `legacyStorageCp` directly with a handcrafted `Option.some(-1)` flags +// object, which `cp.integration.test.ts` cannot exercise since it calls the +// handler directly. +const testRoot = Command.make("supabase").pipe( + Command.withGlobalFlags(LEGACY_GLOBAL_FLAGS), + Command.withSubcommands([legacyStorageCommand]), +); + +function setup(args: ReadonlyArray) { + const out = mockOutput({ format: "text" }); + const layer = Layer.mergeAll( + BunServices.layer, + CliOutput.layer(textCliOutputFormatter()), + out.layer, + Layer.succeed(CliArgs, { args }), + // `legacyStorageGatewayRuntimeLayer`'s cliConfig/credentials layers read + // real env/files when built. The jobs check under test never reaches that + // lazy factory, but isolate ambient env defensively anyway. + processEnvLayer({ SUPABASE_NO_KEYRING: "1" }), + mockRuntimeInfo(), + mockProcessControl().layer, + mockTty({ stdinIsTty: false, stdoutIsTty: false }), + mockAnalytics().layer, + Layer.succeed( + TelemetryRuntime, + TelemetryRuntime.of({ + configDir: "/tmp/supabase-storage-cp-jobs-test/.supabase", + tracesDir: "/tmp/supabase-storage-cp-jobs-test/.supabase/traces", + consent: "granted", + showDebug: false, + deviceId: "test-device-id", + sessionId: "test-session-id", + identity: makeTelemetryIdentity(undefined), + isFirstRun: false, + isTty: false, + isCi: false, + os: "linux", + arch: "x64", + cliVersion: "0.1.0", + }), + ), + ); + return { layer }; +} + +describe("legacy storage cp --jobs negative rejection (command-tree wiring)", () => { + it.live( + "rejects --jobs=-1 with pflag's exact ParseUint message, ahead of the experimental gate and the --linked/--local mutex conflict", + () => { + // `--experimental` is deliberately ABSENT and `--linked`/`--local` are + // BOTH set: in Go, pflag's ParseUint failure preempts the experimental + // gate (`PersistentPreRunE`) and the mutex validation, so this must + // fail with the flag-parse error — not + // `LegacyExperimentalRequiredError`, and not + // `LegacyStorageMutuallyExclusiveFlagsError`. + const args = [ + "storage", + "cp", + "ss:///bucket/a", + "ss:///bucket/b", + "--jobs=-1", + "--linked", + "--local", + ]; + const { layer } = setup(args); + return Effect.gen(function* () { + const exit = yield* Effect.exit(Command.runWith(testRoot, { version: "0.0.0-test" })(args)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure)).toBe(true); + // The parse failure must never reach the handler: neither the + // experimental gate nor the mutex check may fire. + expect(JSON.stringify(exit.cause)).not.toContain( + "must set the --experimental flag to run this command", + ); + expect(JSON.stringify(exit.cause)).not.toContain("LegacyStorageMutuallyExclusiveFlags"); + // `normalizeCause` is the exact rendering path `runCli` uses for + // parse failures — the user-visible line must be pflag's message, + // byte-identical, with no `Invalid value for flag --jobs:` wrapper. + expect(normalizeCause(exit.cause).message).toBe( + 'invalid argument "-1" for "-j, --jobs" flag: strconv.ParseUint: parsing "-1": invalid syntax', + ); + } + }).pipe(Effect.provide(layer)); + }, + ); + + // Go validates the RAW token as unsigned (`strconv.ParseUint(s, 0, 64)`), so + // `-0` — which numeric normalization turns into negative zero, passing a + // `value < 0` check — is rejected, and the message keeps the original + // spelling (`-01`, not a normalized `-1`). Non-numeric tokens get the same + // byte-exact pflag message. All expected strings are go1.26 ground truth. + it.live.each([ + { + token: "-0", + message: + 'invalid argument "-0" for "-j, --jobs" flag: strconv.ParseUint: parsing "-0": invalid syntax', + }, + { + token: "-01", + message: + 'invalid argument "-01" for "-j, --jobs" flag: strconv.ParseUint: parsing "-01": invalid syntax', + }, + { + token: "abc", + message: + 'invalid argument "abc" for "-j, --jobs" flag: strconv.ParseUint: parsing "abc": invalid syntax', + }, + { + token: "3.5", + message: + 'invalid argument "3.5" for "-j, --jobs" flag: strconv.ParseUint: parsing "3.5": invalid syntax', + }, + { + token: "18446744073709551616", + message: + 'invalid argument "18446744073709551616" for "-j, --jobs" flag: strconv.ParseUint: parsing "18446744073709551616": value out of range', + }, + // pflag `%q`s the value and strconv's NumError `strconv.Quote`s `e.Num` + // (`strconv/number.go:258-260`), so escapable tokens stay one escaped + // line — never a raw quote/backslash/newline in stderr. + { + token: 'a"b', + message: + 'invalid argument "a\\"b" for "-j, --jobs" flag: strconv.ParseUint: parsing "a\\"b": invalid syntax', + }, + { + token: "a\\b", + message: + 'invalid argument "a\\\\b" for "-j, --jobs" flag: strconv.ParseUint: parsing "a\\\\b": invalid syntax', + }, + { + token: "1\n2", + message: + 'invalid argument "1\\n2" for "-j, --jobs" flag: strconv.ParseUint: parsing "1\\n2": invalid syntax', + }, + ])( + "rejects --jobs=$token at parse time with pflag's exact raw-token message", + ({ token, message }) => { + const args = ["storage", "cp", "ss:///bucket/a", "ss:///bucket/b", `--jobs=${token}`]; + const { layer } = setup(args); + return Effect.gen(function* () { + const exit = yield* Effect.exit(Command.runWith(testRoot, { version: "0.0.0-test" })(args)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).not.toContain( + "must set the --experimental flag to run this command", + ); + expect(normalizeCause(exit.cause).message).toBe(message); + } + }).pipe(Effect.provide(layer)); + }, + ); + + // Go's base-0 ParseUint ACCEPTS prefix/underscore forms (`0x10` → 16, + // `010` → octal 8, `1_0` → 10), so these must clear flag parsing and fail + // later at the experimental gate — proving the token was not rejected. + it.live.each([{ token: "0x10" }, { token: "010" }, { token: "1_0" }])( + "accepts --jobs=$token (Go base-0 form) through flag parsing, reaching the experimental gate", + ({ token }) => { + const args = ["storage", "cp", "ss:///bucket/a", "ss:///bucket/b", `--jobs=${token}`]; + const { layer } = setup(args); + return Effect.gen(function* () { + const exit = yield* Effect.exit(Command.runWith(testRoot, { version: "0.0.0-test" })(args)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "must set the --experimental flag to run this command", + ); + } + }).pipe(Effect.provide(layer)); + }, + ); + + // Go parses flags before validating `ExactArgs(2)` (`cmd/storage.go:63,107`), + // so a malformed `--jobs` wins even when `src`/`dst` are missing. The config + // record declares flags ahead of the positionals to reproduce that order — + // Effect CLI parses params in config-declaration order. + it.live.each([ + { label: "zero positionals", args: ["storage", "cp", "--jobs=-1"] }, + { label: "one positional", args: ["storage", "cp", "onearg", "--jobs=-1"] }, + ])("rejects --jobs=-1 ahead of missing operands ($label)", ({ args }) => { + const { layer } = setup(args); + return Effect.gen(function* () { + const exit = yield* Effect.exit(Command.runWith(testRoot, { version: "0.0.0-test" })(args)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).not.toContain("MissingArgument"); + expect(normalizeCause(exit.cause).message).toBe( + 'invalid argument "-1" for "-j, --jobs" flag: strconv.ParseUint: parsing "-1": invalid syntax', + ); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("still reports the missing operand when --jobs is valid", () => { + const args = ["storage", "cp", "--jobs=2"]; + const { layer } = setup(args); + return Effect.gen(function* () { + const exit = yield* Effect.exit(Command.runWith(testRoot, { version: "0.0.0-test" })(args)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(normalizeCause(exit.cause).message).toBe("Missing required argument: src"); + } + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/storage/cp/cp.command.ts b/apps/cli/src/legacy/commands/storage/cp/cp.command.ts index d16c06060a..e1befdbf56 100644 --- a/apps/cli/src/legacy/commands/storage/cp/cp.command.ts +++ b/apps/cli/src/legacy/commands/storage/cp/cp.command.ts @@ -7,6 +7,8 @@ import { withJsonErrorHandling } from "../../../../shared/output/json-error-hand import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyRequireExperimental } from "../../../shared/legacy-experimental-gate.ts"; import { legacyStorageGatewayRuntimeLayer } from "../../../shared/legacy-storage-runtime.layer.ts"; +import { legacyStorageInvalidJobsMessage } from "../storage.errors.ts"; +import { legacyParseUintBase0 } from "./cp.parse-uint.ts"; import { LegacyStorageLinkedFlagDef, LegacyStorageLocalFlagDef, @@ -21,9 +23,15 @@ import { legacyStorageCp } from "./cp.handler.ts"; // keeps its empty runtime default (`""` ⇒ auto-detect via sniffing), but Go // overrides only the *displayed* default to `auto-detect` (`storage.go:106`), so // the help text — not the resolved value — reads `auto-detect`. +// Flags are declared BEFORE the `src`/`dst` positionals on purpose: Effect CLI +// parses params in config-declaration order (`parseParams` walks +// `orderedParams`, built from record-key order), so this mirrors cobra running +// `ParseFlags` before `ExactArgs(2)` validation (`cmd/storage.go:63,107`) — a +// malformed `--jobs` must win over a missing operand, exactly as in Go. +// Positional mapping is unaffected (only the relative order of Arguments +// matters), and `--help` renders flags in relative declaration order, so the +// help output is unchanged. const config = { - src: Argument.string("src").pipe(Argument.withDescription("Source path to copy from.")), - dst: Argument.string("dst").pipe(Argument.withDescription("Destination path to copy to.")), recursive: Flag.boolean("recursive").pipe( Flag.withAlias("r"), Flag.withDescription("Recursively copy a directory."), @@ -36,13 +44,44 @@ const config = { Flag.withDescription('Custom Content-Type header for HTTP upload. (default "auto-detect")'), Flag.optional, ), - jobs: Flag.integer("jobs").pipe( + jobs: Flag.string("jobs").pipe( Flag.withAlias("j"), + // Keep the help token `--jobs, -j integer` that `Flag.integer` rendered — + // the flag is a string only so the RAW token reaches the parser below. + Flag.withMetavar("integer"), Flag.withDescription("Maximum number of parallel jobs. (default 1)"), + // Go's `--jobs` is a pflag uint (`UintVarP`, `cmd/storage.go:107`), so a + // non-uint token fails `strconv.ParseUint(s, 0, 64)` at flag-parse time — + // before cobra's group validation, the experimental gate in + // `PersistentPreRunE` (`cmd/root.go:93-96`), and RunE, and without + // emitting telemetry. The raw token is parsed with `legacyParseUintBase0` + // (an exact ParseUint port) rather than `Flag.integer`, because numeric + // normalization loses parity: `-0` normalizes to negative zero (which a + // `value < 0` check accepts, where Go rejects every sign prefix), error + // messages must carry the original spelling (`-01`, not `-1`), and Go's + // base-0 forms (`0x10` → 16, `010` → 8, `1_0` → 10) must keep parsing. + // The resulting `CliError.InvalidValue` carries pflag's complete message, + // which `formatInvalidValueMessage` surfaces verbatim. Must sit before + // `Flag.optional`, which passes `InvalidValue` failures through + // untouched. Because flags are declared ahead of the positionals (see the + // config comment above), this error also wins when `src`/`dst` are + // missing, matching Go's flags-before-args order. + Flag.mapTryCatch( + (token) => { + const parsed = legacyParseUintBase0(token); + if ("cause" in parsed) { + throw new Error(legacyStorageInvalidJobsMessage(token, parsed.cause)); + } + return parsed.value; + }, + (err) => (err instanceof Error ? err.message : String(err)), + ), Flag.optional, ), linked: LegacyStorageLinkedFlagDef, local: LegacyStorageLocalFlagDef, + src: Argument.string("src").pipe(Argument.withDescription("Source path to copy from.")), + dst: Argument.string("dst").pipe(Argument.withDescription("Destination path to copy to.")), } as const; export type LegacyStorageCpFlags = CliCommand.Command.Config.Infer; @@ -67,7 +106,9 @@ export const legacyStorageCpCommand = Command.make("cp", config).pipe( Command.withHandler((flags) => Effect.gen(function* () { // Gate before the mutex check below — order matters; see - // legacyRequireExperimental's doc comment for why. + // legacyRequireExperimental's doc comment for why. (A non-uint `--jobs` + // never reaches this handler: the flag's own `Flag.mapTryCatch` rejects + // it at parse time, matching Go's pflag-before-PersistentPreRunE order.) yield* legacyRequireExperimental; const cliArgs = yield* CliArgs; yield* legacyAssertStorageTargetsExclusive(cliArgs.args); diff --git a/apps/cli/src/legacy/commands/storage/cp/cp.handler.ts b/apps/cli/src/legacy/commands/storage/cp/cp.handler.ts index 7c4764e294..6793d17ad9 100644 --- a/apps/cli/src/legacy/commands/storage/cp/cp.handler.ts +++ b/apps/cli/src/legacy/commands/storage/cp/cp.handler.ts @@ -67,10 +67,12 @@ export const legacyStorageCp = Effect.fn("legacy.storage.cp")(function* ( const runtimeInfo = yield* RuntimeInfo; const jobsFlag = Option.getOrElse(flags.jobs, () => 1); - // Intentional deviation from Go: `--jobs` is a uint there, so `--jobs 0` is - // accepted and reaches NewJobQueue(0) (apps/cli-go/pkg/queue/queue.go), whose - // unbuffered channel + zero-run priming loop deadlocks the first Put. We clamp - // `< 1 → 1` to avoid that hang — do not "restore parity" by removing it. + // A non-uint `--jobs` is already rejected in `cp.command.ts` with pflag's + // uint parse error (Go: `UintVarP`, `cmd/storage.go:107`). The remaining clamp is + // an intentional deviation from Go for `--jobs 0` only: Go accepts 0 and + // reaches NewJobQueue(0) (apps/cli-go/pkg/queue/queue.go), whose unbuffered + // channel + zero-run priming loop deadlocks the first Put. We clamp `0 → 1` + // to avoid that hang — do not "restore parity" by removing it. const jobs = jobsFlag < 1 ? 1 : jobsFlag; const contentTypeFlag = Option.getOrElse(flags.contentType, () => ""); const cacheControlRaw = Option.getOrElse(flags.cacheControl, () => "max-age=3600"); diff --git a/apps/cli/src/legacy/commands/storage/cp/cp.parse-uint.ts b/apps/cli/src/legacy/commands/storage/cp/cp.parse-uint.ts new file mode 100644 index 0000000000..b56e96622f --- /dev/null +++ b/apps/cli/src/legacy/commands/storage/cp/cp.parse-uint.ts @@ -0,0 +1,119 @@ +/** + * Faithful port of Go's `strconv.ParseUint(s, 0, 64)` — the exact parser pflag + * runs for a `UintVarP` flag like `storage cp --jobs` (`uintValue.Set`, + * `pflag/uint.go`). Operating on the RAW flag token (instead of a + * pre-normalized number) is load-bearing for parity: + * + * - every sign prefix is rejected, including `-0` and `+1` (a numeric + * normalization turns `-0` into negative zero, for which `value < 0` is + * false, silently accepting what Go rejects); + * - error messages carry the ORIGINAL spelling (`-01`, not `-1`); + * - base 0 enables Go's prefix/underscore forms: `0x10` → 16, `0o10`/`010` → + * 8 (octal!), `0b10` → 2, and `1_0` → 10 — all of which Go accepts. + * + * All verdicts below are verified against go1.26 (`strconv.ParseUint(s, 0, 64)`): + * `-0`/`-01`/`+1`/`3.5`/`abc`/`09`/`0x`/`_1`/`1_`/`1__0`/` 1` → invalid + * syntax; `0x_10` → 16; `18446744073709551616` → value out of range. + * + * Go iterates bytes where this iterates UTF-16 code units, but every non-ASCII + * unit (and every byte of a multibyte rune) falls outside the digit/letter + * ranges in both, so the verdict is identical. + * + * Known residual: values above 2^53 lose precision in the `Number` conversion + * (Go carries the exact uint64). They still PARSE identically; only the + * resulting parallel-job count differs, in territory where Go's own behavior + * (an `int` conversion of a near-2^64 uint) is already degenerate. + */ + +const MAX_UINT64 = (1n << 64n) - 1n; + +export type LegacyParseUintResult = + | { readonly value: number } + | { readonly cause: "invalid syntax" | "value out of range" }; + +export function legacyParseUintBase0(token: string): LegacyParseUintResult { + if (token.length === 0) return { cause: "invalid syntax" }; + + // Base detection for base 0 (`strconv/atoi.go`): `0x`/`0b`/`0o` prefixes + // (only when at least one more character follows), else a leading `0` means + // octal, else decimal. There is NO sign handling: `-`/`+` fall through to + // the digit loop below and fail as non-digits, exactly like Go. + let s = token; + let base = 10n; + if (s[0] === "0") { + const marker = s.length >= 3 ? s[1]?.toLowerCase() : undefined; + if (marker === "b") { + base = 2n; + s = s.slice(2); + } else if (marker === "o") { + base = 8n; + s = s.slice(2); + } else if (marker === "x") { + base = 16n; + s = s.slice(2); + } else { + base = 8n; + s = s.slice(1); + } + } + + let sawUnderscore = false; + let n = 0n; + for (let i = 0; i < s.length; i++) { + const code = s.charCodeAt(i); + let digit: bigint; + if (code === 0x5f /* _ */) { + // Only base 0 admits underscores; position rules are checked at the end. + sawUnderscore = true; + continue; + } else if (code >= 0x30 && code <= 0x39) { + digit = BigInt(code - 0x30); + } else { + const lower = code | 0x20; + if (lower >= 0x61 && lower <= 0x7a) digit = BigInt(lower - 0x61 + 10); + else return { cause: "invalid syntax" }; + } + if (digit >= base) return { cause: "invalid syntax" }; + n = n * base + digit; + if (n > MAX_UINT64) return { cause: "value out of range" }; + } + if (sawUnderscore && !underscoreOk(token)) return { cause: "invalid syntax" }; + return { value: Number(n) }; +} + +/** + * Go's `underscoreOK` (`strconv/atoi.go`): underscores must sit between + * digits, or between the base prefix and the first digit (`0x_10` is valid). + * The sign skip is unreachable through `legacyParseUintBase0` (a sign already + * fails the digit loop) but is kept for fidelity to the Go source. + */ +function underscoreOk(token: string): boolean { + // `saw` tracks the class of the previous character: `^` start-of-number, + // `0` digit-or-prefix, `_` underscore, `!` anything else. + let saw = "^"; + let s = token; + if (s.length >= 1 && (s[0] === "-" || s[0] === "+")) s = s.slice(1); + let i = 0; + let hex = false; + const marker = s[1]?.toLowerCase(); + if (s.length >= 2 && s[0] === "0" && (marker === "b" || marker === "o" || marker === "x")) { + i = 2; + saw = "0"; // the base prefix counts as a digit for separator purposes + hex = marker === "x"; + } + for (; i < s.length; i++) { + const c = s[i] as string; + if ((c >= "0" && c <= "9") || (hex && c.toLowerCase() >= "a" && c.toLowerCase() <= "f")) { + saw = "0"; + continue; + } + if (c === "_") { + if (saw !== "0") return false; + saw = "_"; + continue; + } + if (saw === "_") return false; + saw = "!"; + } + return saw !== "_"; +} diff --git a/apps/cli/src/legacy/commands/storage/cp/cp.parse-uint.unit.test.ts b/apps/cli/src/legacy/commands/storage/cp/cp.parse-uint.unit.test.ts new file mode 100644 index 0000000000..1133857f3a --- /dev/null +++ b/apps/cli/src/legacy/commands/storage/cp/cp.parse-uint.unit.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; + +import { legacyParseUintBase0 } from "./cp.parse-uint.ts"; + +// Every expectation in this file is ground truth captured from go1.26: +// `strconv.ParseUint(s, 0, 64)` — the exact call pflag makes for a `UintVarP` +// flag (`uintValue.Set`, `pflag/uint.go`). +describe("legacyParseUintBase0 (Go strconv.ParseUint(s, 0, 64) parity)", () => { + it("parses plain decimal", () => { + expect(legacyParseUintBase0("0")).toEqual({ value: 0 }); + expect(legacyParseUintBase0("1")).toEqual({ value: 1 }); + expect(legacyParseUintBase0("42")).toEqual({ value: 42 }); + }); + + it("rejects every sign prefix — including -0, whose numeric normalization (negative zero) passes a `value < 0` check", () => { + expect(legacyParseUintBase0("-0")).toEqual({ cause: "invalid syntax" }); + expect(legacyParseUintBase0("-01")).toEqual({ cause: "invalid syntax" }); + expect(legacyParseUintBase0("-1")).toEqual({ cause: "invalid syntax" }); + expect(legacyParseUintBase0("+1")).toEqual({ cause: "invalid syntax" }); + }); + + it("parses Go's base-0 prefix forms: hex, octal (bare leading zero!), binary", () => { + expect(legacyParseUintBase0("0x10")).toEqual({ value: 16 }); + expect(legacyParseUintBase0("0X10")).toEqual({ value: 16 }); + expect(legacyParseUintBase0("0o10")).toEqual({ value: 8 }); + expect(legacyParseUintBase0("010")).toEqual({ value: 8 }); + expect(legacyParseUintBase0("00")).toEqual({ value: 0 }); + expect(legacyParseUintBase0("0b10")).toEqual({ value: 2 }); + }); + + it("rejects out-of-base digits (09 is an octal syntax error) and bare prefixes", () => { + expect(legacyParseUintBase0("09")).toEqual({ cause: "invalid syntax" }); + expect(legacyParseUintBase0("0x")).toEqual({ cause: "invalid syntax" }); + expect(legacyParseUintBase0("0xg")).toEqual({ cause: "invalid syntax" }); + }); + + it("accepts underscores between digits or after a base prefix, rejecting misplaced ones", () => { + expect(legacyParseUintBase0("1_0")).toEqual({ value: 10 }); + expect(legacyParseUintBase0("0x_10")).toEqual({ value: 16 }); + expect(legacyParseUintBase0("_1")).toEqual({ cause: "invalid syntax" }); + expect(legacyParseUintBase0("1_")).toEqual({ cause: "invalid syntax" }); + expect(legacyParseUintBase0("1__0")).toEqual({ cause: "invalid syntax" }); + expect(legacyParseUintBase0("0x_")).toEqual({ cause: "invalid syntax" }); + }); + + it("rejects non-numeric junk: floats, words, whitespace, empty, non-ASCII digits", () => { + expect(legacyParseUintBase0("3.5")).toEqual({ cause: "invalid syntax" }); + expect(legacyParseUintBase0("abc")).toEqual({ cause: "invalid syntax" }); + expect(legacyParseUintBase0(" 1")).toEqual({ cause: "invalid syntax" }); + expect(legacyParseUintBase0("1 ")).toEqual({ cause: "invalid syntax" }); + expect(legacyParseUintBase0("")).toEqual({ cause: "invalid syntax" }); + expect(legacyParseUintBase0("0")).toEqual({ cause: "invalid syntax" }); // fullwidth 0 + }); + + it("reports uint64 overflow as `value out of range`, accepting max uint64", () => { + expect(legacyParseUintBase0("18446744073709551616")).toEqual({ cause: "value out of range" }); + expect(legacyParseUintBase0("0x10000000000000000")).toEqual({ cause: "value out of range" }); + // Max uint64 parses (the Number conversion is lossy up there — documented + // residual in cp.parse-uint.ts — but the accept/reject verdict matches Go). + expect(legacyParseUintBase0("18446744073709551615")).toEqual({ + value: Number(18446744073709551615n), + }); + }); +}); diff --git a/apps/cli/src/legacy/commands/storage/storage.errors.ts b/apps/cli/src/legacy/commands/storage/storage.errors.ts index af87bd9059..708d254347 100644 --- a/apps/cli/src/legacy/commands/storage/storage.errors.ts +++ b/apps/cli/src/legacy/commands/storage/storage.errors.ts @@ -1,6 +1,7 @@ import { Data } from "effect"; import { legacyAqua } from "../../shared/legacy-colors.ts"; +import { legacyGoQuote } from "../../shared/legacy-go-quote.ts"; /** * Domain errors for `supabase storage ls/cp/mv/rm`, mirroring the Go error paths @@ -51,6 +52,27 @@ export class LegacyStorageUnsupportedOperationError extends Data.TaggedError( } } +/** + * `cp`'s `--jobs` is a pflag uint (`UintVarP`, `cmd/storage.go:107`): a + * non-uint token fails `strconv.ParseUint(s, 0, 64)` at flag-parse time. + * Byte-matches pflag's `invalid argument %q for %q flag: %v` template with + * the shorthand-prefixed flag name (`pflag/errors.go:108-116`), carrying the + * RAW token (so `--jobs=-01` reports `"-01"`, not a normalized `"-1"`) and + * strconv's cause (`invalid syntax` / `value out of range`). Both token + * occurrences are `%q`-quoted like Go's — pflag applies `%q` to the value + * and strconv's `NumError.Error()` wraps `e.Num` in `strconv.Quote` + * (`strconv/number.go:258-260`) — so an escapable token stays one escaped + * line (go1.26: `--jobs 'a"b'` → `… "a\"b" …`, not a raw quote/newline). + * Thrown from the flag's own `Flag.mapTryCatch` in `cp.command.ts` so the + * rejection happens during command parsing, like Go's — + * `formatInvalidValueMessage` surfaces the resulting + * `CliError.InvalidValue`'s message verbatim. + */ +export function legacyStorageInvalidJobsMessage(token: string, cause: string): string { + const quoted = legacyGoQuote(new TextEncoder().encode(token)); + return `invalid argument ${quoted} for "-j, --jobs" flag: strconv.ParseUint: parsing ${quoted}: ${cause}`; +} + /** `cp`'s remote→remote branch (`internal/storage/cp/cp.go:57`). */ export class LegacyStorageCopyBetweenBucketsError extends Data.TaggedError( "LegacyStorageCopyBetweenBucketsError", diff --git a/apps/cli/src/legacy/commands/test/new/new.handler.ts b/apps/cli/src/legacy/commands/test/new/new.handler.ts index 162da56eb3..9c1c471bf6 100644 --- a/apps/cli/src/legacy/commands/test/new/new.handler.ts +++ b/apps/cli/src/legacy/commands/test/new/new.handler.ts @@ -35,15 +35,17 @@ export const legacyTestNew = Effect.fn("legacy.test.new")(function* (flags: Lega ); } + // Go's `utils.WriteFile` pins the dir to 0755 and the test file to 0644 + // (`internal/test/new/new.go:28`, `internal/utils/misc.go:281,284`). yield* fs - .makeDirectory(path.dirname(target), { recursive: true }) + .makeDirectory(path.dirname(target), { recursive: true, mode: 0o755 }) .pipe( Effect.mapError( (cause) => new LegacyTestNewWriteError({ path: relPath, message: String(cause) }), ), ); yield* fs - .writeFileString(target, TEMPLATE_CONTENT[template]) + .writeFileString(target, TEMPLATE_CONTENT[template], { mode: 0o644 }) .pipe( Effect.mapError( (cause) => new LegacyTestNewWriteError({ path: relPath, message: String(cause) }), diff --git a/apps/cli/src/legacy/commands/test/new/new.integration.test.ts b/apps/cli/src/legacy/commands/test/new/new.integration.test.ts index e0605728ee..7ffdeb0c7b 100644 --- a/apps/cli/src/legacy/commands/test/new/new.integration.test.ts +++ b/apps/cli/src/legacy/commands/test/new/new.integration.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; @@ -81,6 +81,16 @@ describe("legacy test new integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("pins the created test file to Go's exact 0644 mode under a permissive umask", () => { + const { layer, workdir } = setup(); + const prevUmask = process.umask(0); + return Effect.gen(function* () { + yield* legacyTestNew(flags("modepin")); + const target = join(workdir, "supabase", "tests", "modepin_test.sql"); + expect(statSync(target).mode & 0o777).toBe(0o644); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(() => process.umask(prevUmask)))); + }); + it.live("defaults the template to pgtap when --template is omitted", () => { const { layer, workdir } = setup(); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/shared/legacy-colors.ts b/apps/cli/src/legacy/shared/legacy-colors.ts index 25d32d7c42..7cdd3587b4 100644 --- a/apps/cli/src/legacy/shared/legacy-colors.ts +++ b/apps/cli/src/legacy/shared/legacy-colors.ts @@ -1,12 +1,55 @@ import { styleText } from "node:util"; +/** + * Structural subset of a write stream that the colour gate inspects. Both + * `process.stdout`/`process.stderr` and minimal test fakes satisfy it. + * Under Bun, piped standard streams are plain `Writable`s without + * `hasColors`, which is itself a correct "no colour" signal. + */ +export interface LegacyColorStream { + readonly hasColors?: (() => boolean) | undefined; +} + +/** + * Port of termenv's colour-profile gate, which is what Go's lipgloss default + * renderer consults (`lipgloss/renderer.go` → `termenv.EnvColorProfile`, + * `termenv@v0.16.0` `termenv.go:68-115`): + * + * 1. `NO_COLOR` non-empty → no colour, beats everything (`EnvNoColor`). + * 2. `CLICOLOR=0` → no colour, unless forced (`EnvNoColor`). + * 3. `CLICOLOR_FORCE` set and not `"0"` → colour even when piped (the + * Ascii→ANSI promotion, `termenv.go:104-106`). + * 4. `CI` non-empty → treated as non-TTY (`termenv.go:31-33`). + * 5. Otherwise: the stream must be a colour-capable TTY. `hasColors()` is + * faithful on Bun TTYs (it also covers `TERM=dumb`) and absent on piped + * streams. + * + * termenv does NOT honor Node's `FORCE_COLOR` — only the `CLICOLOR*` pair — + * so neither does this gate. + */ +function legacySupportsColor(stream: LegacyColorStream): boolean { + const env = process.env; + if ((env["NO_COLOR"] ?? "") !== "") return false; + const clicolorForce = env["CLICOLOR_FORCE"] ?? ""; + const forced = clicolorForce !== "" && clicolorForce !== "0"; + if (env["CLICOLOR"] === "0" && !forced) return false; + if (forced) return true; + if ((env["CI"] ?? "") !== "") return false; + return typeof stream.hasColors === "function" && stream.hasColors(); +} + /** * Ports of Go's `utils.Aqua` / `utils.Bold` (`apps/cli-go/internal/utils/colors.go`). * * Go uses lipgloss, which auto-detects the output profile and renders **plain** - * text when the stream is not a TTY (piped output, CI, tests). `styleText` - * mirrors that: with `validateStream` (the default) it checks the target stream - * and `NO_COLOR`, returning the unstyled string when colour is unsupported. + * text when the stream is not a TTY (piped output, CI, tests). Node's + * `styleText` would mirror that via `validateStream`, but Bun (1.3.14, the + * only runtime the CLI ships on) does not implement `validateStream`: it + * styles unconditionally, even when the stream is piped and even under + * `NO_COLOR=1`. The gate is therefore implemented here — see + * {@link legacySupportsColor} — and `validateStream: false` is passed + * explicitly so that our gate stays authoritative even if a future Bun starts + * validating. * * `stream` defaults to `process.stderr` because every original call site styles * progress/suggestion lines written to stderr. A caller styling content that is @@ -19,25 +62,25 @@ import { styleText } from "node:util"; * lipgloss colour "14" is bright cyan; `"cyan"` is the closest faithful match, * matching `branches.prompt.ts`'s existing port of `utils.Aqua`. */ -export function legacyAqua(text: string, stream: NodeJS.WriteStream = process.stderr): string { - return styleText("cyan", text, { stream }); +export function legacyAqua(text: string, stream: LegacyColorStream = process.stderr): string { + return legacySupportsColor(stream) ? styleText("cyan", text, { validateStream: false }) : text; } -export function legacyBold(text: string, stream: NodeJS.WriteStream = process.stderr): string { - return styleText("bold", text, { stream }); +export function legacyBold(text: string, stream: LegacyColorStream = process.stderr): string { + return legacySupportsColor(stream) ? styleText("bold", text, { validateStream: false }) : text; } /** Port of Go's `utils.Yellow` — lipgloss colour "11" (bright yellow). */ -export function legacyYellow(text: string, stream: NodeJS.WriteStream = process.stderr): string { - return styleText("yellow", text, { stream }); +export function legacyYellow(text: string, stream: LegacyColorStream = process.stderr): string { + return legacySupportsColor(stream) ? styleText("yellow", text, { validateStream: false }) : text; } /** Port of Go's `utils.Red` — lipgloss colour "9" (bright red). */ -export function legacyRed(text: string, stream: NodeJS.WriteStream = process.stderr): string { - return styleText("red", text, { stream }); +export function legacyRed(text: string, stream: LegacyColorStream = process.stderr): string { + return legacySupportsColor(stream) ? styleText("red", text, { validateStream: false }) : text; } /** Port of Go's `utils.Green` — lipgloss colour "10" (bright green). */ -export function legacyGreen(text: string, stream: NodeJS.WriteStream = process.stderr): string { - return styleText("green", text, { stream }); +export function legacyGreen(text: string, stream: LegacyColorStream = process.stderr): string { + return legacySupportsColor(stream) ? styleText("green", text, { validateStream: false }) : text; } diff --git a/apps/cli/src/legacy/shared/legacy-colors.unit.test.ts b/apps/cli/src/legacy/shared/legacy-colors.unit.test.ts index e1f5e7873b..46533ee12a 100644 --- a/apps/cli/src/legacy/shared/legacy-colors.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-colors.unit.test.ts @@ -1,50 +1,87 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { LegacyColorStream } from "./legacy-colors.ts"; import { legacyAqua, legacyBold, legacyGreen, legacyRed, legacyYellow } from "./legacy-colors.ts"; -// These tests only assert that each helper runs without throwing and returns a -// string containing the input text — actual color application depends on the -// stream's live TTY/NO_COLOR state, which isn't controllable from a test -// process. The behavior worth protecting here is the `stream` parameter -// threading through to `styleText`, not a specific ANSI byte sequence. -describe("legacy-colors", () => { - it("legacyAqua defaults to stderr when no stream is given", () => { - expect(legacyAqua("supabase")).toContain("supabase"); - }); +// Bun's `util.styleText` ignores `validateStream` (verified on Bun 1.3.14: a +// piped stdout still gets `\x1b[36m…\x1b[39m`, even under NO_COLOR=1), so +// `legacy-colors.ts` implements termenv's gate itself — the same decision +// order Go's lipgloss default renderer uses (`termenv@v0.16.0` +// `termenv.go:68-115`). These tests pin that gate deterministically with fake +// streams and stubbed env vars; a piped stream (no `hasColors`) must yield +// PLAIN text, exactly like Go's `utils.Aqua` under `go test`'s piped stdout. +const colorTty: LegacyColorStream = { hasColors: () => true }; +const monoTty: LegacyColorStream = { hasColors: () => false }; +const piped: LegacyColorStream = {}; + +beforeEach(() => { + // Neutralize the ambient environment (CI sets `CI`, developers may set + // NO_COLOR) so each case controls the gate's inputs exactly. Empty string + // reads as unset for every variable termenv consults. + vi.stubEnv("NO_COLOR", ""); + vi.stubEnv("CLICOLOR", ""); + vi.stubEnv("CLICOLOR_FORCE", ""); + vi.stubEnv("CI", ""); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); - it("legacyAqua accepts an explicit stream", () => { - expect(legacyAqua("supabase", process.stdout)).toContain("supabase"); +describe("legacy-colors TTY gating (termenv parity)", () => { + it("styles on a colour-capable TTY", () => { + expect(legacyAqua("supabase", colorTty)).toBe("\u001b[36msupabase\u001b[39m"); + expect(legacyBold("text", colorTty)).toBe("\u001b[1mtext\u001b[22m"); + expect(legacyYellow("warning", colorTty)).toBe("\u001b[33mwarning\u001b[39m"); + expect(legacyRed("error", colorTty)).toBe("\u001b[31merror\u001b[39m"); + expect(legacyGreen("label", colorTty)).toBe("\u001b[32mlabel\u001b[39m"); }); - it("legacyBold defaults to stderr when no stream is given", () => { - expect(legacyBold("text")).toContain("text"); + it("renders plain on a piped stream (no hasColors), like lipgloss's Ascii profile", () => { + expect(legacyAqua("supabase", piped)).toBe("supabase"); + expect(legacyBold("text", piped)).toBe("text"); + expect(legacyYellow("warning", piped)).toBe("warning"); + expect(legacyRed("error", piped)).toBe("error"); + expect(legacyGreen("label", piped)).toBe("label"); }); - it("legacyBold accepts an explicit stream", () => { - expect(legacyBold("text", process.stdout)).toContain("text"); + it("renders plain on a TTY that reports no colour support (e.g. TERM=dumb)", () => { + expect(legacyAqua("supabase", monoTty)).toBe("supabase"); }); - it("legacyYellow defaults to stderr when no stream is given", () => { - expect(legacyYellow("warning")).toContain("warning"); + it("NO_COLOR beats everything, including CLICOLOR_FORCE (termenv EnvNoColor)", () => { + vi.stubEnv("NO_COLOR", "1"); + vi.stubEnv("CLICOLOR_FORCE", "1"); + expect(legacyAqua("supabase", colorTty)).toBe("supabase"); }); - it("legacyYellow accepts an explicit stream", () => { - expect(legacyYellow("warning", process.stdout)).toContain("warning"); + it("CLICOLOR=0 disables colour on a capable TTY", () => { + vi.stubEnv("CLICOLOR", "0"); + expect(legacyAqua("supabase", colorTty)).toBe("supabase"); }); - it("legacyRed defaults to stderr when no stream is given", () => { - expect(legacyRed("error")).toContain("error"); + it("CLICOLOR_FORCE forces colour even when piped, and overrides CLICOLOR=0", () => { + vi.stubEnv("CLICOLOR", "0"); + vi.stubEnv("CLICOLOR_FORCE", "1"); + expect(legacyAqua("supabase", piped)).toBe("\u001b[36msupabase\u001b[39m"); }); - it("legacyRed accepts an explicit stream", () => { - expect(legacyRed("error", process.stdout)).toContain("error"); + it("CLICOLOR_FORCE=0 does not force", () => { + vi.stubEnv("CLICOLOR_FORCE", "0"); + expect(legacyAqua("supabase", piped)).toBe("supabase"); }); - it("legacyGreen defaults to stderr when no stream is given", () => { - expect(legacyGreen("label")).toContain("label"); + it("CI is treated as non-TTY (termenv isTTY)", () => { + vi.stubEnv("CI", "true"); + expect(legacyAqua("supabase", colorTty)).toBe("supabase"); }); - it("legacyGreen accepts an explicit stream", () => { - expect(legacyGreen("label", process.stdout)).toContain("label"); + it("defaults to gating on stderr when no stream is given", () => { + // The live TTY-ness of the test process's stderr is environment-dependent, + // so pin the gate closed via the CI branch: the default-stream form must + // still come back plain, proving the default threads through the gate. + vi.stubEnv("CI", "true"); + expect(legacyAqua("supabase")).toBe("supabase"); + expect(legacyBold("text")).toBe("text"); }); }); diff --git a/apps/cli/src/legacy/shared/legacy-container-cli.ts b/apps/cli/src/legacy/shared/legacy-container-cli.ts index 07b10dae3b..0269a84c9c 100644 --- a/apps/cli/src/legacy/shared/legacy-container-cli.ts +++ b/apps/cli/src/legacy/shared/legacy-container-cli.ts @@ -131,6 +131,52 @@ function collectDockerCliText(stream: Stream.Stream) { ).pipe(Effect.map((text) => text + decoder.decode())); } +/** + * Like {@link containerCliExitCode}, but also collecting the child's stdout — + * for callers that need the CLI's own report of what it did (e.g. the `docker + * … prune` deleted-ID lists backing Go's `--debug` "Pruned …" reports in + * `DockerRemoveAll`, `docker.go:123-143`). Collecting (i.e. reading) stdout + * also sidesteps the unread-pipe hang that `stdout: "ignore"` callers avoid by + * discarding it. stderr is discarded, matching the exit-code-only helper. + * `podmanArgs` has the same meaning as on {@link containerCliExitCode}. + */ +export const legacyContainerCliExitCodeAndStdout = ( + spawner: Spawner, + args: ReadonlyArray, + podmanArgs?: ReadonlyArray, +) => + Effect.scoped( + Effect.gen(function* () { + const options = { + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + } satisfies ChildProcess.CommandOptions; + const handle = yield* spawner.spawn(ChildProcess.make("docker", args, options)).pipe( + Effect.catch(() => + spawner.spawn(ChildProcess.make("podman", podmanArgs ?? args, options)).pipe( + Effect.catch(() => + Effect.fail( + new LegacyContainerRuntimeNotFoundError({ + message: legacyContainerRuntimeNotFoundMessage, + }), + ), + ), + ), + ), + ); + // Subscribe to stdout concurrently with awaiting the exit code — Node's + // "exit" event can fire before a fast process's stdio pipes are drained, + // so a late subscriber would see an already-ended, empty stream (same + // pattern as `legacy-docker-lifecycle.ts`'s `spawnDockerPsLines`). + const [exitCode, stdout] = yield* Effect.all( + [handle.exitCode.pipe(Effect.map(Number)), collectDockerCliText(handle.stdout)], + { concurrency: "unbounded" }, + ); + return { exitCode, stdout }; + }), + ); + /** * Mirrors Go's `versions.GreaterThanOrEqualTo` (`docker/api/types/versions`, * used by `apps/cli-go/internal/utils/docker.go:128`): splits each version on diff --git a/apps/cli/src/legacy/shared/legacy-db-target-flags.ts b/apps/cli/src/legacy/shared/legacy-db-target-flags.ts index 6eac57c3cd..a0da256612 100644 --- a/apps/cli/src/legacy/shared/legacy-db-target-flags.ts +++ b/apps/cli/src/legacy/shared/legacy-db-target-flags.ts @@ -7,10 +7,14 @@ * parsed flag values don't carry a `Changed` bit, so we re-derive it from the * raw `process.argv` slice. * - * cobra's `MarkFlagsMutuallyExclusive` sorts the conflicting names before - * building the error string (`apps/cli-go/.../flag_groups.go:204`), hence the - * FIXED insertion order ["db-url","linked","local"] — alphabetical — for the - * `setFlags` array. + * cobra's `MarkFlagsMutuallyExclusive` error has TWO bracketed lists: the + * group list keeps REGISTRATION order (`strings.Join(flagNames, " ")`, + * `flag_groups.go:73`) and is NOT sorted, while the "were all set" list IS + * sorted (`sort.Strings(set)`, `flag_groups.go:203-204`). The FIXED insertion + * order ["db-url","linked","local"] — alphabetical — for the `setFlags` array + * matches only that second, sorted list; each command must hardcode its own + * group list in its own Go registration order (e.g. seed `[local linked]` + * vs storage `[linked local]`). * * pflag accepts `--flag value` (space form) for non-boolean flags: the token * after a value-consuming flag is its value, not a separate flag. The scan diff --git a/apps/cli/src/legacy/shared/legacy-docker-image-resolve.ts b/apps/cli/src/legacy/shared/legacy-docker-image-resolve.ts index d41d903c94..36ad74ebc6 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-image-resolve.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-image-resolve.ts @@ -101,7 +101,10 @@ export function legacyMakeDockerImageResolver( const pullImage = ( image: string, - ): Effect.Effect<{ readonly exitCode: number; readonly stderr: string }, Error> => + ): Effect.Effect< + { readonly exitCode: number; readonly stderr: string; readonly endedWithNewline: boolean }, + Error + > => Effect.gen(function* () { const handle = yield* spawnContainerCli(spawner, ["pull", image], { stdin: "inherit", @@ -117,20 +120,27 @@ export function legacyMakeDockerImageResolver( // buffered copies are kept only to report the error message on a // non-zero exit. Decode each stream separately so a multi-byte UTF-8 // sequence is never split across interleaved chunks. + // `endedWithNewline` records whether the last byte teed to the parent's + // stderr was `\n` (both streams share it — last write wins, which is + // what the terminal shows), so the retry loop can start its banner on a + // fresh line when the child's final output wasn't newline-terminated. const stdoutChunks: Array = []; const stderrChunks: Array = []; + let endedWithNewline = true; yield* Effect.all( [ Stream.runForEach(handle.stdout, (chunk) => Effect.sync(() => { stdoutChunks.push(chunk); globalThis.process.stderr.write(chunk); + if (chunk.length > 0) endedWithNewline = chunk[chunk.length - 1] === 0x0a; }), ), Stream.runForEach(handle.stderr, (chunk) => Effect.sync(() => { stderrChunks.push(chunk); globalThis.process.stderr.write(chunk); + if (chunk.length > 0) endedWithNewline = chunk[chunk.length - 1] === 0x0a; }), ), ], @@ -142,6 +152,7 @@ export function legacyMakeDockerImageResolver( return { exitCode, stderr: `${stdout}${stderr}`.trim(), + endedWithNewline, }; }).pipe(Effect.scoped); @@ -190,6 +201,22 @@ export function legacyMakeDockerImageResolver( if (delay === undefined) { break; } + // Go prints a per-retry banner before sleeping (`docker.go:314`): + // `fmt.Fprintf(os.Stderr, "Retrying after %v: %s\n", period, image)` + // — `%v` of the 4s/8s backoff `time.Duration` renders as `4s`/`8s`. + // Go also `Fprintln`s the failed attempt's error just before the + // banner (`docker.go:312`); here the `docker pull` child's own + // stderr — already teed live to the parent's stderr above — plays + // that role. `Fprintln` always newline-terminates, so when the + // child's final output didn't, add the `\n` ourselves — otherwise + // the banner would glue onto the error text where Go prints two + // lines. + yield* Effect.sync(() => { + if (!result.value.endedWithNewline) { + globalThis.process.stderr.write("\n"); + } + globalThis.process.stderr.write(`Retrying after ${delay / 1000}s: ${candidate}\n`); + }); yield* Effect.sleep(`${delay} millis`); } } diff --git a/apps/cli/src/legacy/shared/legacy-docker-image-resolve.unit.test.ts b/apps/cli/src/legacy/shared/legacy-docker-image-resolve.unit.test.ts index 633dab0a9c..ec30c0deb8 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-image-resolve.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-image-resolve.unit.test.ts @@ -85,6 +85,23 @@ function mockSpawner( }; } +/** + * Joins everything written to stderr — the tee's `Uint8Array` chunks and the + * resolver's own `string` writes — into one transcript, so tests can assert + * on the byte sequence a terminal would actually display (e.g. that a retry + * banner starts on a fresh line after an unterminated child error). + */ +function stderrTranscript(chunks: ReadonlyArray): string { + const decoder = new TextDecoder(); + return chunks + .map((chunk) => { + if (typeof chunk === "string") return chunk; + if (chunk instanceof Uint8Array) return decoder.decode(chunk); + return ""; + }) + .join(""); +} + describe("legacyMakeDockerImageResolver", () => { it.effect( "retries a pull failure unconditionally through messages that wouldn't have matched the old retryable-pattern allowlist, giving up after 3 total attempts", @@ -96,8 +113,17 @@ describe("legacyMakeDockerImageResolver", () => { // list built by `legacyGetRegistryImageUrlCandidates`. const previousRegistry = process.env[REGISTRY_ENV]; process.env[REGISTRY_ENV] = "docker.io"; + // Records every chunk written to stderr, including the `docker pull` child's own + // stdout/stderr, which `pullImage` tees live to the parent's stderr as `Uint8Array` + // chunks — only the `Retrying after …` banner (and its fresh-line `"\n"` separator) + // is ever written as a plain `string`, so filtering by `startsWith("Retrying after")` + // isolates the banner from the tee below. + const stderrChunks: Array = []; const originalWrite = globalThis.process.stderr.write.bind(globalThis.process.stderr); - globalThis.process.stderr.write = (() => true) as typeof globalThis.process.stderr.write; + globalThis.process.stderr.write = ((chunk: unknown) => { + stderrChunks.push(chunk); + return true; + }) as typeof globalThis.process.stderr.write; try { // Mirrors Go's own `docker_test.go` "throws error on failure to pull @@ -133,6 +159,26 @@ describe("legacyMakeDockerImageResolver", () => { for (const options of mock.imageInspectOptions) { expect(options).toMatchObject({ stdin: "ignore", stdout: "ignore", stderr: "pipe" }); } + // Go's per-retry banner (`docker.go:314`): `Fprintf(os.Stderr, "Retrying after %v: %s\n", …)` + // — one banner before each of the 2 retries, escalating 4s then 8s, naming the exact + // candidate this resolver pinned to (see the comment above on `REGISTRY_ENV`). + const retryBanners = stderrChunks.filter( + (chunk): chunk is string => + typeof chunk === "string" && chunk.startsWith("Retrying after"), + ); + expect(retryBanners).toEqual([ + "Retrying after 4s: supabase/postgres:17.6.1.138\n", + "Retrying after 8s: supabase/postgres:17.6.1.138\n", + ]); + // Go `Fprintln`s the failed error before the banner (`docker.go:312`), + // so the banner always starts on a fresh line. The child's error here + // has no trailing newline, so the resolver must add one — never the + // glued `…deviceRetrying after …`. + const transcript = stderrTranscript(stderrChunks); + expect(transcript).toContain( + "no space left on device\nRetrying after 4s: supabase/postgres:17.6.1.138\n", + ); + expect(transcript).not.toContain("deviceRetrying"); } finally { globalThis.process.stderr.write = originalWrite; if (previousRegistry === undefined) delete process.env[REGISTRY_ENV]; @@ -147,8 +193,12 @@ describe("legacyMakeDockerImageResolver", () => { Effect.gen(function* () { const previousRegistry = process.env[REGISTRY_ENV]; process.env[REGISTRY_ENV] = "docker.io"; + const stderrChunks: Array = []; const originalWrite = globalThis.process.stderr.write.bind(globalThis.process.stderr); - globalThis.process.stderr.write = (() => true) as typeof globalThis.process.stderr.write; + globalThis.process.stderr.write = ((chunk: unknown) => { + stderrChunks.push(chunk); + return true; + }) as typeof globalThis.process.stderr.write; try { const mock = mockSpawner([ @@ -165,6 +215,14 @@ describe("legacyMakeDockerImageResolver", () => { expect(mock.pulls).toHaveLength(2); expect(image).toBe("supabase/postgres:17.6.1.138"); + // Only the first candidate's failed attempt sleeps through a retry banner — the + // second attempt succeeds immediately, so the 8s banner (and a third pull) must + // never happen. + const retryBanners = stderrChunks.filter( + (chunk): chunk is string => + typeof chunk === "string" && chunk.startsWith("Retrying after"), + ); + expect(retryBanners).toEqual(["Retrying after 4s: supabase/postgres:17.6.1.138\n"]); } finally { globalThis.process.stderr.write = originalWrite; if (previousRegistry === undefined) delete process.env[REGISTRY_ENV]; @@ -173,6 +231,81 @@ describe("legacyMakeDockerImageResolver", () => { }), ); + it.effect( + "does not inject a blank line before the banner when the child error is newline-terminated", + () => + Effect.gen(function* () { + const previousRegistry = process.env[REGISTRY_ENV]; + process.env[REGISTRY_ENV] = "docker.io"; + const stderrChunks: Array = []; + const originalWrite = globalThis.process.stderr.write.bind(globalThis.process.stderr); + globalThis.process.stderr.write = ((chunk: unknown) => { + stderrChunks.push(chunk); + return true; + }) as typeof globalThis.process.stderr.write; + + try { + const mock = mockSpawner([ + { exitCode: 1, stderr: "no space left on device\n" }, + { exitCode: 0 }, + ]); + const resolve = legacyMakeDockerImageResolver(mock.spawner); + const fiber = yield* resolve("supabase/postgres:17.6.1.138").pipe( + Effect.forkChild({ startImmediately: true }), + ); + + yield* TestClock.adjust("4 seconds"); + const image = yield* Fiber.join(fiber); + + expect(image).toBe("supabase/postgres:17.6.1.138"); + // The child already terminated its own line — Go's `Fprintln` + // output shape is exactly one newline between error and banner, so + // the resolver must not add a second one. + const transcript = stderrTranscript(stderrChunks); + expect(transcript).toContain( + "no space left on device\nRetrying after 4s: supabase/postgres:17.6.1.138\n", + ); + expect(transcript).not.toContain("\n\nRetrying"); + } finally { + globalThis.process.stderr.write = originalWrite; + if (previousRegistry === undefined) delete process.env[REGISTRY_ENV]; + else process.env[REGISTRY_ENV] = previousRegistry; + } + }), + ); + + it.effect("prints no Retrying banner when the first pull attempt succeeds", () => + Effect.gen(function* () { + const previousRegistry = process.env[REGISTRY_ENV]; + process.env[REGISTRY_ENV] = "docker.io"; + const stderrChunks: Array = []; + const originalWrite = globalThis.process.stderr.write.bind(globalThis.process.stderr); + globalThis.process.stderr.write = ((chunk: unknown) => { + stderrChunks.push(chunk); + return true; + }) as typeof globalThis.process.stderr.write; + + try { + const mock = mockSpawner([{ exitCode: 0 }]); + const resolve = legacyMakeDockerImageResolver(mock.spawner); + + const image = yield* resolve("supabase/postgres:17.6.1.138"); + + expect(mock.pulls).toHaveLength(1); + expect(image).toBe("supabase/postgres:17.6.1.138"); + const retryBanners = stderrChunks.filter( + (chunk): chunk is string => + typeof chunk === "string" && chunk.startsWith("Retrying after"), + ); + expect(retryBanners).toEqual([]); + } finally { + globalThis.process.stderr.write = originalWrite; + if (previousRegistry === undefined) delete process.env[REGISTRY_ENV]; + else process.env[REGISTRY_ENV] = previousRegistry; + } + }), + ); + it.effect("fails fast on a daemon-unreachable image inspect without ever attempting a pull", () => Effect.gen(function* () { const previousRegistry = process.env[REGISTRY_ENV]; diff --git a/apps/cli/src/legacy/shared/legacy-docker-remove-all.ts b/apps/cli/src/legacy/shared/legacy-docker-remove-all.ts index ae4e8bd59a..40ade1a768 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-remove-all.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-remove-all.ts @@ -3,6 +3,7 @@ import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSp import { containerCliExitCode, + legacyContainerCliExitCodeAndStdout, legacyDescribeContainerCliFailure, legacyDockerSupportsVolumePruneAllFlag, } from "./legacy-container-cli.ts"; @@ -47,6 +48,34 @@ class LegacyDockerRemoveAllNetworkPruneError extends Data.TaggedError( readonly message: string; }> {} +/** + * Extracts the deleted-object IDs/names from `docker`/`podman` `… prune` + * stdout. Docker prints a `Deleted Containers:`/`Deleted Volumes:`/`Deleted + * Networks:` header, one ID/name per line, then a `Total reclaimed space: …` + * summary; Podman prints the bare IDs/names only. Keep the bare-value lines, + * dropping headers and the summary. + */ +function parsePrunedNames(stdout: string): ReadonlyArray { + return stdout + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter( + (line) => line.length > 0 && !line.endsWith(":") && !line.startsWith("Total reclaimed space"), + ); +} + +/** + * Go's `--debug` prune reports (`docker.go:123,136,143`): `fmt.Fprintln(os.Stderr, + * "Pruned containers:", report.ContainersDeleted)` and siblings — the `[]string` + * renders as `[a b c]` (empty: `[]`), always on stderr regardless of the writer + * the caller passed, and only when `viper.GetBool("DEBUG")` is set. + */ +const reportPruned = (debug: boolean, label: string, stdout: string) => + Effect.sync(() => { + if (!debug) return; + globalThis.process.stderr.write(`${label} [${parsePrunedNames(stdout).join(" ")}]\n`); + }); + /** Every failure {@link legacyDockerRemoveAll} can produce. */ export type LegacyDockerRemoveAllError = | LegacyDockerRemoveAllListError @@ -88,6 +117,7 @@ export const legacyDockerRemoveAll = ( filterValue: string, deleteVolumes: boolean, onContainersRemoved?: (containers: ReadonlyArray) => void, + debug = false, ): Effect.Effect => Effect.gen(function* () { const containers = yield* legacyListContainerIdsAndNames(spawner, { @@ -101,12 +131,13 @@ export const legacyDockerRemoveAll = ( // Go stops containers concurrently via `WaitAll`, joining every failure rather than // short-circuiting on the first one (`docker.go:96-146`). // - // `stdout`/`stderr: "ignore"` on every exit-code-only call below: none of these read the + // `stdout`/`stderr: "ignore"` on the exit-code-only `stop` calls below: they never read the // child's own output, and the default `"pipe"` stdio otherwise leaves an OS pipe unread — - // once `docker`/`podman` write enough to it (e.g. `container prune`'s "Deleted Containers" - // ID list on a host with many stale containers, most likely under `stop --all`), the child - // blocks on write() and this hangs. Matches the existing `stdio: "ignore"` precedent for the - // same "exit-code-only" shape in `legacy-pgdelta.seam.layer.ts`. + // once `docker`/`podman` write enough to it, the child blocks on write() and this hangs. + // Matches the existing `stdio: "ignore"` precedent for the same "exit-code-only" shape in + // `legacy-pgdelta.seam.layer.ts`. The prune calls further down instead COLLECT stdout (via + // `legacyContainerCliExitCodeAndStdout`, which reads the pipe, equally avoiding the hang) + // because their deleted-ID reports back Go's `--debug` `Pruned …:` stderr lines. const stopResults = yield* Effect.all( containerIds.map((id) => containerCliExitCode(spawner, ["stop", id], { @@ -132,11 +163,17 @@ export const legacyDockerRemoveAll = ( ); } - const containerPruneExitCode = yield* containerCliExitCode( - spawner, - ["container", "prune", "--force", "--filter", `label=${filterValue}`], - { stdin: "ignore", stdout: "ignore", stderr: "ignore" }, - ).pipe( + // The prune calls collect stdout (the CLI's deleted-ID report) instead of + // ignoring it — reading the pipe equally avoids the unread-pipe hang the + // exit-code-only calls above dodge with `stdout: "ignore"`, and the report + // backs Go's `--debug` `Pruned …:` stderr lines (`docker.go:123-143`). + const containerPrune = yield* legacyContainerCliExitCodeAndStdout(spawner, [ + "container", + "prune", + "--force", + "--filter", + `label=${filterValue}`, + ]).pipe( Effect.mapError( (cause) => new LegacyDockerRemoveAllContainerPruneError({ @@ -144,11 +181,12 @@ export const legacyDockerRemoveAll = ( }), ), ); - if (containerPruneExitCode !== 0) { + if (containerPrune.exitCode !== 0) { return yield* Effect.fail( new LegacyDockerRemoveAllContainerPruneError({ message: "failed to prune containers" }), ); } + yield* reportPruned(debug, "Pruned containers:", containerPrune.stdout); // Containers are now CONFIRMED removed — see `onContainersRemoved`'s doc comment for why this // must fire here rather than at the listing above, and why it still must fire even if a later // stage (volume/network prune, below) goes on to fail. @@ -173,7 +211,7 @@ export const legacyDockerRemoveAll = ( // Podman-only host. Podman already prunes every unused volume by default, so omitting // `--all` on the Podman fallback is a lossless fix. const dockerSupportsAll = yield* legacyDockerSupportsVolumePruneAllFlag(spawner); - const volumePruneExitCode = yield* containerCliExitCode( + const volumePrune = yield* legacyContainerCliExitCodeAndStdout( spawner, [ "volume", @@ -183,7 +221,6 @@ export const legacyDockerRemoveAll = ( "--filter", `label=${filterValue}`, ], - { stdin: "ignore", stdout: "ignore", stderr: "ignore" }, ["volume", "prune", "--force", "--filter", `label=${filterValue}`], ).pipe( Effect.mapError( @@ -193,18 +230,23 @@ export const legacyDockerRemoveAll = ( }), ), ); - if (volumePruneExitCode !== 0) { + if (volumePrune.exitCode !== 0) { return yield* Effect.fail( new LegacyDockerRemoveAllVolumePruneError({ message: "failed to prune volumes" }), ); } + // Inside the `deleteVolumes` branch, like Go's report inside the + // `NoBackupVolume` block (`docker.go:126-138`). + yield* reportPruned(debug, "Pruned volumes:", volumePrune.stdout); } - const networkPruneExitCode = yield* containerCliExitCode( - spawner, - ["network", "prune", "--force", "--filter", `label=${filterValue}`], - { stdin: "ignore", stdout: "ignore", stderr: "ignore" }, - ).pipe( + const networkPrune = yield* legacyContainerCliExitCodeAndStdout(spawner, [ + "network", + "prune", + "--force", + "--filter", + `label=${filterValue}`, + ]).pipe( Effect.mapError( (cause) => new LegacyDockerRemoveAllNetworkPruneError({ @@ -212,9 +254,11 @@ export const legacyDockerRemoveAll = ( }), ), ); - if (networkPruneExitCode !== 0) { + if (networkPrune.exitCode !== 0) { return yield* Effect.fail( new LegacyDockerRemoveAllNetworkPruneError({ message: "failed to prune networks" }), ); } + // Go: singular "network" (`docker.go:143`), unlike the other two reports. + yield* reportPruned(debug, "Pruned network:", networkPrune.stdout); }); diff --git a/apps/cli/src/legacy/shared/legacy-go-float.ts b/apps/cli/src/legacy/shared/legacy-go-float.ts new file mode 100644 index 0000000000..7390c80f86 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-go-float.ts @@ -0,0 +1,34 @@ +/** + * Render a number the way Go's `fmt.Sprintf("%v", float64)` (and `%+v` — the + * `+` flag only affects structs) does. JSON numbers decode to `float64` in Go, + * so `fmt` uses shortest `%g`: exponent form when the decimal exponent is + * `< -4` or `>= 6` (e.g. `1000000` → `1e+06`, `1.5e8` → `1.5e+08`, `1e-5` → + * `1e-05`), fixed notation otherwise. The exponent is signed and at least two + * digits. JS fixed notation matches Go for the `[-4, 6)` exponent range, so + * only the exponent cases need reformatting — `toExponential()` (no argument) + * yields the same shortest round-trip digits Go's strconv produces. + * + * Shared by `db query`'s value formatter (`db/query/query.format.ts`) and + * `postgres-config`'s pretty table (`postgres-config.shared.ts`, Go + * `get.go:32-35`'s `%+v`). + */ +export function legacyGoFormatFloat(n: number): string { + if (Number.isNaN(n)) return "NaN"; + if (!Number.isFinite(n)) return n > 0 ? "+Inf" : "-Inf"; + // Go's `%v` preserves the sign of negative zero (`-0`); `n === 0` is true for + // both `+0` and `-0`, so distinguish them with `Object.is` before the shortcut. + if (Object.is(n, -0)) return "-0"; + if (n === 0) return "0"; + const neg = n < 0; + const abs = Math.abs(n); + const [mantissa, eRaw] = abs.toExponential().split("e"); + const exp = Number.parseInt(eRaw!, 10); + let out: string; + if (exp < -4 || exp >= 6) { + const mag = Math.abs(exp).toString().padStart(2, "0"); + out = `${mantissa}e${exp < 0 ? "-" : "+"}${mag}`; + } else { + out = abs.toString(); + } + return neg ? `-${out}` : out; +} diff --git a/apps/cli/src/legacy/shared/legacy-go-float.unit.test.ts b/apps/cli/src/legacy/shared/legacy-go-float.unit.test.ts new file mode 100644 index 0000000000..6ccedd8b6e --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-go-float.unit.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { legacyGoFormatFloat } from "./legacy-go-float.ts"; + +describe("legacyGoFormatFloat", () => { + it("renders fixed notation within Go's [-4, 6) decimal-exponent range", () => { + expect(legacyGoFormatFloat(100)).toBe("100"); + expect(legacyGoFormatFloat(100000)).toBe("100000"); + expect(legacyGoFormatFloat(0.5)).toBe("0.5"); + expect(legacyGoFormatFloat(0.0001)).toBe("0.0001"); + }); + + it("switches to signed exponent notation at exponent >= 6 or < -4", () => { + expect(legacyGoFormatFloat(1000000)).toBe("1e+06"); + expect(legacyGoFormatFloat(100000000000)).toBe("1e+11"); + expect(legacyGoFormatFloat(123456789)).toBe("1.23456789e+08"); + expect(legacyGoFormatFloat(0.00001)).toBe("1e-05"); + }); + + it("preserves the sign for negative exponent-notation values", () => { + expect(legacyGoFormatFloat(-1000000)).toBe("-1e+06"); + }); + + it("renders zero as a bare 0", () => { + expect(legacyGoFormatFloat(0)).toBe("0"); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-go-quote.ts b/apps/cli/src/legacy/shared/legacy-go-quote.ts new file mode 100644 index 0000000000..d52d409d4a --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-go-quote.ts @@ -0,0 +1,101 @@ +/** + * Port of Go's `strconv.Quote` (the `%q` verb) over raw UTF-8 bytes, shared by + * every legacy error message that must reproduce a Go-side `%q` interpolation + * byte-for-byte (snippets download's `invalid urn prefix: %q`, storage cp's + * pflag `invalid argument %q … parsing %q`). + * + * Operating on bytes (not JS strings) matters twice over: Go slices like + * `s[:9]` cut by byte and can split a multibyte rune — which `%q` then renders + * as `\xNN` per orphan byte — and Go's escaping decisions are made per decoded + * rune over those bytes. Callers with a whole JS string in hand encode it + * first (`new TextEncoder().encode(s)`); note Bun's `process.argv` has already + * replaced invalid UTF-8 argv bytes with U+FFFD by then, so byte-identical + * output for *invalid-UTF-8 argv* is unattainable at that boundary — the + * fidelity gap is JS-runtime-wide, not per-call-site. + */ + +/** + * `utf8.DecodeRune` semantics over a byte slice: returns the code point and + * byte size at `i`, or `cp: -1` with `size: 1` for an invalid byte (invalid + * lead, truncated/malformed continuation, overlong encoding, surrogate, + * > U+10FFFF) — exactly the cases Go's `%q` renders as a lone `\xNN`. + */ +function decodeUtf8Rune( + bytes: Uint8Array, + i: number, +): { readonly cp: number; readonly size: number } { + const b0 = bytes[i] ?? 0; + if (b0 < 0x80) return { cp: b0, size: 1 }; + let extra: number; + let cp: number; + let min: number; + if (b0 >= 0xc0 && b0 <= 0xdf) { + extra = 1; + cp = b0 & 0x1f; + min = 0x80; + } else if (b0 >= 0xe0 && b0 <= 0xef) { + extra = 2; + cp = b0 & 0x0f; + min = 0x800; + } else if (b0 >= 0xf0 && b0 <= 0xf7) { + extra = 3; + cp = b0 & 0x07; + min = 0x10000; + } else { + return { cp: -1, size: 1 }; + } + if (i + extra >= bytes.length) return { cp: -1, size: 1 }; + for (let k = 1; k <= extra; k++) { + const b = bytes[i + k] ?? 0; + if ((b & 0xc0) !== 0x80) return { cp: -1, size: 1 }; + cp = (cp << 6) | (b & 0x3f); + } + if (cp < min || cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff)) return { cp: -1, size: 1 }; + return { cp, size: extra + 1 }; +} + +// Go's `unicode.IsPrint` for runes ≥ 0x80: letters, marks, numbers, +// punctuation, symbols (the ASCII range is handled explicitly in +// legacyGoQuote). Unicode-table drift between the Go and JS engines is +// possible but only affects which escape a garbage rune gets in one error +// message. +const GO_PRINTABLE_RE = /[\p{L}\p{M}\p{N}\p{P}\p{S}]/u; + +const GO_ESCAPES: Readonly> = { + 0x07: "\\a", + 0x08: "\\b", + 0x0c: "\\f", + 0x0a: "\\n", + 0x0d: "\\r", + 0x09: "\\t", + 0x0b: "\\v", +}; + +/** + * Go `%q` (`strconv.Quote`) over raw UTF-8 bytes (go1.26: `%q` of + * `"12345678\xc3"` → `"12345678\xc3"`). Valid printable runes print + * literally; control/non-printable ones use Go's `\a…\v` shorthands then + * `\xNN` / `\uNNNN` / `\UNNNNNNNN`. + */ +export function legacyGoQuote(bytes: Uint8Array): string { + let out = '"'; + for (let i = 0; i < bytes.length;) { + const { cp, size } = decodeUtf8Rune(bytes, i); + if (cp === -1) { + out += `\\x${(bytes[i] ?? 0).toString(16).padStart(2, "0")}`; + i += 1; + continue; + } + i += size; + const escape = GO_ESCAPES[cp]; + const ch = String.fromCodePoint(cp); + if (ch === '"' || ch === "\\") out += `\\${ch}`; + else if (escape !== undefined) out += escape; + else if (cp >= 0x20 && cp < 0x7f) out += ch; + else if (cp < 0x80) out += `\\x${cp.toString(16).padStart(2, "0")}`; + else if (GO_PRINTABLE_RE.test(ch)) out += ch; + else if (cp < 0x10000) out += `\\u${cp.toString(16).padStart(4, "0")}`; + else out += `\\U${cp.toString(16).padStart(8, "0")}`; + } + return `${out}"`; +} diff --git a/apps/cli/src/legacy/shared/legacy-identity-stitch.ts b/apps/cli/src/legacy/shared/legacy-identity-stitch.ts index 12b1d1a80c..0293e903dd 100644 --- a/apps/cli/src/legacy/shared/legacy-identity-stitch.ts +++ b/apps/cli/src/legacy/shared/legacy-identity-stitch.ts @@ -4,6 +4,7 @@ import type * as HttpClientResponse from "effect/unstable/http/HttpClientRespons import { Analytics } from "../../shared/telemetry/analytics.service.ts"; import { TelemetryRuntime } from "../../shared/telemetry/runtime.service.ts"; import { isEphemeralIdentityRuntime } from "../../shared/telemetry/identity.ts"; +import { legacyTelemetrySchemaVersionToken } from "../telemetry/legacy-telemetry-state.layer.ts"; /** * Session identity stitching, a 1:1 port of Go's `identityTransport` + @@ -133,6 +134,14 @@ const makeLegacyIdentityStitcher: Effect.Effect< } }, }); + // Exact int64 token of the prior schema_version, when there is one: + // `numberField` below rounds valid tokens above 2^53 through `Number` + // (9007199254740993 → …992), and this writer re-persists the field — + // Go decodes and re-encodes the 64-bit `int` verbatim. + const priorSchemaVersionToken = Option.match(existing, { + onNone: () => undefined, + onSome: legacyTelemetrySchemaVersionToken, + }); const enabled = boolField(prior, "enabled") ?? true; if (!enabled) return; @@ -156,7 +165,12 @@ const makeLegacyIdentityStitcher: Effect.Effect< }; yield* fs.makeDirectory(runtime.configDir, { recursive: true }); - yield* fs.writeFileString(telemetryPath, JSON.stringify(state)); + yield* fs.writeFileString( + telemetryPath, + priorSchemaVersionToken === undefined + ? JSON.stringify(state) + : JSON.stringify({ ...state, schema_version: JSON.rawJSON(priorSchemaVersionToken) }), + ); }); const stitch = (response: HttpClientResponse.HttpClientResponse) => { diff --git a/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts index ff4836fea4..c9eccfe455 100644 --- a/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts +++ b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts @@ -14,6 +14,14 @@ interface State { readonly session_last_active: string; readonly distinct_id?: string; readonly schema_version: number; + /** + * Exact decoded `schema_version` token, carried for re-serialization and + * stripped from the written JSON by {@link serializeLegacyTelemetryState}. + * Go decodes the field into a 64-bit `int` and `json.Marshal` re-emits it + * verbatim; a JS `Number` above 2^53 rounds (9007199254740993 → …992) and + * would persist the altered version. + */ + readonly schemaVersionToken?: string; } const SCHEMA_VERSION = 1; @@ -23,53 +31,431 @@ function legacyTelemetryPath(env: Record, pathSvc: P return pathSvc.join(legacySupabaseHome(homedir(), env), "telemetry.json"); } +/** + * Serializes the state like Go's `json.Marshal` of `State` (`state.go:25-31`): + * a carried exact `schema_version` token is spliced back in verbatim via + * `JSON.rawJSON` (review r3683813242 — `Number` rounds valid int64 tokens + * above 2^53, so `9007199254740993` would persist as `…992` where Go + * re-encodes the decoded `int` exactly). Field order matches Go's struct. + */ +function serializeLegacyTelemetryState(state: State): string { + const { schemaVersionToken, ...fields } = state; + if (schemaVersionToken === undefined) return JSON.stringify(fields); + return JSON.stringify({ ...fields, schema_version: JSON.rawJSON(schemaVersionToken) }); +} + interface PriorState { - enabled?: boolean; - device_id?: string; - session_id?: string; - session_last_active?: string; - distinct_id?: string; + readonly enabled: boolean; + readonly device_id: string; + readonly session_id: string; + /** Epoch millis of `session_last_active`, from the Go-shape parse below. */ + readonly sessionLastActiveMs: number; + readonly distinct_id?: string; + /** + * Exact raw token of the decoded non-zero `schema_version`, absent when Go + * would fall back to the `SchemaVersion` constant (`state.go:103-106`). + * Kept as the token — not a `Number` — so re-serialization is int64-exact. + */ + readonly schemaVersionToken?: string; +} + +// Go's `time.Parse(time.RFC3339Nano, …)` shape: date, `T`, time, optional +// fraction, `Z` or a `±hh:mm` offset. JS `new Date(…)` alone accepts far more +// (bare dates, RFC 2822, …) that Go rejects as malformed. The fractional +// separator is `.` OR `,` — Go's parser accepts either (`commaOrPeriod`, +// `time/format.go`; verified against go1.26: `…T00:00:00,1Z` parses) — while +// the digits after it stay mandatory (`…T00:00:00,Z` is rejected). +const RFC3339_RE = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:[.,](\d+))?(?:Z|([+-])(\d{2}):(\d{2}))$/; + +const DAYS_PER_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] as const; + +// Gregorian leap rule, mirroring Go's `isLeap` (`time/time.go`). +function daysInMonth(year: number, month: number): number { + if (month === 2 && year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0)) return 29; + return DAYS_PER_MONTH[month - 1] ?? 0; } -function hasOwn(record: Record, key: string): boolean { - return Object.prototype.hasOwnProperty.call(record, key); +/** + * Component-level port of Go's `time.Parse(time.RFC3339Nano, …)` + * (`parseSessionLastActive`, `state.go:69-85`): validates like Go and, when + * valid, returns the epoch milliseconds of the parsed instant. `Date.parse` / + * `new Date(…)` cannot stand in for it in either direction (verified against + * go1.26 and Bun 1.3): + * - JS silently normalizes valid-range day overflow (`2025-02-29` → Mar 1, + * `2025-04-31` → May 1) and hour 24 (`T24:00:00Z` → next day) that Go + * rejects as "day/hour out of range"; + * - JS rejects forms Go accepts — a `,` fractional separator, and zone + * offsets bounded at hour 24 / minute 60 (`+24:00` and `+05:60` both + * parse) — where JS returns NaN. + * The epoch therefore also has to come from these components, NOT from a + * second `new Date(string)` pass: a Go-valid form JS cannot parse would + * NaN there and wrongly count as session-expired (see the rotation check in + * `loadOrCreateLegacyTelemetryState`). + */ +function parseGoRfc3339Ms(text: string): number | undefined { + const match = RFC3339_RE.exec(text); + if (match === null) return undefined; + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hour = Number(match[4]); + const minute = Number(match[5]); + const second = Number(match[6]); + if (month < 1 || month > 12) return undefined; + if (day < 1 || day > daysInMonth(year, month)) return undefined; + if (hour > 23 || minute > 59 || second > 59) return undefined; + if (match[9] !== undefined && (Number(match[9]) > 24 || Number(match[10]) > 60)) return undefined; + // `setUTCFullYear` (not `Date.UTC`) so years 0000-0099 aren't remapped to + // 1900-1999; components are already range-checked, so no rollover occurs. + const date = new Date(0); + date.setUTCFullYear(year, month - 1, day); + date.setUTCHours(hour, minute, second, 0); + // Go reads at most 9 fractional digits (nanoseconds); ms precision is + // exact for the 30-minute comparison this feeds. + const fractionMs = match[7] !== undefined ? Number(`0.${match[7].slice(0, 9)}`) * 1000 : 0; + const offsetMs = + match[8] !== undefined + ? (match[8] === "-" ? -1 : 1) * (Number(match[9]) * 3600 + Number(match[10]) * 60) * 1000 + : 0; + return date.getTime() + fractionMs - offsetMs; } -function readExistingState(text: string): PriorState | undefined { - try { - const parsed = JSON.parse(text); - if (typeof parsed !== "object" || parsed === null) return undefined; - const record = parsed as Record; - const out: PriorState = {}; - if (hasOwn(record, "enabled")) { - if (typeof record.enabled !== "boolean") return undefined; - out.enabled = record.enabled; - } - if (hasOwn(record, "device_id")) { - if (typeof record.device_id !== "string") return undefined; - out.device_id = record.device_id; - } - if (hasOwn(record, "session_id")) { - if (typeof record.session_id !== "string") return undefined; - out.session_id = record.session_id; +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +const GO_INT64_MIN = -(2n ** 63n); +const GO_INT64_MAX = 2n ** 63n - 1n; +const INT64_TOKEN_RE = /^-?\d+$/; + +/** + * Whether a raw JSON number token would decode into a Go signed 64-bit + * integer. Go decodes both the consent-form unix millis (`int64`, + * `state.go:69-85`) and `schema_version` (`int`, 64-bit on every supported + * platform, `state.go:41`) by unmarshaling the RAW JSON number token, which + * accepts only lexically-integer decimal tokens within the int64 range: + * integer-VALUED tokens like `1.0`, `2.0`, and `1e3` are UnmarshalTypeErrors, + * as are integer tokens outside [-2^63, 2^63-1] (verified against go1.26: + * `json.Unmarshal` into `int64` rejects `1.0`/`1e3`/`1e100`/ + * `9223372036854775808`, and the repo's own `decodeState` maps each to + * `errMalformedState` → full regeneration, `state.go:87-90`). `JSON.parse` + * collapses those tokens to plain integer Numbers, so parsed VALUES alone + * cannot reproduce Go — validation runs on the raw token text, with exact + * BigInt bounds (the doubles for int64-max and int64-max+1 are + * indistinguishable; the tokens are not). + */ +function isInt64Token(token: string): boolean { + return ( + INT64_TOKEN_RE.test(token) && BigInt(token) >= GO_INT64_MIN && BigInt(token) <= GO_INT64_MAX + ); +} + +const JSON_WS = new Set([" ", "\t", "\n", "\r"]); + +/** + * Scans the ROOT object of an already-syntax-validated JSON text (it runs + * only after `JSON.parse(text)` has succeeded) and returns every + * `[key, raw value token]` pair in source order — INCLUDING duplicate keys. + * `JSON.parse` collapses duplicates to the final occurrence before any user + * code runs (even a stage-3 source-access reviver only ever sees the final + * token), but Go's `encoding/json` decodes every occurrence in order, so + * reproducing its behaviour needs the full occurrence list. Keys are + * unescaped (Go matches the escaped key `"\u0063onsent"` to the `consent` + * field). Only depth-1 pairs are emitted: a nested `{"x":{"enabled":"bad"}}` never shadows + * a root field, matching Go's struct decoding. Returns `undefined` when the + * root is not an object. + */ +function scanRootJsonEntries( + text: string, +): ReadonlyArray | undefined { + let i = 0; + const skipWs = (): void => { + while (i < text.length && JSON_WS.has(text[i] ?? "")) i += 1; + }; + // The `i < text.length` bounds below are purely defensive — the text is + // known-valid JSON, so every string and value is well-terminated. + const skipString = (): void => { + i += 1; // opening quote + while (i < text.length && text[i] !== '"') i += text[i] === "\\" ? 2 : 1; + i += 1; // closing quote + }; + const scanValueToken = (): string => { + const start = i; + const first = text[i]; + if (first === '"') { + skipString(); + } else if (first === "{" || first === "[") { + let depth = 0; + while (i < text.length) { + const ch = text[i]; + if (ch === '"') { + skipString(); + continue; + } + if (ch === "{" || ch === "[") depth += 1; + else if (ch === "}" || ch === "]") depth -= 1; + i += 1; + if (depth === 0) break; + } + } else { + // Primitive: true / false / null / number. + while (i < text.length) { + const ch = text[i] ?? ""; + if (ch === "," || ch === "}" || JSON_WS.has(ch)) break; + i += 1; + } } - if (hasOwn(record, "session_last_active")) { - if (typeof record.session_last_active !== "string") return undefined; - const parsedTime = new Date(record.session_last_active).getTime(); - if (!Number.isFinite(parsedTime)) return undefined; - out.session_last_active = record.session_last_active; + return text.slice(start, i); + }; + + skipWs(); + if (text[i] !== "{") return undefined; + i += 1; + const entries: Array = []; + skipWs(); + if (text[i] === "}") return entries; + while (i < text.length) { + skipWs(); + const keyStart = i; + skipString(); + const key: unknown = JSON.parse(text.slice(keyStart, i)); + skipWs(); + i += 1; // ':' + skipWs(); + const token = scanValueToken(); + if (typeof key === "string") entries.push([key, token]); + skipWs(); + if (text[i] !== ",") break; // closing '}' + i += 1; + } + return entries; +} + +/** + * Go's single `json.Unmarshal` into `rawState` (`state.go:34-42`) records an + * `UnmarshalTypeError` for EVERY wrong-typed occurrence of a known field — + * even when a later duplicate is valid and overwrites the value — and any + * such error classifies the whole file as malformed (verified against the + * repo's own `decodeState` on go1.26: + * `{"consent":false,"consent":"denied",…}` fails to decode while + * `{"enabled":true,"enabled":false,…}` decodes cleanly with `Enabled=false`). + * JSON `null` decodes into every field without error (nil for the pointer + * fields, no-op for the rest); `session_last_active` is `json.RawMessage` and + * unknown keys are skipped untyped — any token is fine for those. + * + * DOCUMENTED BOUND (review r3689624837): `encoding/json` also matches field + * names case-INsensitively when no exact match exists, so Go would treat a + * hand-edited `"Enabled": …` as the `enabled` field where this port (here and + * in `lastToken`/`lastNonNullToken`) treats it as unknown. Both CLIs only + * ever WRITE canonical lowercase keys, so case-variant keys require a + * hand-edited file; this emulation intentionally stops at exact tag names — + * do not extend it to fold casing (that path ends at reproducing + * `strings.EqualFold`'s Unicode simple folding). + */ +function hasGoDecodableFieldTokens( + entries: ReadonlyArray, +): boolean { + for (const [key, token] of entries) { + switch (key) { + case "enabled": // *bool + if (token !== "true" && token !== "false" && token !== "null") return false; + break; + case "consent": // *string + case "device_id": // string + case "session_id": // string + case "distinct_id": // string + if (!token.startsWith('"') && token !== "null") return false; + break; + case "schema_version": // int — Go parses the raw token as base-10 int64 + if (token !== "null" && !isInt64Token(token)) return false; + break; + default: + break; } - if (hasOwn(record, "distinct_id")) { - if (typeof record.distinct_id !== "string") return undefined; - out.distinct_id = record.distinct_id; + } + return true; +} + +/** Raw token of the LAST occurrence of `key` (plain overwrite semantics). */ +function lastToken( + entries: ReadonlyArray, + key: string, +): string | undefined { + let result: string | undefined; + for (const [k, token] of entries) { + if (k === key) result = token; + } + return result; +} + +/** + * Raw token of the last NON-NULL occurrence of `key`. This is Go's effective + * value for the non-pointer `rawState` fields: JSON `null` is a decode no-op + * (the field keeps its previous value), so `{"device_id":"a","device_id":null}` + * keeps `"a"` where `JSON.parse` surfaces `null` (verified against go1.26). + */ +function lastNonNullToken( + entries: ReadonlyArray, + key: string, +): string | undefined { + let result: string | undefined; + for (const [k, token] of entries) { + if (k === key && token !== "null") result = token; + } + return result; +} + +function lastNonNullString( + entries: ReadonlyArray, + key: string, +): string | undefined { + const token = lastNonNullToken(entries, key); + if (token === undefined) return undefined; + // The token was validated as a JSON string by `hasGoDecodableFieldTokens`; + // the typeof narrow keeps the typing honest without a cast. + const value: unknown = JSON.parse(token); + return typeof value === "string" ? value : undefined; +} + +/** + * Faithful port of Go's `decodeState` (`internal/telemetry/state.go:87-115`): + * ALL-OR-NOTHING. Go decodes the whole file or classifies it as + * `errMalformedState` — it never salvages individual fields. A file missing + * (or mistyping) any required piece — an `enabled` bool (or a + * `granted`/`denied` `consent`), a parseable `session_last_active`, and + * non-empty `device_id` AND `session_id` — is treated as wholly malformed, so + * `LoadOrCreateState` recreates EVERYTHING fresh: `enabled` back to `true`, + * new `device_id`, new `session_id`. Notably, a corrupt file that still says + * `"enabled": false` does NOT stay disabled. + * + * Go's unmarshal strictness is reproduced at the TOKEN level, over EVERY + * occurrence of every root field ({@link scanRootJsonEntries} + + * {@link hasGoDecodableFieldTokens}): `JSON.parse` collapses `2.0` → `2`, + * `1e3` → `1000`, and duplicated keys down to their final occurrence, so + * parsed values alone would preserve files Go rejects as wholly malformed — + * non-integer number tokens, magnitudes outside the int64 range, and + * wrong-typed non-final duplicates (`{"consent":false,"consent":"denied"}`) + * alike. (Unix millis in-range but beyond ECMAScript's ±8.64e15 `Date` range + * do NOT regenerate: the epoch is kept as a plain number, so — like Go's + * `time.UnixMilli` — the state is preserved and the far-future comparison + * simply never expires the session.) + */ +function readExistingState(text: string): PriorState | undefined { + try { + const parsed: unknown = JSON.parse(text); + if (!isRecord(parsed)) return undefined; + const record = parsed; + + // Per-OCCURRENCE typing first: Go's single-shot unmarshal fails on any + // wrong-typed occurrence — including one shadowed by a later valid + // duplicate that `JSON.parse` would surface (`state.go:88-91`). + const entries = scanRootJsonEntries(text); + if (entries === undefined || !hasGoDecodableFieldTokens(entries)) return undefined; + + // Go's `parseConsent` (`state.go:52-67`): a non-null `consent` must be + // `granted`/`denied` (and unlocks the unix-millis timestamp form); + // otherwise a bool `enabled` is required. Field TYPING — a non-boolean + // `enabled`, a non-string `consent`, on any occurrence — was already + // validated above. + let enabled: boolean; + let allowUnixMillis = false; + const consent = record.consent; + if (consent !== undefined && consent !== null) { + if (consent === "granted") { + enabled = true; + allowUnixMillis = true; + } else if (consent === "denied") { + enabled = false; + allowUnixMillis = true; + } else { + return undefined; + } + } else if (typeof record.enabled === "boolean") { + enabled = record.enabled; + } else { + return undefined; } - if (hasOwn(record, "schema_version")) { - if (!Number.isInteger(record.schema_version)) return undefined; + + // Go's `parseSessionLastActive` (`state.go:69-85`): an RFC3339Nano string, + // or — only on the consent form — integer unix millis (`time.UnixMilli`). + // The field is `json.RawMessage`, so plain last-occurrence overwrite + // applies (nulls included) and only the FINAL token is ever parsed. + const rawLastActive = record.session_last_active; + let sessionLastActiveMs: number; + if (typeof rawLastActive === "string") { + const parsedMs = parseGoRfc3339Ms(rawLastActive); + if (parsedMs === undefined) { + return undefined; + } + sessionLastActiveMs = parsedMs; + } else if (allowUnixMillis && typeof rawLastActive === "number") { + const millisToken = lastToken(entries, "session_last_active"); + if (millisToken === undefined || !isInt64Token(millisToken)) { + return undefined; + } + sessionLastActiveMs = rawLastActive; + } else { + return undefined; } - return out; + + // Go: `if raw.DeviceID == "" || raw.SessionID == ""` → "missing identity". + // Effective values are the last NON-NULL occurrences — `null` decodes as + // a no-op into these non-pointer string fields. + const deviceId = lastNonNullString(entries, "device_id"); + if (deviceId === undefined || deviceId === "") return undefined; + const sessionId = lastNonNullString(entries, "session_id"); + if (sessionId === undefined || sessionId === "") return undefined; + + const distinctId = lastNonNullString(entries, "distinct_id"); + + // `SchemaVersion int`: absent (or only null occurrences) → zero value; + // Go keeps a decoded file's non-zero schema_version (`state.go:103-106`). + // The zero test and the kept value both use the exact TOKEN — `BigInt` + // for the comparison, the raw text for re-serialization — because + // `Number` rounds valid int64 magnitudes above 2^53. + const schemaVersionToken = lastNonNullToken(entries, "schema_version"); + const keptSchemaVersionToken = + schemaVersionToken !== undefined && BigInt(schemaVersionToken) !== 0n + ? schemaVersionToken + : undefined; + + return { + enabled, + device_id: deviceId, + session_id: sessionId, + sessionLastActiveMs, + ...(distinctId !== undefined && distinctId.length > 0 ? { distinct_id: distinctId } : {}), + ...(keptSchemaVersionToken !== undefined + ? { schemaVersionToken: keptSchemaVersionToken } + : {}), + }; + } catch { + return undefined; + } +} + +/** + * Exact raw token of the effective (last non-null) `schema_version` in a + * telemetry.json document, when it is a Go-decodable non-zero int64 token. + * For the identity-stitch writer (`legacy-identity-stitch.ts`), which + * re-persists the field independently of this layer and must not round it + * through `Number` — Go re-encodes the decoded 64-bit `int` verbatim, while + * `Number` rounds magnitudes above 2^53 (review r3683813242). Returns + * `undefined` for invalid JSON, non-object roots, non-int64 tokens, and zero + * (where Go falls back to the `SchemaVersion` constant, `state.go:103-106`). + */ +export function legacyTelemetrySchemaVersionToken(text: string): string | undefined { + try { + JSON.parse(text); // the scanner below assumes well-formed JSON } catch { return undefined; } + const entries = scanRootJsonEntries(text); + if (entries === undefined) return undefined; + const token = lastNonNullToken(entries, "schema_version"); + if (token === undefined || !isInt64Token(token) || BigInt(token) === 0n) return undefined; + return token; } export const loadOrCreateLegacyTelemetryState = Effect.fn("legacy.telemetry.loadOrCreateState")( @@ -83,10 +469,16 @@ export const loadOrCreateLegacyTelemetryState = Effect.fn("legacy.telemetry.load const now = opts.now ?? new Date(); const nowIso = now.toISOString(); - const priorActive = - prior?.session_last_active !== undefined ? new Date(prior.session_last_active).getTime() : 0; + // The expiry comparison uses the epoch computed by `parseGoRfc3339Ms` + // during decode — NOT a `new Date(string)` re-parse. Go-valid forms JS + // cannot parse (comma fraction `…00,5Z`, offsets `+24:00`/`+05:60`) + // would NaN there and read as expired, rotating `session_id` where Go — + // which decoded the instant fine — retains it inside the 30-minute + // window (`LoadOrCreateState`, `state.go:140-148`; verified against the + // Go binary: a recent `…00,5Z` keeps the seeded session id). + const priorActiveMs = prior?.sessionLastActiveMs; const expired = - !Number.isFinite(priorActive) || now.getTime() - priorActive > SESSION_ROTATION_MS; + priorActiveMs === undefined || now.getTime() - priorActiveMs > SESSION_ROTATION_MS; const state: State = { enabled: prior?.enabled ?? true, @@ -95,11 +487,18 @@ export const loadOrCreateLegacyTelemetryState = Effect.fn("legacy.telemetry.load !expired && prior?.session_id !== undefined ? prior.session_id : crypto.randomUUID(), session_last_active: nowIso, ...(prior?.distinct_id !== undefined ? { distinct_id: prior.distinct_id } : {}), - schema_version: SCHEMA_VERSION, + // Go keeps a decoded file's non-zero schema_version (`state.go:103-106`). + // The numeric field is for in-memory readers; the exact token rides + // along for the write so magnitudes above 2^53 round-trip like Go. + schema_version: + prior?.schemaVersionToken !== undefined ? Number(prior.schemaVersionToken) : SCHEMA_VERSION, + ...(prior?.schemaVersionToken !== undefined + ? { schemaVersionToken: prior.schemaVersionToken } + : {}), }; yield* fs.makeDirectory(pathSvc.dirname(filePath), { recursive: true }); - yield* fs.writeFileString(filePath, JSON.stringify(state)); + yield* fs.writeFileString(filePath, serializeLegacyTelemetryState(state)); return state; }, ); @@ -116,7 +515,7 @@ export const setLegacyTelemetryEnabled = Effect.fn("legacy.telemetry.setEnabled" const nextState: State = { ...state, enabled }; const filePath = legacyTelemetryPath(process.env, pathSvc); yield* fs.makeDirectory(pathSvc.dirname(filePath), { recursive: true }); - yield* fs.writeFileString(filePath, JSON.stringify(nextState)); + yield* fs.writeFileString(filePath, serializeLegacyTelemetryState(nextState)); return nextState; }); @@ -138,7 +537,7 @@ const persistLegacyDistinctId = Effect.fn("legacy.telemetry.persistDistinctId")( distinctId !== undefined && distinctId.length > 0 ? { ...rest, distinct_id: distinctId } : rest; const filePath = legacyTelemetryPath(process.env, pathSvc); yield* fs.makeDirectory(pathSvc.dirname(filePath), { recursive: true }); - yield* fs.writeFileString(filePath, JSON.stringify(nextState)); + yield* fs.writeFileString(filePath, serializeLegacyTelemetryState(nextState)); }); const persistLegacyIdentityReset = Effect.fn("legacy.telemetry.persistIdentityReset")(function* () { @@ -149,7 +548,7 @@ const persistLegacyIdentityReset = Effect.fn("legacy.telemetry.persistIdentityRe const nextState: State = { ...rest, device_id: crypto.randomUUID() }; const filePath = legacyTelemetryPath(process.env, pathSvc); yield* fs.makeDirectory(pathSvc.dirname(filePath), { recursive: true }); - yield* fs.writeFileString(filePath, JSON.stringify(nextState)); + yield* fs.writeFileString(filePath, serializeLegacyTelemetryState(nextState)); }); /** diff --git a/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.unit.test.ts b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.unit.test.ts index a63a3f4063..fa1ff9afa4 100644 --- a/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.unit.test.ts +++ b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.unit.test.ts @@ -10,7 +10,12 @@ import { afterEach, beforeEach } from "vitest"; import { mockAnalytics } from "../../../tests/helpers/mocks.ts"; import { TelemetryRuntime } from "../../shared/telemetry/runtime.service.ts"; import { makeTelemetryIdentity } from "../../shared/telemetry/identity.ts"; -import { legacyTelemetryStateLayer } from "./legacy-telemetry-state.layer.ts"; +import { + legacyTelemetrySchemaVersionToken, + legacyTelemetryStateLayer, + loadOrCreateLegacyTelemetryState, + setLegacyTelemetryEnabled, +} from "./legacy-telemetry-state.layer.ts"; import { LegacyTelemetryState } from "./legacy-telemetry-state.service.ts"; let tempHome: string; @@ -170,3 +175,760 @@ describe("legacyTelemetryStateLayer.stitchLogin / clearDistinctId", () => { }, ); }); + +// Go's `decodeState` (`internal/telemetry/state.go:87-115`) is all-or-nothing: +// any missing/mistyped required field invalidates the WHOLE file, not just that +// field, so `LoadOrCreateState` regenerates enabled/device_id/session_id fresh. +describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothing recovery)", () => { + const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu; + + const runLoad = () => loadOrCreateLegacyTelemetryState().pipe(Effect.provide(BunServices.layer)); + const runLoadAt = (now: Date) => + loadOrCreateLegacyTelemetryState({ now }).pipe(Effect.provide(BunServices.layer)); + + it.effect("a bool-only file missing device_id/session_id is wholly regenerated", () => { + writeFileSync(telemetryPath(), JSON.stringify({ enabled: false })); + return Effect.gen(function* () { + const state = yield* runLoad(); + expect(state.enabled).toBe(true); + expect(state.device_id).toMatch(UUID_RE); + expect(state.session_id).toMatch(UUID_RE); + }); + }); + + it.effect("an empty device_id string invalidates an otherwise-valid file", () => { + writeFileSync( + telemetryPath(), + JSON.stringify({ + enabled: false, + device_id: "", + session_id: "session-1", + session_last_active: new Date().toISOString(), + schema_version: 2, + }), + ); + return Effect.gen(function* () { + const state = yield* runLoad(); + expect(state.enabled).toBe(true); + expect(state.device_id).toMatch(UUID_RE); + expect(state.session_id).not.toBe("session-1"); + }); + }); + + it.effect("a fully valid file with a recent session is preserved verbatim", () => { + writeFileSync( + telemetryPath(), + JSON.stringify({ + enabled: false, + device_id: "d", + session_id: "s", + session_last_active: new Date().toISOString(), + schema_version: 2, + }), + ); + return Effect.gen(function* () { + const state = yield* runLoad(); + expect(state.enabled).toBe(false); + expect(state.device_id).toBe("d"); + expect(state.session_id).toBe("s"); + expect(state.schema_version).toBe(2); + }); + }); + + it.effect( + "the consent form with a unix-millis session_last_active decodes and preserves enabled:false", + () => { + writeFileSync( + telemetryPath(), + JSON.stringify({ + consent: "denied", + device_id: "d", + session_id: "s", + session_last_active: 1750000000000, + }), + ); + return Effect.gen(function* () { + const state = yield* runLoad(); + expect(state.enabled).toBe(false); + expect(state.device_id).toBe("d"); + }); + }, + ); + + it.effect("a mistyped enabled on the consent form is malformed and is wholly regenerated", () => { + // Go's single-shot `json.Unmarshal` type-checks `Enabled *bool` even when + // `consent` decides the value (`state.go:35`, `state.go:88-91`): + // `"enabled":"invalid"` is an UnmarshalTypeError → errMalformedState → + // fresh state with telemetry re-enabled and new identities. + writeFileSync( + telemetryPath(), + JSON.stringify({ + consent: "denied", + enabled: "invalid", + device_id: "d", + session_id: "s", + session_last_active: new Date().toISOString(), + }), + ); + return Effect.gen(function* () { + const state = yield* runLoad(); + expect(state.enabled).toBe(true); + expect(state.device_id).not.toBe("d"); + expect(state.session_id).not.toBe("s"); + }); + }); + + it.effect("a null enabled on the consent form decodes and preserves the state", () => { + // JSON `null` unmarshals cleanly into Go's `Enabled *bool` (nil pointer, + // no error) and `parseConsent` then honors the consent value — only + // non-boolean, non-null types invalidate the file. + writeFileSync( + telemetryPath(), + JSON.stringify({ + consent: "denied", + enabled: null, + device_id: "d", + session_id: "s", + session_last_active: new Date().toISOString(), + }), + ); + return Effect.gen(function* () { + const state = yield* runLoad(); + expect(state.enabled).toBe(false); + expect(state.device_id).toBe("d"); + expect(state.session_id).toBe("s"); + }); + }); + + it.effect("an unrecognized consent value is malformed and is wholly regenerated", () => { + writeFileSync( + telemetryPath(), + JSON.stringify({ + consent: "maybe", + device_id: "d", + session_id: "s", + session_last_active: new Date().toISOString(), + }), + ); + return Effect.gen(function* () { + const state = yield* runLoad(); + expect(state.enabled).toBe(true); + expect(state.device_id).not.toBe("d"); + expect(state.session_id).not.toBe("s"); + }); + }); + + // Go's `time.Parse(time.RFC3339Nano, …)` rejects calendar-invalid dates + // ("day out of range" / "hour out of range") that JS `Date.parse` silently + // normalizes (Feb 29 → Mar 1, T24 → next day) — verified against go1.26. + // A file carrying one must be wholly regenerated, not preserved. + it.effect( + "a calendar-invalid session_last_active (Feb 29, non-leap year) is wholly regenerated", + () => { + writeFileSync( + telemetryPath(), + JSON.stringify({ + enabled: false, + device_id: "d", + session_id: "s", + session_last_active: "2025-02-29T00:00:00Z", + }), + ); + return Effect.gen(function* () { + const state = yield* runLoad(); + expect(state.enabled).toBe(true); + expect(state.device_id).toMatch(UUID_RE); + expect(state.session_id).toMatch(UUID_RE); + }); + }, + ); + + it.effect("a valid leap-day session_last_active decodes and preserves the state", () => { + writeFileSync( + telemetryPath(), + JSON.stringify({ + enabled: false, + device_id: "d", + session_id: "s", + session_last_active: "2024-02-29T00:00:00Z", + }), + ); + return Effect.gen(function* () { + const state = yield* runLoad(); + // The timestamp is long-stale so the session rotates, but the file + // decoded: enabled/device_id are preserved, exactly like Go. + expect(state.enabled).toBe(false); + expect(state.device_id).toBe("d"); + }); + }); + + it.effect("an out-of-range hour (T24) in session_last_active is wholly regenerated", () => { + writeFileSync( + telemetryPath(), + JSON.stringify({ + enabled: false, + device_id: "d", + session_id: "s", + session_last_active: "2025-01-01T24:00:00Z", + }), + ); + return Effect.gen(function* () { + const state = yield* runLoad(); + expect(state.enabled).toBe(true); + expect(state.device_id).not.toBe("d"); + }); + }); + + it.effect( + "a Go-valid zone offset JS cannot parse (+24:00) still decodes and preserves the state", + () => { + // Go's parser bounds the offset hour at 24 and minute at 60, so + // `+24:00` is a VALID Go timestamp — regenerating here (as a plain + // `Date.parse` validity check would) would wrongly reset `enabled` and + // rotate the device identity. + writeFileSync( + telemetryPath(), + JSON.stringify({ + enabled: false, + device_id: "d", + session_id: "s", + session_last_active: "2025-01-01T00:00:00+24:00", + }), + ); + return Effect.gen(function* () { + const state = yield* runLoad(); + expect(state.enabled).toBe(false); + expect(state.device_id).toBe("d"); + }); + }, + ); + + it.effect("a Go-valid comma fractional-second separator decodes and preserves the state", () => { + // Go's `time.Parse(time.RFC3339Nano, …)` accepts `,` as well as `.` + // before fractional seconds (`commaOrPeriod`, `time/format.go`; verified + // against go1.26). Classifying this as malformed would regenerate the + // file with telemetry re-enabled and fresh identities — Go preserves it. + writeFileSync( + telemetryPath(), + JSON.stringify({ + enabled: false, + device_id: "d", + session_id: "s", + session_last_active: "2025-01-01T00:00:00,123Z", + }), + ); + return Effect.gen(function* () { + const state = yield* runLoad(); + expect(state.enabled).toBe(false); + expect(state.device_id).toBe("d"); + }); + }); + + it.effect("a comma with no fractional digits is malformed and is wholly regenerated", () => { + // Go rejects `…T00:00:00,Z` ("cannot parse \",Z\" as \"Z07:00\"") — the + // separator only participates when at least one digit follows. + writeFileSync( + telemetryPath(), + JSON.stringify({ + enabled: false, + device_id: "d", + session_id: "s", + session_last_active: "2025-01-01T00:00:00,Z", + }), + ); + return Effect.gen(function* () { + const state = yield* runLoad(); + expect(state.enabled).toBe(true); + expect(state.device_id).not.toBe("d"); + }); + }); + + // Session expiry must be computed from the SAME component-level Go parse + // that validated the string — a `new Date(string)` re-parse NaNs on + // Go-valid forms (comma fraction, exotic offsets) and would count them as + // expired, rotating `session_id` where the Go binary retains it (verified: + // seeding `,5Z` and running `supabase-go telemetry status` keeps the + // seeded session id; the TS CLI before this fix rotated it). + it.effect("a recent comma-fraction timestamp keeps the session id within 30 minutes", () => { + writeFileSync( + telemetryPath(), + JSON.stringify({ + enabled: false, + device_id: "d", + session_id: "s", + session_last_active: "2025-01-01T00:00:00,5Z", + }), + ); + return Effect.gen(function* () { + const state = yield* runLoadAt(new Date("2025-01-01T00:10:00Z")); + expect(state.session_id).toBe("s"); + expect(state.device_id).toBe("d"); + expect(state.enabled).toBe(false); + }); + }); + + it.effect("a Go-exotic +05:60 offset participates in the expiry arithmetic", () => { + // `+05:60` normalizes to a 6-hour offset in Go, so this instant is + // 2025-01-01T00:00:00Z — 10 minutes before `now` → session retained. + // (JS `new Date` returns NaN for minute-60 offsets, which would rotate.) + writeFileSync( + telemetryPath(), + JSON.stringify({ + enabled: false, + device_id: "d", + session_id: "s", + session_last_active: "2025-01-01T06:00:00+05:60", + }), + ); + return Effect.gen(function* () { + const state = yield* runLoadAt(new Date("2025-01-01T00:10:00Z")); + expect(state.session_id).toBe("s"); + expect(state.device_id).toBe("d"); + }); + }); + + it.effect("a +24:00 offset shifts the instant a full day back, expiring the session", () => { + // Wall clock 2025-01-01T00:00:00 at +24:00 is 2024-12-31T00:00:00Z, so at + // `now` = 2025-01-01T00:10:00Z the session is 24h10m stale → Go rotates. + // Reading the wall clock as UTC (ignoring the offset) would wrongly + // retain it. The decoded file is still preserved (enabled/device_id). + writeFileSync( + telemetryPath(), + JSON.stringify({ + enabled: false, + device_id: "d", + session_id: "s", + session_last_active: "2025-01-01T00:00:00+24:00", + }), + ); + return Effect.gen(function* () { + const state = yield* runLoadAt(new Date("2025-01-01T00:10:00Z")); + expect(state.session_id).not.toBe("s"); + expect(state.device_id).toBe("d"); + expect(state.enabled).toBe(false); + }); + }); + + it.effect("consent-form unix millis beyond the JS Date range preserve the state like Go", () => { + // Go's `time.UnixMilli(9e15)` is a valid far-future instant (~year + // 287396): the state decodes, and `now.Sub(last)` is hugely negative → + // never expired, session retained. Kept as a plain number here so the + // comparison behaves identically (a `Date`/`toISOString` round-trip + // throws beyond ±8.64e15 and used to regenerate the whole file). + writeFileSync( + telemetryPath(), + JSON.stringify({ + consent: "denied", + device_id: "d", + session_id: "s", + session_last_active: 9_000_000_000_000_000, + }), + ); + return Effect.gen(function* () { + const state = yield* runLoad(); + expect(state.enabled).toBe(false); + expect(state.device_id).toBe("d"); + expect(state.session_id).toBe("s"); + }); + }); + + it.effect("consent-form unix millis beyond the int64 range regenerate everything like Go", () => { + // Go's `json.Unmarshal` into `int64` rejects the exponent token 1e+100 + // outright (any float/exponent token is an UnmarshalTypeError for int64) + // → `errMalformedState` → wholesale regeneration: telemetry re-enabled, + // fresh identities — even though the file said "denied". + writeFileSync( + telemetryPath(), + JSON.stringify({ + consent: "denied", + device_id: "d", + session_id: "s", + session_last_active: 1e100, + }), + ); + return Effect.gen(function* () { + const state = yield* runLoad(); + expect(state.enabled).toBe(true); + expect(state.device_id).not.toBe("d"); + expect(state.session_id).not.toBe("s"); + }); + }); + + it.effect("consent-form unix millis at Go's int64 bounds preserve the state", () => { + // Hand-built JSON so the raw text pins Go's exact max valid literal + // 9223372036854775807 (JSON.stringify of the rounded double would emit a + // different literal). The raw-token check accepts it via exact BigInt + // bounds — the parsed double rounds to 2^63 and could not distinguish it + // from Go-invalid 9223372036854775808 (see the companion test below). + writeFileSync( + telemetryPath(), + '{"consent":"denied","device_id":"d","session_id":"s","session_last_active":9223372036854775807}', + ); + return Effect.gen(function* () { + const state = yield* runLoad(); + expect(state.enabled).toBe(false); + expect(state.device_id).toBe("d"); + expect(state.session_id).toBe("s"); + }); + }); + + it.effect("consent-form unix millis at Go's int64 min decode but expire the session", () => { + // int64 min -9223372036854775808 = -(2^63) is exactly representable as a + // double, so this Go-valid literal round-trips precisely. The instant is + // far past, so — exactly like Go — the file DECODES (enabled/device_id + // preserved, no wholesale regeneration) while the >30-minute-stale + // session id rotates. + writeFileSync( + telemetryPath(), + '{"consent":"denied","device_id":"d","session_id":"s","session_last_active":-9223372036854775808}', + ); + return Effect.gen(function* () { + const state = yield* runLoad(); + expect(state.enabled).toBe(false); + expect(state.device_id).toBe("d"); + expect(state.session_id).not.toBe("s"); + }); + }); + + // Go's `json.Unmarshal` into `int64` validates the raw TOKEN, not the + // value: `1e3` and `…0.0` are UnmarshalTypeErrors even though `JSON.parse` + // collapses them to integer Numbers that pass `Number.isInteger` (verified + // against go1.26 via the repo's own `decodeState`). A value-level check + // would preserve `consent: "denied"` and the identities where Go + // regenerates a fresh telemetry-enabled state. + it.effect("consent-form unix millis written as an exponent token regenerate like Go", () => { + writeFileSync( + telemetryPath(), + '{"consent":"denied","device_id":"d","session_id":"s","session_last_active":1e3}', + ); + return Effect.gen(function* () { + const state = yield* runLoad(); + expect(state.enabled).toBe(true); + expect(state.device_id).not.toBe("d"); + expect(state.session_id).not.toBe("s"); + }); + }); + + it.effect( + "consent-form unix millis written as an integer-valued float regenerate like Go", + () => { + writeFileSync( + telemetryPath(), + '{"consent":"denied","device_id":"d","session_id":"s","session_last_active":1750000000000.0}', + ); + return Effect.gen(function* () { + const state = yield* runLoad(); + expect(state.enabled).toBe(true); + expect(state.device_id).not.toBe("d"); + expect(state.session_id).not.toBe("s"); + }); + }, + ); + + it.effect("consent-form unix millis one past int64 max regenerate exactly like Go", () => { + // 9223372036854775808 parses to the SAME double as Go's max valid literal + // 9223372036854775807 (both round to 2^63), so only the raw token can + // tell them apart — Go rejects this one with an UnmarshalTypeError. + writeFileSync( + telemetryPath(), + '{"consent":"denied","device_id":"d","session_id":"s","session_last_active":9223372036854775808}', + ); + return Effect.gen(function* () { + const state = yield* runLoad(); + expect(state.enabled).toBe(true); + expect(state.device_id).not.toBe("d"); + expect(state.session_id).not.toBe("s"); + }); + }); + + it.effect("a non-integer number token nested under an unknown key stays out of scope", () => { + // The raw-token capture is scoped to the ROOT object by holder identity. + // Go ignores unknown fields entirely, so a nested `session_last_active` + // must neither shadow nor invalidate the valid top-level millis. + writeFileSync( + telemetryPath(), + '{"consent":"denied","device_id":"d","session_id":"s","session_last_active":1750000000000,"extra":{"session_last_active":1.5}}', + ); + return Effect.gen(function* () { + const state = yield* runLoad(); + expect(state.enabled).toBe(false); + expect(state.device_id).toBe("d"); + }); + }); + + it.effect("a schema_version written as an integer-valued float regenerates like Go", () => { + // `SchemaVersion int` sits in the single-shot unmarshal (`state.go:41`), + // where the token `1.0` is an UnmarshalTypeError → the WHOLE file is + // malformed and regenerated, even though `JSON.parse` reads it as 1. + writeFileSync( + telemetryPath(), + '{"enabled":false,"device_id":"d","session_id":"s","session_last_active":"2026-01-01T00:00:00Z","schema_version":1.0}', + ); + return Effect.gen(function* () { + const state = yield* runLoad(); + expect(state.enabled).toBe(true); + expect(state.device_id).not.toBe("d"); + expect(state.session_id).not.toBe("s"); + }); + }); + + it.effect("a schema_version beyond the int64 range regenerates everything like Go", () => { + // `SchemaVersion int` sits in the same single-shot unmarshal + // (`state.go:41`, `state.go:88-90`): an overflowing value malforms the + // whole file, not just the field. + writeFileSync( + telemetryPath(), + JSON.stringify({ + enabled: false, + device_id: "d", + session_id: "s", + session_last_active: new Date().toISOString(), + schema_version: 1e100, + }), + ); + return Effect.gen(function* () { + const state = yield* runLoad(); + expect(state.enabled).toBe(true); + expect(state.device_id).not.toBe("d"); + expect(state.session_id).not.toBe("s"); + }); + }); + + // Go's single `json.Unmarshal` decodes EVERY occurrence of a duplicated + // key: values overwrite last-wins, but a wrong-typed occurrence records an + // UnmarshalTypeError even when a later duplicate is valid — and any error + // malforms the whole file (`state.go:34-42`, `state.go:88-91`). `JSON.parse` + // only surfaces the final occurrence, so these matrices are pinned against + // the repo's own `decodeState` on go1.26. + describe("duplicate root keys (Go per-occurrence decoding)", () => { + it.effect("a wrong-typed earlier consent regenerates even when the final one is valid", () => { + // Go: `cannot unmarshal bool into … rawState.consent of type string` — + // the file must NOT stay disabled off the surviving `"denied"`. + writeFileSync( + telemetryPath(), + '{"consent":false,"consent":"denied","session_last_active":1750000000000,"device_id":"d","session_id":"s"}', + ); + return Effect.gen(function* () { + const state = yield* runLoad(); + expect(state.enabled).toBe(true); + expect(state.device_id).not.toBe("d"); + }); + }); + + it.effect("a wrong-typed FINAL consent regenerates too", () => { + writeFileSync( + telemetryPath(), + '{"consent":"denied","consent":false,"session_last_active":1750000000000,"device_id":"d","session_id":"s"}', + ); + return Effect.gen(function* () { + const state = yield* runLoad(); + expect(state.enabled).toBe(true); + expect(state.device_id).not.toBe("d"); + }); + }); + + it.effect("well-typed duplicate enabled decodes cleanly with last-value-wins", () => { + writeFileSync( + telemetryPath(), + `{"enabled":true,"enabled":false,"session_last_active":${JSON.stringify(new Date().toISOString())},"device_id":"d","session_id":"s"}`, + ); + return Effect.gen(function* () { + const state = yield* runLoad(); + expect(state.enabled).toBe(false); + expect(state.device_id).toBe("d"); + expect(state.session_id).toBe("s"); + }); + }); + + it.effect("a non-integer earlier schema_version token regenerates like Go", () => { + // `1e3` into `SchemaVersion int` is an UnmarshalTypeError on the first + // occurrence; the valid `2` after it cannot save the file. + writeFileSync( + telemetryPath(), + '{"consent":"granted","session_last_active":1750000000000,"device_id":"d","session_id":"s","schema_version":1e3,"schema_version":2}', + ); + return Effect.gen(function* () { + const state = yield* runLoad(); + expect(state.device_id).not.toBe("d"); + expect(state.schema_version).toBe(1); + }); + }); + + it.effect("duplicate session_last_active takes the last token (json.RawMessage)", () => { + // The RawMessage field is never type-checked per occurrence — only the + // FINAL token is parsed (`state.go:69-85`), so junk before it is fine. + writeFileSync( + telemetryPath(), + '{"consent":"denied","session_last_active":true,"session_last_active":1750000000000,"device_id":"d","session_id":"s"}', + ); + return Effect.gen(function* () { + const state = yield* runLoad(); + expect(state.enabled).toBe(false); + expect(state.device_id).toBe("d"); + }); + }); + + it.effect( + "a wrong-typed earlier device_id regenerates even when the final one is valid", + () => { + writeFileSync( + telemetryPath(), + `{"enabled":false,"device_id":0,"device_id":"d","session_id":"s","session_last_active":${JSON.stringify(new Date().toISOString())}}`, + ); + return Effect.gen(function* () { + const state = yield* runLoad(); + expect(state.enabled).toBe(true); + expect(state.device_id).not.toBe("d"); + }); + }, + ); + + it.effect("null occurrences are decode-valid for pointer and string fields alike", () => { + // `null` → nil for `Enabled *bool` (later duplicate overwrites) and a + // no-op for `DeviceID string` — no UnmarshalTypeError anywhere. + writeFileSync( + telemetryPath(), + `{"enabled":null,"enabled":false,"device_id":null,"device_id":"d","session_id":"s","session_last_active":${JSON.stringify(new Date().toISOString())}}`, + ); + return Effect.gen(function* () { + const state = yield* runLoad(); + expect(state.enabled).toBe(false); + expect(state.device_id).toBe("d"); + expect(state.session_id).toBe("s"); + }); + }); + + it.effect("a null FINAL device_id keeps the earlier value (null is a decode no-op)", () => { + // Go keeps `DeviceID:"d"` — unmarshaling `null` into a non-pointer + // string leaves the previous occurrence's value in place, where + // `JSON.parse`'s last-value-wins would surface `null` and wrongly + // regenerate. + writeFileSync( + telemetryPath(), + `{"enabled":false,"device_id":"d","device_id":null,"session_id":"s","session_last_active":${JSON.stringify(new Date().toISOString())}}`, + ); + return Effect.gen(function* () { + const state = yield* runLoad(); + expect(state.enabled).toBe(false); + expect(state.device_id).toBe("d"); + expect(state.session_id).toBe("s"); + }); + }); + + it.effect("a null FINAL schema_version keeps the earlier non-zero value", () => { + writeFileSync( + telemetryPath(), + `{"enabled":false,"device_id":"d","session_id":"s","session_last_active":${JSON.stringify(new Date().toISOString())},"schema_version":7,"schema_version":null}`, + ); + return Effect.gen(function* () { + const state = yield* runLoad(); + expect(state.enabled).toBe(false); + expect(state.schema_version).toBe(7); + }); + }); + + it.effect("wrong-typed duplicates of UNKNOWN keys never invalidate the file", () => { + // Go skips unknown fields untyped — no occurrence of `junk` can error. + writeFileSync( + telemetryPath(), + `{"enabled":false,"junk":false,"junk":"x","device_id":"d","session_id":"s","session_last_active":${JSON.stringify(new Date().toISOString())}}`, + ); + return Effect.gen(function* () { + const state = yield* runLoad(); + expect(state.enabled).toBe(false); + expect(state.device_id).toBe("d"); + }); + }); + + it.effect("an escaped duplicate key is unescaped before field matching, like Go", () => { + // encoding/json unescapes key tokens before struct-field matching, so + // `"consent":false` is a wrong-typed `consent` occurrence. + writeFileSync( + telemetryPath(), + '{"\\u0063onsent":false,"consent":"denied","session_last_active":1750000000000,"device_id":"d","session_id":"s"}', + ); + return Effect.gen(function* () { + const state = yield* runLoad(); + expect(state.enabled).toBe(true); + expect(state.device_id).not.toBe("d"); + }); + }); + }); +}); + +describe("exact int64 schema_version round-trip (Go json.Marshal parity)", () => { + const runLoad = () => loadOrCreateLegacyTelemetryState().pipe(Effect.provide(BunServices.layer)); + + // File contents are hand-built strings: `JSON.stringify(9007199254740993)` + // would round inside the test itself, hiding exactly the bug under test. + const fileWith = (schemaVersionToken: string): string => + `{"enabled":false,"device_id":"d","session_id":"s","session_last_active":${JSON.stringify( + new Date().toISOString(), + )},"schema_version":${schemaVersionToken}}`; + + it.effect("a valid schema_version above 2^53 is persisted verbatim, like Go's int64", () => { + // Go decodes 9007199254740993 into `SchemaVersion int` exactly and + // `json.Marshal` re-emits it verbatim; a `Number` round-trip persists the + // rounded …992 (review r3683813242). + writeFileSync(telemetryPath(), fileWith("9007199254740993")); + return Effect.gen(function* () { + yield* runLoad(); + const written = readFileSync(telemetryPath(), "utf8"); + expect(written).toContain('"schema_version":9007199254740993'); + expect(written).not.toContain("9007199254740992"); + }); + }); + + it.effect("the int64 maximum round-trips exactly", () => { + writeFileSync(telemetryPath(), fileWith("9223372036854775807")); + return Effect.gen(function* () { + yield* runLoad(); + const written = readFileSync(telemetryPath(), "utf8"); + expect(written).toContain('"schema_version":9223372036854775807'); + }); + }); + + it.effect("setLegacyTelemetryEnabled's rewrite also preserves the exact token", () => { + writeFileSync(telemetryPath(), fileWith("9007199254740993")); + return Effect.gen(function* () { + yield* setLegacyTelemetryEnabled(true).pipe(Effect.provide(BunServices.layer)); + const written = readFileSync(telemetryPath(), "utf8"); + expect(written).toContain('"enabled":true'); + expect(written).toContain('"schema_version":9007199254740993'); + }); + }); + + it.effect("a zero schema_version still falls back to the current constant, like Go", () => { + writeFileSync(telemetryPath(), fileWith("0")); + return Effect.gen(function* () { + const state = yield* runLoad(); + expect(state.schema_version).toBe(1); + const written = readFileSync(telemetryPath(), "utf8"); + expect(written).toContain('"schema_version":1'); + }); + }); + + describe("legacyTelemetrySchemaVersionToken (identity-stitch writer helper)", () => { + it("returns the exact token for a valid non-zero int64, incl. above 2^53", () => { + expect(legacyTelemetrySchemaVersionToken(fileWith("9007199254740993"))).toBe( + "9007199254740993", + ); + expect(legacyTelemetrySchemaVersionToken(fileWith("7"))).toBe("7"); + }); + + it("returns undefined for zero, non-int64 tokens, and invalid documents", () => { + expect(legacyTelemetrySchemaVersionToken(fileWith("0"))).toBeUndefined(); + expect(legacyTelemetrySchemaVersionToken(fileWith("1.5"))).toBeUndefined(); + expect(legacyTelemetrySchemaVersionToken(fileWith("1e3"))).toBeUndefined(); + expect(legacyTelemetrySchemaVersionToken(fileWith("9223372036854775808"))).toBeUndefined(); + expect(legacyTelemetrySchemaVersionToken("not json")).toBeUndefined(); + expect(legacyTelemetrySchemaVersionToken("[1,2]")).toBeUndefined(); + }); + + it("uses the last non-null occurrence, like Go's decode-overwrite semantics", () => { + const doc = + '{"enabled":false,"device_id":"d","session_id":"s","session_last_active":"2026-01-01T00:00:00Z","schema_version":7,"schema_version":null}'; + expect(legacyTelemetrySchemaVersionToken(doc)).toBe("7"); + }); + }); +}); diff --git a/apps/cli/src/shared/cli/invalid-value-message.ts b/apps/cli/src/shared/cli/invalid-value-message.ts index b3eeb376fb..bdd06dfc14 100644 --- a/apps/cli/src/shared/cli/invalid-value-message.ts +++ b/apps/cli/src/shared/cli/invalid-value-message.ts @@ -36,14 +36,15 @@ // instead. const EXPECTED_PREFIX = "Expected "; -// Go-parity passthrough (CLI-1983): legacy flags that byte-match Go pflag's -// parse-time diagnostics (`legacyStringSliceFlag`'s malformed-CSV failure, -// `migration down --last`) fail with the COMPLETE Go message as `expected` — +// Go-parity passthrough (CLI-1983, CLI-1990): legacy flags that byte-match Go +// pflag's parse-time diagnostics (`legacyStringSliceFlag`'s malformed-CSV +// failure, `migration down --last`, and `storage cp --jobs` via +// `Flag.mapTryCatch`) fail with the COMPLETE Go message as `expected` — // pflag's `invalid argument %q for %q flag: %v` (pflag v1.0.10 // `errors.go:116`). Wrapping that in `CliError.InvalidValue`'s own // `Invalid value for flag --X: "V". Expected: ...` template would -// double-frame it and break the legacy shell's stderr contract, so render it -// verbatim instead. +// double-frame it and break the legacy shell's stderr contract (byte-parity +// with the Go CLI), so render it verbatim instead. const PFLAG_INVALID_ARGUMENT_PREFIX = "invalid argument "; export interface InvalidValueMessageFields { @@ -56,9 +57,9 @@ export interface InvalidValueMessageFields { /** * Rebuilds a `CliError.InvalidValue` message from its own template when * `expected` carries the doubled "Expected" prefix, or passes `expected` - * through verbatim when it is a complete pflag-format diagnostic. Returns - * `undefined` when `expected` is unaffected, so callers can fall back to the - * error's own untouched `message`. + * through verbatim when it is a complete pflag-format diagnostic (Go + * flag-parse parity). Returns `undefined` when `expected` is unaffected, so + * callers can fall back to the error's own untouched `message`. */ export function formatInvalidValueMessage(error: InvalidValueMessageFields): string | undefined { if (error.expected.startsWith(PFLAG_INVALID_ARGUMENT_PREFIX)) return error.expected; diff --git a/apps/cli/src/shared/functions/delete.ts b/apps/cli/src/shared/functions/delete.ts index 4e766c2952..810428a682 100644 --- a/apps/cli/src/shared/functions/delete.ts +++ b/apps/cli/src/shared/functions/delete.ts @@ -20,6 +20,13 @@ export interface DeleteFunctionDependencies { readonly resolveProjectRef: ( projectRef: Option.Option, ) => Effect.Effect; + /** + * Optional shell-specific styling for the slug/ref in the success line. + * Defaults to identity (plain text). The legacy shell injects Go's aqua + * here; keeping the hook injected preserves next-shell isolation from + * `legacy/`-specific rendering. + */ + readonly styleIdentifier?: (text: string) => string; } function validateSlug(slug: string): Effect.Effect { @@ -86,6 +93,10 @@ export function deleteFunction( return; } - yield* output.raw(`Deleted Function ${flags.slug} from project ${projectRef}.\n`); + // Go: `fmt.Printf("Deleted Function %s from project %s.\n", utils.Aqua(slug), + // utils.Aqua(projectRef))` (`internal/functions/delete/delete.go:20`) — the + // legacy handler injects the aqua styling via `styleIdentifier`; next stays plain. + const style = dependencies.styleIdentifier ?? ((text: string) => text); + yield* output.raw(`Deleted Function ${style(flags.slug)} from project ${style(projectRef)}.\n`); }).pipe(Effect.withSpan("functions.delete")); } diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index 92abccefce..325d0b16eb 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -71,6 +71,16 @@ interface DeployFunctionsDependencies { readonly resolveProjectRef: ( projectRef: Option.Option, ) => Effect.Effect; + /** + * Optional shell-specific styling hooks. Both default to identity (plain + * text); the legacy shell injects Go's aqua/bold here so the next shell + * stays isolated from `legacy/`-specific rendering. + * - `styleIdentifier`: the project ref in the stdout success line. + * - `styleEmphasis`: the slug in the stderr `Bundling Function:` line and + * the functions dir in the no-functions error. + */ + readonly styleIdentifier?: (text: string) => string; + readonly styleEmphasis?: (text: string) => string; } export interface ResolvedDeployFunctionConfig { @@ -1342,9 +1352,13 @@ const bundleFunctionWithDocker = Effect.fnUntraced(function* ( config: ResolvedDeployFunctionConfig, dockerNetworkId?: string, verbose = false, + styleEmphasis: (text: string) => string = (text) => text, ) { const output = yield* Output; - yield* output.raw(`Bundling Function: ${config.slug}\n`, "stderr"); + // Go: `fmt.Fprintln(os.Stderr, "Bundling Function:", utils.Bold(slug))` + // (`internal/functions/deploy/bundle.go:30`) — the legacy handler injects + // the bold styling via `styleEmphasis`; next stays plain. + yield* output.raw(`Bundling Function: ${styleEmphasis(config.slug)}\n`, "stderr"); const outputRoot = resolve(functionsDir, "..", ".temp"); yield* Effect.tryPromise(() => mkdir(outputRoot, { recursive: true })); @@ -2036,6 +2050,7 @@ const deployViaDocker = Effect.fnUntraced(function* ( api: ApiClient, dockerNetworkId?: string, verbose = false, + styleEmphasis: (text: string) => string = (text) => text, ) { const output = yield* Output; const remoteFunctions = yield* listRemoteFunctions(api, projectRef); @@ -2055,6 +2070,7 @@ const deployViaDocker = Effect.fnUntraced(function* ( config, dockerNetworkId, verbose, + styleEmphasis, ); const current = remoteBySlug.get(config.slug); if ( @@ -2143,6 +2159,8 @@ export function deployFunctions( ) { return Effect.gen(function* () { const output = yield* Output; + const styleIdentifier = dependencies.styleIdentifier ?? ((text: string) => text); + const styleEmphasis = dependencies.styleEmphasis ?? ((text: string) => text); const commandPath = ["functions", "deploy"] as const; // Presence-based (true for `--use-api=false`, not just bare `--use-api`) — mirrors // cobra's `Changed()`-driven `MarkFlagsMutuallyExclusive`, so it's only used for the @@ -2231,7 +2249,16 @@ export function deployFunctions( if (slugs.length === 0) { return yield* Effect.fail( new NoFunctionsToDeployError({ - message: `No Functions specified or found in ${SUPABASE_FUNCTIONS_DIR}`, + // Go: `errors.Errorf("No Functions specified or found in %s", + // utils.Bold(utils.FunctionsDir))` (`internal/functions/deploy/deploy.go:35`) — + // the legacy handler injects the bold styling via `styleEmphasis`. Styling is + // text-mode only: in `--output-format json`/`stream-json` this message lands in + // the structured error payload, which must stay free of ANSI escapes. + message: `No Functions specified or found in ${ + output.format === "text" + ? styleEmphasis(SUPABASE_FUNCTIONS_DIR) + : SUPABASE_FUNCTIONS_DIR + }`, }), ); } @@ -2289,6 +2316,7 @@ export function deployFunctions( dependencies.api, explicitStringFlag(dependencies.rawArgs, "network-id"), debugEnabled, + styleEmphasis, ); return true; }) @@ -2299,7 +2327,13 @@ export function deployFunctions( } if (output.format === "text") { - yield* output.raw(`Deployed Functions on project ${projectRef}: ${uniqueSlugs.join(", ")}\n`); + // Go: `fmt.Printf("Deployed Functions on project %s: %s\n", + // utils.Aqua(flags.ProjectRef), …)` (`internal/functions/deploy/deploy.go:70`) + // — the legacy handler injects the aqua styling via `styleIdentifier` + // (stdout-bound, so its TTY gate must check stdout); next stays plain. + yield* output.raw( + `Deployed Functions on project ${styleIdentifier(projectRef)}: ${uniqueSlugs.join(", ")}\n`, + ); yield* output.raw(`You can inspect your deployment in the Dashboard: ${dashboardUrl}\n`); } else { yield* output.success("Deployed Functions.", { diff --git a/apps/cli/src/shared/init/project-init.modes.integration.test.ts b/apps/cli/src/shared/init/project-init.modes.integration.test.ts new file mode 100644 index 0000000000..27d019e8ec --- /dev/null +++ b/apps/cli/src/shared/init/project-init.modes.integration.test.ts @@ -0,0 +1,85 @@ +import { mkdirSync, mkdtempSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; + +import { mockOutput, mockStdin, mockTty } from "../../../tests/helpers/mocks.ts"; +import { initProject } from "./project-init.ts"; + +function makeTempProjectDir(): string { + return mkdtempSync(join(tmpdir(), "supabase-init-modes-")); +} + +function runInit(cwd: string) { + const out = mockOutput({ format: "text", interactive: false }); + // `initProject`'s type requires `Stdin` (the IDE-settings prompt path threads + // through it), even though `interactive: false` below means it's never read. + const layer = Layer.mergeAll(out.layer, mockTty(), mockStdin(false), BunServices.layer); + return initProject({ + cwd, + force: false, + useOrioledb: false, + interactive: false, + yes: false, + withVscodeSettings: false, + withIntellijSettings: false, + }).pipe(Effect.provide(layer)); +} + +// Go pins every init-scaffolded directory to 0755 and file to 0644 +// (`internal/init/init.go:89,121,138,151,166` via `utils.WriteFile`/ +// `MkdirIfNotExistFS`, `internal/utils/misc.go:273,281-284`). Node's own +// umask-masked defaults happen to coincide under the common `022`, so pin the +// process umask to 0 here to prove the modes are pinned explicitly, not +// incidental to the ambient umask. +describe("initProject file modes (Go parity: 0755 dirs, 0644 files)", () => { + it.live("pins the supabase dir and config.toml to Go's exact modes", () => { + const cwd = makeTempProjectDir(); + const prevUmask = process.umask(0); + + return runInit(cwd).pipe( + Effect.andThen( + Effect.sync(() => { + const supabaseDir = join(cwd, "supabase"); + const configTomlPath = join(supabaseDir, "config.toml"); + + expect(statSync(supabaseDir).mode & 0o777).toBe(0o755); + expect(statSync(configTomlPath).mode & 0o777).toBe(0o644); + }), + ), + Effect.ensuring( + Effect.sync(() => { + process.umask(prevUmask); + rmSync(cwd, { recursive: true, force: true }); + }), + ), + ); + }); + + it.live( + "pins a freshly created supabase/.gitignore to Go's exact file mode inside a git repo", + () => { + const cwd = makeTempProjectDir(); + mkdirSync(join(cwd, ".git")); + const prevUmask = process.umask(0); + + return runInit(cwd).pipe( + Effect.andThen( + Effect.sync(() => { + const gitignorePath = join(cwd, "supabase", ".gitignore"); + expect(statSync(gitignorePath).mode & 0o777).toBe(0o644); + }), + ), + Effect.ensuring( + Effect.sync(() => { + process.umask(prevUmask); + rmSync(cwd, { recursive: true, force: true }); + }), + ), + ); + }, + ); +}); diff --git a/apps/cli/src/shared/init/project-init.ts b/apps/cli/src/shared/init/project-init.ts index 0c73e8f5ed..e9e1d55c98 100644 --- a/apps/cli/src/shared/init/project-init.ts +++ b/apps/cli/src/shared/init/project-init.ts @@ -142,10 +142,20 @@ export interface ProjectInitOptions { readonly withIntellijSettings: boolean; } +// Go pins every init-scaffolded file to 0644 and every directory to 0755 +// (`internal/init/init.go:89,121,138,151,166` via `utils.WriteFile`/ +// `MkdirIfNotExistFS`, `internal/utils/misc.go:273,281-284`; config.toml at +// `internal/utils/config.go:234,243`). Node's umask-masked defaults coincide +// under the common `022`, but pin explicitly to match Go under any umask. +const INIT_FILE_MODE = 0o644; +const INIT_DIR_MODE = 0o755; + function writeJsonFile(pathname: string, contents: Record) { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - yield* fs.writeFileString(pathname, `${JSON.stringify(contents, null, 2)}\n`); + yield* fs.writeFileString(pathname, `${JSON.stringify(contents, null, 2)}\n`, { + mode: INIT_FILE_MODE, + }); }); } @@ -154,13 +164,13 @@ function updateJsonFile(pathname: string, template: string) { const fs = yield* FileSystem.FileSystem; if (!(yield* fs.exists(pathname))) { - yield* fs.writeFileString(pathname, template); + yield* fs.writeFileString(pathname, template, { mode: INIT_FILE_MODE }); return; } const existing = yield* fs.readFileString(pathname); if (existing.trim().length === 0) { - yield* fs.writeFileString(pathname, template); + yield* fs.writeFileString(pathname, template, { mode: INIT_FILE_MODE }); return; } @@ -184,7 +194,7 @@ export const writeVscodeConfig = Effect.fnUntraced(function* ( const extensionsPath = path.join(vscodeDir, "extensions.json"); const settingsPath = path.join(vscodeDir, "settings.json"); - yield* fs.makeDirectory(vscodeDir, { recursive: true }); + yield* fs.makeDirectory(vscodeDir, { recursive: true, mode: INIT_DIR_MODE }); yield* updateJsonFile(extensionsPath, VSCODE_EXTENSIONS_TEMPLATE); yield* updateJsonFile(settingsPath, VSCODE_SETTINGS_TEMPLATE); @@ -207,8 +217,8 @@ export const writeIntelliJConfig = Effect.fnUntraced(function* ( const intellijDir = path.join(cwd, ".idea"); const denoPath = path.join(intellijDir, "deno.xml"); - yield* fs.makeDirectory(intellijDir, { recursive: true }); - yield* fs.writeFileString(denoPath, INTELLIJ_DENO_TEMPLATE); + yield* fs.makeDirectory(intellijDir, { recursive: true, mode: INIT_DIR_MODE }); + yield* fs.writeFileString(denoPath, INTELLIJ_DENO_TEMPLATE, { mode: INIT_FILE_MODE }); if (options?.announce ?? true) { yield* output.raw("Generated IntelliJ settings in .idea/deno.xml.\n"); @@ -272,7 +282,10 @@ const ensureSupabaseGitignore = Effect.fnUntraced(function* (cwd: string) { return; } - yield* fs.writeFileString(gitignorePath, INIT_GITIGNORE_TEMPLATE); + // The append branch above deliberately passes no mode: the file already + // exists there, and `writeFile`'s mode only applies at creation (as does + // Go's `OpenFile(..., os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)`). + yield* fs.writeFileString(gitignorePath, INIT_GITIGNORE_TEMPLATE, { mode: INIT_FILE_MODE }); }); /** @@ -299,10 +312,11 @@ export const initProject = Effect.fnUntraced(function* (options: ProjectInitOpti const projectId = sanitizeProjectId(path.basename(options.cwd)) || "supabase"; - yield* fs.makeDirectory(supabaseDir, { recursive: true }); + yield* fs.makeDirectory(supabaseDir, { recursive: true, mode: INIT_DIR_MODE }); yield* fs.writeFileString( configTomlPath, renderProjectConfigTemplate(projectId, options.useOrioledb), + { mode: INIT_FILE_MODE }, ); yield* ensureSupabaseGitignore(options.cwd); diff --git a/apps/cli/src/shared/output/normalize-error.unit.test.ts b/apps/cli/src/shared/output/normalize-error.unit.test.ts index feb74a74bc..d1fe9a7715 100644 --- a/apps/cli/src/shared/output/normalize-error.unit.test.ts +++ b/apps/cli/src/shared/output/normalize-error.unit.test.ts @@ -172,6 +172,27 @@ describe("normalizeCliError", () => { }); }); + test("InvalidValue surfaces a complete pflag-style 'expected' message verbatim (Go flag-parse parity)", () => { + // Legacy flags that reproduce Go's flag-parse rejections (e.g. + // `storage cp --jobs=-1` via `Flag.mapTryCatch`) put pflag's entire + // `invalid argument %q for %q flag: %v` string in `expected`. Wrapping it + // in Effect's `Invalid value for flag --jobs: …` template would break + // byte-parity with the Go CLI's stderr. + const error = new CliError.InvalidValue({ + option: "jobs", + value: "-1", + expected: + 'invalid argument "-1" for "-j, --jobs" flag: strconv.ParseUint: parsing "-1": invalid syntax', + kind: "flag", + }); + + expect(normalizeCliError(error)).toEqual({ + code: "InvalidValue", + message: + 'invalid argument "-1" for "-j, --jobs" flag: strconv.ParseUint: parsing "-1": invalid syntax', + }); + }); + test("ShowHelp envelope unwraps a single InvalidValue with the same doubled-prefix fix", () => { const error = { _tag: "ShowHelp",