feat(cli): add supabase workers new - #6261
Conversation
ca0599d to
0d433d5
Compare
3b8b0c0 to
ed12377
Compare
The two pieces every `supabase workers` command rests on, landed on their own because they are the subtle ones and deserve their own diff. Both live in `shared/`, so neither carries a shell prefix and neither is specific to one command tree. `worker-paths.ts` resolves the project layout: `supabase/<root>/<name>/`, mirroring `supabase/functions/<slug>/`, with `[workers] root` moving the grouping directory and `[workers.<name>] source` moving one worker's code anywhere in the project. Both are validated rather than joined blindly. `resolveWorkerSource` is load-bearing for safety, not tidiness: the path it returns is the directory `workers new --force` deletes outright, so a value naming the project root, `supabase/`, `functions/`, `migrations/`, or anywhere outside the project is refused before anything is removed. `toml-section.ts` edits one `[section]` of a TOML file textually rather than round-tripping it. `config.toml` belongs to the whole CLI — users hand-edit, comment and commit it — and reserialising preserves the data while discarding every comment and normalising the formatting they chose. Existing keys are rewritten in place, keeping any comment trailing the value; new keys are appended; everything else is left byte for byte. A value spanning several lines cannot be swapped one line at a time, so it is reported as unsupported and the file is left untouched instead of stranded half-rewritten. This follows the approach `legacy-pgdelta.write.ts` already takes for `[db.migrations] schema_paths`, generalised from one hard-coded key.
Scaffolds `supabase/workers/<name>/` from a runtime's starter files and records the choice in `config.toml`. Entirely local disk — nothing is deployed and no network is involved, which is why it lands before the API seam. The runtime and instance size are resolved before anything is written, so cancelling either prompt leaves nothing behind — including the name, which is only generated once both questions are answered. A worker already described in `config.toml` reuses those values instead of re-asking, and says so only when something recorded actually answers for an omitted flag. Two closed sets, both narrow on purpose. The runtimes are the catalog images plus `dockerfile`; the sizes are the alpha envelope's two, each implying its own vCPU count. `root` is refused as a worker name: `[workers] root` is the scalar key in the same table, so `[workers.root]` would stop the whole config parsing. Every runtime's starter runs as scaffolded, `dockerfile` included — its Dockerfile's `CMD` names a `server.js`, so that file is scaffolded too, alongside a `package.json` whose `"type": "module"` is what makes it ESM rather than Node's syntax detection. This also brings the command family's shell wiring, which is where the conventions here differ from a command tree's usual shape: - The project directory is `LegacyCliConfig.workdir`, so `--workdir` and `SUPABASE_WORKDIR` select the project exactly as they do for every sibling command, rather than an ancestor walk of the process's own directory. - Output goes through `output.raw` as plain text with no `intro`/`outro` framing, and tables through `renderGlamourTable`, so `workers` reads like `functions` and `projects` rather than like a second CLI. - `-o`/`--output` is honoured (`workers.output.ts`). It is a global flag 33 of this shell's 37 command families answer to, so ignoring it would print human text to a stdout the user asked to be machine-readable. What workers does not inherit is the Go-parity obligation behind the struct encoders: there is no Go counterpart to be byte-identical to, so the payload is serialised through the generic encoders instead. `-o env` is refused for a payload containing a list, because `encodeEnv` reproduces `godotenv.Marshal`, whose flattening does not descend into slices. - Telemetry state is flushed in `Effect.ensuring`, matching Go's `PersistentPostRun`. Two shell-wide registries have to move in step with the command appearing, and both are enforced by tests rather than convention: `LEGACY_DOCS_TAGS`, without which the generated CLI reference refuses to build, and `VALUE_CONSUMING_LONG_FLAGS`, without which the telemetry argv scan treats `--runtime`'s value as a flag and can fabricate one Go never recorded. `supabase workers` has no Go equivalent, so it is recorded in `docs/go-cli-divergences.md` as TS-only.
…acro
The starter files `workers new` writes lived as string literals with their
newlines and `${}` escaped. Hold them as ordinary files under
`shared/workers/stacks/<runtime>/` instead, authored in the language they are
written in, and narrow the offered runtimes to the three that have starters.
A shipped binary has no `stacks/` directory to read, so the directory is
expanded through a Bun macro: it runs while `worker-stacks.ts` is transpiled
and its return value is inlined as a literal, which means the content is
carried with nothing to pass at a build site and no directory to find at
runtime. Bun expands macros in the runtime transpiler too, so running from
source behaves the same; Vitest does not implement them and degrades to
calling the function against the source tree, which is why the path comes
from `import.meta.url` rather than Bun's `import.meta.dir`.
Discovery stays directory-driven — a new runtime is a new directory plus its
`WORKER_RUNTIMES` entry — and a completeness check inside the macro fails the
build rather than the binary when the two drift. Bun reports a throwing macro
as one it could not coerce to AST, so the reason is logged first to keep the
diagnostic legible.
Nothing imports the starters, which is what keeps them out of the type
program: a `deno` starter is not valid under this workspace's Bun types, and
`tsconfig.json` excludes the directory.
ed12377 to
0fce041
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ed12377e3d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
|
||
| const name = Option.isSome(flags.name) ? flags.name.value : yield* generateWorkerName(); | ||
| if (Option.isNone(flags.name)) { | ||
| yield* output.raw(`Auto-assigned the name ${name}.\n`); |
There was a problem hiding this comment.
Keep auto-name notices off machine stdout
When the name is omitted, supabase workers new -o json writes this human-readable line before the encoded payload, so JSON.parse(stdout) fails; --output-format json and stream-json are likewise corrupted because output.raw writes directly to stdout. The existing-config reuse notice has the same problem. Gate these notices with legacyWorkersMachineOutputRequested and output.format, or send them to stderr in machine modes.
AGENTS.md reference: apps/cli/AGENTS.md:L473-L475
Useful? React with 👍 / 👎.
| ? relative(project.projectRoot, destination) | ||
| : recorded?.source; | ||
|
|
||
| yield* writeWorkerEntry({ |
There was a problem hiding this comment.
Validate config edits before deleting the scaffold
When an existing worker is represented with dotted/inline TOML, or a targeted runtime/size/source value spans multiple lines, writeWorkerEntry deterministically fails here. By this point the handler has already recursively deleted the previous destination under --force and written the replacement starter files, so an exit-code-1 validation failure can destroy the user's old worker and leave an unrecorded scaffold. Preflight the TOML edit before removing or writing the destination.
Useful? React with 👍 / 👎.
| * the API contract (`state_reason`, for one), so an empty one would otherwise | ||
| * render as a label, two spaces of padding and nothing else. | ||
| */ | ||
| export function renderWorkerDetails(rows: ReadonlyArray<readonly [string, string]>): string { |
There was a problem hiding this comment.
Prefix the legacy formatter exports
renderWorkerDetails and renderWorkersTable are exported from the legacy/ tree without the mandatory Legacy/legacy prefix. Rename both exports and their imports so legacy-only helpers do not pollute autocomplete or appear shell-agnostic.
AGENTS.md reference: apps/cli/AGENTS.md:L235-L237
Useful? React with 👍 / 👎.
| if (destination === projectRoot) { | ||
| return refuse("is the project root itself"); | ||
| } | ||
| if (!isAtOrUnder(projectRoot, destination)) { |
There was a problem hiding this comment.
Resolve symlinks before authorizing destructive sources
When --source traverses a symlink inside the project whose target is outside it, this lexical containment check still succeeds. For example, if packages/external points to /other-repo, then --source packages/external/api --force reaches fs.remove and deletes /other-repo/api, despite the command promising to refuse destinations outside the project. Canonicalize existing path components or reject symlink traversal before permitting removal and writes.
Useful? React with 👍 / 👎.
| } | ||
|
|
||
| const source = Option.isSome(flags.source) | ||
| ? relative(project.projectRoot, destination) |
There was a problem hiding this comment.
Store source paths with portable separators
On Windows, node:path.relative records an explicit source as packages\api in the shared config.toml. If that committed config is then used on Linux or macOS, workerSourceDir and resolveWorkerSource treat the backslash as a literal filename character rather than a separator, so subsequent scaffolding and worker commands target the wrong directory. Normalize persisted project-relative paths to forward slashes.
Useful? React with 👍 / 👎.
| const entries = yield* fs.readDirectory(dir).pipe(Effect.orElseSucceed(() => [])); | ||
| return entries.length > 0; |
There was a problem hiding this comment.
Replace regular-file destinations when forced
When the destination already exists as a regular file, readDirectory fails and this fallback classifies it as an empty directory. The --force removal branch is therefore skipped, and the later recursive makeDirectory fails with EEXIST instead of replacing the destination as the flag promises. Distinguish a missing path from files and filesystem errors, and remove an existing file when --force is set.
Useful? React with 👍 / 👎.
| return refuse("climbs outside supabase/"); | ||
| } | ||
| const first = normalized.split(/[/\\]/)[0] ?? ""; | ||
| if (RESERVED_ROOT_DIRS.includes(first)) { |
There was a problem hiding this comment.
Protect reserved roots on case-insensitive filesystems
On Windows and default case-insensitive macOS filesystems, a configured root such as Functions aliases the reserved supabase/functions directory but passes this case-sensitive membership check. Running workers new api --force can then recursively delete an existing Edge Function named api and replace it with a worker scaffold. Compare filesystem identities or otherwise account for case-insensitive path aliases before accepting the root.
Useful? React with 👍 / 👎.
|
|
||
| | Path | Format | When | | ||
| | ------------------------------------ | ------ | ------------------------------------------------------------------------- | | ||
| | `<workdir>/supabase/config.toml` | TOML | always — appends/updates `[workers.<name>]` in place, preserving comments | |
There was a problem hiding this comment.
Document the telemetry state file side effect
The handler always flushes LegacyTelemetryState on both success and failure, so it reads and rewrites <SUPABASE_HOME or ~/.supabase>/telemetry.json, but the files tables omit that path and the environment table omits SUPABASE_HOME. Add those entries so this compatibility checklist accurately covers all filesystem and environment side effects.
AGENTS.md reference: apps/cli/AGENTS.md:L387-L394
Useful? React with 👍 / 👎.
| if (key === "root" || typeof value !== "object" || value === null || Array.isArray(value)) { | ||
| continue; | ||
| } | ||
| const entry = value as Record<string, unknown>; |
There was a problem hiding this comment.
Narrow decoded worker entries without assertions
This assertion, together with the second assertion used to read root, exists only to make unchecked property access type-check in production code. Replace it with a real record guard or restructure the Object.entries processing so malformed decoded values remain narrowed without bypassing the type system.
AGENTS.md reference: AGENTS.md:L75-L75
Useful? React with 👍 / 👎.
| export const DEFAULT_WORKER_RUNTIME: WorkerRuntime = "deno"; | ||
|
|
||
| function isWorkerRuntime(value: string): value is WorkerRuntime { | ||
| return (WORKER_RUNTIMES as ReadonlyArray<string>).includes(value); |
There was a problem hiding this comment.
Check runtime membership without widening assertions
The catalog tuple is widened with an as ReadonlyArray<string> assertion solely to satisfy includes, and the size parser repeats the same pattern. Use a predicate such as some(runtime => runtime === value) or another assertion-free narrowing helper so the production type relationship is represented rather than suppressed.
AGENTS.md reference: AGENTS.md:L75-L75
Useful? React with 👍 / 👎.
Supabase CLI previewnpx --yes https://pkg.pr.new/supabase/cli/supabase@0fce0411942b3ff43329df7f6e97eebb4bfda093Preview package for commit |
Summary
Adds
supabase workers new, plus the project layout andconfig.tomlediting thewhole command family builds on:
shared/workers/— worker path resolution,config.tomlsection reading andpatching (
toml-section.tspreserves surrounding formatting), the runtime/sizeenvelope, and the starter files.
shared/workers/stacks/<runtime>/rather than string literals, and are embedded into the compiled binary through a
Bun macro — the directory is expanded at transpile time and inlined.
A completeness check inside the macro fails the build if
WORKER_RUNTIMESandthe directory drift apart.
Stack 2 of 4, on top of the config schema (#6260).
Reviewer note: the third commit is where the embedding mechanism is explained; the
starters are deliberately kept out of the type program (a
denostarter is notvalid under this workspace's Bun types), which is why
tsconfig.jsonexcludes thedirectory and nothing imports the files.
Linked issue
FUNC-753 (Linear). Supabase maintainer, exempt from the
open-for-contributionflow.Checklist