From b03eac97b53717c3a818e1897dcabf09366dde48 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Tue, 18 Aug 2026 19:00:45 -0300 Subject: [PATCH 1/3] feat(cli): add the workers project layout and config.toml editing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two pieces every `supabase workers` command rests on, landed on their own because they are the subtle ones and deserve their own diff. Both live in `shared/`, so neither carries a shell prefix and neither is specific to one command tree. `worker-paths.ts` resolves the project layout: `supabase///`, mirroring `supabase/functions//`, with `[workers] root` moving the grouping directory and `[workers.] source` moving one worker's code anywhere in the project. Both are validated rather than joined blindly. `resolveWorkerSource` is load-bearing for safety, not tidiness: the path it returns is the directory `workers new --force` deletes outright, so a value naming the project root, `supabase/`, `functions/`, `migrations/`, or anywhere outside the project is refused before anything is removed. `toml-section.ts` edits one `[section]` of a TOML file textually rather than round-tripping it. `config.toml` belongs to the whole CLI — users hand-edit, comment and commit it — and reserialising preserves the data while discarding every comment and normalising the formatting they chose. Existing keys are rewritten in place, keeping any comment trailing the value; new keys are appended; everything else is left byte for byte. A value spanning several lines cannot be swapped one line at a time, so it is reported as unsupported and the file is left untouched instead of stranded half-rewritten. This follows the approach `legacy-pgdelta.write.ts` already takes for `[db.migrations] schema_paths`, generalised from one hard-coded key. --- apps/cli/src/shared/workers/toml-section.ts | 221 ++++++++++++++++++ .../shared/workers/toml-section.unit.test.ts | 162 +++++++++++++ apps/cli/src/shared/workers/worker-paths.ts | 183 +++++++++++++++ .../shared/workers/worker-paths.unit.test.ts | 111 +++++++++ apps/cli/src/shared/workers/workers.errors.ts | 38 +++ 5 files changed, 715 insertions(+) create mode 100644 apps/cli/src/shared/workers/toml-section.ts create mode 100644 apps/cli/src/shared/workers/toml-section.unit.test.ts create mode 100644 apps/cli/src/shared/workers/worker-paths.ts create mode 100644 apps/cli/src/shared/workers/worker-paths.unit.test.ts create mode 100644 apps/cli/src/shared/workers/workers.errors.ts diff --git a/apps/cli/src/shared/workers/toml-section.ts b/apps/cli/src/shared/workers/toml-section.ts new file mode 100644 index 0000000000..5b1241b3f4 --- /dev/null +++ b/apps/cli/src/shared/workers/toml-section.ts @@ -0,0 +1,221 @@ +/** + * Surgical edits to one `[section]` of a TOML file. + * + * `supabase/config.toml` belongs to the whole CLI: users hand-edit it, comment + * it, and commit it. Round-tripping it through `saveProjectConfig` preserves the + * data but discards every comment and normalizes the formatting the user chose + * — a surprising side effect of `supabase workers new`, and a noisy diff in a + * PR. So writes here are textual: locate the worker's own table, update the keys + * that changed in place, append the ones that are new, and leave every other + * byte of the file exactly as it was. + * + * Reading is still done with the real parser (`@supabase/config`); only writing + * is textual, and only ever inside the one table it owns. + */ + +/** A TOML bare key needs no quoting; anything else does. */ +function isBareKey(key: string): boolean { + return /^[A-Za-z0-9_-]+$/.test(key); +} + +/** Escape a string for a TOML basic (double-quoted) string. */ +function quote(value: string): string { + return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +/** Render `key` for use in a table header or key position. */ +export function tomlKey(key: string): string { + return isBareKey(key) ? key : quote(key); +} + +/** `key = "value"` — every value the worker commands write is a string. */ +function renderPair(key: string, value: string): string { + return `${tomlKey(key)} = ${quote(value)}`; +} + +/** + * Index of the standalone `[header]` line, or -1. + * + * TOML allows whitespace inside the brackets and a trailing comment, so + * `[ workers.api ] # mine` is the same table as `[workers.api]`. Matching on + * the exact string would miss it and append a second table with the same name, + * which is not valid TOML. + */ +function findHeader(lines: ReadonlyArray, header: string): number { + const pattern = new RegExp(`^\\s*\\[\\s*${escapeRegExp(header)}\\s*\\]\\s*(?:#.*)?$`); + return lines.findIndex((line) => pattern.test(line)); +} + +/** Whether `text` contains a standalone `[header]` table line. */ +export function sectionExists(text: string, header: string): boolean { + return findHeader(text.split("\n"), header) !== -1; +} + +/** Whether a line opens a new table (`[x]` or `[[x]]`), ending the current one. */ +function opensTable(line: string): boolean { + return /^\s*\[/.test(line); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * What follows the `=` on a key's line: whether the value finishes there, and + * any trailing comment. + * + * Both matter for a rewrite. A value that spans lines (an array, an inline + * table, a `"""` string) cannot be replaced by swapping one line — doing so + * strands its continuation lines and leaves the file unparseable. And a trailing + * comment is the user's, so it survives the rewrite; a module that exists to + * preserve comments should not eat the one sitting next to the value it edits. + */ +interface ScannedValue { + /** `false` when the value continues onto the next line. */ + readonly complete: boolean; + /** The trailing comment including its `#`, or `""`. */ + readonly comment: string; +} + +function scanValue(line: string, from: number): ScannedValue { + let depth = 0; + let index = from; + + while (index < line.length) { + const char = line[index]; + + // A `#` outside any string or bracket starts the comment; everything from + // here to the end of the line belongs to the user. + if (char === "#" && depth === 0) { + return { complete: true, comment: line.slice(index) }; + } + + if (char === '"' || char === "'") { + const quote = char; + const triple = line.startsWith(quote.repeat(3), index); + const closer = triple ? quote.repeat(3) : quote; + // A basic string honours backslash escapes; a literal string does not. + const escapes = quote === '"'; + let cursor = index + closer.length; + let closed = false; + while (cursor < line.length) { + if (escapes && line[cursor] === "\\") { + cursor += 2; + continue; + } + if (line.startsWith(closer, cursor)) { + cursor += closer.length; + closed = true; + break; + } + cursor += 1; + } + if (!closed) { + return { complete: false, comment: "" }; + } + index = cursor; + continue; + } + + if (char === "[" || char === "{") { + depth += 1; + } else if (char === "]" || char === "}") { + depth -= 1; + } + index += 1; + } + + return { complete: depth === 0, comment: "" }; +} + +/** + * The outcome of an edit. A value that spans lines cannot be rewritten one line + * at a time, so rather than emit a broken file this reports which key it could + * not touch and lets the caller say so. + */ +export type TomlSectionEdit = + | { readonly _tag: "Edited"; readonly text: string } + | { readonly _tag: "Unsupported"; readonly key: string }; + +/** + * Set each of `values` inside `[header]`, returning the new file text. + * + * An existing key is rewritten in place, preserving its position, any comment on + * the line above it, and any comment trailing the value itself; a new key is + * appended after the table's last non-blank line, so trailing blank lines stay + * between tables rather than being swallowed. A missing table is appended at the + * end of the file, separated by one blank line. + */ +export function upsertTomlSection( + text: string, + header: string, + values: Readonly>, +): TomlSectionEdit { + const entries = Object.entries(values); + if (entries.length === 0) { + return { _tag: "Edited", text }; + } + + const lines = text.split("\n"); + const start = findHeader(lines, header); + + if (start === -1) { + const block = [`[${header}]`, ...entries.map(([key, value]) => renderPair(key, value))]; + // A file that is empty (or only whitespace) gets no leading blank line; an + // existing one gets exactly one, however it happened to be terminated. + if (text.trim() === "") { + return { _tag: "Edited", text: `${block.join("\n")}\n` }; + } + return { + _tag: "Edited", + text: `${text.replace(/\n*$/, "")}\n\n${block.join("\n")}\n`, + }; + } + + // The table runs to the next table header, or the end of the file. + let end = lines.length; + for (let index = start + 1; index < lines.length; index++) { + if (opensTable(lines[index] ?? "")) { + end = index; + break; + } + } + + const pending = new Map(entries); + for (let index = start + 1; index < end; index++) { + const line = lines[index] ?? ""; + for (const [key, value] of pending) { + // Match `key =`, `"key" =`, with any leading indentation the user used. + const pattern = new RegExp( + `^(\\s*)(?:${escapeRegExp(key)}|${escapeRegExp(quote(key))})(\\s*)=`, + ); + const match = pattern.exec(line); + if (match === null) { + continue; + } + const scanned = scanValue(line, match[0].length); + if (!scanned.complete) { + return { _tag: "Unsupported", key }; + } + const trailing = scanned.comment === "" ? "" : ` ${scanned.comment}`; + lines[index] = + `${match[1] ?? ""}${tomlKey(key)}${match[2] ?? ""}= ${quote(value)}${trailing}`; + pending.delete(key); + break; + } + } + + if (pending.size > 0) { + // Append after the last line with content, so a blank line separating this + // table from the next stays where it is. + let insertAt = start + 1; + for (let index = start + 1; index < end; index++) { + if ((lines[index] ?? "").trim() !== "") { + insertAt = index + 1; + } + } + lines.splice(insertAt, 0, ...[...pending].map(([key, value]) => renderPair(key, value))); + } + + return { _tag: "Edited", text: lines.join("\n") }; +} diff --git a/apps/cli/src/shared/workers/toml-section.unit.test.ts b/apps/cli/src/shared/workers/toml-section.unit.test.ts new file mode 100644 index 0000000000..bc983f6e83 --- /dev/null +++ b/apps/cli/src/shared/workers/toml-section.unit.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, test } from "vitest"; +import { sectionExists, tomlKey, upsertTomlSection } from "./toml-section.ts"; + +/** The edited text, failing the test if the edit was refused. */ +function edited(text: string, header: string, values: Record): string { + const result = upsertTomlSection(text, header, values); + if (result._tag !== "Edited") { + throw new Error(`expected an edit, got Unsupported(${result.key})`); + } + return result.text; +} + +describe("upsertTomlSection", () => { + test("appends a new table to an existing file without disturbing it", () => { + const before = `# my project +project_id = "demo" + +[functions.hello] +verify_jwt = false +`; + + expect(edited(before, "workers.api", { runtime: "node", size: "2gb" })).toBe( + `# my project +project_id = "demo" + +[functions.hello] +verify_jwt = false + +[workers.api] +runtime = "node" +size = "2gb" +`, + ); + }); + + test("writes the table alone into an empty file", () => { + expect(edited(" \n", "workers.api", { runtime: "deno" })).toBe( + `[workers.api] +runtime = "deno" +`, + ); + }); + + test("rewrites an existing key in place, preserving its comment and position", () => { + const before = `[workers.api] +# the runtime this was scaffolded on +runtime = "node" +size = "2gb" + +[workers.other] +runtime = "deno" +`; + + expect(edited(before, "workers.api", { runtime: "bun" })).toBe( + `[workers.api] +# the runtime this was scaffolded on +runtime = "bun" +size = "2gb" + +[workers.other] +runtime = "deno" +`, + ); + }); + + test("appends a new key after the table's last content line, keeping the blank separator", () => { + const before = `[workers.api] +runtime = "node" + +[workers.other] +runtime = "deno" +`; + + expect(edited(before, "workers.api", { size: "4gb" })).toBe( + `[workers.api] +runtime = "node" +size = "4gb" + +[workers.other] +runtime = "deno" +`, + ); + }); + + test("matches a quoted key and the caller's own indentation", () => { + const before = `[workers.api] + "runtime" = "node" +`; + + expect(edited(before, "workers.api", { runtime: "python" })).toBe( + `[workers.api] + runtime = "python" +`, + ); + }); + + test("escapes quotes and backslashes in values", () => { + expect(edited("", "workers.api", { source: 'a"b\\c' })).toBe( + `[workers.api] +source = "a\\"b\\\\c" +`, + ); + }); + + test("matches a header with whitespace inside the brackets and a trailing comment", () => { + // TOML treats these as the same table; appending a second one would make the + // file invalid. + expect( + edited('[ workers.api ] # mine\nruntime = "node"\n', "workers.api", { runtime: "bun" }), + ).toBe('[ workers.api ] # mine\nruntime = "bun"\n'); + }); + + test("keeps a comment trailing the value it rewrites", () => { + expect( + edited('[workers.api]\nruntime = "node" # keep me\n', "workers.api", { runtime: "bun" }), + ).toBe('[workers.api]\nruntime = "bun" # keep me\n'); + }); + + test("does not mistake a # inside a string for a comment", () => { + expect(edited('[workers.api]\nsource = "a#b"\n', "workers.api", { source: "c#d" })).toBe( + '[workers.api]\nsource = "c#d"\n', + ); + }); + + test("refuses a value that spans lines rather than stranding its continuation", () => { + const before = '[workers.api]\nruntime = [\n "a",\n]\n'; + const result = upsertTomlSection(before, "workers.api", { runtime: "bun" }); + + expect(result._tag).toBe("Unsupported"); + if (result._tag === "Unsupported") { + expect(result.key).toBe("runtime"); + } + }); + + test("refuses an unterminated multi-line string the same way", () => { + const result = upsertTomlSection( + '[workers.api]\nsource = """\nstill going\n"""\n', + "workers.api", + { source: "packages/api" }, + ); + + expect(result._tag).toBe("Unsupported"); + }); + + test("returns the text untouched when there is nothing to set", () => { + expect(edited('project_id = "demo"\n', "workers.api", {})).toBe('project_id = "demo"\n'); + }); +}); + +describe("sectionExists", () => { + test("finds a standalone table header and ignores a dotted key of the same name", () => { + expect(sectionExists('[workers.api]\nruntime = "node"\n', "workers.api")).toBe(true); + expect(sectionExists('workers.api.runtime = "node"\n', "workers.api")).toBe(false); + }); +}); + +describe("tomlKey", () => { + test("quotes only what TOML requires quoting", () => { + expect(tomlKey("my-worker_1")).toBe("my-worker_1"); + expect(tomlKey("my worker")).toBe('"my worker"'); + }); +}); diff --git a/apps/cli/src/shared/workers/worker-paths.ts b/apps/cli/src/shared/workers/worker-paths.ts new file mode 100644 index 0000000000..e6457a3597 --- /dev/null +++ b/apps/cli/src/shared/workers/worker-paths.ts @@ -0,0 +1,183 @@ +import { isAbsolute, join, normalize, relative, resolve } from "node:path"; +import { Effect } from "effect"; +import { InvalidWorkerSourceError, InvalidWorkersRootError } from "./workers.errors.ts"; + +/** + * The project layout every worker command resolves against: + * + * supabase/ + * config.toml project config — workers record `[workers.]` here + * workers// one directory per worker; the name IS the directory + * + * This mirrors `supabase/functions//` on purpose: `supabase workers` is a + * sibling of `supabase functions`, not a separate tool with its own + * conventions. A worker's name and its directory are the same fact, so + * `push`/`status`/`delete ` needs no separate lookup, and running from + * inside the directory needs no name at all. + * + * `workers/` is the default, not a rule. Two keys move it, at two scopes: + * `[workers] root` moves the directory workers are grouped in (project-wide, + * relative to `supabase/`), and `[workers.] source` moves one worker's + * code anywhere in the repo (relative to the project root). `source` wins + * wherever it is set. + * + * The root is validated once per command, by {@link resolveWorkersRoot}; every + * helper below takes the validated value, so a bad `[workers] root` fails in + * one place rather than at whichever path happens to be joined first. + */ + +/** Where workers live under `supabase/` unless `[workers] root` says otherwise. */ +const DEFAULT_WORKERS_ROOT = "workers"; + +/** + * Directories under `supabase/` the CLI already owns. Pointing `[workers] root` + * at one would make every Edge Function (or migration) look like a worker to + * `supabase workers list`, so it is refused rather than warned about. + */ +const RESERVED_ROOT_DIRS = ["functions", "migrations"]; + +const rootSuggestion = + 'Set `[workers] root` to a directory name inside supabase/, for example root = "services", ' + + "or move a single worker with `[workers.] source`."; + +/** + * `[workers] root`, validated. Confined to `supabase/`: absolute paths, any + * `..` that would climb out, and `supabase/` itself are refused, as are the + * directories the CLI already owns. The per-worker `source` key is the one that + * can leave. + */ +export function resolveWorkersRoot( + configured: string | undefined, +): Effect.Effect { + if (configured === undefined) { + return Effect.succeed(DEFAULT_WORKERS_ROOT); + } + + const refuse = (why: string) => + Effect.fail( + new InvalidWorkersRootError({ + detail: `[workers] root "${configured}" ${why}.`, + suggestion: rootSuggestion, + }), + ); + + // A trailing slash is how anyone would naturally write a directory. + const trimmed = configured.trim().replace(/[/\\]+$/, ""); + if (trimmed === "" || trimmed === ".") { + return refuse("would make supabase/ itself the workers directory"); + } + if (isAbsolute(trimmed)) { + return refuse("is an absolute path"); + } + + const normalized = normalize(trimmed); + if (normalized.startsWith("..")) { + return refuse("climbs outside supabase/"); + } + const first = normalized.split(/[/\\]/)[0] ?? ""; + if (RESERVED_ROOT_DIRS.includes(first)) { + return refuse( + `is a directory the Supabase CLI already owns: everything in supabase/${first}/ would be listed as a worker`, + ); + } + + return Effect.succeed(normalized); +} + +/** `supabase//` — the validated root resolved against the project. */ +export function workersRootDir(projectRoot: string, root: string): string { + return join(projectRoot, "supabase", root); +} + +/** Whether `candidate` is `parent` itself or sits underneath it. */ +function isAtOrUnder(parent: string, candidate: string): boolean { + const rel = relative(resolve(parent), resolve(candidate)); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +/** + * `--source`, resolved against the directory the user typed it in and validated + * before anything is written. + * + * This one is load-bearing for safety, not just tidiness: the resolved path is + * the directory `--force` deletes outright, so a value naming the project root, + * `supabase/`, or anywhere outside the project would destroy work that has + * nothing to do with the worker. `source` is the key that is *allowed* to leave + * the workers directory, but not the project. + * + * `functions/` and `migrations/` are refused for the same reason `[workers] root` + * refuses them: they belong to other parts of the CLI. + */ +export function resolveWorkerSource(options: { + readonly projectRoot: string; + readonly cwd: string; + readonly raw: string; +}): Effect.Effect { + const refuse = (why: string) => + Effect.fail( + new InvalidWorkerSourceError({ + detail: `--source "${options.raw}" ${why}.`, + suggestion: + "Point --source at a directory inside the project, for example --source packages/api.", + }), + ); + + const trimmed = options.raw.trim().replace(/[/\\]+$/, ""); + if (trimmed === "") { + return refuse("is empty"); + } + + const destination = resolve(options.cwd, trimmed); + const projectRoot = resolve(options.projectRoot); + const supabaseDir = join(projectRoot, "supabase"); + + if (destination === projectRoot) { + return refuse("is the project root itself"); + } + if (!isAtOrUnder(projectRoot, destination)) { + return refuse("is outside the project"); + } + if (destination === supabaseDir) { + return refuse("is the supabase directory itself"); + } + for (const reserved of RESERVED_ROOT_DIRS) { + if (isAtOrUnder(join(supabaseDir, reserved), destination)) { + return refuse(`is inside supabase/${reserved}/, which the Supabase CLI already owns`); + } + } + + return Effect.succeed(destination); +} + +/** A worker's default directory: `supabase///`. */ +export function workerDir(projectRoot: string, root: string, name: string): string { + return join(workersRootDir(projectRoot, root), name); +} + +/** + * A worker's source directory: `[workers.] source` when one is recorded, + * resolved against the project root, otherwise the default directory. + */ +export function workerSourceDir( + projectRoot: string, + defaultDir: string, + configuredSource: string | undefined, +): string { + return configuredSource === undefined || configuredSource === "" + ? defaultDir + : resolve(projectRoot, configuredSource); +} + +/** + * A path as it should be shown to the user: relative to the current directory, + * which is how they referred to it in the first place. Falls back to the + * absolute form when the relative one would climb out of the tree, where `../../` + * chains stop being clearer than the truth. + */ +export function displayPath(cwd: string, target: string): string { + const rel = relative(resolve(cwd), resolve(target)); + if (rel === "") { + return "."; + } + return rel.startsWith("..") ? target : rel; +} diff --git a/apps/cli/src/shared/workers/worker-paths.unit.test.ts b/apps/cli/src/shared/workers/worker-paths.unit.test.ts new file mode 100644 index 0000000000..30e2960b6b --- /dev/null +++ b/apps/cli/src/shared/workers/worker-paths.unit.test.ts @@ -0,0 +1,111 @@ +import { join } from "node:path"; +import { Effect, Exit } from "effect"; +import { describe, expect, test } from "vitest"; +import { + displayPath, + resolveWorkerSource, + resolveWorkersRoot, + workerDir, + workerSourceDir, + workersRootDir, +} from "./worker-paths.ts"; +import { InvalidWorkerSourceError, InvalidWorkersRootError } from "./workers.errors.ts"; + +const PROJECT = "/repo"; + +function rootOf(configured: string | undefined) { + return Effect.runSyncExit(resolveWorkersRoot(configured)); +} + +function rootValue(configured: string | undefined): string { + const exit = rootOf(configured); + if (!Exit.isSuccess(exit)) { + throw new Error(`expected a root for ${String(configured)}`); + } + return exit.value; +} + +describe("resolveWorkersRoot", () => { + test("defaults to workers/ and accepts a plain directory name", () => { + expect(rootValue(undefined)).toBe("workers"); + expect(rootValue("services")).toBe("services"); + expect(rootValue("services/")).toBe("services"); + expect(rootValue("nested/services")).toBe(join("nested", "services")); + }); + + test.each([ + ["", "supabase/ itself"], + [".", "supabase/ itself"], + ["/etc", "an absolute path"], + ["../elsewhere", "a climb out of supabase/"], + ["functions", "a directory the CLI owns"], + ["migrations", "a directory the CLI owns"], + ])("refuses %j — %s", (configured) => { + const error = Effect.runSync(resolveWorkersRoot(configured).pipe(Effect.flip)); + expect(error).toBeInstanceOf(InvalidWorkersRootError); + expect(error.suggestion).toContain('root = "services"'); + }); +}); + +describe("worker directories", () => { + test("resolve under supabase//", () => { + expect(workersRootDir(PROJECT, "workers")).toBe(join(PROJECT, "supabase", "workers")); + expect(workerDir(PROJECT, "services", "api")).toBe( + join(PROJECT, "supabase", "services", "api"), + ); + }); + + test("a recorded source wins and is anchored to the project root", () => { + const fallback = workerDir(PROJECT, "workers", "api"); + expect(workerSourceDir(PROJECT, fallback, undefined)).toBe(fallback); + expect(workerSourceDir(PROJECT, fallback, "")).toBe(fallback); + expect(workerSourceDir(PROJECT, fallback, "packages/api")).toBe( + join(PROJECT, "packages", "api"), + ); + }); +}); + +describe("displayPath", () => { + test("prefers the relative form, and falls back to absolute when it would climb out", () => { + expect(displayPath(PROJECT, join(PROJECT, "supabase", "workers", "api"))).toBe( + join("supabase", "workers", "api"), + ); + expect(displayPath(PROJECT, PROJECT)).toBe("."); + expect(displayPath(join(PROJECT, "deep", "deeper"), "/elsewhere/api")).toBe("/elsewhere/api"); + }); +}); + +describe("resolveWorkerSource", () => { + const cwd = `${PROJECT}/apps/web`; + + test("resolves a directory inside the project against the directory it was typed in", () => { + expect( + Effect.runSync(resolveWorkerSource({ projectRoot: PROJECT, cwd, raw: "../../packages/api" })), + ).toBe(join(PROJECT, "packages", "api")); + expect( + Effect.runSync( + resolveWorkerSource({ projectRoot: PROJECT, cwd: PROJECT, raw: "packages/api/" }), + ), + ).toBe(join(PROJECT, "packages", "api")); + }); + + // `--force` deletes whatever this resolves to, so each of these would destroy + // work belonging to the project or to the machine. + test.each([ + [".", "the project root itself"], + ["", "empty"], + ["..", "outside the project"], + ["/etc", "outside the project"], + ["../elsewhere", "outside the project"], + ["supabase", "the supabase directory itself"], + ["supabase/functions", "supabase/functions/"], + ["supabase/functions/hello", "supabase/functions/"], + ["supabase/migrations", "supabase/migrations/"], + ])("refuses %j", (raw, reason) => { + const error = Effect.runSync( + resolveWorkerSource({ projectRoot: PROJECT, cwd: PROJECT, raw }).pipe(Effect.flip), + ); + expect(error).toBeInstanceOf(InvalidWorkerSourceError); + expect(error.detail).toContain(reason); + }); +}); diff --git a/apps/cli/src/shared/workers/workers.errors.ts b/apps/cli/src/shared/workers/workers.errors.ts new file mode 100644 index 0000000000..601135df6d --- /dev/null +++ b/apps/cli/src/shared/workers/workers.errors.ts @@ -0,0 +1,38 @@ +import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; + +/** + * Every worker failure carries the same shape the rest of the next shell uses: + * a `detail` saying what happened and a `suggestion` naming the exact command + * that fixes it. The POC's `→` recovery lines are this, rendered by the shared + * output layer instead of by each command. + */ + +/** + * `--source` names a directory it is not allowed to name. Worth its own error + * because the destination is a directory `--force` will delete outright, so a + * value that resolves to the project root, `supabase/`, or anywhere outside the + * project has to be refused before anything is removed. + */ +export class InvalidWorkerSourceError extends Data.TaggedError("InvalidWorkerSourceError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +/** `[workers] root` names a directory it is not allowed to name. */ +export class InvalidWorkersRootError extends Data.TaggedError("InvalidWorkersRootError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} From 6e728886f79035ed40db53ab4fac2bb3bfa40bd3 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Tue, 18 Aug 2026 19:01:43 -0300 Subject: [PATCH 2/3] feat(cli): add supabase workers new MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaffolds `supabase/workers//` from a runtime's starter files and records the choice in `config.toml`. Entirely local disk — nothing is deployed and no network is involved, which is why it lands before the API seam. The runtime and instance size are resolved before anything is written, so cancelling either prompt leaves nothing behind — including the name, which is only generated once both questions are answered. A worker already described in `config.toml` reuses those values instead of re-asking, and says so only when something recorded actually answers for an omitted flag. Two closed sets, both narrow on purpose. The runtimes are the catalog images plus `dockerfile`; the sizes are the alpha envelope's two, each implying its own vCPU count. `root` is refused as a worker name: `[workers] root` is the scalar key in the same table, so `[workers.root]` would stop the whole config parsing. Every runtime's starter runs as scaffolded, `dockerfile` included — its Dockerfile's `CMD` names a `server.js`, so that file is scaffolded too, alongside a `package.json` whose `"type": "module"` is what makes it ESM rather than Node's syntax detection. This also brings the command family's shell wiring, which is where the conventions here differ from a command tree's usual shape: - The project directory is `LegacyCliConfig.workdir`, so `--workdir` and `SUPABASE_WORKDIR` select the project exactly as they do for every sibling command, rather than an ancestor walk of the process's own directory. - Output goes through `output.raw` as plain text with no `intro`/`outro` framing, and tables through `renderGlamourTable`, so `workers` reads like `functions` and `projects` rather than like a second CLI. - `-o`/`--output` is honoured (`workers.output.ts`). It is a global flag 33 of this shell's 37 command families answer to, so ignoring it would print human text to a stdout the user asked to be machine-readable. What workers does not inherit is the Go-parity obligation behind the struct encoders: there is no Go counterpart to be byte-identical to, so the payload is serialised through the generic encoders instead. `-o env` is refused for a payload containing a list, because `encodeEnv` reproduces `godotenv.Marshal`, whose flattening does not descend into slices. - Telemetry state is flushed in `Effect.ensuring`, matching Go's `PersistentPostRun`. Two shell-wide registries have to move in step with the command appearing, and both are enforced by tests rather than convention: `LEGACY_DOCS_TAGS`, without which the generated CLI reference refuses to build, and `VALUE_CONSUMING_LONG_FLAGS`, without which the telemetry argv scan treats `--runtime`'s value as a flag and can fabricate one Go never recorded. `supabase workers` has no Go equivalent, so it is recorded in `docs/go-cli-divergences.md` as TS-only. --- apps/cli/docs/go-cli-divergences.md | 15 +- apps/cli/src/legacy/cli/root.ts | 2 + .../commands/workers/new/SIDE_EFFECTS.md | 56 +++ .../commands/workers/new/new.command.ts | 80 ++++ .../commands/workers/new/new.handler.ts | 347 ++++++++++++++++++ .../workers/new/new.integration.test.ts | 311 ++++++++++++++++ .../commands/workers/workers.command.ts | 10 + .../legacy/commands/workers/workers.errors.ts | 25 ++ .../legacy/commands/workers/workers.format.ts | 24 ++ .../legacy/commands/workers/workers.output.ts | 66 ++++ .../legacy/commands/workers/workers.shared.ts | 125 +++++++ .../legacy/docs/legacy-docs-spec.tables.ts | 1 + .../legacy/shared/legacy-db-target-flags.ts | 4 +- apps/cli/src/shared/workers/worker-config.ts | 141 +++++++ .../shared/workers/worker-config.unit.test.ts | 142 +++++++ .../cli/src/shared/workers/worker-runtimes.ts | 130 +++++++ .../workers/worker-runtimes.unit.test.ts | 72 ++++ apps/cli/src/shared/workers/worker-stacks.ts | 104 ++++++ apps/cli/src/shared/workers/workers.errors.ts | 36 ++ apps/cli/tests/helpers/legacy-workers.ts | 263 +++++++++++++ 20 files changed, 1946 insertions(+), 8 deletions(-) create mode 100644 apps/cli/src/legacy/commands/workers/new/SIDE_EFFECTS.md create mode 100644 apps/cli/src/legacy/commands/workers/new/new.command.ts create mode 100644 apps/cli/src/legacy/commands/workers/new/new.handler.ts create mode 100644 apps/cli/src/legacy/commands/workers/new/new.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/workers/workers.command.ts create mode 100644 apps/cli/src/legacy/commands/workers/workers.errors.ts create mode 100644 apps/cli/src/legacy/commands/workers/workers.format.ts create mode 100644 apps/cli/src/legacy/commands/workers/workers.output.ts create mode 100644 apps/cli/src/legacy/commands/workers/workers.shared.ts create mode 100644 apps/cli/src/shared/workers/worker-config.ts create mode 100644 apps/cli/src/shared/workers/worker-config.unit.test.ts create mode 100644 apps/cli/src/shared/workers/worker-runtimes.ts create mode 100644 apps/cli/src/shared/workers/worker-runtimes.unit.test.ts create mode 100644 apps/cli/src/shared/workers/worker-stacks.ts create mode 100644 apps/cli/tests/helpers/legacy-workers.ts diff --git a/apps/cli/docs/go-cli-divergences.md b/apps/cli/docs/go-cli-divergences.md index 228fc2af93..95bcde8c18 100644 --- a/apps/cli/docs/go-cli-divergences.md +++ b/apps/cli/docs/go-cli-divergences.md @@ -10,13 +10,14 @@ something the old Go CLI didn't — it is not a compatibility promise. These commands exist in the TS CLI today but have no direct top-level equivalent in the old Go CLI reference. -| TS command | TS path | Notes | -| ----------------- | ------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `dev` | `planned` | Reserved for a TS-native long-running local development workflow command that watches files and orchestrates subcommands. Track this as TS-only unless a direct Go equivalent emerges. | -| `logs` | [`../src/next/commands/logs/logs.command.ts`](../src/next/commands/logs/logs.command.ts) | Streams local stack logs. No top-level `logs` command exists in the old Go CLI reference. | -| `api` | [`../src/next/commands/platform/api.command.ts`](../src/next/commands/platform/api.command.ts) | Low-level Management API client. It supersedes the old generated tree with explicit discovery via `supabase api routes` and execution via `supabase api request [--method ]`. | -| `stack` | [`../src/next/cli/root.ts`](../src/next/cli/root.ts) | TS-only local runtime namespace exposing `stack start`, `stack stop`, `stack status`, `stack list`, and `stack update`. Top-level `start`, `stop`, and `status` remain aliases. | -| `branches switch` | [`../src/next/commands/branches/switch/switch.command.ts`](../src/next/commands/branches/switch/switch.command.ts) | No direct Go equivalent. Updates local active-branch state so subsequent commands target the selected branch. | +| TS command | TS path | Notes | +| ----------------- | ------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `dev` | `planned` | Reserved for a TS-native long-running local development workflow command that watches files and orchestrates subcommands. Track this as TS-only unless a direct Go equivalent emerges. | +| `logs` | [`../src/next/commands/logs/logs.command.ts`](../src/next/commands/logs/logs.command.ts) | Streams local stack logs. No top-level `logs` command exists in the old Go CLI reference. | +| `api` | [`../src/next/commands/platform/api.command.ts`](../src/next/commands/platform/api.command.ts) | Low-level Management API client. It supersedes the old generated tree with explicit discovery via `supabase api routes` and execution via `supabase api request [--method ]`. | +| `stack` | [`../src/next/cli/root.ts`](../src/next/cli/root.ts) | TS-only local runtime namespace exposing `stack start`, `stack stop`, `stack status`, `stack list`, and `stack update`. Top-level `start`, `stop`, and `status` remain aliases. | +| `branches switch` | [`../src/next/commands/branches/switch/switch.command.ts`](../src/next/commands/branches/switch/switch.command.ts) | No direct Go equivalent. Updates local active-branch state so subsequent commands target the selected branch. | +| `workers` | [`../src/legacy/commands/workers/workers.command.ts`](../src/legacy/commands/workers/workers.command.ts) | No Go equivalent. Manages Supabase Workers — containers deployed from `supabase/workers//` — via the v2 Management API (`/v2/projects/{ref}/workers`). Notably the first legacy-shell command to call a **v2** route; every other legacy command is a Go-parity port and calls v1 only. | ## Flag divergences from the Go reference diff --git a/apps/cli/src/legacy/cli/root.ts b/apps/cli/src/legacy/cli/root.ts index db904dca84..6883aee159 100644 --- a/apps/cli/src/legacy/cli/root.ts +++ b/apps/cli/src/legacy/cli/root.ts @@ -35,6 +35,7 @@ import { legacyStorageCommand } from "../commands/storage/storage.command.ts"; import { legacyTestCommand } from "../commands/test/test.command.ts"; import { legacyTelemetryCommand } from "../commands/telemetry/telemetry.command.ts"; import { legacyUnlinkCommand } from "../commands/unlink/unlink.command.ts"; +import { legacyWorkersCommand } from "../commands/workers/workers.command.ts"; import { legacyVanitySubdomainsCommand } from "../commands/vanity-subdomains/vanity-subdomains.command.ts"; import { OutputFormatFlag } from "../../shared/cli/global-flags.ts"; import { outputLayerFor } from "../../shared/output/output.layer.ts"; @@ -70,6 +71,7 @@ export const legacyRoot = Command.make("supabase").pipe( legacyDomainsCommand, legacyEncryptionCommand, legacyFunctionsCommand, + legacyWorkersCommand, legacyGenCommand, legacyInitCommand, legacyInspectCommand, diff --git a/apps/cli/src/legacy/commands/workers/new/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/workers/new/SIDE_EFFECTS.md new file mode 100644 index 0000000000..59a903e460 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/new/SIDE_EFFECTS.md @@ -0,0 +1,56 @@ +# `supabase workers new [name]` + +> **TS-only command.** `supabase workers` has no Go counterpart — there is no +> `apps/cli-go/internal/workers` to match, and nothing is proxied. See +> `docs/go-cli-divergences.md`. + +## Files Read + +| Path | Format | When | +| -------------------------------- | ------ | ----------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always, for `[workers]` root and any existing entry | +| `/` | dir | always, to refuse a non-empty destination without `--force` | + +## Files Written + +| Path | Format | When | +| ------------------------------------ | ------ | ------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always — appends/updates `[workers.]` in place, preserving comments | +| `/supabase///*` | varies | always, unless `--source` names another directory | +| `//*` | varies | when `--source` is given | + +Existing files at the destination are **deleted** when `--force` is passed. +`--source` is refused when it resolves to the project root, `supabase/`, +`supabase/functions/`, `supabase/migrations/`, or outside the project. + +## API Routes + +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---- | ---- | ------------ | ---------------------- | +| — | — | — | — | — | + +## Exit Codes + +| Code | Condition | +| ---- | --------------------------------------------------------------------- | +| `0` | success | +| `1` | invalid or reserved worker name, unknown runtime/size, bad `--source` | +| `1` | destination exists and is not empty without `--force` | +| `1` | `config.toml` records a worker in a form that cannot be edited safely | + +## Environment Variables + +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | + +No custom events. `workers` has no Go counterpart, so there is no +`phtelemetry.*` call to reproduce. diff --git a/apps/cli/src/legacy/commands/workers/new/new.command.ts b/apps/cli/src/legacy/commands/workers/new/new.command.ts new file mode 100644 index 0000000000..239cfb904e --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/new/new.command.ts @@ -0,0 +1,80 @@ +import { Layer } from "effect"; +import { Argument, Command, Flag } from "effect/unstable/cli"; +import type * as CliCommand from "effect/unstable/cli/Command"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { randomLayer } from "../../../../shared/runtime/random.layer.ts"; +import { WORKER_RUNTIMES, WORKER_SIZES } from "../../../../shared/workers/worker-runtimes.ts"; +import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; +import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; +import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyWorkersNew } from "./new.handler.ts"; + +const config = { + name: Argument.string("name").pipe( + Argument.withDescription("Worker name. Doubles as its directory; generated when omitted."), + Argument.optional, + ), + runtime: Flag.choice("runtime", WORKER_RUNTIMES).pipe( + Flag.withDescription( + "Runtime to scaffold and record in supabase/config.toml. Prompted when omitted.", + ), + Flag.optional, + ), + size: Flag.choice("size", WORKER_SIZES).pipe( + Flag.withDescription( + "Instance size to record in supabase/config.toml. Each size implies its own vCPU count, so there is no separate --cpu. Prompted when omitted.", + ), + Flag.optional, + ), + source: Flag.string("source").pipe( + Flag.withDescription( + "Scaffold the worker here instead of the default workers directory, recorded as `source` in supabase/config.toml.", + ), + Flag.optional, + ), + force: Flag.boolean("force").pipe( + Flag.withDescription("Replace the destination if it already exists and is not empty."), + ), +} as const; + +export type LegacyWorkersNewFlags = CliCommand.Command.Config.Infer; + +const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); + +/** Local-disk only: no Management API, so no platform stack is built. */ +const legacyWorkersNewRuntimeLayer = Layer.mergeAll( + cliConfig, + legacyTelemetryStateLayer, + randomLayer, + commandRuntimeLayer(["workers", "new"]), +); + +export const legacyWorkersNewCommand = Command.make("new", config).pipe( + Command.withDescription( + "Scaffold a worker directory from a runtime's starter files and record the choice in supabase/config.toml. Nothing is deployed.", + ), + Command.withShortDescription("Scaffold a worker locally"), + Command.withExamples([ + { + command: "supabase workers new", + description: "Scaffold a worker, prompting for runtime and size", + }, + { + command: "supabase workers new api --runtime node", + description: "Scaffold supabase/workers/api on the node runtime", + }, + { + command: "supabase workers new api --source packages/api", + description: "Scaffold the worker outside the workers directory", + }, + ]), + Command.withHandler((flags) => + legacyWorkersNew(flags).pipe( + withLegacyCommandInstrumentation({ flags, config }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyWorkersNewRuntimeLayer), +); diff --git a/apps/cli/src/legacy/commands/workers/new/new.handler.ts b/apps/cli/src/legacy/commands/workers/new/new.handler.ts new file mode 100644 index 0000000000..c569994143 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/new/new.handler.ts @@ -0,0 +1,347 @@ +import { dirname, join, relative } from "node:path"; +import { Effect, FileSystem, Option } from "effect"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { renderWorkerDetails } from "../workers.format.ts"; +import { legacyEmitWorkersGoOutput } from "../workers.output.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { Random } from "../../../../shared/runtime/random.service.ts"; +import { writeWorkerEntry } from "../../../../shared/workers/worker-config.ts"; +import { displayPath, resolveWorkerSource } from "../../../../shared/workers/worker-paths.ts"; +import { + DEFAULT_WORKER_RUNTIME, + DEFAULT_WORKER_SIZE, + parseWorkerRuntime, + parseWorkerSize, + validateWorkerNameMessage, + vcpuForSize, + WORKER_RUNTIME_DESCRIPTIONS, + WORKER_RUNTIMES, + WORKER_SIZES, + type WorkerRuntime, + type WorkerSize, +} from "../../../../shared/workers/worker-runtimes.ts"; +import { WORKER_STACKS } from "../../../../shared/workers/worker-stacks.ts"; +import { + InvalidWorkerNameError, + UnknownWorkerRuntimeError, + UnknownWorkerSizeError, + WorkerDirectoryExistsError, +} from "../../../../shared/workers/workers.errors.ts"; +import { legacyLoadWorkersProject } from "../workers.shared.ts"; +import type { LegacyWorkersNewFlags } from "./new.command.ts"; + +/** + * `supabase workers new [name]` — scaffold `supabase///` from the + * chosen runtime's starter files and record the choice in `config.toml`. + * Nothing is deployed; this is entirely local-disk work. + * + * The runtime and size are resolved *before* anything is written, so a + * cancelled prompt leaves nothing behind for this worker at all — including the + * name, which is only generated once both questions have been answered. + */ + +/** Adjective pool for an auto-assigned name, when no name is given. */ +const NAME_WORDS = [ + "agile", + "amber", + "apex", + "astral", + "atomic", + "aurora", + "bold", + "bright", + "brisk", + "calm", + "cipher", + "cobalt", + "cosmic", + "crimson", + "delta", + "echo", + "ember", + "flint", + "flux", + "frosty", + "glide", + "halo", + "helix", + "ionic", + "jade", + "keen", + "kinetic", + "lithe", + "lucid", + "lunar", + "maple", + "mist", + "mystic", + "neon", + "nimble", + "noble", + "nomad", + "nova", + "onyx", + "opal", + "orbit", + "prism", + "quartz", + "rapid", + "reef", + "rogue", + "sage", + "sharp", + "silver", + "solar", + "sonic", + "spark", + "steady", + "stellar", + "summit", + "surge", + "swift", +] as const; + +const generateWorkerName = Effect.fnUntraced(function* () { + const random = yield* Random; + // Six bytes, split so the word and the number are drawn independently. + const hex = yield* random.randomHex(6); + const word = NAME_WORDS[Number.parseInt(hex.slice(0, 6), 16) % NAME_WORDS.length]; + const suffix = 10000 + (Number.parseInt(hex.slice(6, 12), 16) % 90000); + return `worker-${word}-${suffix}`; +}); + +/** `values`, with `defaultValue` first, so a prompt pre-selects what it shows first. */ +function defaultFirst(values: ReadonlyArray, defaultValue: T): Array { + return [defaultValue, ...values.filter((value) => value !== defaultValue)]; +} + +const resolveRuntime = Effect.fnUntraced(function* (options: { + readonly explicit: Option.Option; + readonly recorded: string | undefined; +}) { + // `--runtime` is a choice flag, so the parser has already rejected anything + // outside the catalog by the time it gets here. + if (Option.isSome(options.explicit)) { + return options.explicit.value; + } + + if (options.recorded !== undefined) { + const recorded = parseWorkerRuntime(options.recorded); + if (recorded === undefined) { + return yield* Effect.fail( + new UnknownWorkerRuntimeError({ + detail: `supabase/config.toml records an unknown runtime "${options.recorded}".`, + suggestion: `Set it to one of: ${WORKER_RUNTIMES.join(", ")}, or pass --runtime.`, + }), + ); + } + return recorded; + } + + const output = yield* Output; + if (output.format === "text" && output.interactive) { + const selected = yield* output.promptSelect( + "Which runtime should this worker use?", + defaultFirst([...WORKER_RUNTIMES], DEFAULT_WORKER_RUNTIME).map((runtime) => ({ + value: runtime, + label: runtime, + hint: WORKER_RUNTIME_DESCRIPTIONS[runtime], + })), + ); + return parseWorkerRuntime(selected) ?? DEFAULT_WORKER_RUNTIME; + } + + return DEFAULT_WORKER_RUNTIME; +}); + +const resolveSize = Effect.fnUntraced(function* (options: { + readonly explicit: Option.Option; + readonly recorded: string | undefined; +}) { + if (Option.isSome(options.explicit)) { + return options.explicit.value; + } + + if (options.recorded !== undefined) { + const recorded = parseWorkerSize(options.recorded); + if (recorded === undefined) { + return yield* Effect.fail( + new UnknownWorkerSizeError({ + detail: `supabase/config.toml records an unknown size "${options.recorded}".`, + suggestion: `Set it to one of: ${WORKER_SIZES.join(", ")}, or pass --size.`, + }), + ); + } + return recorded; + } + + const output = yield* Output; + if (output.format === "text" && output.interactive) { + const selected = yield* output.promptSelect( + "Which instance size should this worker use?", + defaultFirst([...WORKER_SIZES], DEFAULT_WORKER_SIZE).map((size) => ({ + value: size, + label: `${size} · ${vcpuForSize(size)} vCPU`, + })), + ); + return parseWorkerSize(selected) ?? DEFAULT_WORKER_SIZE; + } + + return DEFAULT_WORKER_SIZE; +}); + +const isNonEmptyDirectory = Effect.fnUntraced(function* (dir: string) { + const fs = yield* FileSystem.FileSystem; + const entries = yield* fs.readDirectory(dir).pipe(Effect.orElseSucceed(() => [])); + return entries.length > 0; +}); + +export const legacyWorkersNew = Effect.fn("legacy.workers.new")(function* ( + flags: LegacyWorkersNewFlags, +) { + const fs = yield* FileSystem.FileSystem; + const output = yield* Output; + const telemetryState = yield* LegacyTelemetryState; + + // Go writes the linked-project cache and flushes telemetry in + // `PersistentPostRun`, so both happen whether the command succeeds or fails. + yield* Effect.gen(function* () { + const project = yield* legacyLoadWorkersProject(); + + if (Option.isSome(flags.name)) { + const invalid = validateWorkerNameMessage(flags.name.value); + if (invalid !== undefined) { + return yield* Effect.fail( + new InvalidWorkerNameError({ + detail: `"${flags.name.value}" is not a valid worker name. ${invalid}`, + suggestion: "Worker names become hostnames, so they must be DNS labels.", + }), + ); + } + } + + // A named worker may already have a `[workers.]` entry — hand-written, + // or left over from a `new` whose directory was since deleted. Its recorded + // choices become this run's defaults instead of re-asking questions + // config.toml has already answered. + const recorded = Option.isSome(flags.name) + ? project.section.workers[flags.name.value] + : undefined; + // Only worth saying when a recorded value is actually about to answer for a + // flag the user omitted. An entry that is present but empty, or one whose + // every key was overridden on the command line, contributes nothing. + const inherits = + (recorded?.runtime !== undefined && Option.isNone(flags.runtime)) || + (recorded?.size !== undefined && Option.isNone(flags.size)) || + (recorded?.source !== undefined && Option.isNone(flags.source)); + if (inherits && Option.isSome(flags.name)) { + yield* output.raw( + `Reusing the existing config.toml entry for "${flags.name.value}". Pass --runtime/--size/--source to override.\n`, + ); + } + + // Resolved before anything is written, so cancelling either prompt leaves + // nothing behind — the name included. + const runtime = yield* resolveRuntime({ explicit: flags.runtime, recorded: recorded?.runtime }); + const size = yield* resolveSize({ explicit: flags.size, recorded: recorded?.size }); + + const name = Option.isSome(flags.name) ? flags.name.value : yield* generateWorkerName(); + if (Option.isNone(flags.name)) { + yield* output.raw(`Auto-assigned the name "${name}".\n`); + } + + // An explicit --source always wins; absent one, a recorded `source` keeps the + // scaffold where config.toml already says the code lives rather than + // defaulting to the workers directory out from under it. + // Validated before anything is written: `--force` deletes this directory + // outright, so a value naming the project root or `supabase/` must never get + // as far as the removal below. + const destination = Option.isSome(flags.source) + ? yield* resolveWorkerSource({ + projectRoot: project.projectRoot, + cwd: project.projectRoot, + raw: flags.source.value, + }) + : recorded?.source === undefined + ? join(project.rootDir, name) + : yield* resolveWorkerSource({ + projectRoot: project.projectRoot, + cwd: project.projectRoot, + raw: recorded.source, + }); + + if (yield* isNonEmptyDirectory(destination)) { + if (!flags.force) { + return yield* Effect.fail( + new WorkerDirectoryExistsError({ + detail: `${displayPath(project.projectRoot, destination)} already exists and is not empty.`, + suggestion: `Replace it with \`supabase workers new ${name} --force\`, or pick a different name.`, + }), + ); + } + // Replaced wholesale rather than merged: a re-run with a different runtime + // would otherwise leave the previous runtime's files mixed in with the new + // ones. + yield* fs.remove(destination, { recursive: true }); + } + + yield* fs.makeDirectory(dirname(destination), { recursive: true }); + yield* fs.makeDirectory(destination, { recursive: true }); + // config.toml lives in supabase/, which may not exist yet when --source puts + // the worker elsewhere in the project. + yield* fs.makeDirectory(project.supabaseDir, { recursive: true }); + + for (const [filename, contents] of Object.entries(WORKER_STACKS[runtime])) { + yield* fs.writeFileString(join(destination, filename), contents); + } + + const source = Option.isSome(flags.source) + ? relative(project.projectRoot, destination) + : recorded?.source; + + yield* writeWorkerEntry({ + configPath: project.configPath, + name, + existingWorkers: project.section.workers, + patch: { + runtime, + size, + ...(source === undefined ? {} : { source }), + }, + }); + + const sourceDisplay = displayPath(project.projectRoot, destination); + + const payload = { + worker_name: name, + runtime, + size, + vcpu: vcpuForSize(size), + source: sourceDisplay, + config_path: project.configPath, + }; + + // `-o` asks for a machine-readable stdout, so nothing human may be written + // to it — `output.success` logs to stdout in text mode. + if (yield* legacyEmitWorkersGoOutput(payload)) { + return; + } + + if (output.format !== "text") { + yield* output.success("", payload); + return; + } + + { + yield* output.raw( + renderWorkerDetails([ + ["source", sourceDisplay], + ["runtime", runtime], + ["size", `${size} · ${vcpuForSize(size)} vCPU`], + ["access", runtime === "sandbox" ? "private (no HTTP endpoint)" : "public"], + ["next", `supabase workers push ${name}`], + ]), + ); + } + }).pipe(Effect.ensuring(telemetryState.flush)); +}); diff --git a/apps/cli/src/legacy/commands/workers/new/new.integration.test.ts b/apps/cli/src/legacy/commands/workers/new/new.integration.test.ts new file mode 100644 index 0000000000..b588f62b8b --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/new/new.integration.test.ts @@ -0,0 +1,311 @@ +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Option } from "effect"; +import { + makeWorkersProject, + setupLegacyWorkers, +} from "../../../../../tests/helpers/legacy-workers.ts"; +import { + InvalidWorkerNameError, + InvalidWorkerSourceError, + UnknownWorkerRuntimeError, + UnknownWorkerSizeError, + WorkerDirectoryExistsError, +} from "../../../../shared/workers/workers.errors.ts"; +import { legacyWorkersNew } from "./new.handler.ts"; +import type { LegacyWorkersNewFlags } from "./new.command.ts"; + +const CONFIG_WITH_COMMENTS = `# hand-written, and it should stay that way +project_id = "demo" + +[functions.hello] +verify_jwt = false +`; + +function flags(overrides: Partial = {}): LegacyWorkersNewFlags { + return { + name: Option.none(), + runtime: Option.none(), + size: Option.none(), + source: Option.none(), + force: false, + ...overrides, + }; +} + +function project(files: Readonly> = {}) { + const created = makeWorkersProject({ + "supabase/config.toml": CONFIG_WITH_COMMENTS, + ...files, + }); + const configPath = join(created.dir, "supabase", "config.toml"); + return { + dir: created.dir, + config: () => readFileSync(configPath, "utf8"), + cleanup: () => rmSync(created.dir, { recursive: true, force: true }), + }; +} + +describe("legacy workers new", () => { + it.live("scaffolds the runtime's starter files and records the choice", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + yield* legacyWorkersNew(flags({ name: Option.some("api"), runtime: Option.some("node") })); + + const workerDir = join(repo.dir, "supabase", "workers", "api"); + expect(existsSync(join(workerDir, "index.js"))).toBe(true); + expect(existsSync(join(workerDir, "package.json"))).toBe(true); + + expect(repo.config()).toBe( + `${CONFIG_WITH_COMMENTS}\n[workers.api]\nruntime = "node"\nsize = "2gb"\n`, + ); + + expect(out.stdoutText).toContain("source"); + expect(out.stdoutText).toContain("supabase workers push api"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("generates a valid worker name when none is given", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + yield* legacyWorkersNew(flags({ runtime: Option.some("deno"), size: Option.some("2gb") })); + + const match = /\[workers\.(?[a-z0-9-]+)\]/.exec(repo.config()); + const name = match?.groups?.["name"]; + expect(name).toMatch(/^worker-[a-z]+-\d{5}$/); + expect(existsSync(join(repo.dir, "supabase", "workers", name ?? "", "main.ts"))).toBe(true); + expect(out.stdoutText).toContain("Auto-assigned"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("prompts for runtime and size when neither is given", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + promptSelectResponses: ["python", "4gb"], + }); + + return Effect.gen(function* () { + yield* legacyWorkersNew(flags({ name: Option.some("api") })); + + expect(out.promptSelectCalls.map((call) => call.message)).toEqual([ + "Which runtime should this worker use?", + "Which instance size should this worker use?", + ]); + expect(repo.config()).toContain('runtime = "python"'); + expect(repo.config()).toContain('size = "4gb"'); + expect(existsSync(join(repo.dir, "supabase", "workers", "api", "main.py"))).toBe(true); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("falls back to the defaults without prompting when not interactive", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ workdir: repo.dir, format: "json" }); + + return Effect.gen(function* () { + yield* legacyWorkersNew(flags({ name: Option.some("api") })); + + expect(out.promptSelectCalls).toHaveLength(0); + expect(repo.config()).toContain('runtime = "deno"'); + expect(repo.config()).toContain('size = "2gb"'); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("reuses an existing config entry instead of re-asking", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + yield* legacyWorkersNew( + flags({ name: Option.some("api"), runtime: Option.some("bun"), size: Option.some("4gb") }), + ); + // The second run gives no runtime or size at all: the recorded ones answer for it. + yield* legacyWorkersNew(flags({ name: Option.some("api"), force: true })); + + expect(out.promptSelectCalls).toHaveLength(0); + expect(repo.config()).toContain('runtime = "bun"'); + expect(repo.config()).toContain('size = "4gb"'); + expect(out.stdoutText).toContain("Reusing the existing"); + expect(existsSync(join(repo.dir, "supabase", "workers", "api", "index.ts"))).toBe(true); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("records a --source worker relative to the project root", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + yield* legacyWorkersNew( + flags({ + name: Option.some("api"), + runtime: Option.some("node"), + source: Option.some("packages/api"), + }), + ); + + expect(existsSync(join(repo.dir, "packages", "api", "index.js"))).toBe(true); + expect(existsSync(join(repo.dir, "supabase", "workers", "api"))).toBe(false); + expect(repo.config()).toContain('source = "packages/api"'); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("refuses a --source that --force would delete outside the worker", () => { + const repo = project({ "README.md": "keep me", "src/app.ts": "keep me too" }); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + for (const source of [".", "..", "supabase", "supabase/functions"]) { + const error = yield* legacyWorkersNew( + flags({ + name: Option.some("api"), + runtime: Option.some("node"), + source: Option.some(source), + force: true, + }), + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(InvalidWorkerSourceError); + } + + // Nothing was removed: --force never reached a directory it should not own. + expect(existsSync(join(repo.dir, "README.md"))).toBe(true); + expect(existsSync(join(repo.dir, "src", "app.ts"))).toBe(true); + expect(repo.config()).toContain("project_id"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("does not claim to reuse an entry that has nothing recorded", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\n`, + }); + const { layer, out } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + yield* legacyWorkersNew(flags({ name: Option.some("api"), runtime: Option.some("node") })); + + expect(out.stdoutText).not.toContain("Reusing"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("honours [workers] root", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers]\nroot = "services"\n`, + }); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + yield* legacyWorkersNew(flags({ name: Option.some("api"), runtime: Option.some("node") })); + + expect(existsSync(join(repo.dir, "supabase", "services", "api", "index.js"))).toBe(true); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("scaffolds in a directory that has no Supabase project yet", () => { + const created = makeWorkersProject(); + const { layer } = setupLegacyWorkers({ workdir: created.dir }); + + return Effect.gen(function* () { + yield* legacyWorkersNew(flags({ name: Option.some("api"), runtime: Option.some("node") })); + + expect(existsSync(join(created.dir, "supabase", "workers", "api", "index.js"))).toBe(true); + expect(readFileSync(join(created.dir, "supabase", "config.toml"), "utf8")).toBe( + `[workers.api]\nruntime = "node"\nsize = "2gb"\n`, + ); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => rmSync(created.dir, { recursive: true, force: true }))), + ); + }); + + it.live("refuses a non-empty destination unless --force is given", () => { + const repo = project({ "supabase/workers/api/leftover.txt": "old" }); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersNew( + flags({ name: Option.some("api"), runtime: Option.some("node") }), + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerDirectoryExistsError); + expect(existsSync(join(repo.dir, "supabase", "workers", "api", "leftover.txt"))).toBe(true); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("replaces the destination wholesale with --force", () => { + const repo = project({ "supabase/workers/api/leftover.txt": "old" }); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + yield* legacyWorkersNew( + flags({ name: Option.some("api"), runtime: Option.some("node"), force: true }), + ); + + expect(existsSync(join(repo.dir, "supabase", "workers", "api", "leftover.txt"))).toBe(false); + expect(existsSync(join(repo.dir, "supabase", "workers", "api", "index.js"))).toBe(true); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("rejects a name that could not become a hostname", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersNew(flags({ name: Option.some("My_Worker") })).pipe( + Effect.flip, + ); + + expect(error).toBeInstanceOf(InvalidWorkerNameError); + expect(existsSync(join(repo.dir, "supabase", "workers"))).toBe(false); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("rejects a config.toml that records an unknown runtime or size", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "rust"\n`, + }); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + const runtimeError = yield* legacyWorkersNew(flags({ name: Option.some("api") })).pipe( + Effect.flip, + ); + expect(runtimeError).toBeInstanceOf(UnknownWorkerRuntimeError); + expect(existsSync(join(repo.dir, "supabase", "workers"))).toBe(false); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("rejects a config.toml that records an unknown size", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsize = "64gb"\n`, + }); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + const sizeError = yield* legacyWorkersNew(flags({ name: Option.some("api") })).pipe( + Effect.flip, + ); + expect(sizeError).toBeInstanceOf(UnknownWorkerSizeError); + expect(existsSync(join(repo.dir, "supabase", "workers"))).toBe(false); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("refuses [workers] root pointed at a directory the CLI owns", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers]\nroot = "functions"\n`, + }); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersNew( + flags({ name: Option.some("api"), runtime: Option.some("node") }), + ).pipe(Effect.flip); + + expect(error._tag).toBe("InvalidWorkersRootError"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); +}); diff --git a/apps/cli/src/legacy/commands/workers/workers.command.ts b/apps/cli/src/legacy/commands/workers/workers.command.ts new file mode 100644 index 0000000000..ac4555f3de --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/workers.command.ts @@ -0,0 +1,10 @@ +import { Command } from "effect/unstable/cli"; +import { legacyWorkersNewCommand } from "./new/new.command.ts"; + +export const legacyWorkersCommand = Command.make("workers").pipe( + Command.withDescription( + "Manage Supabase Workers: containers that run your code next to your project, deployed from supabase/workers//.", + ), + Command.withShortDescription("Manage Supabase Workers"), + Command.withSubcommands([legacyWorkersNewCommand]), +); diff --git a/apps/cli/src/legacy/commands/workers/workers.errors.ts b/apps/cli/src/legacy/commands/workers/workers.errors.ts new file mode 100644 index 0000000000..9d50b8a447 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/workers.errors.ts @@ -0,0 +1,25 @@ +import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; + +/** + * `--output env` cannot represent a payload containing a list. + * + * `encodeEnv` reproduces `godotenv.Marshal`, whose flattening does not descend + * into slices — a `workers` array would land as a single `WORKERS=""` line + * rather than one entry per worker. Refusing is the same call `functions list` + * makes for the same reason, rather than emitting output that silently omits + * the data. + */ +export class LegacyWorkersEnvNotSupportedError extends Data.TaggedError( + "LegacyWorkersEnvNotSupportedError", +)<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} diff --git a/apps/cli/src/legacy/commands/workers/workers.format.ts b/apps/cli/src/legacy/commands/workers/workers.format.ts new file mode 100644 index 0000000000..66662d044b --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/workers.format.ts @@ -0,0 +1,24 @@ +import { renderGlamourTable } from "../../output/legacy-glamour-table.ts"; + +/** + * Text rendering for the workers commands. + * + * Two conventions this shell holds and `supabase workers` follows rather than + * inventing its own: results are written with `output.raw` as plain text — no + * `intro`/`outro` framing, which no other handler here uses — and tabular + * output goes through `renderGlamourTable`, so `workers list` sits beside + * `functions list` and `projects list` looking like them. + */ + +/** `label value` detail lines, aligned the way this shell's key/value output is. */ +export function renderWorkerDetails(rows: ReadonlyArray): string { + const width = Math.max(...rows.map(([label]) => label.length)); + return `${rows.map(([label, value]) => ` ${label.padEnd(width)} ${value}`).join("\n")}\n`; +} + +export function renderWorkersTable( + headers: ReadonlyArray, + rows: ReadonlyArray>, +): string { + return renderGlamourTable(headers, rows); +} diff --git a/apps/cli/src/legacy/commands/workers/workers.output.ts b/apps/cli/src/legacy/commands/workers/workers.output.ts new file mode 100644 index 0000000000..927b7be470 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/workers.output.ts @@ -0,0 +1,66 @@ +import { Effect, Option } from "effect"; +import { LegacyOutputFlag } from "../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../shared/output/output.service.ts"; +import { + encodeEnv, + encodeGoJson, + encodeToml, + encodeYaml, +} from "../../shared/legacy-go-output.encoders.ts"; +import { LegacyWorkersEnvNotSupportedError } from "./workers.errors.ts"; + +/** + * Emits a command's payload in the format `-o`/`--output` asked for. + * + * `-o` is a global flag on this shell that 33 of its 37 command families + * honour, so a workers command that ignored it would print human text to a + * stdout the user had asked to be machine-readable. What it does *not* inherit + * is the Go-parity obligation: the struct-shaped encoders exist to reproduce a + * Go type's byte output, and `supabase workers` has no Go counterpart, so it + * serialises its own payload through the generic encoders instead. + * + * Returns whether it emitted anything, so the caller can skip its text + * rendering — `output.success` writes to stdout in text mode and would corrupt + * the payload otherwise. + */ +export const legacyEmitWorkersGoOutput = Effect.fnUntraced(function* ( + payload: Record, +) { + const output = yield* Output; + const goFormat = Option.getOrUndefined(yield* LegacyOutputFlag); + + if (goFormat === undefined || goFormat === "pretty") { + return false; + } + + if (goFormat === "env") { + if (Object.values(payload).some((value) => Array.isArray(value))) { + return yield* new LegacyWorkersEnvNotSupportedError({ + message: "--output env flag is not supported", + }); + } + yield* output.raw(`${encodeEnv(payload)}\n`); + return true; + } + + if (goFormat === "json") { + yield* output.raw(encodeGoJson(payload)); + return true; + } + if (goFormat === "yaml") { + yield* output.raw(encodeYaml(payload)); + return true; + } + yield* output.raw(encodeToml(payload)); + return true; +}); + +/** + * Whether a machine-readable stdout was requested via `-o`. Callers that emit + * human lines *before* their payload need this: the `-o` branch runs at the end, + * by which point those lines would already be on stdout. + */ +export const legacyWorkersMachineOutputRequested = Effect.fnUntraced(function* () { + const goFormat = Option.getOrUndefined(yield* LegacyOutputFlag); + return goFormat !== undefined && goFormat !== "pretty"; +}); diff --git a/apps/cli/src/legacy/commands/workers/workers.shared.ts b/apps/cli/src/legacy/commands/workers/workers.shared.ts new file mode 100644 index 0000000000..4dd4b06111 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/workers.shared.ts @@ -0,0 +1,125 @@ +import { join } from "node:path"; +import { loadProjectConfig } from "@supabase/config"; +import { Effect, FileSystem } from "effect"; +import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; +import { + readWorkersSection, + type WorkerEntry, + type WorkersSection, +} from "../../../shared/workers/worker-config.ts"; +import { + resolveWorkersRoot, + workerDir, + workersRootDir, + workerSourceDir, +} from "../../../shared/workers/worker-paths.ts"; +import { validateWorkerNameMessage } from "../../../shared/workers/worker-runtimes.ts"; +import { InvalidWorkerNameError } from "../../../shared/workers/workers.errors.ts"; + +/** + * What every `supabase workers` command needs before it does anything: where + * the project is, what `[workers]` says, and which worker is being acted on. + * + * The project directory is `LegacyCliConfig.workdir` rather than an ancestor + * walk from the current directory. That is the resolved workdir every other + * legacy command acts on — `--workdir`/`SUPABASE_WORKDIR` when given, else the + * ancestor walk Go's own `getProjectRoot` performs — so `supabase workers` + * answers to the same flag as its siblings instead of inventing a second notion + * of "which project". + */ + +export interface LegacyWorkersProject { + readonly projectRoot: string; + readonly supabaseDir: string; + readonly configPath: string; + readonly section: WorkersSection; + /** `[workers] root`, validated. */ + readonly root: string; + /** `supabase//`. */ + readonly rootDir: string; +} + +export const legacyLoadWorkersProject = Effect.fnUntraced(function* () { + const cliConfig = yield* LegacyCliConfig; + const projectRoot = cliConfig.workdir; + const supabaseDir = join(projectRoot, "supabase"); + + // `loadProjectConfig` returns null when the directory holds no project yet, + // which is what lets `workers new` scaffold into a bare one. + const loaded = yield* loadProjectConfig(projectRoot); + const section = readWorkersSection(loaded?.config.workers); + const root = yield* resolveWorkersRoot(section.root); + + return { + projectRoot, + supabaseDir, + configPath: loaded?.path ?? join(supabaseDir, "config.toml"), + section, + root, + rootDir: workersRootDir(projectRoot, root), + } satisfies LegacyWorkersProject; +}); + +export interface LegacyResolvedWorker { + readonly name: string; + readonly entry: WorkerEntry | undefined; + /** The worker's default directory, `supabase///`. */ + readonly defaultDir: string; + /** Where its code actually lives, honouring `[workers.] source`. */ + readonly sourceDir: string; +} + +export function legacyDescribeWorker( + project: LegacyWorkersProject, + name: string, +): LegacyResolvedWorker { + const entry = project.section.workers[name]; + const defaultDir = workerDir(project.projectRoot, project.root, name); + return { + name, + entry, + defaultDir, + sourceDir: workerSourceDir(project.projectRoot, defaultDir, entry?.source), + }; +} + +/** Reject a name the CLI could never have written, before acting on it. */ +export const legacyValidateWorkerName = Effect.fnUntraced(function* (name: string) { + const invalid = validateWorkerNameMessage(name); + if (invalid !== undefined) { + return yield* Effect.fail( + new InvalidWorkerNameError({ + detail: `"${name}" is not a valid worker name. ${invalid}`, + suggestion: "Worker names become hostnames, so they must be DNS labels.", + }), + ); + } + return name; +}); + +/** + * Every worker in the project, for a command given no names: the directories + * under the workers root, unioned with the `[workers.]` entries, since a + * worker with a `source` lives outside that root and would otherwise be missed. + * + * Sorted, so a bare `push` deploys in a stable order rather than whatever the + * filesystem happened to return. + */ +export const legacyDiscoverWorkerNames = Effect.fnUntraced(function* ( + project: LegacyWorkersProject, +) { + const fs = yield* FileSystem.FileSystem; + const entries = yield* fs.readDirectory(project.rootDir).pipe(Effect.orElseSucceed(() => [])); + + const scaffolded: Array = []; + for (const entry of entries) { + const info = yield* fs.stat(join(project.rootDir, entry)).pipe(Effect.option); + if (info._tag === "Some" && info.value.type === "Directory") { + scaffolded.push(entry); + } + } + + return [...new Set([...scaffolded, ...Object.keys(project.section.workers)])] + .filter((name) => validateWorkerNameMessage(name) === undefined) + .sort(); +}); diff --git a/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts b/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts index d3648e8ad7..bd9659d06f 100644 --- a/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts +++ b/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts @@ -64,6 +64,7 @@ export const LEGACY_DOCS_TAGS: Readonly>> = "supabase-secrets": ["management-api"], "supabase-seed": ["local-dev"], "supabase-services": ["local-dev"], + "supabase-workers": ["management-api"], "supabase-snippets": ["management-api"], "supabase-ssl-enforcement": ["management-api"], "supabase-sso": ["management-api"], 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 964434c3a7..e182e4f9ff 100644 --- a/apps/cli/src/legacy/shared/legacy-db-target-flags.ts +++ b/apps/cli/src/legacy/shared/legacy-db-target-flags.ts @@ -139,7 +139,9 @@ export const VALUE_CONSUMING_LONG_FLAGS = new Set([ "release-channel", "remove-domains", "role", + "runtime", "size", + "source", "status", "sub", "swift-access-control", @@ -177,7 +179,7 @@ export const VALUE_CONSUMING_LONG_FLAGS = new Set([ export const VALUE_CONSUMING_SHORT_FLAGS = new Set([ "s", // --schema / -s "o", // --output / -o - "p", // --password / -p (migration list, db push/pull/dump/remote) + "p", // --password / -p (migration list, db push/pull/dump/remote); --source / -p (workers new) "j", // --jobs / -j (storage cp) "f", // --file / -f (db dump/diff/query, db schema declarative sync) "t", // --template / -t (test new); --type / -t (sso add); --timestamp / -t (backups restore) diff --git a/apps/cli/src/shared/workers/worker-config.ts b/apps/cli/src/shared/workers/worker-config.ts new file mode 100644 index 0000000000..58b9883ed0 --- /dev/null +++ b/apps/cli/src/shared/workers/worker-config.ts @@ -0,0 +1,141 @@ +import { Data, Effect, FileSystem } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; +import { sectionExists, tomlKey, upsertTomlSection } from "./toml-section.ts"; + +/** + * The `[workers]` section of `supabase/config.toml`, read through the decoded + * project config and written back surgically. + * + * `[workers]` carries a project-wide `root` plus one `[workers.]` table + * per worker. The schema in `@supabase/config` models exactly that, so reading + * is a matter of splitting the scalar off the record; writing goes through + * `./toml-section.ts` so a user's comments and formatting survive. + */ + +/** One worker's recorded metadata. Every key is optional. */ +export interface WorkerEntry { + readonly runtime?: string; + readonly size?: string; + readonly source?: string; +} + +export interface WorkersSection { + /** `[workers] root`, unvalidated — see `resolveWorkersRoot`. */ + readonly root: string | undefined; + /** `[workers.]` tables, keyed by worker name, in file order. */ + readonly workers: Readonly>; +} + +/** + * A worker is present in the config but not as its own `[workers.]` table + * — an inline `workers = { … }`, or dotted `workers..runtime = …` keys. + * Appending a table would duplicate the key and leave the file invalid, and + * rewriting the whole file would cost the user every comment in it, so this + * asks for the one edit that makes a surgical write possible. + */ +export class WorkerEntryNotATableError extends Data.TaggedError("WorkerEntryNotATableError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} + +/** + * A key the CLI wants to set is written across several lines (an array, an + * inline table, a `"""` string). Swapping one line would strand its + * continuation and leave the file unparseable, so the edit stops instead. + */ +export class WorkerEntryValueNotEditableError extends Data.TaggedError( + "WorkerEntryValueNotEditableError", +)<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} + +const stringOrUndefined = (value: unknown): string | undefined => + typeof value === "string" && value !== "" ? value : undefined; + +/** + * Split the decoded `[workers]` section into its project-wide `root` and its + * per-worker tables. A scalar written directly under `[workers]` parses as a + * sibling of the sub-tables, so anything that is not an object is dropped here + * rather than read as a worker named after it. + */ +export function readWorkersSection(workers: unknown): WorkersSection { + if (typeof workers !== "object" || workers === null || Array.isArray(workers)) { + return { root: undefined, workers: {} }; + } + + const entries: Record = {}; + for (const [key, value] of Object.entries(workers)) { + if (key === "root" || typeof value !== "object" || value === null || Array.isArray(value)) { + continue; + } + const entry = value as Record; + entries[key] = { + runtime: stringOrUndefined(entry["runtime"]), + size: stringOrUndefined(entry["size"]), + source: stringOrUndefined(entry["source"]), + }; + } + + // `root` is passed through as written (empty string included) so an obviously + // wrong value reaches `resolveWorkersRoot` and gets named, rather than + // silently falling back to the default. + const root = (workers as Record)["root"]; + return { + root: typeof root === "string" ? root : undefined, + workers: entries, + }; +} + +/** + * Set `patch`'s keys on `[workers.]` in `configPath`, leaving every other + * byte of the file untouched. Creates the file (and its `supabase/` directory) + * when it does not exist yet, so `new` works in a directory that has never been + * `supabase init`-ed. + */ +export const writeWorkerEntry = Effect.fnUntraced(function* (options: { + readonly configPath: string; + readonly name: string; + readonly patch: Readonly>; + /** The already-parsed config, used only to detect a non-table entry. */ + readonly existingWorkers: Readonly>; +}) { + const fs = yield* FileSystem.FileSystem; + + const exists = yield* fs.exists(options.configPath); + const text = exists ? yield* fs.readFileString(options.configPath) : ""; + const header = `workers.${tomlKey(options.name)}`; + + if (options.existingWorkers[options.name] !== undefined && !sectionExists(text, header)) { + return yield* Effect.fail( + new WorkerEntryNotATableError({ + detail: `"${options.name}" is configured in ${options.configPath} but not as a standalone [${header}] table.`, + suggestion: `Move it into its own [${header}] table so the CLI can update it without rewriting the file.`, + }), + ); + } + + const edit = upsertTomlSection(text, header, options.patch); + if (edit._tag === "Unsupported") { + return yield* Effect.fail( + new WorkerEntryValueNotEditableError({ + detail: `[${header}] ${edit.key} in ${options.configPath} spans multiple lines, so the CLI cannot rewrite it safely.`, + suggestion: `Put ${edit.key} on a single line, or remove it and let the CLI write it.`, + }), + ); + } + + yield* fs.writeFileString(options.configPath, edit.text); +}); diff --git a/apps/cli/src/shared/workers/worker-config.unit.test.ts b/apps/cli/src/shared/workers/worker-config.unit.test.ts new file mode 100644 index 0000000000..0af577f681 --- /dev/null +++ b/apps/cli/src/shared/workers/worker-config.unit.test.ts @@ -0,0 +1,142 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { Effect } from "effect"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + readWorkersSection, + WorkerEntryNotATableError, + WorkerEntryValueNotEditableError, + writeWorkerEntry, +} from "./worker-config.ts"; + +describe("readWorkersSection", () => { + test("splits the project-wide root from the per-worker tables", () => { + expect( + readWorkersSection({ + root: "services", + api: { runtime: "node", size: "2gb", source: "packages/api" }, + box: { runtime: "sandbox" }, + }), + ).toEqual({ + root: "services", + workers: { + api: { runtime: "node", size: "2gb", source: "packages/api" }, + box: { runtime: "sandbox", size: undefined, source: undefined }, + }, + }); + }); + + test("drops non-object values so a stray scalar is not read as a worker", () => { + expect(readWorkersSection({ root: "services", stray: "oops", api: {} })).toEqual({ + root: "services", + workers: { api: { runtime: undefined, size: undefined, source: undefined } }, + }); + }); + + test("treats a missing or malformed section as empty", () => { + expect(readWorkersSection(undefined)).toEqual({ root: undefined, workers: {} }); + expect(readWorkersSection([])).toEqual({ root: undefined, workers: {} }); + // An empty root is passed through so the validator can name it. + expect(readWorkersSection({ root: "" })).toEqual({ root: "", workers: {} }); + }); +}); + +describe("writeWorkerEntry", () => { + let dir: string; + let configPath: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "supabase-worker-config-")); + configPath = join(dir, "config.toml"); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + const run = (effect: Effect.Effect) => Effect.runPromise(effect); + + test("creates the file when there is none yet", async () => { + await run( + writeWorkerEntry({ + configPath, + name: "api", + existingWorkers: {}, + patch: { runtime: "node" }, + }).pipe(Effect.provide(BunServices.layer)), + ); + + expect(readFileSync(configPath, "utf8")).toBe('[workers.api]\nruntime = "node"\n'); + }); + + test("updates an existing table without touching the rest of the file", async () => { + writeFileSync( + configPath, + '# keep me\nproject_id = "demo"\n\n[workers.api]\nruntime = "node"\n', + ); + + await run( + writeWorkerEntry({ + configPath, + name: "api", + existingWorkers: { api: { runtime: "node" } }, + patch: { runtime: "bun", size: "4gb" }, + }).pipe(Effect.provide(BunServices.layer)), + ); + + expect(readFileSync(configPath, "utf8")).toBe( + '# keep me\nproject_id = "demo"\n\n[workers.api]\nruntime = "bun"\nsize = "4gb"\n', + ); + }); + + test("refuses a multi-line value instead of stranding its continuation lines", async () => { + const before = '[workers.api]\nruntime = [\n "node",\n]\n'; + writeFileSync(configPath, before); + + const error = await run( + writeWorkerEntry({ + configPath, + name: "api", + existingWorkers: { api: {} }, + patch: { runtime: "bun" }, + }).pipe(Effect.provide(BunServices.layer), Effect.flip), + ); + + expect(error).toBeInstanceOf(WorkerEntryValueNotEditableError); + expect(readFileSync(configPath, "utf8")).toBe(before); + }); + + test("keeps a trailing comment on the value it rewrites", async () => { + writeFileSync(configPath, '[workers.api]\nruntime = "node" # hand-picked\n'); + + await run( + writeWorkerEntry({ + configPath, + name: "api", + existingWorkers: { api: { runtime: "node" } }, + patch: { runtime: "bun" }, + }).pipe(Effect.provide(BunServices.layer)), + ); + + expect(readFileSync(configPath, "utf8")).toBe('[workers.api]\nruntime = "bun" # hand-picked\n'); + }); + + test("refuses to rewrite a worker expressed as dotted keys rather than its own table", async () => { + writeFileSync(configPath, 'workers.api.runtime = "node"\n'); + + const error = await run( + writeWorkerEntry({ + configPath, + name: "api", + existingWorkers: { api: { runtime: "node" } }, + patch: { runtime: "bun" }, + }).pipe(Effect.provide(BunServices.layer), Effect.flip), + ); + + expect(error).toBeInstanceOf(WorkerEntryNotATableError); + // The file is left exactly as it was rather than duplicated into invalid TOML. + expect(readFileSync(configPath, "utf8")).toBe('workers.api.runtime = "node"\n'); + }); +}); diff --git a/apps/cli/src/shared/workers/worker-runtimes.ts b/apps/cli/src/shared/workers/worker-runtimes.ts new file mode 100644 index 0000000000..5b05ce2a86 --- /dev/null +++ b/apps/cli/src/shared/workers/worker-runtimes.ts @@ -0,0 +1,130 @@ +/** + * The alpha envelope a worker is described by: which runtime it is built on, + * and how big an instance it runs as. + * + * Both are deliberately small closed sets. The Workers API takes `spec.size` as + * one opaque string (`2gb-1vcpu`) rather than independent cpu/memory dials, so + * the CLI offers exactly the sizes that string has values for and derives the + * vCPU count from the memory the user picked — one choice, not two that could + * be combined into a shape the platform does not run. + */ + +/** A worker's runtime: its own Dockerfile, or one of the catalog base images. */ +export const WORKER_RUNTIMES = ["dockerfile", "node", "bun", "deno", "python", "sandbox"] as const; + +export type WorkerRuntime = (typeof WORKER_RUNTIMES)[number]; + +/** + * The runtime a worker gets when nobody names one: what `new`'s prompt + * pre-selects, and what the classifier falls back to for a directory it does + * not recognize. Deno, because it is the runtime the rest of the Supabase CLI's + * function tooling assumes. + */ +export const DEFAULT_WORKER_RUNTIME: WorkerRuntime = "deno"; + +function isWorkerRuntime(value: string): value is WorkerRuntime { + return (WORKER_RUNTIMES as ReadonlyArray).includes(value); +} + +/** + * The runtime a user (or a config file) named, case-insensitively — so the + * `Dockerfile` this CLI displays is accepted by `--runtime`, which would + * otherwise reject the one value it shows you. The canonical lowercase form is + * what gets recorded. + */ +export function parseWorkerRuntime(value: string): WorkerRuntime | undefined { + const canonical = value.trim().toLowerCase(); + return isWorkerRuntime(canonical) ? canonical : undefined; +} + +/** One-line description of each runtime, for `--runtime`'s prompt and help. */ +export const WORKER_RUNTIME_DESCRIPTIONS: Record = { + dockerfile: "Build the directory's own Dockerfile.", + node: "Node.js runtime (Web-standard fetch handler).", + bun: "Bun runtime (Web-standard fetch handler).", + deno: "Deno runtime (Web-standard fetch handler).", + python: "Python runtime (ASGI app).", + sandbox: "Bare sandbox environment; no HTTP handler.", +}; + +/** + * The only instance sizes the alpha envelope offers, denominated by memory. + * There is no resize — a different size later means a new worker, not a flag on + * `push`. + */ +export const WORKER_SIZES = ["2gb", "4gb"] as const; + +export type WorkerSize = (typeof WORKER_SIZES)[number]; + +/** The first available option — what `new` records when `--size` is omitted. */ +export const DEFAULT_WORKER_SIZE: WorkerSize = "2gb"; + +function isWorkerSize(value: string): value is WorkerSize { + return (WORKER_SIZES as ReadonlyArray).includes(value); +} + +/** As {@link parseWorkerRuntime}, for instance sizes. */ +export function parseWorkerSize(value: string): WorkerSize | undefined { + const canonical = value.trim().toLowerCase(); + return isWorkerSize(canonical) ? canonical : undefined; +} + +const VCPU_FOR_SIZE: Record = { "2gb": 1, "4gb": 2 }; + +/** The vCPU count that comes with `size` — not independently choosable. */ +export function vcpuForSize(size: WorkerSize): number { + return VCPU_FOR_SIZE[size]; +} + +/** `spec.size` as the Workers API spells it: `2gb-1vcpu`. */ +export function apiSizeFor(size: WorkerSize): string { + return `${size}-${vcpuForSize(size)}vcpu`; +} + +/** + * How a size reads in output: `2gb · 1 vCPU`. Takes the API's own spelling so a + * worker deployed at a size this CLI never offered still renders, verbatim, + * rather than being forced into the local enum. + */ +export function formatApiSize(apiSize: string): string { + const match = /^(\d+gb)-(\d+)vcpu$/.exec(apiSize.trim().toLowerCase()); + if (match === null) { + return apiSize; + } + return `${match[1]} · ${match[2]} vCPU`; +} + +/** + * `spec.exposure`: whether the worker is reachable over HTTP. Every catalog + * runtime and every Dockerfile build serves traffic; a bare `sandbox` has no + * HTTP handler at all, so it is deployed private and reached another way. + */ +export function exposureFor(runtime: WorkerRuntime): "public" | "private" { + return runtime === "sandbox" ? "private" : "public"; +} + +/** + * Worker names end up in hostnames, so they are DNS labels — the same pattern + * the Management API validates the `:name` path parameter against. + */ +const WORKER_NAME_PATTERN = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/; + +/** + * `root` is the one name a worker cannot have: `[workers] root` is the + * project-wide scalar in the same table, so `[workers.root]` would be a string + * key holding a table and the whole `config.toml` stops parsing — taking every + * other worker command down with it. Refuse it at the door rather than let the + * CLI write a file it can no longer read. + */ +const RESERVED_WORKER_NAMES = ["root"]; + +const workerNameRequirement = + "Use lowercase letters, digits and hyphens, starting and ending with a letter or digit."; + +/** `undefined` when `name` is a valid worker name, else why it is not. */ +export function validateWorkerNameMessage(name: string): string | undefined { + if (RESERVED_WORKER_NAMES.includes(name)) { + return `"${name}" is reserved by the [workers] table's own \`${name}\` key.`; + } + return WORKER_NAME_PATTERN.test(name) ? undefined : workerNameRequirement; +} diff --git a/apps/cli/src/shared/workers/worker-runtimes.unit.test.ts b/apps/cli/src/shared/workers/worker-runtimes.unit.test.ts new file mode 100644 index 0000000000..fd064448d5 --- /dev/null +++ b/apps/cli/src/shared/workers/worker-runtimes.unit.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "vitest"; +import { + apiSizeFor, + exposureFor, + formatApiSize, + parseWorkerRuntime, + parseWorkerSize, + validateWorkerNameMessage, + vcpuForSize, +} from "./worker-runtimes.ts"; + +describe("parseWorkerRuntime", () => { + test("accepts the value it displays, case-insensitively, and canonicalizes it", () => { + expect(parseWorkerRuntime("Dockerfile")).toBe("dockerfile"); + expect(parseWorkerRuntime(" NODE ")).toBe("node"); + }); + + test("rejects anything outside the catalog", () => { + expect(parseWorkerRuntime("rust")).toBeUndefined(); + expect(parseWorkerRuntime("")).toBeUndefined(); + }); +}); + +describe("sizes", () => { + test("each size implies its own vCPU count", () => { + expect(vcpuForSize("2gb")).toBe(1); + expect(vcpuForSize("4gb")).toBe(2); + }); + + test("map onto the spelling the Workers API takes", () => { + expect(apiSizeFor("2gb")).toBe("2gb-1vcpu"); + expect(apiSizeFor("4gb")).toBe("4gb-2vcpu"); + }); + + test("render back for display, and pass through anything unrecognized verbatim", () => { + expect(formatApiSize("2gb-1vcpu")).toBe("2gb · 1 vCPU"); + expect(formatApiSize("16gb-8vcpu")).toBe("16gb · 8 vCPU"); + expect(formatApiSize("something-else")).toBe("something-else"); + }); + + test("parse case-insensitively and reject sizes outside the alpha envelope", () => { + expect(parseWorkerSize("4GB")).toBe("4gb"); + expect(parseWorkerSize("8gb")).toBeUndefined(); + }); +}); + +describe("exposureFor", () => { + test("is public for everything that serves HTTP and private for a bare sandbox", () => { + expect(exposureFor("node")).toBe("public"); + expect(exposureFor("dockerfile")).toBe("public"); + expect(exposureFor("sandbox")).toBe("private"); + }); +}); + +describe("validateWorkerNameMessage", () => { + test("accepts DNS labels", () => { + expect(validateWorkerNameMessage("api")).toBeUndefined(); + expect(validateWorkerNameMessage("my-worker-1")).toBeUndefined(); + expect(validateWorkerNameMessage("a")).toBeUndefined(); + }); + + test("refuses `root`, which collides with the [workers] table's own key", () => { + expect(validateWorkerNameMessage("root")).toContain("reserved"); + }); + + test.each(["My-Worker", "-leading", "trailing-", "under_score", "", "a".repeat(64)])( + "rejects %j", + (name) => { + expect(validateWorkerNameMessage(name)).toBeDefined(); + }, + ); +}); diff --git a/apps/cli/src/shared/workers/worker-stacks.ts b/apps/cli/src/shared/workers/worker-stacks.ts new file mode 100644 index 0000000000..008f901e3e --- /dev/null +++ b/apps/cli/src/shared/workers/worker-stacks.ts @@ -0,0 +1,104 @@ +import type { WorkerRuntime } from "./worker-runtimes.ts"; + +/** + * The starter files `supabase workers new` writes, per runtime. + * + * Held as source text rather than read from a directory on disk, the same way + * `supabase functions new` holds its entrypoint: the compiled binary ships no + * template tree, so the content has to travel in the module graph. + * + * The catalog runtimes all scaffold a Web-standard `fetch` handler (or, for + * python, an ASGI `app`) rather than binding a port themselves — the base + * image's own entrypoint does the binding in production. + */ + +const packageJson = `${JSON.stringify( + { + name: "worker", + private: true, + type: "module", + }, + null, + 2, +)}\n`; + +const fetchHandler = ( + runtime: string, +) => `// Starter for the "${runtime}" runtime — edit before deploying. +// Export a default object with a Web-standard \`fetch\` handler; the runtime +// binds the port and serves it. +export default { + fetch() { + return new Response("hello from supabase workers\\n"); + }, +}; +`; + +const dockerfile = `FROM node:24-alpine +WORKDIR /app +COPY . . +EXPOSE 8080 +CMD ["node", "server.js"] +`; + +/** + * Scaffolded alongside the Dockerfile above, because that Dockerfile's \`CMD\` + * names it. A starter whose only file references a second file that does not + * exist builds cleanly and then crash-loops on deploy — the one runtime whose + * scaffold could not run as written. + */ +const dockerfileServer = `// Starter for the "dockerfile" runtime — edit before deploying. +// The Dockerfile's CMD runs this file; it binds the port itself, unlike the +// catalog runtimes where the base image does the binding. +import { createServer } from "node:http"; + +const port = Number(process.env.PORT ?? 8080); + +createServer((_request, response) => { + response.writeHead(200, { "content-type": "text/plain" }); + response.end("hello from supabase workers\\n"); +}).listen(port, () => { + console.log(\`listening on \${port}\`); +}); +`; + +const pythonApp = `# Starter for the "python" runtime — edit before deploying. +# \`app\` is an ASGI application, so FastAPI/Starlette/etc. work too — just +# assign your app to \`app\`. The runtime serves it on the ingress port. +async def app(scope, receive, send): + if scope["type"] != "http": + return + await send({ + "type": "http.response.start", + "status": 200, + "headers": [(b"content-type", b"text/plain")], + }) + await send({"type": "http.response.body", "body": b"hello from supabase workers\\n"}) +`; + +const pythonRequirements = `# add your dependencies here +`; + +const sandboxReadme = `# sandbox + +A bare sandbox runtime — no HTTP handler and no baked code. It is an environment +to run things in, not a served app, so \`supabase workers push\` here provisions +the environment and gives it no URL. +`; + +/** Each runtime's starter files, keyed by the filename to write in the worker directory. */ +export const WORKER_STACKS: Record>> = { + // `package.json` is scaffolded for its `"type": "module"` alone: without it + // `server.js` is only ESM by Node's syntax detection, which is a heuristic to + // rely on for a file the image's CMD depends on. + dockerfile: { + Dockerfile: dockerfile, + "package.json": packageJson, + "server.js": dockerfileServer, + }, + node: { "package.json": packageJson, "index.js": fetchHandler("node") }, + bun: { "package.json": packageJson, "index.ts": fetchHandler("bun") }, + deno: { "main.ts": fetchHandler("deno") }, + python: { "main.py": pythonApp, "requirements.txt": pythonRequirements }, + sandbox: { "README.md": sandboxReadme }, +}; diff --git a/apps/cli/src/shared/workers/workers.errors.ts b/apps/cli/src/shared/workers/workers.errors.ts index 601135df6d..3ee033f0d5 100644 --- a/apps/cli/src/shared/workers/workers.errors.ts +++ b/apps/cli/src/shared/workers/workers.errors.ts @@ -12,6 +12,42 @@ import { * output layer instead of by each command. */ +export class InvalidWorkerNameError extends Data.TaggedError("InvalidWorkerNameError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +export class UnknownWorkerRuntimeError extends Data.TaggedError("UnknownWorkerRuntimeError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +export class UnknownWorkerSizeError extends Data.TaggedError("UnknownWorkerSizeError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +export class WorkerDirectoryExistsError extends Data.TaggedError("WorkerDirectoryExistsError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + /** * `--source` names a directory it is not allowed to name. Worth its own error * because the destination is a directory `--force` will delete outright, so a diff --git a/apps/cli/tests/helpers/legacy-workers.ts b/apps/cli/tests/helpers/legacy-workers.ts new file mode 100644 index 0000000000..a0472807f4 --- /dev/null +++ b/apps/cli/tests/helpers/legacy-workers.ts @@ -0,0 +1,263 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { makeApiClient } from "@supabase/api/effect"; +import { Effect, Layer, Option, Redacted } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import type * as HttpClientError from "effect/unstable/http/HttpClientError"; +import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import { LegacyPlatformApi } from "../../src/legacy/auth/legacy-platform-api.service.ts"; +import { LegacyCliConfig } from "../../src/legacy/config/legacy-cli-config.service.ts"; +import { LegacyProjectRefResolver } from "../../src/legacy/config/legacy-project-ref.service.ts"; +import { LegacyOutputFlag } from "../../src/shared/legacy/global-flags.ts"; +import { randomLayer } from "../../src/shared/runtime/random.layer.ts"; +import { LegacyProjectNotLinkedError } from "../../src/legacy/config/legacy-project-ref.errors.ts"; +import { + mockLegacyLinkedProjectCacheLayer, + mockLegacyTelemetryStateLayer, +} from "./legacy-mocks.ts"; +import { mockOutput, mockRuntimeInfo } from "./mocks.ts"; + +/** + * Shared scaffolding for the `supabase workers` command integration tests. + * + * Every worker command reads a real `supabase/config.toml` and a real worker + * directory, so these tests run against a per-test temp project rather than a + * mocked filesystem — the config-writing and packaging behaviour is most of + * what is worth asserting. Only the network is faked. + */ + +export const WORKERS_PROJECT_REF = "abcdefghijklmnopqrst"; + +export interface RecordedRequest { + readonly method: string; + readonly url: string; + /** The request body decoded as UTF-8 — meaningful for the JSON requests. */ + readonly body: string; + /** Byte length of the body, which is what matters for the binary upload. */ + readonly byteLength: number; +} + +export interface StubResponse { + readonly status: number; + readonly body?: unknown; +} + +/** How a test answers one request; sequential entries reply to repeated calls. */ +export type RouteHandler = StubResponse | ReadonlyArray; + +export interface WorkersHttpRoutes { + /** Keyed `" "`, e.g. `"GET /v2/projects/abc.../workers"`. */ + readonly [route: string]: RouteHandler; +} + +function respond( + request: HttpClientRequest.HttpClientRequest, + stub: StubResponse, +): HttpClientResponse.HttpClientResponse { + const hasBody = stub.body !== undefined; + return HttpClientResponse.fromWeb( + request, + new Response(hasBody ? JSON.stringify(stub.body) : "", { + status: stub.status, + headers: hasBody ? { "content-type": "application/json" } : { "content-type": "text/plain" }, + }), + ); +} + +/** + * A single HTTP stub shared by the Management API client and the presigned + * build-context upload, so a test can assert the whole request sequence — mint + * the slot, PUT the bytes, deploy, poll — in the order it happened. + */ +export function mockWorkersHttp(routes: WorkersHttpRoutes) { + const requests: Array = []; + const remaining = new Map>( + Object.entries(routes).map(([route, handler]) => [ + route, + Array.isArray(handler) ? [...handler] : [handler as StubResponse], + ]), + ); + + const handle = ( + request: HttpClientRequest.HttpClientRequest, + ): Effect.Effect => + Effect.sync(() => { + const bytes = request.body._tag === "Uint8Array" ? request.body.body : new Uint8Array(0); + const url = new URL(request.url); + requests.push({ + method: request.method, + url: request.url, + body: new TextDecoder().decode(bytes), + byteLength: bytes.length, + }); + + const key = `${request.method} ${url.pathname}`; + const queue = remaining.get(key); + if (queue === undefined || queue.length === 0) { + return respond(request, { status: 599, body: { error: `unstubbed route: ${key}` } }); + } + // The last stub for a route keeps answering, so a poll loop does not have + // to be stubbed a fixed number of times. + const stub = queue.length === 1 ? queue[0]! : queue.shift()!; + return respond(request, stub); + }); + + const httpClientLayer = Layer.succeed(HttpClient.HttpClient, HttpClient.make(handle)); + + const apiLayer = Layer.effect( + LegacyPlatformApi, + makeApiClient({ + baseUrl: "https://api.supabase.com", + accessToken: "test-token", + userAgent: "supabase", + headers: { + "X-Supabase-Command": "workers", + "X-Supabase-Command-Run-ID": "run-123", + }, + }), + ).pipe(Layer.provide(httpClientLayer)); + + return { + layer: Layer.mergeAll(apiLayer, httpClientLayer), + requests, + get routeKeys(): Array { + return requests.map((request) => `${request.method} ${new URL(request.url).pathname}`); + }, + }; +} + +/** Worker resource JSON, as the Management API's JSON:API envelope wraps it. */ +export function workerResource(options: { + readonly name: string; + readonly runtime?: string; + readonly size?: string; + readonly exposure?: string; + readonly instances?: number; + readonly buildState?: "building" | "active" | "failed"; + readonly stateReason?: string; + readonly imageVersion?: string; + readonly deleting?: boolean; + readonly instanceCounts?: { + declared: number; + live: number; + ready: number; + stale: number; + }; + readonly instancesError?: string; +}) { + return { + type: "project_worker", + id: options.name, + attributes: { + spec: { + ...(options.runtime === undefined ? {} : { runtime: options.runtime }), + size: options.size ?? "2gb-1vcpu", + exposure: options.exposure ?? "public", + instances: options.instances ?? 1, + }, + build_state: options.buildState ?? "active", + secret_generation: "gen-1", + ...(options.stateReason === undefined ? {} : { state_reason: options.stateReason }), + ...(options.imageVersion === undefined ? {} : { image_version: options.imageVersion }), + ...(options.deleting === undefined ? {} : { deleting: options.deleting }), + ...(options.instanceCounts === undefined ? {} : { instances: options.instanceCounts }), + ...(options.instancesError === undefined ? {} : { instances_error: options.instancesError }), + }, + }; +} + +export const workersRoute = (suffix = "") => `/v2/projects/${WORKERS_PROJECT_REF}/workers${suffix}`; + +/** A per-test temp project, optionally pre-seeded with files. */ +export function makeWorkersProject(files: Readonly> = {}): { + readonly dir: string; +} { + const dir = mkdtempSync(join(tmpdir(), "supabase-workers-")); + for (const [relativePath, contents] of Object.entries(files)) { + const absolutePath = join(dir, relativePath); + mkdirSync(dirname(absolutePath), { recursive: true }); + writeFileSync(absolutePath, contents); + } + return { dir }; +} + +/** + * `LegacyCliConfig`, trimmed to what the worker commands read: the workdir they + * treat as the project, and the host their URLs are built on. + */ +const legacyTestCliConfigLayer = (workdir: string) => + Layer.succeed(LegacyCliConfig, { + profile: "supabase", + apiUrl: "https://api.supabase.com", + projectHost: "supabase.co", + poolerHost: "pooler.supabase.com", + dashboardUrl: "https://supabase.com/dashboard", + accessToken: Option.some(Redacted.make("sbp_test")), + projectId: Option.none(), + workdir, + userAgent: "supabase", + } as unknown as LegacyCliConfig["Service"]); + +/** The resolver, stubbed: `--project-ref` wins, else the linked project. */ +const legacyTestProjectRefLayer = (linked: boolean) => + Layer.succeed(LegacyProjectRefResolver, { + resolve: (flagValue: Option.Option) => + Option.isSome(flagValue) + ? Effect.succeed(flagValue.value) + : linked + ? Effect.succeed(WORKERS_PROJECT_REF) + : Effect.fail( + new LegacyProjectNotLinkedError({ + message: "Cannot find project ref. Have you run supabase link?", + }), + ), + } as unknown as LegacyProjectRefResolver["Service"]); + +export interface WorkersSetupOptions { + readonly workdir: string; + readonly format?: "text" | "json" | "stream-json"; + readonly interactive?: boolean; + readonly linked?: boolean; + readonly promptTextResponses?: ReadonlyArray; + readonly promptSelectResponses?: ReadonlyArray; + readonly routes?: WorkersHttpRoutes; + /** The Go `-o`/`--output` flag, which every command family here honours. */ + readonly goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; +} + +export function setupLegacyWorkers(options: WorkersSetupOptions) { + const out = mockOutput({ + format: options.format ?? "text", + interactive: options.interactive ?? (options.format ?? "text") === "text", + ...(options.promptTextResponses === undefined + ? {} + : { promptTextResponses: options.promptTextResponses }), + ...(options.promptSelectResponses === undefined + ? {} + : { promptSelectResponses: options.promptSelectResponses }), + }); + const http = mockWorkersHttp(options.routes ?? {}); + + return { + out, + http, + layer: Layer.mergeAll( + out.layer, + http.layer, + mockRuntimeInfo({ cwd: options.workdir }), + legacyTestCliConfigLayer(options.workdir), + legacyTestProjectRefLayer(options.linked !== false), + mockLegacyTelemetryStateLayer, + mockLegacyLinkedProjectCacheLayer, + randomLayer, + Layer.succeed( + LegacyOutputFlag, + options.goOutput === undefined ? Option.none() : Option.some(options.goOutput), + ), + BunServices.layer, + ), + }; +} From 0fce0411942b3ff43329df7f6e97eebb4bfda093 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Tue, 18 Aug 2026 23:18:49 -0300 Subject: [PATCH 3/3] feat(cli): scaffold worker starters from files, embedded with a Bun macro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The starter files `workers new` writes lived as string literals with their newlines and `${}` escaped. Hold them as ordinary files under `shared/workers/stacks//` instead, authored in the language they are written in, and narrow the offered runtimes to the three that have starters. A shipped binary has no `stacks/` directory to read, so the directory is expanded through a Bun macro: it runs while `worker-stacks.ts` is transpiled and its return value is inlined as a literal, which means the content is carried with nothing to pass at a build site and no directory to find at runtime. Bun expands macros in the runtime transpiler too, so running from source behaves the same; Vitest does not implement them and degrades to calling the function against the source tree, which is why the path comes from `import.meta.url` rather than Bun's `import.meta.dir`. Discovery stays directory-driven — a new runtime is a new directory plus its `WORKER_RUNTIMES` entry — and a completeness check inside the macro fails the build rather than the binary when the two drift. Bun reports a throwing macro as one it could not coerce to AST, so the reason is logged first to keep the diagnostic legible. Nothing imports the starters, which is what keeps them out of the type program: a `deno` starter is not valid under this workspace's Bun types, and `tsconfig.json` excludes the directory. --- apps/cli/package.json | 3 +- apps/cli/scripts/build-binary.ts | 3 +- .../commands/workers/new/new.handler.ts | 29 ++--- .../workers/new/new.integration.test.ts | 29 ++--- .../legacy/commands/workers/workers.format.ts | 27 ++++- apps/cli/src/shared/workers/stacks/README.md | 14 +++ .../src/shared/workers/stacks/deno/main.ts | 10 ++ .../workers/stacks/dockerfile/Dockerfile | 3 + .../workers/stacks/dockerfile/server.mjs | 15 +++ .../src/shared/workers/stacks/node/index.mjs | 10 ++ .../cli/src/shared/workers/worker-runtimes.ts | 31 ++--- .../workers/worker-runtimes.unit.test.ts | 9 +- .../src/shared/workers/worker-stacks.macro.ts | 81 +++++++++++++ apps/cli/src/shared/workers/worker-stacks.ts | 110 ++---------------- apps/cli/tsconfig.json | 2 +- 15 files changed, 224 insertions(+), 152 deletions(-) create mode 100644 apps/cli/src/shared/workers/stacks/README.md create mode 100644 apps/cli/src/shared/workers/stacks/deno/main.ts create mode 100644 apps/cli/src/shared/workers/stacks/dockerfile/Dockerfile create mode 100644 apps/cli/src/shared/workers/stacks/dockerfile/server.mjs create mode 100644 apps/cli/src/shared/workers/stacks/node/index.mjs create mode 100644 apps/cli/src/shared/workers/worker-stacks.macro.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index d5cc742b7e..2f0dbdf9d0 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -122,7 +122,8 @@ "ignore": [ "scripts/*.ts", "tests/**/*.ts", - "src/shared/telemetry/event-catalog.ts" + "src/shared/telemetry/event-catalog.ts", + "src/shared/workers/stacks/**" ], "ignoreBinaries": [ "nx", diff --git a/apps/cli/scripts/build-binary.ts b/apps/cli/scripts/build-binary.ts index 453a050ee5..56637f725d 100644 --- a/apps/cli/scripts/build-binary.ts +++ b/apps/cli/scripts/build-binary.ts @@ -7,8 +7,7 @@ import { bundleServeMainTemplate } from "../src/shared/functions/serve-main-bund * Compile a single CLI shell to a standalone binary, embedding the pre-bundled * edge-runtime template via the `SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE` define so * the binary serves Functions offline without bundling at runtime - * (supabase/supabase#45570). Used by the `build:next` / `build:legacy` scripts; the - * multi-target release build in `build.ts` injects the same define. + * (supabase/supabase#45570). Used by the `build:next` / `build:legacy` scripts. */ const shell = process.argv[2]; if (shell !== "next" && shell !== "legacy") { diff --git a/apps/cli/src/legacy/commands/workers/new/new.handler.ts b/apps/cli/src/legacy/commands/workers/new/new.handler.ts index c569994143..a8a98cbb21 100644 --- a/apps/cli/src/legacy/commands/workers/new/new.handler.ts +++ b/apps/cli/src/legacy/commands/workers/new/new.handler.ts @@ -181,7 +181,7 @@ const resolveSize = Effect.fnUntraced(function* (options: { "Which instance size should this worker use?", defaultFirst([...WORKER_SIZES], DEFAULT_WORKER_SIZE).map((size) => ({ value: size, - label: `${size} · ${vcpuForSize(size)} vCPU`, + label: `${size} (${vcpuForSize(size)} vCPU)`, })), ); return parseWorkerSize(selected) ?? DEFAULT_WORKER_SIZE; @@ -247,7 +247,7 @@ export const legacyWorkersNew = Effect.fn("legacy.workers.new")(function* ( const name = Option.isSome(flags.name) ? flags.name.value : yield* generateWorkerName(); if (Option.isNone(flags.name)) { - yield* output.raw(`Auto-assigned the name "${name}".\n`); + yield* output.raw(`Auto-assigned the name ${name}.\n`); } // An explicit --source always wins; absent one, a recorded `source` keeps the @@ -332,16 +332,19 @@ export const legacyWorkersNew = Effect.fn("legacy.workers.new")(function* ( return; } - { - yield* output.raw( - renderWorkerDetails([ - ["source", sourceDisplay], - ["runtime", runtime], - ["size", `${size} · ${vcpuForSize(size)} vCPU`], - ["access", runtime === "sandbox" ? "private (no HTTP endpoint)" : "public"], - ["next", `supabase workers push ${name}`], - ]), - ); - } + // Leads with a declarative line the way every other scaffold does + // (`functions new`: "Created new Function at supabase/functions/hello"), + // then the details. Guidance goes in a closing sentence rather than a + // pseudo-row, since no other command puts a next step inside its output + // table. + yield* output.raw(`Created new Worker at ${sourceDisplay}\n`); + yield* output.raw( + renderWorkerDetails([ + ["Runtime", runtime], + ["Size", `${size} (${vcpuForSize(size)} vCPU)`], + ["Access", "public"], + ]), + ); + yield* output.raw(`Deploy it with supabase workers push ${name}.\n`); }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/workers/new/new.integration.test.ts b/apps/cli/src/legacy/commands/workers/new/new.integration.test.ts index b588f62b8b..e36d09fb54 100644 --- a/apps/cli/src/legacy/commands/workers/new/new.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/new/new.integration.test.ts @@ -56,14 +56,15 @@ describe("legacy workers new", () => { yield* legacyWorkersNew(flags({ name: Option.some("api"), runtime: Option.some("node") })); const workerDir = join(repo.dir, "supabase", "workers", "api"); - expect(existsSync(join(workerDir, "index.js"))).toBe(true); - expect(existsSync(join(workerDir, "package.json"))).toBe(true); - + expect(existsSync(join(workerDir, "index.mjs"))).toBe(true); expect(repo.config()).toBe( `${CONFIG_WITH_COMMENTS}\n[workers.api]\nruntime = "node"\nsize = "2gb"\n`, ); - expect(out.stdoutText).toContain("source"); + // Declarative line first, then the detail rows, then the next step — + // the shape `functions new` established. + expect(out.stdoutText).toContain("Created new Worker at supabase/workers/api"); + expect(out.stdoutText).toContain("Runtime"); expect(out.stdoutText).toContain("supabase workers push api"); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); @@ -87,7 +88,7 @@ describe("legacy workers new", () => { const repo = project(); const { layer, out } = setupLegacyWorkers({ workdir: repo.dir, - promptSelectResponses: ["python", "4gb"], + promptSelectResponses: ["node", "4gb"], }); return Effect.gen(function* () { @@ -97,9 +98,9 @@ describe("legacy workers new", () => { "Which runtime should this worker use?", "Which instance size should this worker use?", ]); - expect(repo.config()).toContain('runtime = "python"'); + expect(repo.config()).toContain('runtime = "node"'); expect(repo.config()).toContain('size = "4gb"'); - expect(existsSync(join(repo.dir, "supabase", "workers", "api", "main.py"))).toBe(true); + expect(existsSync(join(repo.dir, "supabase", "workers", "api", "index.mjs"))).toBe(true); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); @@ -122,16 +123,16 @@ describe("legacy workers new", () => { return Effect.gen(function* () { yield* legacyWorkersNew( - flags({ name: Option.some("api"), runtime: Option.some("bun"), size: Option.some("4gb") }), + flags({ name: Option.some("api"), runtime: Option.some("deno"), size: Option.some("4gb") }), ); // The second run gives no runtime or size at all: the recorded ones answer for it. yield* legacyWorkersNew(flags({ name: Option.some("api"), force: true })); expect(out.promptSelectCalls).toHaveLength(0); - expect(repo.config()).toContain('runtime = "bun"'); + expect(repo.config()).toContain('runtime = "deno"'); expect(repo.config()).toContain('size = "4gb"'); expect(out.stdoutText).toContain("Reusing the existing"); - expect(existsSync(join(repo.dir, "supabase", "workers", "api", "index.ts"))).toBe(true); + expect(existsSync(join(repo.dir, "supabase", "workers", "api", "main.ts"))).toBe(true); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); @@ -148,7 +149,7 @@ describe("legacy workers new", () => { }), ); - expect(existsSync(join(repo.dir, "packages", "api", "index.js"))).toBe(true); + expect(existsSync(join(repo.dir, "packages", "api", "index.mjs"))).toBe(true); expect(existsSync(join(repo.dir, "supabase", "workers", "api"))).toBe(false); expect(repo.config()).toContain('source = "packages/api"'); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); @@ -201,7 +202,7 @@ describe("legacy workers new", () => { return Effect.gen(function* () { yield* legacyWorkersNew(flags({ name: Option.some("api"), runtime: Option.some("node") })); - expect(existsSync(join(repo.dir, "supabase", "services", "api", "index.js"))).toBe(true); + expect(existsSync(join(repo.dir, "supabase", "services", "api", "index.mjs"))).toBe(true); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); @@ -212,7 +213,7 @@ describe("legacy workers new", () => { return Effect.gen(function* () { yield* legacyWorkersNew(flags({ name: Option.some("api"), runtime: Option.some("node") })); - expect(existsSync(join(created.dir, "supabase", "workers", "api", "index.js"))).toBe(true); + expect(existsSync(join(created.dir, "supabase", "workers", "api", "index.mjs"))).toBe(true); expect(readFileSync(join(created.dir, "supabase", "config.toml"), "utf8")).toBe( `[workers.api]\nruntime = "node"\nsize = "2gb"\n`, ); @@ -246,7 +247,7 @@ describe("legacy workers new", () => { ); expect(existsSync(join(repo.dir, "supabase", "workers", "api", "leftover.txt"))).toBe(false); - expect(existsSync(join(repo.dir, "supabase", "workers", "api", "index.js"))).toBe(true); + expect(existsSync(join(repo.dir, "supabase", "workers", "api", "index.mjs"))).toBe(true); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); diff --git a/apps/cli/src/legacy/commands/workers/workers.format.ts b/apps/cli/src/legacy/commands/workers/workers.format.ts index 66662d044b..89248c749b 100644 --- a/apps/cli/src/legacy/commands/workers/workers.format.ts +++ b/apps/cli/src/legacy/commands/workers/workers.format.ts @@ -4,16 +4,33 @@ import { renderGlamourTable } from "../../output/legacy-glamour-table.ts"; * Text rendering for the workers commands. * * Two conventions this shell holds and `supabase workers` follows rather than - * inventing its own: results are written with `output.raw` as plain text — no - * `intro`/`outro` framing, which no other handler here uses — and tabular + * inventing its own: results are written with `output.raw` as plain text, with + * no `intro`/`outro` framing, which no other handler here uses, and tabular * output goes through `renderGlamourTable`, so `workers list` sits beside * `functions list` and `projects list` looking like them. */ -/** `label value` detail lines, aligned the way this shell's key/value output is. */ +/** + * `Label value` detail lines for a single worker. + * + * Vertical rather than a one-row `renderGlamourTable` because a worker's values + * include a URL and a source path: `branches get` gets away with laying its + * seven narrow columns out horizontally, and these would not fit. Labels are + * Title Case to match the other vertical key/value view this CLI renders, + * `supabase status` (`legacy-status-pretty.ts`), rather than inventing a third + * casing. + * + * Rows whose value is empty are dropped: several fields are optional strings in + * the API contract (`state_reason`, for one), so an empty one would otherwise + * render as a label, two spaces of padding and nothing else. + */ export function renderWorkerDetails(rows: ReadonlyArray): string { - const width = Math.max(...rows.map(([label]) => label.length)); - return `${rows.map(([label, value]) => ` ${label.padEnd(width)} ${value}`).join("\n")}\n`; + const present = rows.filter(([, value]) => value !== ""); + if (present.length === 0) { + return ""; + } + const width = Math.max(...present.map(([label]) => label.length)); + return `${present.map(([label, value]) => ` ${label.padEnd(width)} ${value}`).join("\n")}\n`; } export function renderWorkersTable( diff --git a/apps/cli/src/shared/workers/stacks/README.md b/apps/cli/src/shared/workers/stacks/README.md new file mode 100644 index 0000000000..1098b00c95 --- /dev/null +++ b/apps/cli/src/shared/workers/stacks/README.md @@ -0,0 +1,14 @@ +# Examples + +Minimal deployable workers, one per way of packaging code for the lambda +backend. Each runtime directory is discovered by +`worker-stacks.macro.ts` and scaffolded verbatim by `workers new`; adding a +runtime here means adding it to `WORKER_RUNTIMES` too, which the macro checks +at build time. Each returns JSON that includes the `GREETING` secret (null until the +project has one), so the secret-rotation loop is visible in responses. + +| Example | Spec | Notes | +| ------------ | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| `node` | `{"runtime":"node","size":"2gb-1vcpu","exposure":"public","instances":1}` | catalog runtime; entry `index.mjs` exports `{ fetch }` | +| `deno` | `{"runtime":"deno","size":"2gb-1vcpu","exposure":"public","instances":1}` | catalog runtime; entry `main.ts` exports `{ fetch }` | +| `dockerfile` | `{"size":"2gb-1vcpu","exposure":"public","instances":1}` | no `runtime`: the context carries its own Dockerfile; the app serves plain HTTP on `$PORT` | diff --git a/apps/cli/src/shared/workers/stacks/deno/main.ts b/apps/cli/src/shared/workers/stacks/deno/main.ts new file mode 100644 index 0000000000..66cd89170e --- /dev/null +++ b/apps/cli/src/shared/workers/stacks/deno/main.ts @@ -0,0 +1,10 @@ +export default { + fetch(request: Request): Response { + const { pathname } = new URL(request.url); + return Response.json({ + worker: "hello-deno", + path: pathname, + greeting: Deno.env.get("GREETING") ?? null, + }); + }, +}; diff --git a/apps/cli/src/shared/workers/stacks/dockerfile/Dockerfile b/apps/cli/src/shared/workers/stacks/dockerfile/Dockerfile new file mode 100644 index 0000000000..74dffeaa95 --- /dev/null +++ b/apps/cli/src/shared/workers/stacks/dockerfile/Dockerfile @@ -0,0 +1,3 @@ +FROM public.ecr.aws/docker/library/node:22-alpine +COPY server.mjs /srv/server.mjs +CMD ["node", "/srv/server.mjs"] diff --git a/apps/cli/src/shared/workers/stacks/dockerfile/server.mjs b/apps/cli/src/shared/workers/stacks/dockerfile/server.mjs new file mode 100644 index 0000000000..e005b02f8b --- /dev/null +++ b/apps/cli/src/shared/workers/stacks/dockerfile/server.mjs @@ -0,0 +1,15 @@ +// A user image serves plain HTTP on $PORT; the injected launcher wraps the +// image's CMD and provides it. +import { createServer } from "node:http"; + +const port = Number(process.env.PORT ?? 8080); +createServer((req, res) => { + res.setHeader("content-type", "application/json"); + res.end( + JSON.stringify({ + worker: "hello-dockerfile", + path: new URL(req.url, "http://localhost").pathname, + greeting: process.env.GREETING ?? null, + }), + ); +}).listen(port); diff --git a/apps/cli/src/shared/workers/stacks/node/index.mjs b/apps/cli/src/shared/workers/stacks/node/index.mjs new file mode 100644 index 0000000000..00b518cae1 --- /dev/null +++ b/apps/cli/src/shared/workers/stacks/node/index.mjs @@ -0,0 +1,10 @@ +export default { + fetch(request) { + const { pathname } = new URL(request.url); + return Response.json({ + worker: "hello-node", + path: pathname, + greeting: process.env.GREETING ?? null, + }); + }, +}; diff --git a/apps/cli/src/shared/workers/worker-runtimes.ts b/apps/cli/src/shared/workers/worker-runtimes.ts index 5b05ce2a86..9a9800483c 100644 --- a/apps/cli/src/shared/workers/worker-runtimes.ts +++ b/apps/cli/src/shared/workers/worker-runtimes.ts @@ -10,7 +10,12 @@ */ /** A worker's runtime: its own Dockerfile, or one of the catalog base images. */ -export const WORKER_RUNTIMES = ["dockerfile", "node", "bun", "deno", "python", "sandbox"] as const; +/** + * Kept in step with the directories under `./stacks/` — a runtime offered here + * with no starter files there would scaffold an empty worker, which + * `worker-stacks.macro.ts` refuses at build time. + */ +export const WORKER_RUNTIMES = ["dockerfile", "node", "deno"] as const; export type WorkerRuntime = (typeof WORKER_RUNTIMES)[number]; @@ -39,12 +44,9 @@ export function parseWorkerRuntime(value: string): WorkerRuntime | undefined { /** One-line description of each runtime, for `--runtime`'s prompt and help. */ export const WORKER_RUNTIME_DESCRIPTIONS: Record = { - dockerfile: "Build the directory's own Dockerfile.", - node: "Node.js runtime (Web-standard fetch handler).", - bun: "Bun runtime (Web-standard fetch handler).", - deno: "Deno runtime (Web-standard fetch handler).", - python: "Python runtime (ASGI app).", - sandbox: "Bare sandbox environment; no HTTP handler.", + dockerfile: "Build the directory's own Dockerfile; it serves plain HTTP on $PORT.", + node: "Node.js catalog runtime (Web-standard fetch handler).", + deno: "Deno catalog runtime (Web-standard fetch handler).", }; /** @@ -91,16 +93,19 @@ export function formatApiSize(apiSize: string): string { if (match === null) { return apiSize; } - return `${match[1]} · ${match[2]} vCPU`; + return `${match[1]} (${match[2]} vCPU)`; } /** - * `spec.exposure`: whether the worker is reachable over HTTP. Every catalog - * runtime and every Dockerfile build serves traffic; a bare `sandbox` has no - * HTTP handler at all, so it is deployed private and reached another way. + * `spec.exposure`: whether the worker is reachable over HTTP. + * + * Every runtime offered today serves traffic, so this is always `public` — it + * stays a named function because `exposure` is a required field of the deploy + * spec, and a runtime that does not serve HTTP (the API also accepts a bare + * sandbox) would answer differently here rather than at each call site. */ -export function exposureFor(runtime: WorkerRuntime): "public" | "private" { - return runtime === "sandbox" ? "private" : "public"; +export function exposureFor(_runtime: WorkerRuntime): "public" | "private" { + return "public"; } /** diff --git a/apps/cli/src/shared/workers/worker-runtimes.unit.test.ts b/apps/cli/src/shared/workers/worker-runtimes.unit.test.ts index fd064448d5..22ef1d7492 100644 --- a/apps/cli/src/shared/workers/worker-runtimes.unit.test.ts +++ b/apps/cli/src/shared/workers/worker-runtimes.unit.test.ts @@ -17,6 +17,7 @@ describe("parseWorkerRuntime", () => { test("rejects anything outside the catalog", () => { expect(parseWorkerRuntime("rust")).toBeUndefined(); + expect(parseWorkerRuntime("sandbox")).toBeUndefined(); expect(parseWorkerRuntime("")).toBeUndefined(); }); }); @@ -33,8 +34,8 @@ describe("sizes", () => { }); test("render back for display, and pass through anything unrecognized verbatim", () => { - expect(formatApiSize("2gb-1vcpu")).toBe("2gb · 1 vCPU"); - expect(formatApiSize("16gb-8vcpu")).toBe("16gb · 8 vCPU"); + expect(formatApiSize("2gb-1vcpu")).toBe("2gb (1 vCPU)"); + expect(formatApiSize("16gb-8vcpu")).toBe("16gb (8 vCPU)"); expect(formatApiSize("something-else")).toBe("something-else"); }); @@ -45,10 +46,10 @@ describe("sizes", () => { }); describe("exposureFor", () => { - test("is public for everything that serves HTTP and private for a bare sandbox", () => { + test("is public for every runtime offered today", () => { expect(exposureFor("node")).toBe("public"); + expect(exposureFor("deno")).toBe("public"); expect(exposureFor("dockerfile")).toBe("public"); - expect(exposureFor("sandbox")).toBe("private"); }); }); diff --git a/apps/cli/src/shared/workers/worker-stacks.macro.ts b/apps/cli/src/shared/workers/worker-stacks.macro.ts new file mode 100644 index 0000000000..6c7538dca7 --- /dev/null +++ b/apps/cli/src/shared/workers/worker-stacks.macro.ts @@ -0,0 +1,81 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { WORKER_RUNTIMES, type WorkerRuntime } from "./worker-runtimes.ts"; + +/** The files a scaffolded worker is made of, keyed by the name each is written as. */ +export type WorkerStack = Readonly>; + +/** + * Fails unless every offered runtime has a non-empty stack, and every stack + * belongs to an offered runtime. + * + * The two lists are declared separately — `WORKER_RUNTIMES` drives `--runtime` + * and the type union, the directory holds the content — so this is what stops + * them drifting into a runtime users can pick that scaffolds nothing. It runs + * as the macro is expanded, which is to say at build time. + */ +function assertCompleteWorkerStacks( + stacks: Record, +): asserts stacks is Record { + const offered = new Set(WORKER_RUNTIMES); + const present = new Set(Object.keys(stacks)); + + const missing = [...offered].filter((runtime) => !present.has(runtime)); + if (missing.length > 0) { + throw new Error(`no starter files for ${missing.join(", ")}`); + } + const unexpected = [...present].filter((runtime) => !offered.has(runtime)); + if (unexpected.length > 0) { + throw new Error( + `stacks/${unexpected.join(", stacks/")} has no matching entry in WORKER_RUNTIMES`, + ); + } + for (const [runtime, files] of Object.entries(stacks)) { + if (Object.keys(files).length === 0) { + throw new Error(`stacks/${runtime} is empty`); + } + } +} + +/** + * Every runtime's starter files, discovered by reading `./stacks/`. + * + * Expanded as a Bun macro, so this runs while the importing module is + * transpiled and its return value is inlined as a literal — a compiled binary + * carries the content with no `stacks/` directory beside it and no `--define` + * to forget at a build site. Adding a runtime is adding a directory; nothing + * here names the files. + * + * Bun expands macros in the runtime transpiler too, so running from source + * behaves the same. Vitest does not implement them, and degrades to calling + * this as an ordinary function against the source tree — which is why the path + * comes from `import.meta.url` rather than Bun's `import.meta.dir`, undefined + * once the test runner has bundled the module. + * + * Throwing here fails the build. Bun reports it as a macro that could not be + * coerced to AST, so the reason is logged first to make the diagnostic legible. + */ +export function readWorkerStacks(): Record { + const root = fileURLToPath(new URL("stacks", import.meta.url)); + const stacks: Record = {}; + for (const entry of readdirSync(root, { withFileTypes: true })) { + // `README.md` sits beside the runtime directories and documents them. + if (!entry.isDirectory()) { + continue; + } + const files: Record = {}; + for (const name of readdirSync(join(root, entry.name))) { + files[name] = readFileSync(join(root, entry.name, name), "utf8"); + } + stacks[entry.name] = files; + } + + try { + assertCompleteWorkerStacks(stacks); + } catch (cause) { + console.error(`[worker-stacks] ${String(cause)}`); + throw cause; + } + return stacks; +} diff --git a/apps/cli/src/shared/workers/worker-stacks.ts b/apps/cli/src/shared/workers/worker-stacks.ts index 008f901e3e..4ef3a78a1b 100644 --- a/apps/cli/src/shared/workers/worker-stacks.ts +++ b/apps/cli/src/shared/workers/worker-stacks.ts @@ -1,104 +1,16 @@ +import { + readWorkerStacks, + type WorkerStack, +} from "./worker-stacks.macro.ts" with { type: "macro" }; import type { WorkerRuntime } from "./worker-runtimes.ts"; /** - * The starter files `supabase workers new` writes, per runtime. + * The starter files `supabase workers new` writes, per runtime — the contents + * of `./stacks//`, keyed by the name each file is scaffolded as. * - * Held as source text rather than read from a directory on disk, the same way - * `supabase functions new` holds its entrypoint: the compiled binary ships no - * template tree, so the content has to travel in the module graph. - * - * The catalog runtimes all scaffold a Web-standard `fetch` handler (or, for - * python, an ASGI `app`) rather than binding a port themselves — the base - * image's own entrypoint does the binding in production. - */ - -const packageJson = `${JSON.stringify( - { - name: "worker", - private: true, - type: "module", - }, - null, - 2, -)}\n`; - -const fetchHandler = ( - runtime: string, -) => `// Starter for the "${runtime}" runtime — edit before deploying. -// Export a default object with a Web-standard \`fetch\` handler; the runtime -// binds the port and serves it. -export default { - fetch() { - return new Response("hello from supabase workers\\n"); - }, -}; -`; - -const dockerfile = `FROM node:24-alpine -WORKDIR /app -COPY . . -EXPOSE 8080 -CMD ["node", "server.js"] -`; - -/** - * Scaffolded alongside the Dockerfile above, because that Dockerfile's \`CMD\` - * names it. A starter whose only file references a second file that does not - * exist builds cleanly and then crash-loops on deploy — the one runtime whose - * scaffold could not run as written. + * The content lives there as ordinary files, authored in the language they are + * written in rather than as string literals, and is discovered by reading the + * directory: a new runtime is a new directory, with nothing to wire up here. + * `worker-stacks.macro.ts` explains how that survives compilation. */ -const dockerfileServer = `// Starter for the "dockerfile" runtime — edit before deploying. -// The Dockerfile's CMD runs this file; it binds the port itself, unlike the -// catalog runtimes where the base image does the binding. -import { createServer } from "node:http"; - -const port = Number(process.env.PORT ?? 8080); - -createServer((_request, response) => { - response.writeHead(200, { "content-type": "text/plain" }); - response.end("hello from supabase workers\\n"); -}).listen(port, () => { - console.log(\`listening on \${port}\`); -}); -`; - -const pythonApp = `# Starter for the "python" runtime — edit before deploying. -# \`app\` is an ASGI application, so FastAPI/Starlette/etc. work too — just -# assign your app to \`app\`. The runtime serves it on the ingress port. -async def app(scope, receive, send): - if scope["type"] != "http": - return - await send({ - "type": "http.response.start", - "status": 200, - "headers": [(b"content-type", b"text/plain")], - }) - await send({"type": "http.response.body", "body": b"hello from supabase workers\\n"}) -`; - -const pythonRequirements = `# add your dependencies here -`; - -const sandboxReadme = `# sandbox - -A bare sandbox runtime — no HTTP handler and no baked code. It is an environment -to run things in, not a served app, so \`supabase workers push\` here provisions -the environment and gives it no URL. -`; - -/** Each runtime's starter files, keyed by the filename to write in the worker directory. */ -export const WORKER_STACKS: Record>> = { - // `package.json` is scaffolded for its `"type": "module"` alone: without it - // `server.js` is only ESM by Node's syntax detection, which is a heuristic to - // rely on for a file the image's CMD depends on. - dockerfile: { - Dockerfile: dockerfile, - "package.json": packageJson, - "server.js": dockerfileServer, - }, - node: { "package.json": packageJson, "index.js": fetchHandler("node") }, - bun: { "package.json": packageJson, "index.ts": fetchHandler("bun") }, - deno: { "main.ts": fetchHandler("deno") }, - python: { "main.py": pythonApp, "requirements.txt": pythonRequirements }, - sandbox: { "README.md": sandboxReadme }, -}; +export const WORKER_STACKS: Record = readWorkerStacks(); diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 362fa4e4dc..50b81a2098 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "@tsconfig/bun/tsconfig.json", - "exclude": ["supabase"] + "exclude": ["supabase", "src/shared/workers/stacks"] }