diff --git a/apps/cli/package.json b/apps/cli/package.json index 96706fd08e..4395aea916 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -121,7 +121,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/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..53033d7495 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/new/SIDE_EFFECTS.md @@ -0,0 +1,64 @@ +# `supabase workers new [name]` + +> **Local-disk only.** Nothing is deployed and no Management API route is +> called; `workers push` is what talks to the platform. + +## Files Read + +| Path | Format | When | +| ---------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always, to refuse a worker that is already recorded | +| `/` | dir | always, to refuse a destination that is not empty | +| `/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` | +| `` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command | + +## Files Written + +| Path | Format | When | +| ----------------------------------------------- | ------ | ------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always — appends/updates `[workers.]` in place, preserving comments | +| `/supabase/workers//*` | varies | always, unless `--source` names another directory | +| `//*` | varies | when `--source` is given | +| `/telemetry.json` | JSON | always — flushed on success and on failure | + +Nothing at the destination is ever removed or overwritten: a destination that +exists and is not empty is refused, and clearing it is left to the user. +`--source` is refused when it resolves to the project root, `supabase/`, +`supabase/functions/`, `supabase/migrations/`, or outside the project. Symlinks +are resolved first, so a path inside the project that points outside it is +refused too. A relative `--source` is resolved against the directory the command +was run in; a `source` recorded in `config.toml` is resolved against the project +root. + +## 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 | +| `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) | +| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | + +## 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 — only the `cli_command_executed` that the instrumentation +wrapper emits for every command. 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..e82bf6550c --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/new/new.command.ts @@ -0,0 +1,74 @@ +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 { 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, and its hostname."), + ), + 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, + ), +} 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, + 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..4be159261c --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/new/new.handler.ts @@ -0,0 +1,275 @@ +import { join, relative, sep } from "node:path"; +import { Effect, FileSystem, Option } from "effect"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { legacyRenderWorkerDetails } from "../workers.format.ts"; +import { + legacyEmitWorkersMachineOutput, + legacyWorkersMachineOutputRequested, +} from "../workers.output.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; +import { + commitWorkerEntry, + planWorkerEntry, + WorkerAlreadyConfiguredError, +} from "../../../../shared/workers/worker-config.ts"; +import { + confineWorkerPath, + 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, + 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. + */ + +/** `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; + /** `-o json|yaml|toml|env` — stdout belongs to the payload, so do not prompt. */ + readonly machineOutput: boolean; +}) { + // `--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; + } + + const output = yield* Output; + if (output.format === "text" && output.interactive && !options.machineOutput) { + 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; + /** `-o json|yaml|toml|env` — stdout belongs to the payload, so do not prompt. */ + readonly machineOutput: boolean; +}) { + if (Option.isSome(options.explicit)) { + return options.explicit.value; + } + + const output = yield* Output; + if (output.format === "text" && output.interactive && !options.machineOutput) { + 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; +}); + +/** + * Whether the destination is free for a scaffold: nothing there, or an empty + * directory. A plain file counts as occupied, so it is refused by name rather + * than by a bare `EEXIST` from `makeDirectory`. + */ +const destinationIsFree = Effect.fnUntraced(function* (target: string) { + const fs = yield* FileSystem.FileSystem; + const info = yield* fs.stat(target).pipe(Effect.option); + if (info._tag === "None") { + return true; + } + if (info.value.type !== "Directory") { + return false; + } + const entries = yield* fs.readDirectory(target).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; + const runtimeInfo = yield* RuntimeInfo; + + // The telemetry state file is written on every invocation, success or failure. + yield* Effect.gen(function* () { + const project = yield* legacyLoadWorkersProject(); + + const name = flags.name; + 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.", + }), + ); + } + + // Refused before anything is asked or written. `new` creates a worker; + // changing one that already exists is a `config.toml` edit, and the file is + // the user's. Checking here rather than only in `planWorkerEntry` means the + // prompts never run for a name that was going to be refused anyway. + if (project.section.workers[name] !== undefined) { + return yield* Effect.fail( + new WorkerAlreadyConfiguredError({ + detail: `"${name}" is already configured in ${project.configPath}.`, + suggestion: `Edit [workers.${name}] in ${project.configPath} yourself, or pick a different worker name.`, + }), + ); + } + + // Resolved before anything is written, so cancelling either prompt leaves + // nothing behind — the name included. + // `-o` leaves `output.format` as `text`, and `promptSelect` goes through + // Clack, which writes its terminal UI to stdout with no stream override — so + // a prompt would land in front of the payload just as the notices did. With a + // machine format requested there is nowhere to ask, so the defaults stand. + const machineOutput = yield* legacyWorkersMachineOutputRequested(); + const runtime = yield* resolveRuntime({ explicit: flags.runtime, machineOutput }); + const size = yield* resolveSize({ explicit: flags.size, machineOutput }); + + // Validated before anything is written: this is the directory the starter + // files land in, so a value naming the project root, `supabase/`, or + // anywhere outside the project must never get as far as the write below. + // + // `--source` resolves against the directory the user typed it in, the way a + // shell would read it: `--source generated` from `apps/web` means + // `apps/web/generated`. + const destination = Option.isSome(flags.source) + ? yield* resolveWorkerSource({ + projectRoot: project.projectRoot, + cwd: runtimeInfo.cwd, + raw: flags.source.value, + }) + : yield* confineWorkerPath({ + projectRoot: project.projectRoot, + target: join(project.workersDir, name), + subject: `The default directory for "${name}"`, + suggestion: "Point [workers] root at a directory inside supabase/.", + }); + + // Nothing here replaces what is already on disk. Scaffolding over an + // existing directory would have to delete it first, and a command whose job + // is to create a worker has no business removing whatever happens to share + // its name — so it says what is in the way and leaves the choice to the user. + if (!(yield* destinationIsFree(destination))) { + const shown = displayPath(project.projectRoot, destination); + return yield* Effect.fail( + new WorkerDirectoryExistsError({ + detail: `${shown} already exists and is not empty.`, + suggestion: `Remove ${shown} yourself if you meant to replace it, or pick a different worker name.`, + }), + ); + } + + // Recorded as forward slashes whatever platform wrote it. `config.toml` is + // committed and shared, and `path.relative` yields `packages\api` on + // Windows — a backslash the POSIX resolvers on every other machine read as + // a literal character in a filename rather than a separator. + const source = Option.isSome(flags.source) + ? relative(project.projectRoot, destination).split(sep).join("/") + : undefined; + + // Planned before anything is written. Every way this can fail is knowable + // from the current config.toml, so finding out afterwards would leave a + // scaffold on disk that nothing records. + const configWrite = yield* planWorkerEntry({ + configPath: project.configPath, + name, + existingWorkers: project.section.workers, + patch: { + runtime, + size, + ...(source === undefined ? {} : { source }), + }, + }); + + // Everything below this line changes the user's disk, and nothing below it + // can fail for a reason the plan above could have caught. + yield* fs.makeDirectory(destination, { recursive: true }); + + for (const [filename, contents] of Object.entries(WORKER_STACKS[runtime])) { + yield* fs.writeFileString(join(destination, filename), contents); + } + + yield* commitWorkerEntry(configWrite); + + 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* legacyEmitWorkersMachineOutput(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( + legacyRenderWorkerDetails([ + ["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..52a74e7e7a --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/new/new.integration.test.ts @@ -0,0 +1,380 @@ +import { existsSync, mkdirSync, 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 { WorkerAlreadyConfiguredError } from "../../../../shared/workers/worker-config.ts"; +import { + InvalidWorkerNameError, + InvalidWorkerSourceError, + 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: "api", + runtime: Option.none(), + size: Option.none(), + source: Option.none(), + ...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: "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("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: "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: "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))); + }); + + // A second `new` for the same name is refused rather than re-recorded. Changing + // a worker that exists is a `config.toml` edit, and the file is the user's. + it.live("refuses a name that config.toml already records", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + yield* legacyWorkersNew( + flags({ name: "api", runtime: Option.some("deno"), size: Option.some("4gb") }), + ); + const recorded = repo.config(); + + const error = yield* legacyWorkersNew( + flags({ name: "api", runtime: Option.some("node") }), + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerAlreadyConfiguredError); + // Refused before anything was asked, and the entry is byte-identical. + expect(out.promptSelectCalls).toHaveLength(0); + expect(repo.config()).toBe(recorded); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // Refused whichever way the entry happens to be written — the decoded config + // is what answers "does this exist", so no TOML shape matters here. + it.live.each(['workers.api.runtime = "node"', "[workers.api]"])( + "refuses an entry recorded as %s", + (entry) => { + const config = `project_id = "demo"\n\n${entry}\n`; + const repo = project({ "supabase/config.toml": config }); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersNew( + flags({ name: "api", runtime: Option.some("node") }), + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerAlreadyConfiguredError); + expect(repo.config()).toBe(config); + // Nothing scaffolded either. + expect(existsSync(join(repo.dir, "supabase", "workers", "api"))).toBe(false); + }).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: "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 outside the directories a worker may own", () => { + 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: "api", + runtime: Option.some("node"), + source: Option.some(source), + }), + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(InvalidWorkerSourceError); + } + + // Nothing was written: the resolver refused before any directory was created. + 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("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: "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 destination that already has something in it", () => { + 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: "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))); + }); + + // Scaffolding into an empty directory is fine — it is only a destination with + // contents that is refused. + it.live("scaffolds into a directory that exists but is empty", () => { + const repo = project(); + mkdirSync(join(repo.dir, "supabase", "workers", "api"), { recursive: true }); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + yield* legacyWorkersNew(flags({ name: "api", runtime: Option.some("node") })); + + expect(existsSync(join(repo.dir, "supabase", "workers", "api", "index.mjs"))).toBe(true); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("tells the user how to proceed when the destination is occupied", () => { + 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: "api", runtime: Option.some("node") }), + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerDirectoryExistsError); + // No flag to suggest any more, so the advice has to be actionable on its own. + const suggestion = error instanceof WorkerDirectoryExistsError ? error.suggestion : ""; + expect(suggestion).toContain("Remove"); + expect(suggestion).not.toContain("--force"); + expect(repo.config()).toBe(CONFIG_WITH_COMMENTS); + }).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: "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("keeps stdout parseable under -o json", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ workdir: repo.dir, goOutput: "json" }); + + return Effect.gen(function* () { + yield* legacyWorkersNew(flags({ runtime: Option.some("node") })); + + const payload: unknown = JSON.parse(out.stdoutText); + expect(payload).toMatchObject({ runtime: "node", size: "2gb" }); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // Why the config edit is planned before the starter files are written: this + // failure is knowable up front, and discovering it afterwards would leave a + // scaffold on disk that nothing records. + it.live("writes no scaffold at all when the config edit cannot be made", () => { + const repo = project({ + "supabase/config.toml": 'project_id = "demo"\n\nworkers.api.runtime = "node"\n', + }); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersNew( + flags({ name: "api", runtime: Option.some("deno") }), + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerAlreadyConfiguredError); + // No directory, and config.toml exactly as it was. + expect(existsSync(join(repo.dir, "supabase", "workers", "api"))).toBe(false); + expect(repo.config()).toBe('project_id = "demo"\n\nworkers.api.runtime = "node"\n'); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // A plain file used to read as an empty directory, which then failed with a + // bare EEXIST from `makeDirectory` instead of naming what was in the way. + it.live("refuses a plain file at the destination", () => { + const repo = project({ "supabase/workers/api": "not a directory" }); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersNew( + flags({ name: "api", runtime: Option.some("node") }), + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerDirectoryExistsError); + expect(readFileSync(join(repo.dir, "supabase", "workers", "api"), "utf8")).toBe( + "not a directory", + ); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // A relative `--source` is something typed at a shell prompt, so it means + // what it would mean to the shell: relative to where you are. + it.live("resolves a relative --source against the directory it was typed in", () => { + const repo = project({ "apps/web/.keep": "" }); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + cwd: join(repo.dir, "apps", "web"), + }); + + return Effect.gen(function* () { + yield* legacyWorkersNew( + flags({ + name: "api", + runtime: Option.some("node"), + source: Option.some("generated"), + }), + ); + + expect(existsSync(join(repo.dir, "apps", "web", "generated", "index.mjs"))).toBe(true); + expect(existsSync(join(repo.dir, "generated"))).toBe(false); + // Persisted project-root-relative, with forward slashes on every platform. + expect(repo.config()).toContain('source = "apps/web/generated"'); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + // Clack writes its prompt UI to stdout with no stream override, and `-o json` + // leaves `output.format` as `text` — so a prompt lands in front of the payload + // exactly as the notices did. + it.live("does not prompt under -o json, so stdout stays parseable", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + goOutput: "json", + // Answers are available, so a prompt would succeed and corrupt stdout + // rather than fail the test some other way. + promptSelectResponses: ["node", "4gb"], + }); + + return Effect.gen(function* () { + yield* legacyWorkersNew(flags({ name: "api" })); + + const payload: unknown = JSON.parse(out.stdoutText); + // The defaults stand, because there was nowhere to ask. + expect(payload).toMatchObject({ runtime: "deno", size: "2gb" }); + expect(out.promptSelectCalls).toEqual([]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("refuses --source pointed at the project config file", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersNew( + flags({ + name: "api", + runtime: Option.some("node"), + source: Option.some(join("supabase", "config.toml")), + }), + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(InvalidWorkerSourceError); + // The config survived, which is the whole point. + expect(repo.config()).toBe(CONFIG_WITH_COMMENTS); + }).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..5b50fc8af4 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/workers.format.ts @@ -0,0 +1,32 @@ +/** + * 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 legacyRenderWorkerDetails(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`; +} 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..840b0a23d2 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/workers.output.ts @@ -0,0 +1,78 @@ +import { Effect, Option } from "effect"; +import { LegacyOutputFlag } from "../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../shared/output/output.service.ts"; +import { 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 nearly every command family on this shell honours, so + * ignoring it would print human text to a stdout the user asked to be + * machine-readable. + * + * The struct-shaped encoders elsewhere reproduce a payload shape their command + * already shipped. `workers` has none to match, so it serialises through the + * generic encoders and shapes its payload as the command reads best. + * + * 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 legacyEmitWorkersMachineOutput = 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") { + // Unreachable when the command called `legacyRejectWorkersEnvOutput` first, + // which is where the refusal belongs; here as the backstop that stops a new + // command silently emitting TOML for `-o env`. + return yield* new LegacyWorkersEnvNotSupportedError({ + message: "--output env flag is not supported", + }); + } + + 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"; +}); + +/** + * Refuse `-o env` before the command does anything. + * + * `env` is a flat `KEY=value` list and every workers payload has structure a + * flat list cannot hold — a collection, or a nested instance tally. So it is + * refused for the whole command family rather than per payload, and refused up + * front: discovering it at emit time means failing after the work is done, which + * for `push` is after the remote project has already changed. + */ +export const legacyRejectWorkersEnvOutput = Effect.fnUntraced(function* () { + if (Option.getOrUndefined(yield* LegacyOutputFlag) === "env") { + return yield* new LegacyWorkersEnvNotSupportedError({ + message: "--output env flag is not supported", + }); + } +}); 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..871c17e23c --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/workers.shared.ts @@ -0,0 +1,126 @@ +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 { workerDir, workersDir, 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; + /** `supabase/workers/`, where every worker lives unless it names a `source`. */ + readonly workersDir: 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); + + return { + projectRoot, + supabaseDir, + configPath: loaded?.path ?? join(supabaseDir, "config.toml"), + section, + workersDir: workersDir(projectRoot), + } satisfies LegacyWorkersProject; +}); + +export interface LegacyResolvedWorker { + readonly name: string; + readonly entry: WorkerEntry | undefined; + /** The worker's default directory, `supabase/workers//`. */ + readonly defaultDir: string; + /** Where its code actually lives, honouring `[workers.] source`. */ + readonly sourceDir: string; +} + +/** + * Effectful because resolving `sourceDir` confines it to the project, and that + * verdict needs the filesystem: `source` comes from a committed `config.toml`, + * and a directory inside the project can symlink anywhere outside it. + */ +export const legacyDescribeWorker = Effect.fnUntraced(function* ( + project: LegacyWorkersProject, + name: string, +) { + const entry = project.section.workers[name]; + const defaultDir = workerDir(project.projectRoot, name); + return { + name, + entry, + defaultDir, + sourceDir: yield* workerSourceDir({ + projectRoot: project.projectRoot, + defaultDir, + name, + configuredSource: entry?.source, + }), + } satisfies LegacyResolvedWorker; +}); + +/** 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.workersDir).pipe(Effect.orElseSucceed(() => [])); + + const scaffolded: Array = []; + for (const entry of entries) { + const info = yield* fs.stat(join(project.workersDir, 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..d9ad846999 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", 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..8baab4025d --- /dev/null +++ b/apps/cli/src/shared/workers/toml-section.ts @@ -0,0 +1,88 @@ +/** + * Appending one `[section]` to a TOML file. + * + * `supabase/config.toml` belongs to the whole CLI: users hand-edit it, comment + * it, and commit it. Round-tripping through `saveProjectConfig` preserves the + * data but discards every comment and normalizes the formatting the user chose, + * so the write here is textual — render the table, put it at the end, and leave + * every other byte alone. + * + * Append-only by design: locating an existing table means being right about + * multiline strings, the three ways to quote a key, and where one table ends. + * Callers ask the decoded config whether an entry exists instead, so nothing + * here has to find one. + */ + +/** A TOML bare key needs no quoting; anything else does. */ +function isBareKey(key: string): boolean { + return /^[A-Za-z0-9_-]+$/.test(key); +} + +/** The escapes TOML names, for the control characters that have one. */ +const TOML_NAMED_ESCAPES: Record = { + "\b": "\\b", + "\t": "\\t", + "\n": "\\n", + "\f": "\\f", + "\r": "\\r", +}; + +/** + * Escape a string for a TOML basic (double-quoted) string. + * + * Control characters need the same treatment as quotes and backslashes: TOML + * forbids them raw inside a basic string, and a path is allowed to contain them + * on Unix — a directory name with an embedded newline is legal. Writing one + * through verbatim leaves `config.toml` unparseable after the scaffold is + * already on disk. + */ +function quote(value: string): string { + let escaped = ""; + for (const char of value) { + const code = char.codePointAt(0) ?? 0; + if (char === "\\") { + escaped += "\\\\"; + } else if (char === '"') { + escaped += '\\"'; + } else if (code < 0x20 || code === 0x7f) { + escaped += TOML_NAMED_ESCAPES[char] ?? `\\u${code.toString(16).padStart(4, "0")}`; + } else { + escaped += char; + } + } + return `"${escaped}"`; +} + +/** 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)}`; +} + +/** + * `text` with a `[header]` table holding `values` appended to the end. + * + * Cannot fail: the caller has already established that no such table exists, so + * there is nothing to reconcile. 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. + */ +export function appendTomlSection( + text: string, + header: string, + values: Readonly>, +): string { + const block = [ + `[${header}]`, + ...Object.entries(values).map(([key, value]) => renderPair(key, value)), + ].join("\n"); + + if (text.trim() === "") { + return `${block}\n`; + } + return `${text.replace(/\n*$/, "")}\n\n${block}\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..d00fca6933 --- /dev/null +++ b/apps/cli/src/shared/workers/toml-section.unit.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from "vitest"; +import { appendTomlSection, tomlKey } from "./toml-section.ts"; + +describe("appendTomlSection", () => { + 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(appendTomlSection(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(appendTomlSection("", "workers.api", { runtime: "deno" })).toBe( + '[workers.api]\nruntime = "deno"\n', + ); + expect(appendTomlSection("\n \n", "workers.api", { runtime: "deno" })).toBe( + '[workers.api]\nruntime = "deno"\n', + ); + }); + + // However the file happened to be terminated, the new table is separated by + // exactly one blank line. + test.each([ + ['project_id = "demo"', "no trailing newline"], + ['project_id = "demo"\n', "one trailing newline"], + ['project_id = "demo"\n\n\n', "several trailing newlines"], + ])("separates the appended table with one blank line given %s", (before) => { + expect(appendTomlSection(before, "workers.api", { runtime: "node" })).toBe( + 'project_id = "demo"\n\n[workers.api]\nruntime = "node"\n', + ); + }); + + test("escapes quotes and backslashes in values", () => { + expect(appendTomlSection("", "workers.api", { source: 'pack"age\\api' })).toBe( + '[workers.api]\nsource = "pack\\"age\\\\api"\n', + ); + }); + + // A path may legally contain a newline on Unix. Writing it through verbatim + // would leave config.toml unparseable, after the directory is already on disk. + test("escapes control characters in a written value", () => { + const after = appendTomlSection("", "workers.api", { source: "packages/od\nd\tname" }); + + expect(after).toContain('source = "packages/od\\nd\\tname"'); + expect(after).not.toContain("od\nd"); + }); + + test("quotes a worker name that is not a bare key", () => { + expect(appendTomlSection("", `workers.${tomlKey("my worker")}`, { runtime: "node" })).toBe( + '[workers."my worker"]\nruntime = "node"\n', + ); + }); + + test("writes a header with no keys when there is nothing to set", () => { + expect(appendTomlSection("", "workers.api", {})).toBe("[workers.api]\n"); + }); +}); + +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..b316692178 --- /dev/null +++ b/apps/cli/src/shared/workers/worker-config.ts @@ -0,0 +1,140 @@ +import { dirname } from "node:path"; +import { Data, Effect, FileSystem } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; +import { appendTomlSection, tomlKey } 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.]` tables, keyed by worker name, in file order. */ + readonly workers: Readonly>; +} + +/** + * The worker is already recorded in `config.toml`. + * + * `workers new` creates a worker; changing one that exists is a different + * operation, and the file is the user's to edit. Refusing is also what keeps + * writes here append-only — amending an entry in place is what required knowing + * enough TOML to find and rewrite it safely. + */ +export class WorkerAlreadyConfiguredError extends Data.TaggedError("WorkerAlreadyConfiguredError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} + +const stringOrUndefined = (value: unknown): string | undefined => + typeof value === "string" && value !== "" ? value : undefined; + +/** A plain object — a `[workers.]` table rather than a scalar or a list. */ +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** + * The decoded `[workers]` section as per-worker tables. Anything that is not an + * object is dropped rather than read as a worker named after it. + */ +export function readWorkersSection(workers: unknown): WorkersSection { + // Null-prototype, so a worker legitimately named `constructor`, `toString` or + // `hasOwnProperty` reads as absent when it is absent. A plain `{}` answers + // every one of those lookups with something inherited from + // `Object.prototype`, which is enough to make `workers new constructor` write + // its starter files and then refuse to record them. + const entries: Record = Object.create(null); + + if (!isRecord(workers)) { + return { workers: entries }; + } + + for (const [key, value] of Object.entries(workers)) { + if (!isRecord(value)) { + continue; + } + entries[key] = { + runtime: stringOrUndefined(value["runtime"]), + size: stringOrUndefined(value["size"]), + source: stringOrUndefined(value["source"]), + }; + } + + return { workers: entries }; +} + +/** A rendered `config.toml`, not yet written. */ +export interface WorkerEntryWrite { + readonly configPath: string; + readonly text: string; +} + +/** + * Render `config.toml` with `[workers.]` appended, without writing it. + * + * Split from the write so callers can find out an entry already exists before + * they scaffold anything: `new` writes the starter files first, and a failure + * after that would leave a directory nothing records. + */ +export const planWorkerEntry = Effect.fnUntraced(function* (options: { + readonly configPath: string; + readonly name: string; + readonly patch: Readonly>; + /** The already-parsed config — the authority on whether an entry exists. */ + readonly existingWorkers: Readonly>; +}) { + const fs = yield* FileSystem.FileSystem; + + // Append-only, so an entry that is already there cannot be amended. The + // decoded config is the authority on whether one exists — a question the + // parser has answered, and one no amount of regex over the file text answers + // reliably for a dotted or inline entry. + if (options.existingWorkers[options.name] !== undefined) { + return yield* Effect.fail( + new WorkerAlreadyConfiguredError({ + detail: `"${options.name}" is already configured in ${options.configPath}.`, + suggestion: `Edit [workers.${options.name}] in ${options.configPath} yourself, or pick a different worker name.`, + }), + ); + } + + const exists = yield* fs.exists(options.configPath); + const text = exists ? yield* fs.readFileString(options.configPath) : ""; + const header = `workers.${tomlKey(options.name)}`; + + return { + configPath: options.configPath, + text: appendTomlSection(text, header, options.patch), + } satisfies WorkerEntryWrite; +}); + +/** + * Commit a {@link planWorkerEntry} result. Creates `supabase/` if it does not + * exist yet, so `new` works in a directory that has never been `supabase + * init`-ed. + */ +export const commitWorkerEntry = Effect.fnUntraced(function* (write: WorkerEntryWrite) { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(dirname(write.configPath), { recursive: true }); + yield* fs.writeFileString(write.configPath, write.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..668ef584d3 --- /dev/null +++ b/apps/cli/src/shared/workers/worker-config.unit.test.ts @@ -0,0 +1,177 @@ +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, + WorkerAlreadyConfiguredError, + commitWorkerEntry, + planWorkerEntry, +} from "./worker-config.ts"; + +describe("readWorkersSection", () => { + test("reads each worker's recorded dials", () => { + expect( + readWorkersSection({ + api: { runtime: "node", size: "2gb", source: "packages/api" }, + box: { runtime: "sandbox" }, + }), + ).toEqual({ + 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({ stray: "oops", api: {} })).toEqual({ + workers: { api: { runtime: undefined, size: undefined, source: undefined } }, + }); + }); + + test("treats a missing or malformed section as empty", () => { + expect(readWorkersSection(undefined)).toEqual({ workers: {} }); + expect(readWorkersSection([])).toEqual({ workers: {} }); + }); +}); + +describe("planWorkerEntry + commitWorkerEntry", () => { + 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); + + /** plan + commit — the pairing `new` performs once it has decided to write. */ + const writeWorkerEntry = (options: Parameters[0]) => + planWorkerEntry(options).pipe(Effect.flatMap(commitWorkerEntry)); + + 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("appends to an existing file without touching the rest of it", async () => { + writeFileSync(configPath, '# keep me\nproject_id = "demo"\n'); + + await run( + writeWorkerEntry({ + configPath, + name: "api", + existingWorkers: {}, + patch: { runtime: "node", size: "4gb" }, + }).pipe(Effect.provide(BunServices.layer)), + ); + + expect(readFileSync(configPath, "utf8")).toBe( + '# keep me\nproject_id = "demo"\n\n[workers.api]\nruntime = "node"\nsize = "4gb"\n', + ); + }); + + // `new` creates a worker; changing one that exists is a `config.toml` edit and + // the file is the user's. Refusing is also what keeps writes append-only. + test("refuses a worker that is already configured, leaving the file alone", async () => { + const before = '# hand-written\n[workers.api]\nruntime = "node" # mine\n'; + writeFileSync(configPath, before); + + const error = await run( + writeWorkerEntry({ + configPath, + name: "api", + existingWorkers: { api: { runtime: "node" } }, + patch: { runtime: "deno" }, + }).pipe(Effect.provide(BunServices.layer), Effect.flip), + ); + + expect(error).toBeInstanceOf(WorkerAlreadyConfiguredError); + expect(readFileSync(configPath, "utf8")).toBe(before); + }); + + // How the entry is written — dotted, inline or a table — does not matter. The + // decoded config says it exists, which is the whole question, and answering it + // from the parser rather than the file text is what removed the need to know + // any TOML beyond how to render a value. + test.each([ + ["dotted keys", 'workers.api.runtime = "node"\n'], + ["an inline table", 'workers = { api = { runtime = "node" } }\n'], + ["a value spanning lines", '[workers.api]\nruntime = [\n "node",\n]\n'], + ["a header inside a multiline string", 'notes = """\n[workers.api]\nstill inside"""\n'], + ])("refuses an entry written as %s without reading the file text", async (_label, before) => { + writeFileSync(configPath, before); + + const error = await run( + writeWorkerEntry({ + configPath, + name: "api", + existingWorkers: { api: { runtime: "node" } }, + patch: { runtime: "node" }, + }).pipe(Effect.provide(BunServices.layer), Effect.flip), + ); + + expect(error).toBeInstanceOf(WorkerAlreadyConfiguredError); + expect(readFileSync(configPath, "utf8")).toBe(before); + }); + + // Why rendering is separate from writing: `new` writes the starter files before + // it records anything, so a failure that could only surface at the write would + // leave a scaffold on disk that nothing records. + test("renders without writing, and only writes when committed", async () => { + writeFileSync(configPath, 'project_id = "demo"\n'); + + const write = await Effect.runPromise( + planWorkerEntry({ + configPath, + name: "api", + existingWorkers: {}, + patch: { runtime: "node" }, + }).pipe(Effect.provide(BunServices.layer)), + ); + + expect(write.text).toContain("[workers.api]"); + expect(readFileSync(configPath, "utf8")).toBe('project_id = "demo"\n'); + + await run(commitWorkerEntry(write).pipe(Effect.provide(BunServices.layer))); + expect(readFileSync(configPath, "utf8")).toContain("[workers.api]"); + }); +}); + +describe("readWorkersSection prototype safety", () => { + // `constructor` is a valid DNS label, so it is a valid worker name. Read into + // a plain `{}`, looking it up would return `Object.prototype.constructor` and + // every caller would believe the worker was already configured. + test.each([["constructor"], ["toString"], ["hasOwnProperty"]])( + "reports %j as absent when it is absent", + (name) => { + const section = readWorkersSection({ api: { runtime: "node" } }); + expect(section.workers[name]).toBeUndefined(); + }, + ); + + test("still reads a worker actually named constructor", () => { + const section = readWorkersSection({ constructor: { runtime: "node" } }); + expect(section.workers["constructor"]).toEqual({ + runtime: "node", + size: undefined, + source: undefined, + }); + }); +}); 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..95ac98ae8f --- /dev/null +++ b/apps/cli/src/shared/workers/worker-paths.ts @@ -0,0 +1,225 @@ +import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { Effect, FileSystem } from "effect"; +import { InvalidWorkerSourceError } 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. + * + * `supabase/workers/` is where they live. One worker whose code belongs + * somewhere else uses `[workers.] source`, relative to the project root, + * which is the only key that moves anything. + */ + +/** The directory workers live in, under `supabase/`. */ +const WORKERS_DIR = "workers"; + +/** + * Directories under `supabase/` the CLI already owns, so no worker's `source` + * may name one: `functions` and `migrations` belong to other parts of the CLI, + * and `.temp` holds CLI state including the linked-project reference. + */ +const RESERVED_SUPABASE_DIRS = ["functions", "migrations", ".temp"]; + +/** + * Files directly under `supabase/` that the CLI owns. Refused separately from the + * directories above, which do not cover them — `supabase/config.toml` sits + * outside every reserved subdirectory. + */ +const RESERVED_SUPABASE_FILES = ["config.toml", "config.json"]; + +/** `supabase/workers/` — where workers live, resolved against the project. */ +export function workersDir(projectRoot: string): string { + return join(projectRoot, "supabase", WORKERS_DIR); +} + +/** 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)); +} + +/** + * `target` with every symlink in it resolved, as far as it exists. + * + * `realPath` fails outright on a path that is not there yet, and the whole point + * of canonicalizing here is to vet a destination *before* creating it. So this + * walks up to the deepest ancestor that does exist, resolves that, and re-joins + * the part that doesn't. + */ +const canonicalize = Effect.fnUntraced(function* (target: string) { + const fs = yield* FileSystem.FileSystem; + const absolute = resolve(target); + const pending: Array = []; + let cursor = absolute; + + for (;;) { + const real = yield* fs.realPath(cursor).pipe(Effect.option); + if (real._tag === "Some") { + return pending.length === 0 ? real.value : join(real.value, ...pending); + } + const parent = dirname(cursor); + if (parent === cursor) { + // Walked to the filesystem root without finding anything that exists. + return absolute; + } + pending.unshift(basename(cursor)); + cursor = parent; + } +}); + +/** + * Confine a resolved worker path to the project, on the filesystem's terms + * rather than the string's. + * + * A string comparison cannot see a symlink: `packages/external -> /other-repo` + * makes `--source packages/external/api` write into `/other-repo`. So both the + * target and the project root are canonicalized before comparing — the root too, + * or a project under a symlink (macOS `/tmp` -> `/private/tmp`, most CI + * checkouts) fails containment against itself. + * + * Returns the path as given, not the canonical form, so what gets displayed and + * persisted stays the path the user named. + */ +export const confineWorkerPath = Effect.fnUntraced(function* (options: { + readonly projectRoot: string; + readonly target: string; + /** How the path is named in the error, e.g. `--source "packages/api"`. */ + readonly subject: string; + readonly suggestion: string; +}) { + const refuse = (why: string) => + Effect.fail( + new InvalidWorkerSourceError({ + detail: `${options.subject} ${why}.`, + suggestion: options.suggestion, + }), + ); + + const projectRoot = yield* canonicalize(options.projectRoot); + const target = yield* canonicalize(options.target); + const supabaseDir = join(projectRoot, "supabase"); + + if (target === projectRoot) { + return yield* refuse("is the project root itself"); + } + if (!isAtOrUnder(projectRoot, target)) { + return yield* refuse("resolves outside the project"); + } + if (target === supabaseDir) { + return yield* refuse("is the supabase directory itself"); + } + for (const owned of RESERVED_SUPABASE_DIRS) { + if (isAtOrUnder(join(supabaseDir, owned), target)) { + return yield* refuse(`is inside supabase/${owned}/, which the Supabase CLI already owns`); + } + } + for (const owned of RESERVED_SUPABASE_FILES) { + if (target === join(supabaseDir, owned)) { + return yield* refuse(`is supabase/${owned}, which the Supabase CLI already owns`); + } + } + + return options.target; +}); + +/** + * `--source`, resolved against the directory the user typed it in and validated + * before anything is written. + * + * The resolved path is where the starter files land, so a value naming the + * project root, `supabase/`, or anywhere outside the project is refused. + * `source` is the key that may leave the workers directory, but not the project; + * `functions/` and `migrations/` are refused for the same reason `[workers] root` + * refuses them. + */ +export const resolveWorkerSource = Effect.fnUntraced(function* (options: { + readonly projectRoot: string; + readonly cwd: string; + readonly raw: string; +}) { + const suggestion = + "Point --source at a directory inside the project, for example --source packages/api."; + + // Whitespace is not trimmed. A directory name may legally begin or end with a + // space on Unix, and the shell only delivers one in a single argv entry if the + // user quoted it — so trimming would silently retarget the scaffold at a + // neighbouring directory. Only the trailing separator, which is syntax rather + // than part of the name, comes off. An argument that is nothing but + // whitespace is refused rather than trimmed into something else. + if (options.raw.trim() === "") { + return yield* Effect.fail( + new InvalidWorkerSourceError({ + detail: `--source "${options.raw}" is empty.`, + suggestion, + }), + ); + } + + return yield* confineWorkerPath({ + projectRoot: options.projectRoot, + target: resolve(options.cwd, options.raw.replace(/[/\\]+$/, "")), + subject: `--source "${options.raw}"`, + suggestion, + }); +}); + +/** A worker's default directory: `supabase/workers//`. */ +export function workerDir(projectRoot: string, name: string): string { + return join(workersDir(projectRoot), name); +} + +/** + * A worker's source directory: `[workers.] source` when one is recorded, + * resolved against the project root, otherwise the default directory. + * + * Confined, not just resolved. `source` arrives from `config.toml`, which is + * committed and shared — so it is as much an input as `--source` is, and a + * checkout carrying `source = "../../.."` or an absolute path would otherwise + * have `push` package and upload a directory that has nothing to do with the + * project. The default directory goes through the same guard so a symlinked + * `[workers] root` cannot escape either. + */ +export const workerSourceDir = Effect.fnUntraced(function* (options: { + readonly projectRoot: string; + readonly defaultDir: string; + readonly name: string; + readonly configuredSource: string | undefined; +}) { + const configured = options.configuredSource; + const recorded = configured !== undefined && configured !== ""; + + return yield* confineWorkerPath({ + projectRoot: options.projectRoot, + target: recorded ? resolve(options.projectRoot, configured) : options.defaultDir, + subject: recorded + ? `[workers.${options.name}] source "${configured}"` + : `The default directory for "${options.name}"`, + suggestion: recorded + ? `Set [workers.${options.name}] source to a directory inside the project, relative to the project root.` + : "Point [workers] root at a directory inside supabase/.", + }); +}); + +/** + * 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..79cc357a58 --- /dev/null +++ b/apps/cli/src/shared/workers/worker-paths.unit.test.ts @@ -0,0 +1,200 @@ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, FileSystem } from "effect"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + displayPath, + resolveWorkerSource, + workerDir, + workersDir, + workerSourceDir, +} from "./worker-paths.ts"; +import { InvalidWorkerSourceError } from "./workers.errors.ts"; + +const PROJECT = "/repo"; + +/** + * Confinement is decided on the filesystem's terms, so these need a real one. + * A path that does not exist still resolves — `canonicalize` walks up to the + * deepest existing ancestor — which is what lets the `/repo` cases below stay + * pure string scenarios. + */ +const runFs = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(BunServices.layer))); + +describe("worker directories", () => { + test("resolve under supabase/workers/", () => { + expect(workersDir(PROJECT)).toBe(join(PROJECT, "supabase", "workers")); + expect(workerDir(PROJECT, "api")).toBe(join(PROJECT, "supabase", "workers", "api")); + }); + + test("a recorded source wins and is anchored to the project root", async () => { + const defaultDir = workerDir(PROJECT, "api"); + const sourceDir = (configuredSource: string | undefined) => + runFs(workerSourceDir({ projectRoot: PROJECT, defaultDir, name: "api", configuredSource })); + + expect(await sourceDir(undefined)).toBe(defaultDir); + expect(await sourceDir("")).toBe(defaultDir); + expect(await sourceDir("packages/api")).toBe(join(PROJECT, "packages", "api")); + }); + + // `source` arrives from a committed `config.toml`, so it is as much an input + // as `--source` is — and `push` packages and uploads whatever it resolves to. + test.each([["../../elsewhere"], ["/etc"], ["supabase/functions/hello"]])( + "refuses a recorded source of %j", + async (configuredSource) => { + const error = await runFs( + workerSourceDir({ + projectRoot: PROJECT, + defaultDir: workerDir(PROJECT, "api"), + name: "api", + configuredSource, + }).pipe(Effect.flip), + ); + expect(error).toBeInstanceOf(InvalidWorkerSourceError); + expect(error.detail).toContain("[workers.api] source"); + }, + ); +}); + +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", async () => { + expect( + await runFs(resolveWorkerSource({ projectRoot: PROJECT, cwd, raw: "../../packages/api" })), + ).toBe(join(PROJECT, "packages", "api")); + expect( + await runFs( + resolveWorkerSource({ projectRoot: PROJECT, cwd: PROJECT, raw: "packages/api/" }), + ), + ).toBe(join(PROJECT, "packages", "api")); + }); + + // The starter files land in whatever this resolves to, so each of these would + // write into 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/"], + ["supabase/.temp", "supabase/.temp/"], + ["supabase/.temp/project-ref", "supabase/.temp/"], + // Refusing the reserved directories is not enough on its own: this path is + // inside the project, is not `supabase/` itself, and is in no reserved + // subdirectory — so without this it would be authorized as a scaffold + // destination, and the project's config file is not that. + ["supabase/config.toml", "supabase/config.toml"], + ["supabase/config.json", "supabase/config.json"], + ])("refuses %j", async (raw, reason) => { + const error = await runFs( + resolveWorkerSource({ projectRoot: PROJECT, cwd: PROJECT, raw }).pipe(Effect.flip), + ); + expect(error).toBeInstanceOf(InvalidWorkerSourceError); + expect(error.detail).toContain(reason); + }); +}); + +// Containment on a real filesystem, because a string comparison cannot see a +// symlink: a directory inside the project is free to point anywhere outside it, +// and the starter files land wherever the path really resolves. +describe("resolveWorkerSource containment on a real filesystem", () => { + let project = ""; + let outside = ""; + + beforeEach(() => { + const scratch = mkdtempSync(join(tmpdir(), "worker-paths-")); + project = join(scratch, "project"); + outside = join(scratch, "outside"); + mkdirSync(join(project, "packages"), { recursive: true }); + mkdirSync(join(outside, "api"), { recursive: true }); + mkdirSync(join(project, "supabase", "functions", "hello"), { recursive: true }); + }); + + afterEach(() => { + rmSync(join(project, ".."), { recursive: true, force: true }); + }); + + test("resolves a genuine directory inside the project", async () => { + expect( + await runFs(resolveWorkerSource({ projectRoot: project, cwd: project, raw: "packages" })), + ).toBe(join(project, "packages")); + }); + + test("refuses a path that reaches outside the project through a symlink", async () => { + symlinkSync(outside, join(project, "packages", "external")); + + const error = await runFs( + resolveWorkerSource({ + projectRoot: project, + cwd: project, + raw: join("packages", "external", "api"), + }).pipe(Effect.flip), + ); + + expect(error).toBeInstanceOf(InvalidWorkerSourceError); + expect(error.detail).toContain("resolves outside the project"); + }); + + test("refuses a reserved directory reached through a symlink", async () => { + symlinkSync(join(project, "supabase", "functions"), join(project, "fns")); + + const error = await runFs( + resolveWorkerSource({ + projectRoot: project, + cwd: project, + raw: join("fns", "hello"), + }).pipe(Effect.flip), + ); + + expect(error).toBeInstanceOf(InvalidWorkerSourceError); + expect(error.detail).toContain("supabase/functions/"); + }); + + // A destination that does not exist yet is the normal case for `new`, and the + // project root itself is usually behind a symlink on macOS (`/var` -> + // `/private/var`). Both have to compare equal, not fail containment. + // A name that ends in a space is legal on Unix, and only reaches argv as one + // entry if the user quoted it. Trimming it pointed the scaffold at a different + // directory than the one asked for. + test("keeps whitespace that is part of the directory name", async () => { + expect( + await runFs( + resolveWorkerSource({ projectRoot: project, cwd: project, raw: "packages/api " }), + ), + ).toBe(join(project, "packages", "api ")); + }); + + test.each([[""], [" "], ["\t"]])("refuses an all-whitespace --source of %j", async (raw) => { + const error = await runFs( + resolveWorkerSource({ projectRoot: project, cwd: project, raw }).pipe(Effect.flip), + ); + expect(error).toBeInstanceOf(InvalidWorkerSourceError); + expect(error.detail).toContain("is empty"); + }); + + test("accepts a destination that does not exist yet", async () => { + expect( + await runFs( + resolveWorkerSource({ projectRoot: project, cwd: project, raw: "packages/brand-new" }), + ), + ).toBe(join(project, "packages", "brand-new")); + }); +}); 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..7c9f93e8eb --- /dev/null +++ b/apps/cli/src/shared/workers/worker-runtimes.ts @@ -0,0 +1,115 @@ +/** + * 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.some((runtime) => runtime === value); +} + +/** + * The runtime a config file named, case-insensitively. The canonical lowercase + * form is what gets recorded. + * + * This is for hand-written `[workers.] runtime` values, where the casing + * is the user's own and `Runtime = "Node"` plainly means `node`. It is not what + * validates `--runtime`: that is a `Flag.choice` over the same catalog, so the + * parser rejects anything outside it — including a case variant — before a + * handler runs, and lists the accepted values when it does. + */ +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.some((size) => size === 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)`; +} + +/** + * 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])?$/; + +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 { + 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..1eb1f9bccd --- /dev/null +++ b/apps/cli/src/shared/workers/worker-runtimes.unit.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "vitest"; +import { + apiSizeFor, + 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 anything outside the catalog", () => { + expect(parseWorkerSize("4GB")).toBe("4gb"); + expect(parseWorkerSize(" 2gb ")).toBe("2gb"); + expect(parseWorkerSize("64gb")).toBeUndefined(); + expect(parseWorkerSize("")).toBeUndefined(); + }); +}); + +describe("validateWorkerNameMessage", () => { + test("accepts DNS labels", () => { + expect(validateWorkerNameMessage("api")).toBeUndefined(); + expect(validateWorkerNameMessage("my-worker-1")).toBeUndefined(); + expect(validateWorkerNameMessage("a")).toBeUndefined(); + }); + + 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..ecd09ac1fb --- /dev/null +++ b/apps/cli/src/shared/workers/workers.errors.ts @@ -0,0 +1,45 @@ +import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; + +/** + * Every worker failure carries a `detail` saying what happened and a + * `suggestion` naming the command that fixes it. The shared output layer renders + * the pair, so no command formats its own recovery line. + */ + +export class InvalidWorkerNameError extends Data.TaggedError("InvalidWorkerNameError")<{ + 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 where the starter files land, so a value that + * resolves to the project root, `supabase/`, or anywhere outside the project has + * to be refused before anything is written. + */ +export class InvalidWorkerSourceError extends Data.TaggedError("InvalidWorkerSourceError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/tests/helpers/legacy-workers.ts b/apps/cli/tests/helpers/legacy-workers.ts new file mode 100644 index 0000000000..774e41baed --- /dev/null +++ b/apps/cli/tests/helpers/legacy-workers.ts @@ -0,0 +1,268 @@ +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; + /** + * The directory the command was invoked from, when it differs from the + * project — which is what a relative `--source` resolves against. + */ + readonly cwd?: 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.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"] }