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/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/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..a8a98cbb21 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/new/new.handler.ts @@ -0,0 +1,350 @@ +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; + } + + // 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 new file mode 100644 index 0000000000..e36d09fb54 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/new/new.integration.test.ts @@ -0,0 +1,312 @@ +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.mjs"))).toBe(true); + expect(repo.config()).toBe( + `${CONFIG_WITH_COMMENTS}\n[workers.api]\nruntime = "node"\nsize = "2gb"\n`, + ); + + // 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))); + }); + + 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: ["node", "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 = "node"'); + expect(repo.config()).toContain('size = "4gb"'); + expect(existsSync(join(repo.dir, "supabase", "workers", "api", "index.mjs"))).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("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 = "deno"'); + expect(repo.config()).toContain('size = "4gb"'); + expect(out.stdoutText).toContain("Reusing the existing"); + expect(existsSync(join(repo.dir, "supabase", "workers", "api", "main.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.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))); + }); + + 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.mjs"))).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.mjs"))).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.mjs"))).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..89248c749b --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/workers.format.ts @@ -0,0 +1,41 @@ +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, 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 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 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( + 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/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/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-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-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/worker-runtimes.ts b/apps/cli/src/shared/workers/worker-runtimes.ts new file mode 100644 index 0000000000..9a9800483c --- /dev/null +++ b/apps/cli/src/shared/workers/worker-runtimes.ts @@ -0,0 +1,135 @@ +/** + * 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. */ +/** + * 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]; + +/** + * 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; it serves plain HTTP on $PORT.", + node: "Node.js catalog runtime (Web-standard fetch handler).", + deno: "Deno catalog runtime (Web-standard fetch 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 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 "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..22ef1d7492 --- /dev/null +++ b/apps/cli/src/shared/workers/worker-runtimes.unit.test.ts @@ -0,0 +1,73 @@ +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("sandbox")).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 every runtime offered today", () => { + expect(exposureFor("node")).toBe("public"); + expect(exposureFor("deno")).toBe("public"); + expect(exposureFor("dockerfile")).toBe("public"); + }); +}); + +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.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 new file mode 100644 index 0000000000..4ef3a78a1b --- /dev/null +++ b/apps/cli/src/shared/workers/worker-stacks.ts @@ -0,0 +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 contents + * of `./stacks//`, keyed by the name each file is scaffolded as. + * + * 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. + */ +export const WORKER_STACKS: Record = readWorkerStacks(); 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..3ee033f0d5 --- /dev/null +++ b/apps/cli/src/shared/workers/workers.errors.ts @@ -0,0 +1,74 @@ +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. + */ + +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 + * 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; + } +} 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, + ), + }; +} 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"] }