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);