Skip to content
Open
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# `supabase experimental workers new <name>`
# `supabase experimental workers new [name]`

> **Local-disk only.** Nothing is deployed and no Management API route is
> called; `workers push` is what talks to the platform.
Expand Down Expand Up @@ -35,6 +35,16 @@ same resolver `start`/`stop`/`status` use) and never climbs to an ancestor. A
therefore records the worker in that directory's own `config.toml` — created if
absent — rather than in the ancestor project's.

The name is prompted for when the command line does not carry one, and the
prompt refuses a name that is not a DNS label or that `config.toml` already
records — so nothing is asked, and nothing written, for a name the command was
going to refuse. With `-o json|yaml|toml|env`, a redirected stdout, or a stdin
that is not a terminal, there is nowhere to ask, and the command fails instead
of defaulting: unlike the runtime and size, the name has no default to fall back
on. Every prompt is gated on both streams, so
`printf 'api\n' | supabase experimental workers new` takes that failure path
rather than reading the worker name off the pipe.

Writes to `config.toml` are append-only. A worker already recorded under
`[workers.<name>]` is refused outright — before the runtime and size prompts,
and before anything reaches disk — because editing an entry the user owns is
Expand All @@ -57,14 +67,15 @@ root.

## Exit Codes

| Code | Condition |
| ---- | ----------------------------------------------------------------------------------- |
| `0` | success |
| `1` | invalid worker name — the name must be a DNS label |
| `1` | bad `--source`: outside the project, or a path the CLI owns |
| `1` | destination exists and is not empty |
| `1` | the worker is already recorded in `config.toml`, in any form |
| `1` | the rendered `config.toml` would not parse, or `[workers]` is a sealed inline table |
| Code | Condition |
| ---- | -------------------------------------------------------------------------------------------------- |
| `0` | success |
| `1` | invalid worker name — the name must be a DNS label |
| `1` | no name given, and nowhere to ask for one — stdin or stdout is not a terminal, or `-o` is in force |
| `1` | bad `--source`: outside the project, or a path the CLI owns |
| `1` | destination exists and is not empty |
| `1` | the worker is already recorded in `config.toml`, in any form |
| `1` | the rendered `config.toml` would not parse, or `[workers]` is a sealed inline table |

## Environment Variables

Expand All @@ -83,7 +94,9 @@ root.
No custom events — only the `cli_command_executed` that the instrumentation
wrapper emits for every command.

Nothing is emitted for a failure the parser catches, such as a missing worker
name or a `--runtime`/`--size` value outside the choice list. The wrapper is
installed by `Command.withHandler`, so a command that never reaches its handler
never reaches the instrumentation either — and `telemetry.json` is not written.
Nothing is emitted for a failure the parser catches, such as a
`--runtime`/`--size` value outside the choice list. The wrapper is installed by
`Command.withHandler`, so a command that never reaches its handler never reaches
the instrumentation either — and `telemetry.json` is not written. A missing name
is _not_ one of those: the argument is optional, so a bare `workers new` reaches
the handler, which asks for the name or fails for want of anywhere to ask.
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ import { legacyWorkersNew } from "./new.handler.ts";

const config = {
name: Argument.string("name").pipe(
Argument.withDescription("Worker name. Doubles as its directory, and its hostname."),
Argument.withDescription(
"Worker name. Doubles as its directory, and its hostname. Prompted when omitted.",
),
Argument.optional,
),
runtime: Flag.choice("runtime", WORKER_RUNTIMES).pipe(
Flag.withDescription(
Expand Down Expand Up @@ -51,6 +54,10 @@ export const legacyWorkersNewCommand = Command.make("new", config).pipe(
),
Command.withShortDescription("Scaffold a worker locally"),
Command.withExamples([
{
command: "supabase experimental workers new",
description: "Prompt for the name, then for runtime and size",
},
{
command: "supabase experimental workers new api",
description: "Scaffold supabase/workers/api, prompting for runtime and size",
Expand Down
124 changes: 93 additions & 31 deletions apps/cli/src/legacy/commands/experimental/workers/new/new.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from "../workers.output.ts";
import { LegacyTelemetryState } from "../../../../telemetry/legacy-telemetry-state.service.ts";
import { RuntimeInfo } from "../../../../../shared/runtime/runtime-info.service.ts";
import { Tty } from "../../../../../shared/runtime/tty.service.ts";
import {
commitWorkerEntry,
planWorkerEntry,
Expand All @@ -33,18 +34,22 @@ import {
} from "../../../../../shared/workers/worker-runtimes.ts";
import { WORKER_STACKS } from "../../../../../shared/workers/worker-stacks.ts";
import {
InvalidWorkerNameError,
MissingWorkerNameError,
WorkerDirectoryExistsError,
} from "../../../../../shared/workers/workers.errors.ts";
import { legacyLoadWorkersProjectForEntryWrite } from "../workers.shared.ts";
import {
legacyLoadWorkersProjectForEntryWrite,
legacyValidateWorkerName,
type LegacyWorkersProject,
} from "../workers.shared.ts";
import type { LegacyWorkersNewFlags } from "./new.command.ts";

/**
* `supabase experimental workers new <name>` — scaffold `supabase/workers/<name>/` from the
* `supabase experimental workers new [name]` — scaffold `supabase/workers/<name>/` 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
* The name, runtime and size are all resolved *before* anything is written, so a
* cancelled prompt leaves nothing behind for this worker at all.
*/

Expand All @@ -53,19 +58,82 @@ function defaultFirst<T>(values: ReadonlyArray<T>, defaultValue: T): Array<T> {
return [defaultValue, ...values.filter((value) => value !== defaultValue)];
}

/**
* Whether this run has a terminal to ask on.
*
* `-o json|yaml|toml|env` leaves `output.format` as `text`, and the prompts go
* through Clack, which writes its terminal UI to stdout with no stream
* override — so a machine format is as non-interactive as a redirected stdout,
* whichever flag asked for it.
*
* `output.interactive` only tracks *stdout*, so on its own it still let
* `printf 'api\n' | supabase experimental workers new` feed the pipe straight
* into the name prompt instead of taking the documented non-interactive path. A
* prompt is only answerable from a keyboard, so stdin has to be a terminal too
* — the same pair `workers delete` guards its confirmation with.
*/
const canPromptFor = Effect.fnUntraced(function* (machineOutput: boolean) {
const output = yield* Output;
const tty = yield* Tty;
return output.format === "text" && output.interactive && !machineOutput && tty.stdinIsTty;
});

/**
* The worker name, asked for when the command line did not carry one.
*
* The name is the one input here that cannot be defaulted — it is the
* directory, the `config.toml` key and the hostname — so a bare
* `supabase experimental workers new` asks rather than failing the parse. The
* prompt validates against everything the command would otherwise refuse a
* moment later, so a mistyped or already-recorded name is corrected in place
* instead of ending the run.
*/
const resolveName = Effect.fnUntraced(function* (options: {
readonly explicit: Option.Option<string>;
/** Whether there is a terminal to ask on — see `canPromptFor`. */
readonly canPrompt: boolean;
readonly project: LegacyWorkersProject;
}) {
if (Option.isSome(options.explicit)) {
return options.explicit.value;
}

if (options.canPrompt) {
const output = yield* Output;
return yield* output.promptText("What should this worker be called?", {
validate: (value) => {
const invalid = validateWorkerNameMessage(value);
if (invalid !== undefined) {
return invalid;
}
return options.project.section.workers[value] === undefined
? undefined
: `"${value}" is already configured in ${options.project.configPath}.`;
},
});
}

return yield* Effect.fail(
new MissingWorkerNameError({
detail: "Worker name is required in non-interactive mode.",
suggestion: "Pass a worker name, for example `supabase experimental workers new api`.",
}),
);
});

const resolveRuntime = Effect.fnUntraced(function* (options: {
readonly explicit: Option.Option<WorkerRuntime>;
/** `-o json|yaml|toml|env` — stdout belongs to the payload, so do not prompt. */
readonly machineOutput: boolean;
/** Whether there is a terminal to ask on — see `canPromptFor`. */
readonly canPrompt: 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) {
if (options.canPrompt) {
const output = yield* Output;
const selected = yield* output.promptSelect(
"Which runtime should this worker use?",
defaultFirst([...WORKER_RUNTIMES], DEFAULT_WORKER_RUNTIME).map((runtime) => ({
Expand All @@ -82,15 +150,15 @@ const resolveRuntime = Effect.fnUntraced(function* (options: {

const resolveSize = Effect.fnUntraced(function* (options: {
readonly explicit: Option.Option<WorkerSize>;
/** `-o json|yaml|toml|env` — stdout belongs to the payload, so do not prompt. */
readonly machineOutput: boolean;
/** Whether there is a terminal to ask on — see `canPromptFor`. */
readonly canPrompt: boolean;
}) {
if (Option.isSome(options.explicit)) {
return options.explicit.value;
}

const output = yield* Output;
if (output.format === "text" && output.interactive && !options.machineOutput) {
if (options.canPrompt) {
const output = yield* Output;
const selected = yield* output.promptSelect(
"Which instance size should this worker use?",
defaultFirst([...WORKER_SIZES], DEFAULT_WORKER_SIZE).map((size) => ({
Expand Down Expand Up @@ -134,21 +202,19 @@ export const legacyWorkersNew = Effect.fn("legacy.experimental.workers.new")(fun
yield* Effect.gen(function* () {
const project = yield* legacyLoadWorkersProjectForEntryWrite();

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.",
}),
);
}
// Decided once, before the first prompt rather than beside the last, since
// the name is now asked for too — every prompt below shares the answer.
const machineOutput = yield* legacyWorkersMachineOutputRequested();
const canPrompt = yield* canPromptFor(machineOutput);

const name = yield* resolveName({ explicit: flags.name, canPrompt, project });
yield* legacyValidateWorkerName(name);

// 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.
// runtime and size prompts never run for a name that was going to be
// refused anyway; the name prompt rejects it up front for the same reason.
if (project.section.workers[name] !== undefined) {
return yield* Effect.fail(
new WorkerAlreadyConfiguredError({
Expand All @@ -159,14 +225,10 @@ export const legacyWorkersNew = Effect.fn("legacy.experimental.workers.new")(fun
}

// 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 });
// nothing behind — the name included. With nowhere to ask, the defaults
// stand; only the name has nothing to fall back to.
const runtime = yield* resolveRuntime({ explicit: flags.runtime, canPrompt });
const size = yield* resolveSize({ explicit: flags.size, canPrompt });

// Validated before anything is written: this is the directory the starter
// files land in, so a value naming the project root, `supabase/`, or
Expand Down
Loading
Loading