From e2fbd7309af2540d400097d18a9742e670ae002e Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Tue, 28 Jul 2026 18:59:27 +0100 Subject: [PATCH 01/22] fix(cli): edge and cosmetic parity sweep from the 2026-07-24 audit (CLI-1990) Batch of small confirmed Go-parity divergences, each verified against apps/cli-go before fixing: Output/colour - functions delete/deploy: Aqua slug/ref and Bold bundling slug + No-Functions dir, with stdout-gated colour where the line is stdout-bound (delete.go:20, deploy.go:35,70, bundle.go:30) - encryption update-root-key: Aqua'd Finished line; removed the comment inventing a render-Aqua-as-plain convention (update.go:26) - start rollback: print "Stopping containers..." to stderr (docker.go:97 via start.go:77) - --debug prune reports: Pruned containers/volumes/network lines on stderr from legacyDockerRemoveAll, threading LegacyDebugFlag through stop/start (docker.go:123-143; "network" singular) - per-retry image-pull banner: Retrying after 4s/8s: (docker.go:312-314) - inspect db blocking: backtick-wrap blocking_statement (col 2) like Go's row format (blocking.go:56) - seed buckets: mutex group bracket keeps Go's registration order [local linked] (cmd/seed.go:32, cobra flag_groups.go:73) Input handling - snippets download: accept google/uuid Parse's 4 forms incl. the braced-form s[1:] quirk, canonical lowercase into the URL, Go's three error branches verbatim (download.go:15-19, uuid.go:68-117) - storage cp: reject negative --jobs with pflag's uint error template; keep the documented 0->1 clamp (cmd/storage.go:107) - postgres-config: strconv.ParseBool's exact case-sensitive set, int64-overflow falls back to the verbatim string, and the pretty table renders numbers with Go's %+v float64 formatting via the hoisted legacyGoFormatFloat (update.go:41-49, get.go:32-35) Filesystem modes (Go pins them; Node defaulted) - init templates 0644/0755, test new 0644/0755, inspect report 0755/0644, login fallback dir 0700 -> Go's 0755 (token stays 0600) Misc edges - migration new: print the Created line even when the stdin copy fails (Go's deferred Println, new.go:24-28), stdout-gated Bold - telemetry state: all-or-nothing decode like Go's decodeState -- a partially-corrupt file recreates everything fresh (state.go:87-115) - services: warn failed to load project ref on a ref-file read error and continue unlinked (services.go:18-20) - db dump: --file "" means stdout, no Dumped-schema line (dump.go:20-32, cmd/db.go:152-159) Fixes CLI-1990 --- .../legacy/auth/legacy-credentials.layer.ts | 5 +- .../legacy-credentials.layer.unit.test.ts | 26 +++- .../legacy/commands/db/dump/dump.handler.ts | 12 +- .../commands/db/dump/dump.integration.test.ts | 13 ++ .../legacy/commands/db/query/query.format.ts | 34 +---- .../update-root-key.handler.ts | 8 +- .../update-root-key.integration.test.ts | 6 +- .../delete/delete.integration.test.ts | 9 +- .../deploy/deploy.integration.test.ts | 12 +- .../inspect/db/blocking/blocking.query.ts | 8 +- .../inspect/db/legacy-inspect-query.ts | 8 +- .../legacy-inspect-specs.integration.test.ts | 21 +++ .../commands/inspect/report/report.handler.ts | 6 +- .../inspect/report/report.integration.test.ts | 11 +- .../commands/migration/new/new.handler.ts | 36 +++-- .../migration/new/new.integration.test.ts | 7 + .../postgres-config.integration.test.ts | 17 +++ .../postgres-config/postgres-config.shared.ts | 43 +++++- .../postgres-config.shared.unit.test.ts | 43 ++++++ .../commands/seed/buckets/buckets.e2e.test.ts | 4 +- .../commands/seed/buckets/buckets.flags.ts | 8 +- .../seed/buckets/buckets.flags.unit.test.ts | 2 +- .../commands/services/services.handler.ts | 20 ++- .../services/services.integration.test.ts | 15 ++ .../snippets/download/download.handler.ts | 97 ++++++++++--- .../download/download.integration.test.ts | 15 ++ .../download/download.uuid.unit.test.ts | 75 ++++++++++ .../legacy/commands/start/start.handler.ts | 28 ++-- .../legacy/commands/start/start.rollback.ts | 21 ++- .../start/start.rollback.unit.test.ts | 20 ++- .../src/legacy/commands/stop/stop.handler.ts | 16 ++- .../commands/stop/stop.integration.test.ts | 56 ++++++++ .../storage/cp/cp.command.integration.test.ts | 115 +++++++++++++++ .../legacy/commands/storage/cp/cp.command.ts | 10 +- .../legacy/commands/storage/cp/cp.handler.ts | 10 +- .../legacy/commands/storage/storage.errors.ts | 18 +++ .../legacy/commands/test/new/new.handler.ts | 6 +- .../commands/test/new/new.integration.test.ts | 12 +- .../src/legacy/shared/legacy-container-cli.ts | 48 +++++++ .../legacy/shared/legacy-db-target-flags.ts | 12 +- .../shared/legacy-docker-image-resolve.ts | 10 ++ .../legacy-docker-image-resolve.unit.test.ts | 67 ++++++++- .../legacy/shared/legacy-docker-remove-all.ts | 84 ++++++++--- apps/cli/src/legacy/shared/legacy-go-float.ts | 34 +++++ .../shared/legacy-go-float.unit.test.ts | 27 ++++ .../telemetry/legacy-telemetry-state.layer.ts | 134 +++++++++++++----- .../legacy-telemetry-state.layer.unit.test.ts | 101 ++++++++++++- .../delete/delete.integration.test.ts | 9 +- .../deploy/deploy.integration.test.ts | 27 ++-- apps/cli/src/shared/functions/delete.ts | 8 +- apps/cli/src/shared/functions/deploy.ts | 17 ++- .../project-init.modes.integration.test.ts | 85 +++++++++++ apps/cli/src/shared/init/project-init.ts | 30 ++-- 53 files changed, 1348 insertions(+), 218 deletions(-) create mode 100644 apps/cli/src/legacy/commands/postgres-config/postgres-config.shared.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/snippets/download/download.uuid.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/storage/cp/cp.command.integration.test.ts create mode 100644 apps/cli/src/legacy/shared/legacy-go-float.ts create mode 100644 apps/cli/src/legacy/shared/legacy-go-float.unit.test.ts create mode 100644 apps/cli/src/shared/init/project-init.modes.integration.test.ts 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.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.integration.test.ts b/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts index 9f735d97c5..0731cd601a 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 @@ -113,7 +113,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 +253,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 +402,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( @@ -636,7 +636,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( @@ -694,7 +694,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( @@ -765,7 +765,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( 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..47975f55df 100644 --- a/apps/cli/src/legacy/commands/snippets/download/download.handler.ts +++ b/apps/cli/src/legacy/commands/snippets/download/download.handler.ts @@ -16,25 +16,79 @@ 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 HEX_32_RE = /^[0-9a-fA-F]{32}$/; + +/** Minimal Go `%q` for the urn-prefix error — CLI args are ASCII in practice. */ +function goQuote(value: string): string { + let out = '"'; + for (const ch of value) { + const code = ch.codePointAt(0) ?? 0; + if (ch === '"' || ch === "\\") out += `\\${ch}`; + else if (ch === "\n") out += "\\n"; + else if (ch === "\t") out += "\\t"; + else if (ch === "\r") out += "\\r"; + else if (code >= 0x20 && code < 0x7f) out += ch; + else out += `\\x${code.toString(16).padStart(2, "0")}`; + } + return `${out}"`; +} + +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)}`; +} + +/** + * 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)`. + * + * 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 = input; + switch (s.length) { + // xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + case 36: + break; + // urn:uuid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + case 45: { + if (s.slice(0, 9).toLowerCase() !== "urn:uuid:") { + return { error: `invalid urn prefix: ${goQuote(s.slice(0, 9))}` }; + } + s = s.slice(9); + break; + } + // {xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx} + case 38: + s = s.slice(1); + break; + // xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + case 32: { + if (!HEX_32_RE.test(s)) return { error: "invalid UUID format" }; + return { canonical: canonicalFromHex(s.toLowerCase()) }; + } + default: + return { error: `invalid UUID length: ${input.length}` }; + } + // s is now at least 36 chars and must be xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx. + if (s[8] !== "-" || s[13] !== "-" || s[18] !== "-" || s[23] !== "-") { + return { error: "invalid UUID format" }; } - return "invalid snippet ID: invalid UUID format"; + const hex = s.slice(0, 8) + s.slice(9, 13) + s.slice(14, 18) + s.slice(19, 23) + s.slice(24, 36); + if (!HEX_32_RE.test(hex)) return { error: "invalid UUID format" }; + return { canonical: canonicalFromHex(hex.toLowerCase()) }; } // Tolerant body parse — see `list.handler.ts` for the rationale. The real @@ -66,9 +120,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 +134,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..83e102039c --- /dev/null +++ b/apps/cli/src/legacy/commands/snippets/download/download.uuid.unit.test.ts @@ -0,0 +1,75 @@ +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", + }); + }); +}); 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/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..bae787b404 --- /dev/null +++ b/apps/cli/src/legacy/commands/storage/cp/cp.command.integration.test.ts @@ -0,0 +1,115 @@ +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 { 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 { LegacyStorageMutuallyExclusiveFlagsError } from "../storage.errors.ts"; +import { legacyStorageCommand } from "../storage.command.ts"; +import { LegacyStorageInvalidJobsError } from "../storage.errors.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 cobra's +// mutual-exclusivity check and RunE. `cp.command.ts` reproduces that ordering +// explicitly in its own `Command.withHandler` (Effect CLI's `Flag.integer` +// accepts negatives, unlike a Go `uint` flag), checking it BEFORE the +// `--linked`/`--local` mutex check. 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 a --linked/--local mutex conflict", + () => { + // `--linked` and `--local` are BOTH set here on purpose: if the jobs + // check were not wired ahead of the mutex check, this would instead + // fail with `LegacyStorageMutuallyExclusiveFlagsError`. + const args = [ + "storage", + "cp", + "ss:///bucket/a", + "ss:///bucket/b", + "--jobs=-1", + "--linked", + "--local", + "--experimental", + ]; + 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); + expect( + Option.isSome(failure) && failure.value instanceof LegacyStorageInvalidJobsError, + ).toBe(true); + expect( + Option.isSome(failure) && + failure.value instanceof LegacyStorageMutuallyExclusiveFlagsError, + ).toBe(false); + if (Option.isSome(failure) && failure.value instanceof LegacyStorageInvalidJobsError) { + expect(failure.value.message).toBe( + 'invalid argument "-1" for "-j, --jobs" flag: strconv.ParseUint: parsing "-1": invalid syntax', + ); + } + } + }).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..0bdbf98d86 100644 --- a/apps/cli/src/legacy/commands/storage/cp/cp.command.ts +++ b/apps/cli/src/legacy/commands/storage/cp/cp.command.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect"; +import { Effect, Option } from "effect"; import { Argument, Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; @@ -7,6 +7,7 @@ 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 { LegacyStorageInvalidJobsError } from "../storage.errors.ts"; import { LegacyStorageLinkedFlagDef, LegacyStorageLocalFlagDef, @@ -70,6 +71,13 @@ export const legacyStorageCpCommand = Command.make("cp", config).pipe( // legacyRequireExperimental's doc comment for why. yield* legacyRequireExperimental; const cliArgs = yield* CliArgs; + // Go's `--jobs` is a pflag uint (`UintVarP`, `cmd/storage.go:107`), so a + // negative fails at flag-parse time — before cobra's group validation + // and RunE, and without emitting telemetry. `Flag.integer` accepts + // negatives, so reject here, before the mutex check and instrumentation. + if (Option.isSome(flags.jobs) && flags.jobs.value < 0) { + return yield* new LegacyStorageInvalidJobsError(flags.jobs.value); + } yield* legacyAssertStorageTargetsExclusive(cliArgs.args); const telemetryFlags = { recursive: flags.recursive, 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..8161588a7d 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. + // Negative `--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/storage.errors.ts b/apps/cli/src/legacy/commands/storage/storage.errors.ts index af87bd9059..b594d445d9 100644 --- a/apps/cli/src/legacy/commands/storage/storage.errors.ts +++ b/apps/cli/src/legacy/commands/storage/storage.errors.ts @@ -51,6 +51,24 @@ export class LegacyStorageUnsupportedOperationError extends Data.TaggedError( } } +/** + * `cp`'s `--jobs` is a pflag uint (`UintVarP`, `cmd/storage.go:107`): a + * negative value fails `strconv.ParseUint` 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`). + */ +export class LegacyStorageInvalidJobsError extends Data.TaggedError( + "LegacyStorageInvalidJobsError", +)<{ + readonly message: string; +}> { + constructor(jobs: number) { + super({ + message: `invalid argument "${jobs}" for "-j, --jobs" flag: strconv.ParseUint: parsing "${jobs}": invalid syntax`, + }); + } +} + /** `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-container-cli.ts b/apps/cli/src/legacy/shared/legacy-container-cli.ts index 4708903b38..82edb6b7a5 100644 --- a/apps/cli/src/legacy/shared/legacy-container-cli.ts +++ b/apps/cli/src/legacy/shared/legacy-container-cli.ts @@ -125,6 +125,54 @@ 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: RUNTIME_NOT_FOUND_MESSAGE }), + ), + ), + ), + ), + ); + // 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..de1e7562d2 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-image-resolve.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-image-resolve.ts @@ -190,6 +190,16 @@ 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, so only the banner itself is added. + yield* Effect.sync(() => { + 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..11d317477a 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 @@ -96,8 +96,16 @@ 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 is ever written as a plain `string`, so + // filtering by `typeof chunk === "string"` 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 +141,17 @@ 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", + ]); } finally { globalThis.process.stderr.write = originalWrite; if (previousRegistry === undefined) delete process.env[REGISTRY_ENV]; @@ -147,8 +166,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 +188,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 +204,38 @@ describe("legacyMakeDockerImageResolver", () => { }), ); + 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/telemetry/legacy-telemetry-state.layer.ts b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts index ff4836fea4..3224b1a0e9 100644 --- a/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts +++ b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts @@ -24,49 +24,116 @@ function legacyTelemetryPath(env: Record, pathSvc: P } 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; + readonly session_last_active: string; + readonly distinct_id?: string; + readonly schema_version: number; } -function hasOwn(record: Record, key: string): boolean { - return Object.prototype.hasOwnProperty.call(record, key); -} +// 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. +const RFC3339_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; +/** + * 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. + * + * Two Go strictness corners are not reproducible here: `JSON.parse` collapses + * `2.0` → `2`, so an integer-valued float `schema_version`/millis passes where + * Go's unmarshal-into-int rejects it; and `enabled`'s type is only checked on + * the non-consent path, where Go's single-shot `json.Unmarshal` type-checks + * every field regardless. Both require a hand-corrupted file the CLI never + * writes. + */ function readExistingState(text: string): PriorState | undefined { try { const parsed = JSON.parse(text); - if (typeof parsed !== "object" || parsed === null) return undefined; + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) 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; + + // 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. + 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, "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; + + // Go's `parseSessionLastActive` (`state.go:69-85`): an RFC3339Nano string, + // or — only on the consent form — integer unix millis. + const rawLastActive = record.session_last_active; + let sessionLastActive: string; + if (typeof rawLastActive === "string") { + if (!RFC3339_RE.test(rawLastActive) || !Number.isFinite(Date.parse(rawLastActive))) { + return undefined; + } + sessionLastActive = rawLastActive; + } else if (allowUnixMillis && typeof rawLastActive === "number") { + if (!Number.isInteger(rawLastActive)) return undefined; + sessionLastActive = new Date(rawLastActive).toISOString(); + } else { + return undefined; } - if (hasOwn(record, "distinct_id")) { - if (typeof record.distinct_id !== "string") return undefined; - out.distinct_id = record.distinct_id; + + // Go: `if raw.DeviceID == "" || raw.SessionID == ""` → "missing identity". + if (typeof record.device_id !== "string" || record.device_id === "") return undefined; + if (typeof record.session_id !== "string" || record.session_id === "") return undefined; + + // `DistinctID string` / `SchemaVersion int`: absent → zero value; a wrong + // JSON type is a field-level unmarshal error → malformed. + if ( + record.distinct_id !== undefined && + record.distinct_id !== null && + typeof record.distinct_id !== "string" + ) { + return undefined; } - if (hasOwn(record, "schema_version")) { - if (!Number.isInteger(record.schema_version)) return undefined; + if ( + record.schema_version !== undefined && + record.schema_version !== null && + !Number.isInteger(record.schema_version) + ) { + return undefined; } - return out; + const schemaVersion = + typeof record.schema_version === "number" && record.schema_version !== 0 + ? record.schema_version + : SCHEMA_VERSION; + + return { + enabled, + device_id: record.device_id, + session_id: record.session_id, + session_last_active: sessionLastActive, + ...(typeof record.distinct_id === "string" && record.distinct_id.length > 0 + ? { distinct_id: record.distinct_id } + : {}), + schema_version: schemaVersion, + }; } catch { return undefined; } @@ -95,7 +162,8 @@ 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`). + schema_version: prior?.schema_version ?? SCHEMA_VERSION, }; yield* fs.makeDirectory(pathSvc.dirname(filePath), { recursive: true }); 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..b78c037aaa 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,10 @@ 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 { + legacyTelemetryStateLayer, + loadOrCreateLegacyTelemetryState, +} from "./legacy-telemetry-state.layer.ts"; import { LegacyTelemetryState } from "./legacy-telemetry-state.service.ts"; let tempHome: string; @@ -170,3 +173,99 @@ 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)); + + 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("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"); + }); + }); +}); diff --git a/apps/cli/src/next/commands/functions/delete/delete.integration.test.ts b/apps/cli/src/next/commands/functions/delete/delete.integration.test.ts index 6914188219..3ae4b6fbdd 100644 --- a/apps/cli/src/next/commands/functions/delete/delete.integration.test.ts +++ b/apps/cli/src/next/commands/functions/delete/delete.integration.test.ts @@ -129,6 +129,11 @@ function setup( return { out, layer, api }; } +// 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("functions delete", () => { it.live("deletes a function from the linked project in text mode", () => Effect.gen(function* () { @@ -141,7 +146,7 @@ describe("functions delete", () => { "https://api.supabase.com/v1/projects/abcdefghijklmnopqrst/functions/hello-world", ); expect(api.requests[0]?.headers["x-supabase-command"]).toBe("functions delete"); - expect(out.stdoutText).toBe( + expect(stripSgr(out.stdoutText)).toBe( "Deleted Function hello-world from project abcdefghijklmnopqrst.\n", ); }), @@ -160,7 +165,7 @@ describe("functions delete", () => { expect(api.requests[0]?.url).toBe( "https://api.supabase.com/v1/projects/qrstuvwxyzabcdefghij/functions/hello-world", ); - expect(out.stdoutText).toBe( + expect(stripSgr(out.stdoutText)).toBe( "Deleted Function hello-world from project qrstuvwxyzabcdefghij.\n", ); }), diff --git a/apps/cli/src/next/commands/functions/deploy/deploy.integration.test.ts b/apps/cli/src/next/commands/functions/deploy/deploy.integration.test.ts index 5709a21d5a..87fa1fab76 100644 --- a/apps/cli/src/next/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/next/commands/functions/deploy/deploy.integration.test.ts @@ -458,6 +458,11 @@ function setup( return { out, api, layer }; } +// Strip ANSI SGR (bold/aqua via `legacyBold`/`legacyAqua`) so byte-assertions +// are stable whether or not the test stdio supports color. +// eslint-disable-next-line no-control-regex +const stripSgr = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); + describe("functions deploy", () => { it.live("deploys multiple local functions through the API by default", () => { const tempDir = makeTempDir(); @@ -498,7 +503,7 @@ describe("functions deploy", () => { }); expect(out.stderrText).toContain("Deploying Function: hello-world\n"); expect(out.stderrText).toContain("Deploying Function: bye-world\n"); - expect(out.stdoutText).toContain( + expect(stripSgr(out.stdoutText)).toContain( `Deployed Functions on project ${PROJECT_REF}: hello-world, bye-world\n`, ); }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); @@ -515,7 +520,7 @@ describe("functions deploy", () => { yield* functionsDeploy(BASE_FLAGS).pipe(Effect.provide(layer)); - expect(out.stdoutText).toContain( + expect(stripSgr(out.stdoutText)).toContain( `Deployed Functions on project ${PROJECT_REF}: hello-world\n`, ); expect(out.stdoutText).not.toContain("hello-world, hello-world"); @@ -656,7 +661,7 @@ describe("functions deploy", () => { (request) => request.method === "POST" && request.path.endsWith("/functions/deploy"), ); expect(deployRequest?.urlParams).toContain("slug=custom-entry"); - expect(out.stdoutText).toContain( + expect(stripSgr(out.stdoutText)).toContain( `Deployed Functions on project ${PROJECT_REF}: custom-entry\n`, ); }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); @@ -693,7 +698,7 @@ describe("functions deploy", () => { expect(out.stderrText).toContain( "Rate limit exceeded while bulk updating functions. Retrying in 0s.\n", ); - expect(out.stdoutText).toContain( + expect(stripSgr(out.stdoutText)).toContain( `Deployed Functions on project ${PROJECT_REF}: hello-world, bye-world\n`, ); }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); @@ -1327,7 +1332,7 @@ describe("functions deploy", () => { path: `/v1/projects/${PROJECT_REF}/functions/deploy`, }); expect(out.stderrText).toContain("WARNING: Docker is not running\n"); - expect(out.stdoutText).toContain( + expect(stripSgr(out.stdoutText)).toContain( `Deployed Functions on project ${PROJECT_REF}: hello-world\n`, ); }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); @@ -1554,9 +1559,9 @@ describe("functions deploy", () => { expectedDockerBind(join(tempDir, "supabase", "custom_import_map.json")), ), ); - expect(out.stderrText).toContain("Bundling Function: hello-world\n"); + expect(stripSgr(out.stderrText)).toContain("Bundling Function: hello-world\n"); expect(out.stderrText).toContain("Deploying Function: hello-world (script size:"); - expect(out.stdoutText).toContain( + expect(stripSgr(out.stdoutText)).toContain( `Deployed Functions on project ${PROJECT_REF}: hello-world\n`, ); }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); @@ -1751,7 +1756,7 @@ describe("functions deploy", () => { expect(error.message).toContain("failed to open eszip:"); expect(error.message).toContain("output.eszip"); } - expect(out.stderrText).toContain("Bundling Function: hello-world\n"); + expect(stripSgr(out.stderrText)).toContain("Bundling Function: hello-world\n"); }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); @@ -2296,16 +2301,12 @@ describe("functions deploy", () => { jobs: Option.some(2), }).pipe(Effect.provide(layer)); - expect(out.stdoutText).toContain(`Deployed Functions on project ${PROJECT_REF}`); + expect(stripSgr(out.stdoutText)).toContain(`Deployed Functions on project ${PROJECT_REF}`); }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); }); describe("--prune confirmation (Go parity: deploy.go:180-195, console.go:64-102)", () => { - // Strip ANSI SGR (bold slugs via `legacyBold`) so byte-assertions are stable - // whether or not the test stderr supports color. - // eslint-disable-next-line no-control-regex - const stripSgr = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); const PRUNE_PROMPT = "Do you want to delete the following Functions from your project?\n \u2022 remote-only\n\n [y/N] "; diff --git a/apps/cli/src/shared/functions/delete.ts b/apps/cli/src/shared/functions/delete.ts index 4e766c2952..99eff6a9f4 100644 --- a/apps/cli/src/shared/functions/delete.ts +++ b/apps/cli/src/shared/functions/delete.ts @@ -1,6 +1,7 @@ import { operationDefinitions, type ApiClient } from "@supabase/api/effect"; import { Effect, type Option } from "effect"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import { legacyAqua } from "../../legacy/shared/legacy-colors.ts"; import { Output } from "../output/output.service.ts"; import { DeleteFunctionNetworkError, @@ -86,6 +87,11 @@ 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`) — + // stdout-bound, so the TTY gate must check stdout. + yield* output.raw( + `Deleted Function ${legacyAqua(flags.slug, process.stdout)} from project ${legacyAqua(projectRef, process.stdout)}.\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 a58ef2c432..e975f7c2ac 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -15,7 +15,7 @@ import { legacyPromptYesNo } from "../legacy/legacy-prompt-yes-no.ts"; import { CONTEXT_CANCELED_MESSAGE } from "../output/errors.ts"; import { Output } from "../output/output.service.ts"; import { spawnContainerCli } from "../../legacy/shared/legacy-container-cli.ts"; -import { legacyBold } from "../../legacy/shared/legacy-colors.ts"; +import { legacyAqua, legacyBold } from "../../legacy/shared/legacy-colors.ts"; import { legacyGetRegistryImageUrl } from "../../legacy/shared/legacy-docker-registry.ts"; import { findGitRootPath } from "../git/git-root.ts"; import { @@ -1344,7 +1344,9 @@ const bundleFunctionWithDocker = Effect.fnUntraced(function* ( verbose = false, ) { 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`). + yield* output.raw(`Bundling Function: ${legacyBold(config.slug)}\n`, "stderr"); const outputRoot = resolve(functionsDir, "..", ".temp"); yield* Effect.tryPromise(() => mkdir(outputRoot, { recursive: true })); @@ -2197,7 +2199,9 @@ 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`). + message: `No Functions specified or found in ${legacyBold(SUPABASE_FUNCTIONS_DIR)}`, }), ); } @@ -2265,7 +2269,12 @@ 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`) + // — stdout-bound, so the TTY gate must check stdout. + yield* output.raw( + `Deployed Functions on project ${legacyAqua(projectRef, process.stdout)}: ${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 8efcc21336..83daaafc7f 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"); @@ -270,7 +280,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 }); }); /** @@ -297,10 +310,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); From 9ae621eaa746f3e122cd7e5c8bf84a8084862d84 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 29 Jul 2026 11:09:42 +0100 Subject: [PATCH 02/22] fix(cli): inject Go aqua/bold styling from legacy handlers so next shell stays plain (review: shared delete/deploy styling) --- .../functions/delete/delete.handler.ts | 5 +++ .../functions/deploy/deploy.handler.ts | 9 +++++ .../delete/delete.integration.test.ts | 9 ++--- .../deploy/deploy.integration.test.ts | 27 ++++++++------- apps/cli/src/shared/functions/delete.ts | 17 ++++++---- apps/cli/src/shared/functions/deploy.ts | 33 +++++++++++++++---- 6 files changed, 66 insertions(+), 34 deletions(-) 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/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/next/commands/functions/delete/delete.integration.test.ts b/apps/cli/src/next/commands/functions/delete/delete.integration.test.ts index 3ae4b6fbdd..6914188219 100644 --- a/apps/cli/src/next/commands/functions/delete/delete.integration.test.ts +++ b/apps/cli/src/next/commands/functions/delete/delete.integration.test.ts @@ -129,11 +129,6 @@ function setup( return { out, layer, api }; } -// 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("functions delete", () => { it.live("deletes a function from the linked project in text mode", () => Effect.gen(function* () { @@ -146,7 +141,7 @@ describe("functions delete", () => { "https://api.supabase.com/v1/projects/abcdefghijklmnopqrst/functions/hello-world", ); expect(api.requests[0]?.headers["x-supabase-command"]).toBe("functions delete"); - expect(stripSgr(out.stdoutText)).toBe( + expect(out.stdoutText).toBe( "Deleted Function hello-world from project abcdefghijklmnopqrst.\n", ); }), @@ -165,7 +160,7 @@ describe("functions delete", () => { expect(api.requests[0]?.url).toBe( "https://api.supabase.com/v1/projects/qrstuvwxyzabcdefghij/functions/hello-world", ); - expect(stripSgr(out.stdoutText)).toBe( + expect(out.stdoutText).toBe( "Deleted Function hello-world from project qrstuvwxyzabcdefghij.\n", ); }), diff --git a/apps/cli/src/next/commands/functions/deploy/deploy.integration.test.ts b/apps/cli/src/next/commands/functions/deploy/deploy.integration.test.ts index 87fa1fab76..5709a21d5a 100644 --- a/apps/cli/src/next/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/next/commands/functions/deploy/deploy.integration.test.ts @@ -458,11 +458,6 @@ function setup( return { out, api, layer }; } -// Strip ANSI SGR (bold/aqua via `legacyBold`/`legacyAqua`) so byte-assertions -// are stable whether or not the test stdio supports color. -// eslint-disable-next-line no-control-regex -const stripSgr = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); - describe("functions deploy", () => { it.live("deploys multiple local functions through the API by default", () => { const tempDir = makeTempDir(); @@ -503,7 +498,7 @@ describe("functions deploy", () => { }); expect(out.stderrText).toContain("Deploying Function: hello-world\n"); expect(out.stderrText).toContain("Deploying Function: bye-world\n"); - expect(stripSgr(out.stdoutText)).toContain( + expect(out.stdoutText).toContain( `Deployed Functions on project ${PROJECT_REF}: hello-world, bye-world\n`, ); }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); @@ -520,7 +515,7 @@ describe("functions deploy", () => { yield* functionsDeploy(BASE_FLAGS).pipe(Effect.provide(layer)); - expect(stripSgr(out.stdoutText)).toContain( + expect(out.stdoutText).toContain( `Deployed Functions on project ${PROJECT_REF}: hello-world\n`, ); expect(out.stdoutText).not.toContain("hello-world, hello-world"); @@ -661,7 +656,7 @@ describe("functions deploy", () => { (request) => request.method === "POST" && request.path.endsWith("/functions/deploy"), ); expect(deployRequest?.urlParams).toContain("slug=custom-entry"); - expect(stripSgr(out.stdoutText)).toContain( + expect(out.stdoutText).toContain( `Deployed Functions on project ${PROJECT_REF}: custom-entry\n`, ); }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); @@ -698,7 +693,7 @@ describe("functions deploy", () => { expect(out.stderrText).toContain( "Rate limit exceeded while bulk updating functions. Retrying in 0s.\n", ); - expect(stripSgr(out.stdoutText)).toContain( + expect(out.stdoutText).toContain( `Deployed Functions on project ${PROJECT_REF}: hello-world, bye-world\n`, ); }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); @@ -1332,7 +1327,7 @@ describe("functions deploy", () => { path: `/v1/projects/${PROJECT_REF}/functions/deploy`, }); expect(out.stderrText).toContain("WARNING: Docker is not running\n"); - expect(stripSgr(out.stdoutText)).toContain( + expect(out.stdoutText).toContain( `Deployed Functions on project ${PROJECT_REF}: hello-world\n`, ); }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); @@ -1559,9 +1554,9 @@ describe("functions deploy", () => { expectedDockerBind(join(tempDir, "supabase", "custom_import_map.json")), ), ); - expect(stripSgr(out.stderrText)).toContain("Bundling Function: hello-world\n"); + expect(out.stderrText).toContain("Bundling Function: hello-world\n"); expect(out.stderrText).toContain("Deploying Function: hello-world (script size:"); - expect(stripSgr(out.stdoutText)).toContain( + expect(out.stdoutText).toContain( `Deployed Functions on project ${PROJECT_REF}: hello-world\n`, ); }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); @@ -1756,7 +1751,7 @@ describe("functions deploy", () => { expect(error.message).toContain("failed to open eszip:"); expect(error.message).toContain("output.eszip"); } - expect(stripSgr(out.stderrText)).toContain("Bundling Function: hello-world\n"); + expect(out.stderrText).toContain("Bundling Function: hello-world\n"); }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); @@ -2301,12 +2296,16 @@ describe("functions deploy", () => { jobs: Option.some(2), }).pipe(Effect.provide(layer)); - expect(stripSgr(out.stdoutText)).toContain(`Deployed Functions on project ${PROJECT_REF}`); + expect(out.stdoutText).toContain(`Deployed Functions on project ${PROJECT_REF}`); }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); }); describe("--prune confirmation (Go parity: deploy.go:180-195, console.go:64-102)", () => { + // Strip ANSI SGR (bold slugs via `legacyBold`) so byte-assertions are stable + // whether or not the test stderr supports color. + // eslint-disable-next-line no-control-regex + const stripSgr = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); const PRUNE_PROMPT = "Do you want to delete the following Functions from your project?\n \u2022 remote-only\n\n [y/N] "; diff --git a/apps/cli/src/shared/functions/delete.ts b/apps/cli/src/shared/functions/delete.ts index 99eff6a9f4..810428a682 100644 --- a/apps/cli/src/shared/functions/delete.ts +++ b/apps/cli/src/shared/functions/delete.ts @@ -1,7 +1,6 @@ import { operationDefinitions, type ApiClient } from "@supabase/api/effect"; import { Effect, type Option } from "effect"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; -import { legacyAqua } from "../../legacy/shared/legacy-colors.ts"; import { Output } from "../output/output.service.ts"; import { DeleteFunctionNetworkError, @@ -21,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 { @@ -88,10 +94,9 @@ export function deleteFunction( } // 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. - yield* output.raw( - `Deleted Function ${legacyAqua(flags.slug, process.stdout)} from project ${legacyAqua(projectRef, process.stdout)}.\n`, - ); + // 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 e975f7c2ac..ff5919f105 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -15,7 +15,7 @@ import { legacyPromptYesNo } from "../legacy/legacy-prompt-yes-no.ts"; import { CONTEXT_CANCELED_MESSAGE } from "../output/errors.ts"; import { Output } from "../output/output.service.ts"; import { spawnContainerCli } from "../../legacy/shared/legacy-container-cli.ts"; -import { legacyAqua, legacyBold } from "../../legacy/shared/legacy-colors.ts"; +import { legacyBold } from "../../legacy/shared/legacy-colors.ts"; import { legacyGetRegistryImageUrl } from "../../legacy/shared/legacy-docker-registry.ts"; import { findGitRootPath } from "../git/git-root.ts"; import { @@ -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,11 +1352,13 @@ const bundleFunctionWithDocker = Effect.fnUntraced(function* ( config: ResolvedDeployFunctionConfig, dockerNetworkId?: string, verbose = false, + styleEmphasis: (text: string) => string = (text) => text, ) { const output = yield* Output; // Go: `fmt.Fprintln(os.Stderr, "Bundling Function:", utils.Bold(slug))` - // (`internal/functions/deploy/bundle.go:30`). - yield* output.raw(`Bundling Function: ${legacyBold(config.slug)}\n`, "stderr"); + // (`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 })); @@ -2004,6 +2016,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); @@ -2023,6 +2036,7 @@ const deployViaDocker = Effect.fnUntraced(function* ( config, dockerNetworkId, verbose, + styleEmphasis, ); const current = remoteBySlug.get(config.slug); if ( @@ -2111,6 +2125,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 @@ -2200,8 +2216,9 @@ export function deployFunctions( return yield* Effect.fail( new NoFunctionsToDeployError({ // Go: `errors.Errorf("No Functions specified or found in %s", - // utils.Bold(utils.FunctionsDir))` (`internal/functions/deploy/deploy.go:35`). - message: `No Functions specified or found in ${legacyBold(SUPABASE_FUNCTIONS_DIR)}`, + // utils.Bold(utils.FunctionsDir))` (`internal/functions/deploy/deploy.go:35`) — + // the legacy handler injects the bold styling via `styleEmphasis`. + message: `No Functions specified or found in ${styleEmphasis(SUPABASE_FUNCTIONS_DIR)}`, }), ); } @@ -2259,6 +2276,7 @@ export function deployFunctions( dependencies.api, explicitStringFlag(dependencies.rawArgs, "network-id"), debugEnabled, + styleEmphasis, ); return true; }) @@ -2271,9 +2289,10 @@ export function deployFunctions( if (output.format === "text") { // 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. + // — 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 ${legacyAqua(projectRef, process.stdout)}: ${uniqueSlugs.join(", ")}\n`, + `Deployed Functions on project ${styleIdentifier(projectRef)}: ${uniqueSlugs.join(", ")}\n`, ); yield* output.raw(`You can inspect your deployment in the Dashboard: ${dashboardUrl}\n`); } else { From da936f1b693129794e632b2999f83e3690d04df8 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 29 Jul 2026 11:17:22 +0100 Subject: [PATCH 03/22] fix(cli): reject negative storage cp --jobs at flag-parse time like Go's pflag uint (review: cp.command.ts thread) --- .../storage/cp/cp.command.integration.test.ts | 53 ++++++++++--------- .../legacy/commands/storage/cp/cp.command.ts | 35 ++++++++---- .../legacy/commands/storage/storage.errors.ts | 17 +++--- .../src/shared/cli/invalid-value-message.ts | 15 ++++-- .../output/normalize-error.unit.test.ts | 21 ++++++++ 5 files changed, 92 insertions(+), 49 deletions(-) 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 index bae787b404..14d9e2db0b 100644 --- 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 @@ -5,6 +5,7 @@ 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, @@ -16,17 +17,17 @@ import { } from "../../../../../tests/helpers/mocks.ts"; import { makeTelemetryIdentity } from "../../../../shared/telemetry/identity.ts"; import { TelemetryRuntime } from "../../../../shared/telemetry/runtime.service.ts"; -import { LegacyStorageMutuallyExclusiveFlagsError } from "../storage.errors.ts"; import { legacyStorageCommand } from "../storage.command.ts"; -import { LegacyStorageInvalidJobsError } from "../storage.errors.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 cobra's -// mutual-exclusivity check and RunE. `cp.command.ts` reproduces that ordering -// explicitly in its own `Command.withHandler` (Effect CLI's `Flag.integer` -// accepts negatives, unlike a Go `uint` flag), checking it BEFORE the -// `--linked`/`--local` mutex check. This suite proves the rejection is wired -// into the real command tree — not just reachable by calling +// 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. @@ -74,11 +75,14 @@ function setup(args: ReadonlyArray) { describe("legacy storage cp --jobs negative rejection (command-tree wiring)", () => { it.live( - "rejects --jobs=-1 with pflag's exact ParseUint message, ahead of a --linked/--local mutex conflict", + "rejects --jobs=-1 with pflag's exact ParseUint message, ahead of the experimental gate and the --linked/--local mutex conflict", () => { - // `--linked` and `--local` are BOTH set here on purpose: if the jobs - // check were not wired ahead of the mutex check, this would instead - // fail with `LegacyStorageMutuallyExclusiveFlagsError`. + // `--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", @@ -87,7 +91,6 @@ describe("legacy storage cp --jobs negative rejection (command-tree wiring)", () "--jobs=-1", "--linked", "--local", - "--experimental", ]; const { layer } = setup(args); return Effect.gen(function* () { @@ -96,18 +99,18 @@ describe("legacy storage cp --jobs negative rejection (command-tree wiring)", () if (Exit.isFailure(exit)) { const failure = Cause.findErrorOption(exit.cause); expect(Option.isSome(failure)).toBe(true); - expect( - Option.isSome(failure) && failure.value instanceof LegacyStorageInvalidJobsError, - ).toBe(true); - expect( - Option.isSome(failure) && - failure.value instanceof LegacyStorageMutuallyExclusiveFlagsError, - ).toBe(false); - if (Option.isSome(failure) && failure.value instanceof LegacyStorageInvalidJobsError) { - expect(failure.value.message).toBe( - 'invalid argument "-1" for "-j, --jobs" flag: strconv.ParseUint: parsing "-1": invalid syntax', - ); - } + // 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)); }, 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 0bdbf98d86..696eb29234 100644 --- a/apps/cli/src/legacy/commands/storage/cp/cp.command.ts +++ b/apps/cli/src/legacy/commands/storage/cp/cp.command.ts @@ -1,4 +1,4 @@ -import { Effect, Option } from "effect"; +import { Effect } from "effect"; import { Argument, Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; @@ -7,7 +7,7 @@ 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 { LegacyStorageInvalidJobsError } from "../storage.errors.ts"; +import { legacyStorageInvalidJobsMessage } from "../storage.errors.ts"; import { LegacyStorageLinkedFlagDef, LegacyStorageLocalFlagDef, @@ -40,6 +40,26 @@ const config = { jobs: Flag.integer("jobs").pipe( Flag.withAlias("j"), Flag.withDescription("Maximum number of parallel jobs. (default 1)"), + // Go's `--jobs` is a pflag uint (`UintVarP`, `cmd/storage.go:107`), so a + // negative fails 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. `Flag.integer` accepts negatives, + // so reject during parsing here; the resulting `CliError.InvalidValue` + // carries pflag's complete message, which `formatInvalidValueMessage` + // surfaces verbatim. Must sit before `Flag.optional`, which passes + // `InvalidValue` failures through untouched. Known residual: with a + // missing positional, Effect CLI reports the missing-argument error first + // (params parse in config-declaration order) where Go reports the flag + // error — same family as the `--jobs abc`/`3.5` parser-error divergence. + Flag.mapTryCatch( + (value) => { + if (value < 0) { + throw new Error(legacyStorageInvalidJobsMessage(value)); + } + return value; + }, + (err) => (err instanceof Error ? err.message : String(err)), + ), Flag.optional, ), linked: LegacyStorageLinkedFlagDef, @@ -68,16 +88,11 @@ 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 negative `--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; - // Go's `--jobs` is a pflag uint (`UintVarP`, `cmd/storage.go:107`), so a - // negative fails at flag-parse time — before cobra's group validation - // and RunE, and without emitting telemetry. `Flag.integer` accepts - // negatives, so reject here, before the mutex check and instrumentation. - if (Option.isSome(flags.jobs) && flags.jobs.value < 0) { - return yield* new LegacyStorageInvalidJobsError(flags.jobs.value); - } yield* legacyAssertStorageTargetsExclusive(cliArgs.args); const telemetryFlags = { recursive: flags.recursive, diff --git a/apps/cli/src/legacy/commands/storage/storage.errors.ts b/apps/cli/src/legacy/commands/storage/storage.errors.ts index b594d445d9..4a9545ea9c 100644 --- a/apps/cli/src/legacy/commands/storage/storage.errors.ts +++ b/apps/cli/src/legacy/commands/storage/storage.errors.ts @@ -55,18 +55,13 @@ export class LegacyStorageUnsupportedOperationError extends Data.TaggedError( * `cp`'s `--jobs` is a pflag uint (`UintVarP`, `cmd/storage.go:107`): a * negative value fails `strconv.ParseUint` 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`). + * shorthand-prefixed flag name (`pflag/errors.go:108-116`). 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 class LegacyStorageInvalidJobsError extends Data.TaggedError( - "LegacyStorageInvalidJobsError", -)<{ - readonly message: string; -}> { - constructor(jobs: number) { - super({ - message: `invalid argument "${jobs}" for "-j, --jobs" flag: strconv.ParseUint: parsing "${jobs}": invalid syntax`, - }); - } +export function legacyStorageInvalidJobsMessage(jobs: number): string { + return `invalid argument "${jobs}" for "-j, --jobs" flag: strconv.ParseUint: parsing "${jobs}": invalid syntax`; } /** `cp`'s remote→remote branch (`internal/storage/cp/cp.go:57`). */ diff --git a/apps/cli/src/shared/cli/invalid-value-message.ts b/apps/cli/src/shared/cli/invalid-value-message.ts index ff895e2a94..67bc9b2af6 100644 --- a/apps/cli/src/shared/cli/invalid-value-message.ts +++ b/apps/cli/src/shared/cli/invalid-value-message.ts @@ -36,6 +36,13 @@ // instead. const EXPECTED_PREFIX = "Expected "; +// Complete pflag-style messages: legacy flags that reproduce Go's flag-parse +// rejections (e.g. `storage cp --jobs`, via `Flag.mapTryCatch`) place pflag's +// entire `invalid argument %q for %q flag: %v` string in `expected`. Those +// must surface verbatim — wrapping them in Effect's `Invalid value for flag +// --x: …` template would break byte-parity with the Go CLI's stderr. +const PFLAG_MESSAGE_PREFIX = 'invalid argument "'; + export interface InvalidValueMessageFields { readonly option: string; readonly value: string; @@ -45,11 +52,13 @@ export interface InvalidValueMessageFields { /** * Rebuilds a `CliError.InvalidValue` message from its own template when - * `expected` carries the doubled "Expected" prefix. Returns `undefined` when - * `expected` is unaffected, so callers can fall back to the error's own - * untouched `message`. + * `expected` carries the doubled "Expected" prefix, or surfaces `expected` + * verbatim when it is already a complete pflag-style message (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_MESSAGE_PREFIX)) return error.expected; if (!error.expected.startsWith(EXPECTED_PREFIX)) return undefined; return error.kind === "argument" ? `Invalid value for argument <${error.option}>: "${error.value}". ${error.expected}` 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 5f7c122ae3..1305724a1f 100644 --- a/apps/cli/src/shared/output/normalize-error.unit.test.ts +++ b/apps/cli/src/shared/output/normalize-error.unit.test.ts @@ -129,6 +129,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", From c5f2e4ddecaf3184be8a4ec61aa103a23be6e1dc Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 29 Jul 2026 11:20:26 +0100 Subject: [PATCH 04/22] fix(cli): validate telemetry session_last_active with Go's calendar rules, not Date.parse (review: telemetry state thread) --- .../telemetry/legacy-telemetry-state.layer.ts | 55 ++++++++++-- .../legacy-telemetry-state.layer.unit.test.ts | 85 +++++++++++++++++++ 2 files changed, 132 insertions(+), 8 deletions(-) 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 3224b1a0e9..909b236584 100644 --- a/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts +++ b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts @@ -35,7 +35,40 @@ interface PriorState { // 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. -const RFC3339_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; +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; +} + +/** + * Component-level port of Go's `time.Parse(time.RFC3339Nano, …)` validity + * (`parseSessionLastActive`, `state.go:69-85`). `Date.parse` 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 zone offsets Go accepts — Go bounds the offset hour at 24 and + * the offset minute at 60 (`+24:00` and `+05:60` both parse), where JS + * returns NaN. + */ +function isGoRfc3339(text: string): boolean { + const match = RFC3339_RE.exec(text); + if (match === null) return false; + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + if (month < 1 || month > 12) return false; + if (day < 1 || day > daysInMonth(year, month)) return false; + if (Number(match[4]) > 23 || Number(match[5]) > 59 || Number(match[6]) > 59) return false; + if (match[7] !== undefined && (Number(match[7]) > 24 || Number(match[8]) > 60)) return false; + return true; +} /** * Faithful port of Go's `decodeState` (`internal/telemetry/state.go:87-115`): @@ -48,12 +81,14 @@ const RFC3339_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2} * new `device_id`, new `session_id`. Notably, a corrupt file that still says * `"enabled": false` does NOT stay disabled. * - * Two Go strictness corners are not reproducible here: `JSON.parse` collapses - * `2.0` → `2`, so an integer-valued float `schema_version`/millis passes where - * Go's unmarshal-into-int rejects it; and `enabled`'s type is only checked on - * the non-consent path, where Go's single-shot `json.Unmarshal` type-checks - * every field regardless. Both require a hand-corrupted file the CLI never - * writes. + * Three Go strictness corners are not reproducible here: `JSON.parse` + * collapses `2.0` → `2`, so an integer-valued float `schema_version`/millis + * passes where Go's unmarshal-into-int rejects it; `enabled`'s type is only + * checked on the non-consent path, where Go's single-shot `json.Unmarshal` + * type-checks every field regardless; and integer unix millis beyond + * ECMAScript's ±8.64e15 `Date` range but within int64 make `toISOString()` + * throw → caught → full regeneration, where Go preserves the state. All + * require a hand-corrupted file the CLI never writes. */ function readExistingState(text: string): PriorState | undefined { try { @@ -88,7 +123,7 @@ function readExistingState(text: string): PriorState | undefined { const rawLastActive = record.session_last_active; let sessionLastActive: string; if (typeof rawLastActive === "string") { - if (!RFC3339_RE.test(rawLastActive) || !Number.isFinite(Date.parse(rawLastActive))) { + if (!isGoRfc3339(rawLastActive)) { return undefined; } sessionLastActive = rawLastActive; @@ -150,6 +185,10 @@ export const loadOrCreateLegacyTelemetryState = Effect.fn("legacy.telemetry.load const now = opts.now ?? new Date(); const nowIso = now.toISOString(); + // A Go-valid timestamp JS cannot parse (offset `+24:00`/`+05:60`) yields + // NaN here → treated as expired, rotating the session — which Go would + // also do for any realistic such timestamp; `enabled`/identity are + // preserved either way. const priorActive = prior?.session_last_active !== undefined ? new Date(prior.session_last_active).getTime() : 0; const expired = 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 b78c037aaa..c97fff4f88 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 @@ -268,4 +268,89 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin 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"); + }); + }, + ); }); From 1372885d5b4bfadc94c3fd04afc671f64d021640 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 29 Jul 2026 13:19:48 +0100 Subject: [PATCH 05/22] fix(cli): narrow telemetry.json parse with an isRecord type guard instead of an as cast (review: telemetry typing thread) --- .../legacy/telemetry/legacy-telemetry-state.layer.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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 909b236584..db9abd465d 100644 --- a/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts +++ b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts @@ -70,6 +70,10 @@ function isGoRfc3339(text: string): boolean { return true; } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + /** * Faithful port of Go's `decodeState` (`internal/telemetry/state.go:87-115`): * ALL-OR-NOTHING. Go decodes the whole file or classifies it as @@ -92,9 +96,9 @@ function isGoRfc3339(text: string): boolean { */ function readExistingState(text: string): PriorState | undefined { try { - const parsed = JSON.parse(text); - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined; - const record = parsed as Record; + const parsed: unknown = JSON.parse(text); + if (!isRecord(parsed)) return undefined; + const record = parsed; // Go's `parseConsent` (`state.go:52-67`): a non-null `consent` must be // `granted`/`denied` (and unlocks the unix-millis timestamp form); From 8bf2f29488e29ae57c90366c33f36db61fed9671 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 29 Jul 2026 13:19:48 +0100 Subject: [PATCH 06/22] fix(cli): keep Go bold styling out of json/stream-json no-functions deploy error (review: deploy.ts styling thread) --- .../deploy/deploy.integration.test.ts | 91 ++++++++++++++++++- apps/cli/src/shared/functions/deploy.ts | 10 +- 2 files changed, 98 insertions(+), 3 deletions(-) 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 0731cd601a..112811d3d3 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"; @@ -776,4 +782,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/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index ff5919f105..62a33616ee 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -2217,8 +2217,14 @@ export function deployFunctions( new NoFunctionsToDeployError({ // 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`. - message: `No Functions specified or found in ${styleEmphasis(SUPABASE_FUNCTIONS_DIR)}`, + // 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 + }`, }), ); } From 32e0c04a0343b6dd7a3a26f5ca33c30dc4577128 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 29 Jul 2026 14:46:10 +0100 Subject: [PATCH 07/22] fix(cli): accept Go's comma fractional-second separator in telemetry state (review: telemetry RFC3339 thread) --- .../telemetry/legacy-telemetry-state.layer.ts | 15 ++++--- .../legacy-telemetry-state.layer.unit.test.ts | 43 +++++++++++++++++++ 2 files changed, 52 insertions(+), 6 deletions(-) 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 db9abd465d..191b579ee9 100644 --- a/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts +++ b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts @@ -34,9 +34,12 @@ interface PriorState { // 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. +// (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}))$/; + /^(\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; @@ -189,10 +192,10 @@ export const loadOrCreateLegacyTelemetryState = Effect.fn("legacy.telemetry.load const now = opts.now ?? new Date(); const nowIso = now.toISOString(); - // A Go-valid timestamp JS cannot parse (offset `+24:00`/`+05:60`) yields - // NaN here → treated as expired, rotating the session — which Go would - // also do for any realistic such timestamp; `enabled`/identity are - // preserved either way. + // A Go-valid timestamp JS cannot parse (offset `+24:00`/`+05:60`, comma + // fraction `…00,1Z`) yields NaN here → treated as expired, rotating the + // session — which Go would also do for any realistic such timestamp; + // `enabled`/identity are preserved either way. const priorActive = prior?.session_last_active !== undefined ? new Date(prior.session_last_active).getTime() : 0; const expired = 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 c97fff4f88..9daab63f58 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 @@ -353,4 +353,47 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin }); }, ); + + 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"); + }); + }); }); From 40b3adb56309cfe4bd85bbc1dcb7f9cca40d5918 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 29 Jul 2026 14:51:43 +0100 Subject: [PATCH 08/22] fix(cli): validate storage cp --jobs raw token like Go's strconv.ParseUint (review: --jobs unsigned thread) --- .../storage/cp/cp.command.integration.test.ts | 69 ++++++++++ .../legacy/commands/storage/cp/cp.command.ts | 42 ++++--- .../legacy/commands/storage/cp/cp.handler.ts | 4 +- .../commands/storage/cp/cp.parse-uint.ts | 119 ++++++++++++++++++ .../storage/cp/cp.parse-uint.unit.test.ts | 64 ++++++++++ .../legacy/commands/storage/storage.errors.ts | 12 +- 6 files changed, 287 insertions(+), 23 deletions(-) create mode 100644 apps/cli/src/legacy/commands/storage/cp/cp.parse-uint.ts create mode 100644 apps/cli/src/legacy/commands/storage/cp/cp.parse-uint.unit.test.ts 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 index 14d9e2db0b..d5e0c97318 100644 --- 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 @@ -115,4 +115,73 @@ describe("legacy storage cp --jobs negative rejection (command-tree wiring)", () }).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', + }, + ])( + "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)); + }, + ); }); 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 696eb29234..d1ee6e049e 100644 --- a/apps/cli/src/legacy/commands/storage/cp/cp.command.ts +++ b/apps/cli/src/legacy/commands/storage/cp/cp.command.ts @@ -8,6 +8,7 @@ import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-comm 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, @@ -37,26 +38,35 @@ 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 - // negative fails 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. `Flag.integer` accepts negatives, - // so reject during parsing here; the resulting `CliError.InvalidValue` - // carries pflag's complete message, which `formatInvalidValueMessage` - // surfaces verbatim. Must sit before `Flag.optional`, which passes - // `InvalidValue` failures through untouched. Known residual: with a - // missing positional, Effect CLI reports the missing-argument error first - // (params parse in config-declaration order) where Go reports the flag - // error — same family as the `--jobs abc`/`3.5` parser-error divergence. + // 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. Known residual: with a missing positional, Effect CLI + // reports the missing-argument error first (params parse in + // config-declaration order) where Go reports the flag error. Flag.mapTryCatch( - (value) => { - if (value < 0) { - throw new Error(legacyStorageInvalidJobsMessage(value)); + (token) => { + const parsed = legacyParseUintBase0(token); + if ("cause" in parsed) { + throw new Error(legacyStorageInvalidJobsMessage(token, parsed.cause)); } - return value; + return parsed.value; }, (err) => (err instanceof Error ? err.message : String(err)), ), @@ -88,7 +98,7 @@ 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. (A negative `--jobs` + // 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; 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 8161588a7d..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,8 +67,8 @@ export const legacyStorageCp = Effect.fn("legacy.storage.cp")(function* ( const runtimeInfo = yield* RuntimeInfo; const jobsFlag = Option.getOrElse(flags.jobs, () => 1); - // Negative `--jobs` is already rejected in `cp.command.ts` with pflag's uint - // parse error (Go: `UintVarP`, `cmd/storage.go:107`). The remaining clamp is + // 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` 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 4a9545ea9c..2cf60c1c40 100644 --- a/apps/cli/src/legacy/commands/storage/storage.errors.ts +++ b/apps/cli/src/legacy/commands/storage/storage.errors.ts @@ -53,15 +53,17 @@ export class LegacyStorageUnsupportedOperationError extends Data.TaggedError( /** * `cp`'s `--jobs` is a pflag uint (`UintVarP`, `cmd/storage.go:107`): a - * negative value fails `strconv.ParseUint` 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`). Thrown from the + * 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`). 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(jobs: number): string { - return `invalid argument "${jobs}" for "-j, --jobs" flag: strconv.ParseUint: parsing "${jobs}": invalid syntax`; +export function legacyStorageInvalidJobsMessage(token: string, cause: string): string { + return `invalid argument "${token}" for "-j, --jobs" flag: strconv.ParseUint: parsing "${token}": ${cause}`; } /** `cp`'s remote→remote branch (`internal/storage/cp/cp.go:57`). */ From 41a9e6c542dfaa317d873082afeae20d8b76c8e4 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 29 Jul 2026 14:54:42 +0100 Subject: [PATCH 09/22] test(cli): apply oxfmt to telemetry state comma tests --- .../legacy-telemetry-state.layer.unit.test.ts | 43 +++++++++---------- 1 file changed, 20 insertions(+), 23 deletions(-) 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 9daab63f58..5d50007a1e 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 @@ -354,29 +354,26 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin }, ); - 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 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 From 0d1aace4ea0dd8b5cf197f911f7ccd6be3eb6112 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 29 Jul 2026 14:54:42 +0100 Subject: [PATCH 10/22] fix(cli): measure snippet UUID forms in UTF-8 bytes like Go's uuid.Parse (review: UUID byte-length thread) --- .../snippets/download/download.handler.ts | 170 +++++++++++++++--- .../download/download.uuid.unit.test.ts | 38 ++++ 2 files changed, 185 insertions(+), 23 deletions(-) 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 47975f55df..ba3c550002 100644 --- a/apps/cli/src/legacy/commands/snippets/download/download.handler.ts +++ b/apps/cli/src/legacy/commands/snippets/download/download.handler.ts @@ -16,19 +16,91 @@ import { } from "../snippets.errors.ts"; import type { LegacySnippetsDownloadFlags } from "./download.command.ts"; -const HEX_32_RE = /^[0-9a-fA-F]{32}$/; +const DASH_BYTE = 0x2d; -/** Minimal Go `%q` for the urn-prefix error — CLI args are ASCII in practice. */ -function goQuote(value: string): string { +/** + * `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 goQuote). +// 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 the RAW UTF-8 bytes of the urn prefix, since + * Go slices `s[:9]` by byte — a slice that can split a multibyte rune, which + * `%q` then renders as `\xNN` per orphan byte (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`. + */ +function goQuote(bytes: Uint8Array): string { let out = '"'; - for (const ch of value) { - const code = ch.codePointAt(0) ?? 0; + 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 (ch === "\n") out += "\\n"; - else if (ch === "\t") out += "\\t"; - else if (ch === "\r") out += "\\r"; - else if (code >= 0x20 && code < 0x7f) out += ch; - else out += `\\x${code.toString(16).padStart(2, "0")}`; + 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}"`; } @@ -37,6 +109,42 @@ 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 @@ -49,6 +157,13 @@ function canonicalFromHex(hex32: string): string { * 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 @@ -57,38 +172,47 @@ function canonicalFromHex(hex32: string): string { export function legacyParseSnippetUuid( input: string, ): { readonly canonical: string } | { readonly error: string } { - let s = input; + 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 (s.slice(0, 9).toLowerCase() !== "urn:uuid:") { - return { error: `invalid urn prefix: ${goQuote(s.slice(0, 9))}` }; + if (!isUrnUuidPrefix(s)) { + return { error: `invalid urn prefix: ${goQuote(s.subarray(0, 9))}` }; } - s = s.slice(9); + s = s.subarray(9); break; } // {xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx} case 38: - s = s.slice(1); + s = s.subarray(1); break; // xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx case 32: { - if (!HEX_32_RE.test(s)) return { error: "invalid UUID format" }; - return { canonical: canonicalFromHex(s.toLowerCase()) }; + const hex = readHexRange(s, 0, 32); + if (hex === undefined) return { error: "invalid UUID format" }; + return { canonical: canonicalFromHex(hex) }; } default: - return { error: `invalid UUID length: ${input.length}` }; + 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" }; } - // s is now at least 36 chars and must be xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx. - if (s[8] !== "-" || s[13] !== "-" || s[18] !== "-" || s[23] !== "-") { + 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" }; } - const hex = s.slice(0, 8) + s.slice(9, 13) + s.slice(14, 18) + s.slice(19, 23) + s.slice(24, 36); - if (!HEX_32_RE.test(hex)) return { error: "invalid UUID format" }; - return { canonical: canonicalFromHex(hex.toLowerCase()) }; + return { canonical: canonicalFromHex(segments.join("")) }; } // Tolerant body parse — see `list.handler.ts` for the rationale. The real 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 index 83e102039c..189fe17892 100644 --- 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 @@ -72,4 +72,42 @@ describe("legacyParseSnippetUuid", () => { 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"', + }); + }); + }); }); From 2743a67211fc9389937508525e8467843a713c97 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 29 Jul 2026 16:14:39 +0100 Subject: [PATCH 11/22] fix(cli): quote --jobs error token with Go %q semantics like pflag/strconv (review: jobs %q thread) --- .../snippets/download/download.handler.ts | 90 +--------------- .../storage/cp/cp.command.integration.test.ts | 18 ++++ .../legacy/commands/storage/storage.errors.ts | 17 ++- apps/cli/src/legacy/shared/legacy-go-quote.ts | 101 ++++++++++++++++++ 4 files changed, 133 insertions(+), 93 deletions(-) create mode 100644 apps/cli/src/legacy/shared/legacy-go-quote.ts 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 ba3c550002..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 { @@ -18,93 +19,6 @@ import type { LegacySnippetsDownloadFlags } from "./download.command.ts"; const DASH_BYTE = 0x2d; -/** - * `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 goQuote). -// 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 the RAW UTF-8 bytes of the urn prefix, since - * Go slices `s[:9]` by byte — a slice that can split a multibyte rune, which - * `%q` then renders as `\xNN` per orphan byte (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`. - */ -function goQuote(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}"`; -} - 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)}`; } @@ -180,7 +94,7 @@ export function legacyParseSnippetUuid( // urn:uuid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx case 45: { if (!isUrnUuidPrefix(s)) { - return { error: `invalid urn prefix: ${goQuote(s.subarray(0, 9))}` }; + return { error: `invalid urn prefix: ${legacyGoQuote(s.subarray(0, 9))}` }; } s = s.subarray(9); break; 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 index d5e0c97318..b525282113 100644 --- 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 @@ -147,6 +147,24 @@ describe("legacy storage cp --jobs negative rejection (command-tree wiring)", () 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 }) => { diff --git a/apps/cli/src/legacy/commands/storage/storage.errors.ts b/apps/cli/src/legacy/commands/storage/storage.errors.ts index 2cf60c1c40..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 @@ -57,13 +58,19 @@ export class LegacyStorageUnsupportedOperationError extends Data.TaggedError( * 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`). 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. + * 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 { - return `invalid argument "${token}" for "-j, --jobs" flag: strconv.ParseUint: parsing "${token}": ${cause}`; + 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`). */ 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}"`; +} From c2e22b9a1b636d9a5d3778aaf132e3fc7c5d9a9e Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 29 Jul 2026 16:14:39 +0100 Subject: [PATCH 12/22] fix(cli): compute telemetry session expiry from the Go-shape timestamp parse (review: session rotation thread) --- .../telemetry/legacy-telemetry-state.layer.ts | 96 ++++++++++++------- .../legacy-telemetry-state.layer.unit.test.ts | 91 ++++++++++++++++++ 2 files changed, 152 insertions(+), 35 deletions(-) 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 191b579ee9..28e09368cd 100644 --- a/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts +++ b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts @@ -27,7 +27,8 @@ interface PriorState { readonly enabled: boolean; readonly device_id: string; readonly session_id: string; - readonly session_last_active: string; + /** Epoch millis of `session_last_active`, from the Go-shape parse below. */ + readonly sessionLastActiveMs: number; readonly distinct_id?: string; readonly schema_version: number; } @@ -39,7 +40,7 @@ interface PriorState { // `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}))$/; + /^(\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; @@ -50,27 +51,48 @@ function daysInMonth(year: number, month: number): number { } /** - * Component-level port of Go's `time.Parse(time.RFC3339Nano, …)` validity - * (`parseSessionLastActive`, `state.go:69-85`). `Date.parse` cannot stand in - * for it in either direction (verified against go1.26 and Bun 1.3): + * 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 zone offsets Go accepts — Go bounds the offset hour at 24 and - * the offset minute at 60 (`+24:00` and `+05:60` both parse), where JS - * returns NaN. + * - 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 isGoRfc3339(text: string): boolean { +function parseGoRfc3339Ms(text: string): number | undefined { const match = RFC3339_RE.exec(text); - if (match === null) return false; + if (match === null) return undefined; const year = Number(match[1]); const month = Number(match[2]); const day = Number(match[3]); - if (month < 1 || month > 12) return false; - if (day < 1 || day > daysInMonth(year, month)) return false; - if (Number(match[4]) > 23 || Number(match[5]) > 59 || Number(match[6]) > 59) return false; - if (match[7] !== undefined && (Number(match[7]) > 24 || Number(match[8]) > 60)) return false; - return true; + 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 isRecord(value: unknown): value is Record { @@ -88,14 +110,15 @@ function isRecord(value: unknown): value is Record { * new `device_id`, new `session_id`. Notably, a corrupt file that still says * `"enabled": false` does NOT stay disabled. * - * Three Go strictness corners are not reproducible here: `JSON.parse` + * Two Go strictness corners are not reproducible here: `JSON.parse` * collapses `2.0` → `2`, so an integer-valued float `schema_version`/millis - * passes where Go's unmarshal-into-int rejects it; `enabled`'s type is only - * checked on the non-consent path, where Go's single-shot `json.Unmarshal` - * type-checks every field regardless; and integer unix millis beyond - * ECMAScript's ±8.64e15 `Date` range but within int64 make `toISOString()` - * throw → caught → full regeneration, where Go preserves the state. All - * require a hand-corrupted file the CLI never writes. + * passes where Go's unmarshal-into-int rejects it; and `enabled`'s type is + * only checked on the non-consent path, where Go's single-shot + * `json.Unmarshal` type-checks every field regardless. Both require a + * hand-corrupted file the CLI never writes. (Unix millis beyond ECMAScript's + * ±8.64e15 `Date` range no longer 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 { @@ -126,17 +149,18 @@ function readExistingState(text: string): PriorState | undefined { } // Go's `parseSessionLastActive` (`state.go:69-85`): an RFC3339Nano string, - // or — only on the consent form — integer unix millis. + // or — only on the consent form — integer unix millis (`time.UnixMilli`). const rawLastActive = record.session_last_active; - let sessionLastActive: string; + let sessionLastActiveMs: number; if (typeof rawLastActive === "string") { - if (!isGoRfc3339(rawLastActive)) { + const parsedMs = parseGoRfc3339Ms(rawLastActive); + if (parsedMs === undefined) { return undefined; } - sessionLastActive = rawLastActive; + sessionLastActiveMs = parsedMs; } else if (allowUnixMillis && typeof rawLastActive === "number") { if (!Number.isInteger(rawLastActive)) return undefined; - sessionLastActive = new Date(rawLastActive).toISOString(); + sessionLastActiveMs = rawLastActive; } else { return undefined; } @@ -170,7 +194,7 @@ function readExistingState(text: string): PriorState | undefined { enabled, device_id: record.device_id, session_id: record.session_id, - session_last_active: sessionLastActive, + sessionLastActiveMs, ...(typeof record.distinct_id === "string" && record.distinct_id.length > 0 ? { distinct_id: record.distinct_id } : {}), @@ -192,14 +216,16 @@ export const loadOrCreateLegacyTelemetryState = Effect.fn("legacy.telemetry.load const now = opts.now ?? new Date(); const nowIso = now.toISOString(); - // A Go-valid timestamp JS cannot parse (offset `+24:00`/`+05:60`, comma - // fraction `…00,1Z`) yields NaN here → treated as expired, rotating the - // session — which Go would also do for any realistic such timestamp; - // `enabled`/identity are preserved either way. - 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, 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 5d50007a1e..67101a9626 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 @@ -181,6 +181,8 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin 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 })); @@ -393,4 +395,93 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin 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"); + }); + }); }); From c5890a9334c906ecb9536422dff1f09930176596 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 30 Jul 2026 11:50:36 +0100 Subject: [PATCH 13/22] fix(cli): reject mistyped enabled on the telemetry consent path like Go's single-shot unmarshal (review: codex) --- .../telemetry/legacy-telemetry-state.layer.ts | 23 +++++++--- .../legacy-telemetry-state.layer.unit.test.ts | 45 +++++++++++++++++++ 2 files changed, 62 insertions(+), 6 deletions(-) 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 28e09368cd..84a984c06f 100644 --- a/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts +++ b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts @@ -110,12 +110,10 @@ function isRecord(value: unknown): value is Record { * new `device_id`, new `session_id`. Notably, a corrupt file that still says * `"enabled": false` does NOT stay disabled. * - * Two Go strictness corners are not reproducible here: `JSON.parse` - * collapses `2.0` → `2`, so an integer-valued float `schema_version`/millis - * passes where Go's unmarshal-into-int rejects it; and `enabled`'s type is - * only checked on the non-consent path, where Go's single-shot - * `json.Unmarshal` type-checks every field regardless. Both require a - * hand-corrupted file the CLI never writes. (Unix millis beyond ECMAScript's + * One Go strictness corner is not reproducible here: `JSON.parse` collapses + * `2.0` → `2`, so an integer-valued float `schema_version`/millis passes + * where Go's unmarshal-into-int rejects it. It requires a hand-corrupted + * file the CLI never writes. (Unix millis beyond ECMAScript's * ±8.64e15 `Date` range no longer 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.) @@ -126,6 +124,19 @@ function readExistingState(text: string): PriorState | undefined { if (!isRecord(parsed)) return undefined; const record = parsed; + // Go's single-shot `json.Unmarshal` type-checks `Enabled *bool` + // (`state.go:35`, `state.go:88-91`) even when consent takes precedence + // below: a present, non-boolean `enabled` is a field-level unmarshal + // error → the whole file is malformed. JSON `null` into the pointer + // field is valid (leaves it nil), so null passes through. + if ( + record.enabled !== undefined && + record.enabled !== null && + typeof record.enabled !== "boolean" + ) { + 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. 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 67101a9626..8c8650cc60 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 @@ -253,6 +253,51 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin }, ); + 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(), From b09081ebc92073bfbe4fd4527e1d4d0f3f1a8243 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 30 Jul 2026 13:43:53 +0100 Subject: [PATCH 14/22] fix(cli): reject telemetry-state integers outside Go's int64 range (review: codex) --- .../telemetry/legacy-telemetry-state.layer.ts | 34 ++++++-- .../legacy-telemetry-state.layer.unit.test.ts | 79 +++++++++++++++++++ 2 files changed, 108 insertions(+), 5 deletions(-) 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 84a984c06f..182d5207c2 100644 --- a/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts +++ b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts @@ -99,6 +99,28 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +/** + * 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`) with `json.Unmarshal` into a signed 64-bit integer, so a + * non-integer or a magnitude outside that range fails the decode and the + * whole file is malformed → regenerated (`state.go:87-90`). The upper bound + * is `2 ** 63` INCLUSIVE: `JSON.parse` rounds Go's max valid value + * 9223372036854775807 up to exactly 2^63 (doubles at that magnitude are + * 1024 apart), so an exclusive bound would regenerate a file Go accepts. + * Go-invalid 9223372036854775808 collapses to the same double — that single + * post-parse value is inherently ambiguous, and inclusive is the closest + * achievable parity. (int64 min, -(2^63), is exactly representable.) + */ +function isGoInt64(value: unknown): value is number { + return ( + typeof value === "number" && + Number.isInteger(value) && + value >= -(2 ** 63) && + value <= 2 ** 63 + ); +} + /** * Faithful port of Go's `decodeState` (`internal/telemetry/state.go:87-115`): * ALL-OR-NOTHING. Go decodes the whole file or classifies it as @@ -111,9 +133,11 @@ function isRecord(value: unknown): value is Record { * `"enabled": false` does NOT stay disabled. * * One Go strictness corner is not reproducible here: `JSON.parse` collapses - * `2.0` → `2`, so an integer-valued float `schema_version`/millis passes - * where Go's unmarshal-into-int rejects it. It requires a hand-corrupted - * file the CLI never writes. (Unix millis beyond ECMAScript's + * `2.0` → `2` and `1e3` → `1000`, so an integer-valued float / in-range + * exponent-form `schema_version`/millis passes where Go's unmarshal-into-int + * rejects it. It requires a hand-corrupted file the CLI never writes. + * Magnitudes outside Go's int64 range DO regenerate like Go (see + * {@link isGoInt64}). (Unix millis in-range but beyond ECMAScript's * ±8.64e15 `Date` range no longer 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.) @@ -170,7 +194,7 @@ function readExistingState(text: string): PriorState | undefined { } sessionLastActiveMs = parsedMs; } else if (allowUnixMillis && typeof rawLastActive === "number") { - if (!Number.isInteger(rawLastActive)) return undefined; + if (!isGoInt64(rawLastActive)) return undefined; sessionLastActiveMs = rawLastActive; } else { return undefined; @@ -192,7 +216,7 @@ function readExistingState(text: string): PriorState | undefined { if ( record.schema_version !== undefined && record.schema_version !== null && - !Number.isInteger(record.schema_version) + !isGoInt64(record.schema_version) ) { return undefined; } 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 8c8650cc60..4a9a8bfc3d 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 @@ -529,4 +529,83 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin 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 1e100 (overflows the signed + // 64-bit range) → `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 + // 9223372036854775808 e-notation-free but a different literal). After + // JSON.parse it becomes exactly 2^63, which the inclusive bound accepts — + // an exclusive bound would regenerate a file Go decodes fine. + 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"); + }); + }); + + 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"); + }); + }); }); From f3b72a330bc0f23ab764b5e35695738944175f67 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 30 Jul 2026 13:45:58 +0100 Subject: [PATCH 15/22] fix(cli): start pull retry banners on a fresh line like Go's Fprintln (review: codex) --- .../shared/legacy-docker-image-resolve.ts | 21 +++++- .../legacy-docker-image-resolve.unit.test.ts | 74 ++++++++++++++++++- 2 files changed, 91 insertions(+), 4 deletions(-) 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 de1e7562d2..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); @@ -196,8 +207,14 @@ export function legacyMakeDockerImageResolver( // 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, so only the banner itself is added. + // 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 11d317477a..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", @@ -98,8 +115,9 @@ describe("legacyMakeDockerImageResolver", () => { 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 is ever written as a plain `string`, so - // filtering by `typeof chunk === "string"` isolates the banner from the tee below. + // 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 = ((chunk: unknown) => { @@ -152,6 +170,15 @@ describe("legacyMakeDockerImageResolver", () => { "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]; @@ -204,6 +231,49 @@ 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]; From 067e7e4f60e83947e04a1447b3c05b141f52eb74 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 30 Jul 2026 13:52:05 +0100 Subject: [PATCH 16/22] test(cli): add live coverage for stop --no-backup --debug prune reports (review: codex) --- .../legacy/commands/stop/stop.live.test.ts | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) 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(""); + }, + ); }); From 33ddb2e80dc00d5c4e9651006fff7d0290d48c60 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 30 Jul 2026 13:53:12 +0100 Subject: [PATCH 17/22] chore(cli): apply oxfmt formatting to telemetry state layer --- .../cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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 182d5207c2..0eca98ba5b 100644 --- a/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts +++ b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts @@ -114,10 +114,7 @@ function isRecord(value: unknown): value is Record { */ function isGoInt64(value: unknown): value is number { return ( - typeof value === "number" && - Number.isInteger(value) && - value >= -(2 ** 63) && - value <= 2 ** 63 + typeof value === "number" && Number.isInteger(value) && value >= -(2 ** 63) && value <= 2 ** 63 ); } From 54855da07a1b232e5eb2a4fb03b7137c3814d60c Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 30 Jul 2026 14:36:19 +0100 Subject: [PATCH 18/22] fix(cli): reject non-integer telemetry-state number tokens like Go (review: codex) --- .../telemetry/legacy-telemetry-state.layer.ts | 112 ++++++++++++++---- .../legacy-telemetry-state.layer.unit.test.ts | 97 +++++++++++++-- 2 files changed, 179 insertions(+), 30 deletions(-) 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 0eca98ba5b..2a91cf27a7 100644 --- a/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts +++ b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts @@ -99,22 +99,84 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +/** Stage-3 `JSON.parse` source-access reviver shape (implemented by Bun's JSC). */ +type ReviverWithSource = ( + this: unknown, + key: string, + value: unknown, + context?: { readonly source?: string }, +) => unknown; + +// Typed view of `JSON.parse` exposing the reviver's third `context` parameter, +// which TypeScript's lib.d.ts does not declare yet. A plain assignment (not a +// cast): a reviver taking an extra OPTIONAL parameter is assignable to the +// lib's two-parameter reviver type. +const jsonParseWithSource: (text: string, reviver: ReviverWithSource) => unknown = JSON.parse; + +/** + * `JSON.parse` that also captures the RAW source token of every number sitting + * directly on the root object (stage-3 "source access" reviver context, + * supported by Bun — the only runtime the CLI ships on). Holder identity + * (`this` in the reviver) scopes the capture to the root: a nested + * `{"x":{"session_last_active":1.5}}` never shadows a top-level key. A key + * duplicated in the text keeps the LAST token, matching both `JSON.parse` + * value semantics and Go's `encoding/json` overwrite behaviour. + */ +function parseWithRootNumberTokens(text: string): { + readonly parsed: unknown; + readonly rootNumberTokens: ReadonlyMap; +} { + const tokensByHolder = new Map>(); + const parsed = jsonParseWithSource(text, function (key, value, context) { + if ( + typeof value === "number" && + typeof this === "object" && + this !== null && + context?.source !== undefined + ) { + let tokens = tokensByHolder.get(this); + if (tokens === undefined) { + tokens = new Map(); + tokensByHolder.set(this, tokens); + } + tokens.set(key, context.source); + } + return value; + }); + const rootNumberTokens = + typeof parsed === "object" && parsed !== null ? tokensByHolder.get(parsed) : undefined; + return { parsed, rootNumberTokens: rootNumberTokens ?? new Map() }; +} + +const GO_INT64_MIN = -(2n ** 63n); +const GO_INT64_MAX = 2n ** 63n - 1n; +const INT64_TOKEN_RE = /^-?\d+$/; + /** * 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`) with `json.Unmarshal` into a signed 64-bit integer, so a - * non-integer or a magnitude outside that range fails the decode and the - * whole file is malformed → regenerated (`state.go:87-90`). The upper bound - * is `2 ** 63` INCLUSIVE: `JSON.parse` rounds Go's max valid value - * 9223372036854775807 up to exactly 2^63 (doubles at that magnitude are - * 1024 apart), so an exclusive bound would regenerate a file Go accepts. - * Go-invalid 9223372036854775808 collapses to the same double — that single - * post-parse value is inherently ambiguous, and inclusive is the closest - * achievable parity. (int64 min, -(2^63), is exactly representable.) + * `state.go:41`) by unmarshaling the RAW JSON number token into a signed + * 64-bit integer, 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 the parsed VALUE alone cannot reproduce Go — this check + * therefore validates the raw token text, with exact BigInt bounds (the + * doubles for int64-max and int64-max+1 are indistinguishable; the tokens are + * not). If the runtime ever fails to expose the reviver's `context.source` + * (never the case under Bun), it degrades to the closest value-level + * approximation rather than regenerating files Go accepts. */ -function isGoInt64(value: unknown): value is number { +function isGoInt64Token(source: string | undefined, value: unknown): value is number { + if (typeof value !== "number") return false; + if (source === undefined) { + return Number.isInteger(value) && value >= -(2 ** 63) && value <= 2 ** 63; + } return ( - typeof value === "number" && Number.isInteger(value) && value >= -(2 ** 63) && value <= 2 ** 63 + INT64_TOKEN_RE.test(source) && BigInt(source) >= GO_INT64_MIN && BigInt(source) <= GO_INT64_MAX ); } @@ -129,19 +191,21 @@ function isGoInt64(value: unknown): value is number { * new `device_id`, new `session_id`. Notably, a corrupt file that still says * `"enabled": false` does NOT stay disabled. * - * One Go strictness corner is not reproducible here: `JSON.parse` collapses - * `2.0` → `2` and `1e3` → `1000`, so an integer-valued float / in-range - * exponent-form `schema_version`/millis passes where Go's unmarshal-into-int - * rejects it. It requires a hand-corrupted file the CLI never writes. - * Magnitudes outside Go's int64 range DO regenerate like Go (see - * {@link isGoInt64}). (Unix millis in-range but beyond ECMAScript's - * ±8.64e15 `Date` range no longer 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.) + * Go's unmarshal-into-int strictness is reproduced at the TOKEN level: + * `JSON.parse` collapses `2.0` → `2` and `1e3` → `1000`, so the parsed value + * alone would preserve integer-valued float / exponent-form + * `schema_version`/millis that Go rejects as wholly malformed. The raw + * tokens captured by {@link parseWithRootNumberTokens} let + * {@link isGoInt64Token} reject exactly what Go rejects — non-integer + * lexical forms and magnitudes outside the int64 range 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); + const { parsed, rootNumberTokens } = parseWithRootNumberTokens(text); if (!isRecord(parsed)) return undefined; const record = parsed; @@ -191,7 +255,9 @@ function readExistingState(text: string): PriorState | undefined { } sessionLastActiveMs = parsedMs; } else if (allowUnixMillis && typeof rawLastActive === "number") { - if (!isGoInt64(rawLastActive)) return undefined; + if (!isGoInt64Token(rootNumberTokens.get("session_last_active"), rawLastActive)) { + return undefined; + } sessionLastActiveMs = rawLastActive; } else { return undefined; @@ -213,7 +279,7 @@ function readExistingState(text: string): PriorState | undefined { if ( record.schema_version !== undefined && record.schema_version !== null && - !isGoInt64(record.schema_version) + !isGoInt64Token(rootNumberTokens.get("schema_version"), record.schema_version) ) { return undefined; } 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 4a9a8bfc3d..178b06f55f 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 @@ -531,9 +531,10 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin }); it.effect("consent-form unix millis beyond the int64 range regenerate everything like Go", () => { - // Go's `json.Unmarshal` into `int64` rejects 1e100 (overflows the signed - // 64-bit range) → `errMalformedState` → wholesale regeneration: telemetry - // re-enabled, fresh identities — even though the file said "denied". + // 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({ @@ -553,10 +554,10 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin 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 - // 9223372036854775808 e-notation-free but a different literal). After - // JSON.parse it becomes exactly 2^63, which the inclusive bound accepts — - // an exclusive bound would regenerate a file Go decodes fine. + // 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}', @@ -587,6 +588,88 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin }); }); + // 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 From d75959ea334bccccc741239cc767d60b0985f861 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 30 Jul 2026 15:34:13 +0100 Subject: [PATCH 19/22] fix(cli): parse storage cp --jobs ahead of missing operands like Go (review: codex) --- .../storage/cp/cp.command.integration.test.ts | 33 +++++++++++++++++++ .../legacy/commands/storage/cp/cp.command.ts | 18 +++++++--- 2 files changed, 46 insertions(+), 5 deletions(-) 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 index b525282113..47ff9454b1 100644 --- 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 @@ -202,4 +202,37 @@ describe("legacy storage cp --jobs negative rejection (command-tree wiring)", () }).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 d1ee6e049e..e1befdbf56 100644 --- a/apps/cli/src/legacy/commands/storage/cp/cp.command.ts +++ b/apps/cli/src/legacy/commands/storage/cp/cp.command.ts @@ -23,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."), @@ -57,9 +63,9 @@ const config = { // The resulting `CliError.InvalidValue` carries pflag's complete message, // which `formatInvalidValueMessage` surfaces verbatim. Must sit before // `Flag.optional`, which passes `InvalidValue` failures through - // untouched. Known residual: with a missing positional, Effect CLI - // reports the missing-argument error first (params parse in - // config-declaration order) where Go reports the flag error. + // 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); @@ -74,6 +80,8 @@ const config = { ), 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; From 44d3b44efdbf153fd30ba39bc6e1fbbd0f6cf728 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 30 Jul 2026 15:38:10 +0100 Subject: [PATCH 20/22] fix(cli): suppress legacy ANSI styling on non-TTY streams like Go lipgloss (review: codex) --- apps/cli/src/legacy/shared/legacy-colors.ts | 69 +++++++++++--- .../legacy/shared/legacy-colors.unit.test.ts | 93 +++++++++++++------ 2 files changed, 121 insertions(+), 41 deletions(-) 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"); }); }); From 74a24b29c13c0ceede23b2249cfe6ed94b682899 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 30 Jul 2026 15:46:21 +0100 Subject: [PATCH 21/22] fix(cli): reject wrong-typed duplicate telemetry-state fields like Go (review: codex) --- .../telemetry/legacy-telemetry-state.layer.ts | 333 +++++++++++------- .../legacy-telemetry-state.layer.unit.test.ts | 161 +++++++++ 2 files changed, 372 insertions(+), 122 deletions(-) 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 2a91cf27a7..47658bb259 100644 --- a/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts +++ b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts @@ -99,85 +99,188 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -/** Stage-3 `JSON.parse` source-access reviver shape (implemented by Bun's JSC). */ -type ReviverWithSource = ( - this: unknown, - key: string, - value: unknown, - context?: { readonly source?: string }, -) => unknown; +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 + ); +} -// Typed view of `JSON.parse` exposing the reviver's third `context` parameter, -// which TypeScript's lib.d.ts does not declare yet. A plain assignment (not a -// cast): a reviver taking an extra OPTIONAL parameter is assignable to the -// lib's two-parameter reviver type. -const jsonParseWithSource: (text: string, reviver: ReviverWithSource) => unknown = JSON.parse; +const JSON_WS = new Set([" ", "\t", "\n", "\r"]); /** - * `JSON.parse` that also captures the RAW source token of every number sitting - * directly on the root object (stage-3 "source access" reviver context, - * supported by Bun — the only runtime the CLI ships on). Holder identity - * (`this` in the reviver) scopes the capture to the root: a nested - * `{"x":{"session_last_active":1.5}}` never shadows a top-level key. A key - * duplicated in the text keeps the LAST token, matching both `JSON.parse` - * value semantics and Go's `encoding/json` overwrite behaviour. + * 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 parseWithRootNumberTokens(text: string): { - readonly parsed: unknown; - readonly rootNumberTokens: ReadonlyMap; -} { - const tokensByHolder = new Map>(); - const parsed = jsonParseWithSource(text, function (key, value, context) { - if ( - typeof value === "number" && - typeof this === "object" && - this !== null && - context?.source !== undefined - ) { - let tokens = tokensByHolder.get(this); - if (tokens === undefined) { - tokens = new Map(); - tokensByHolder.set(this, tokens); +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; } - tokens.set(key, context.source); } - return value; - }); - const rootNumberTokens = - typeof parsed === "object" && parsed !== null ? tokensByHolder.get(parsed) : undefined; - return { parsed, rootNumberTokens: rootNumberTokens ?? new Map() }; + 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; } -const GO_INT64_MIN = -(2n ** 63n); -const GO_INT64_MAX = 2n ** 63n - 1n; -const INT64_TOKEN_RE = /^-?\d+$/; +/** + * 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. + */ +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; + } + } + 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; +} /** - * 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 into a signed - * 64-bit integer, 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 the parsed VALUE alone cannot reproduce Go — this check - * therefore validates the raw token text, with exact BigInt bounds (the - * doubles for int64-max and int64-max+1 are indistinguishable; the tokens are - * not). If the runtime ever fails to expose the reviver's `context.source` - * (never the case under Bun), it degrades to the closest value-level - * approximation rather than regenerating files Go accepts. + * 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 isGoInt64Token(source: string | undefined, value: unknown): value is number { - if (typeof value !== "number") return false; - if (source === undefined) { - return Number.isInteger(value) && value >= -(2 ** 63) && value <= 2 ** 63; +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 ( - INT64_TOKEN_RE.test(source) && BigInt(source) >= GO_INT64_MIN && BigInt(source) <= GO_INT64_MAX - ); + 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; } /** @@ -191,40 +294,35 @@ function isGoInt64Token(source: string | undefined, value: unknown): value is nu * new `device_id`, new `session_id`. Notably, a corrupt file that still says * `"enabled": false` does NOT stay disabled. * - * Go's unmarshal-into-int strictness is reproduced at the TOKEN level: - * `JSON.parse` collapses `2.0` → `2` and `1e3` → `1000`, so the parsed value - * alone would preserve integer-valued float / exponent-form - * `schema_version`/millis that Go rejects as wholly malformed. The raw - * tokens captured by {@link parseWithRootNumberTokens} let - * {@link isGoInt64Token} reject exactly what Go rejects — non-integer - * lexical forms and magnitudes outside the int64 range 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.) + * 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, rootNumberTokens } = parseWithRootNumberTokens(text); + const parsed: unknown = JSON.parse(text); if (!isRecord(parsed)) return undefined; const record = parsed; - // Go's single-shot `json.Unmarshal` type-checks `Enabled *bool` - // (`state.go:35`, `state.go:88-91`) even when consent takes precedence - // below: a present, non-boolean `enabled` is a field-level unmarshal - // error → the whole file is malformed. JSON `null` into the pointer - // field is valid (leaves it nil), so null passes through. - if ( - record.enabled !== undefined && - record.enabled !== null && - typeof record.enabled !== "boolean" - ) { - return undefined; - } + // 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. + // 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; @@ -246,6 +344,8 @@ function readExistingState(text: string): PriorState | 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") { @@ -255,7 +355,8 @@ function readExistingState(text: string): PriorState | undefined { } sessionLastActiveMs = parsedMs; } else if (allowUnixMillis && typeof rawLastActive === "number") { - if (!isGoInt64Token(rootNumberTokens.get("session_last_active"), rawLastActive)) { + const millisToken = lastToken(entries, "session_last_active"); + if (millisToken === undefined || !isInt64Token(millisToken)) { return undefined; } sessionLastActiveMs = rawLastActive; @@ -264,39 +365,27 @@ function readExistingState(text: string): PriorState | undefined { } // Go: `if raw.DeviceID == "" || raw.SessionID == ""` → "missing identity". - if (typeof record.device_id !== "string" || record.device_id === "") return undefined; - if (typeof record.session_id !== "string" || record.session_id === "") return undefined; - - // `DistinctID string` / `SchemaVersion int`: absent → zero value; a wrong - // JSON type is a field-level unmarshal error → malformed. - if ( - record.distinct_id !== undefined && - record.distinct_id !== null && - typeof record.distinct_id !== "string" - ) { - return undefined; - } - if ( - record.schema_version !== undefined && - record.schema_version !== null && - !isGoInt64Token(rootNumberTokens.get("schema_version"), record.schema_version) - ) { - return undefined; - } - const schemaVersion = - typeof record.schema_version === "number" && record.schema_version !== 0 - ? record.schema_version - : SCHEMA_VERSION; + // 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`). + const schemaVersionToken = lastNonNullToken(entries, "schema_version"); + const schemaVersionValue = schemaVersionToken !== undefined ? Number(schemaVersionToken) : 0; return { enabled, - device_id: record.device_id, - session_id: record.session_id, + device_id: deviceId, + session_id: sessionId, sessionLastActiveMs, - ...(typeof record.distinct_id === "string" && record.distinct_id.length > 0 - ? { distinct_id: record.distinct_id } - : {}), - schema_version: schemaVersion, + ...(distinctId !== undefined && distinctId.length > 0 ? { distinct_id: distinctId } : {}), + schema_version: schemaVersionValue !== 0 ? schemaVersionValue : SCHEMA_VERSION, }; } catch { return undefined; 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 178b06f55f..5969ca5337 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 @@ -691,4 +691,165 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin 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"); + }); + }); + }); }); From ce3b524b25d96e26b4a5ad2fc425ec7057af1ab9 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 30 Jul 2026 16:26:49 +0100 Subject: [PATCH 22/22] fix(cli): persist schema_version tokens above 2^53 exactly like Go (review: codex) --- .../legacy/shared/legacy-identity-stitch.ts | 16 +++- .../telemetry/legacy-telemetry-state.layer.ts | 79 +++++++++++++++++-- .../legacy-telemetry-state.layer.unit.test.ts | 79 +++++++++++++++++++ 3 files changed, 165 insertions(+), 9 deletions(-) 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 47658bb259..e1d5c5db60 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,6 +31,19 @@ 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 { readonly enabled: boolean; readonly device_id: string; @@ -30,7 +51,12 @@ interface PriorState { /** Epoch millis of `session_last_active`, from the Go-shape parse below. */ readonly sessionLastActiveMs: number; readonly distinct_id?: string; - readonly schema_version: number; + /** + * 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 @@ -376,8 +402,14 @@ function readExistingState(text: string): PriorState | undefined { // `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 schemaVersionValue = schemaVersionToken !== undefined ? Number(schemaVersionToken) : 0; + const keptSchemaVersionToken = + schemaVersionToken !== undefined && BigInt(schemaVersionToken) !== 0n + ? schemaVersionToken + : undefined; return { enabled, @@ -385,13 +417,38 @@ function readExistingState(text: string): PriorState | undefined { session_id: sessionId, sessionLastActiveMs, ...(distinctId !== undefined && distinctId.length > 0 ? { distinct_id: distinctId } : {}), - schema_version: schemaVersionValue !== 0 ? schemaVersionValue : SCHEMA_VERSION, + ...(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")( function* (opts: { readonly now?: Date } = {}) { const fs = yield* FileSystem.FileSystem; @@ -422,11 +479,17 @@ export const loadOrCreateLegacyTelemetryState = Effect.fn("legacy.telemetry.load session_last_active: nowIso, ...(prior?.distinct_id !== undefined ? { distinct_id: prior.distinct_id } : {}), // Go keeps a decoded file's non-zero schema_version (`state.go:103-106`). - schema_version: prior?.schema_version ?? SCHEMA_VERSION, + // 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; }, ); @@ -443,7 +506,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; }); @@ -465,7 +528,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* () { @@ -476,7 +539,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 5969ca5337..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 @@ -11,8 +11,10 @@ import { mockAnalytics } from "../../../tests/helpers/mocks.ts"; import { TelemetryRuntime } from "../../shared/telemetry/runtime.service.ts"; import { makeTelemetryIdentity } from "../../shared/telemetry/identity.ts"; import { + legacyTelemetrySchemaVersionToken, legacyTelemetryStateLayer, loadOrCreateLegacyTelemetryState, + setLegacyTelemetryEnabled, } from "./legacy-telemetry-state.layer.ts"; import { LegacyTelemetryState } from "./legacy-telemetry-state.service.ts"; @@ -853,3 +855,80 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin }); }); }); + +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"); + }); + }); +});