Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/cli/src/legacy/auth/legacy-credentials.layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}),

Expand Down
26 changes: 23 additions & 3 deletions apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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", () => {
Expand Down
12 changes: 9 additions & 3 deletions apps/cli/src/legacy/commands/db/dump/dump.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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;
Expand All @@ -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|
Expand Down
13 changes: 13 additions & 0 deletions apps/cli/src/legacy/commands/db/dump/dump.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
34 changes: 3 additions & 31 deletions apps/cli/src/legacy/commands/db/query/query.format.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 ...]`
Expand All @@ -58,7 +30,7 @@ function goFormatValue(value: unknown): string {
if (value === null || value === undefined) return "<nil>";
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
Expand Down Expand Up @@ -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);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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));
},
);
Original file line number Diff line number Diff line change
Expand Up @@ -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));
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
legacyInspectBacktickStmt,
legacyInspectInt,
legacyInspectStmt,
legacyInspectText,
Expand All @@ -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",
Expand All @@ -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"]),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {
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("");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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}` }),
Expand All @@ -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({
Expand Down
Loading
Loading