Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 25 additions & 13 deletions apps/cli/src/legacy/commands/workers/new/SIDE_EFFECTS.md
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
# `supabase workers new <name>`
# `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.
Expand DownExpand Up@@ -35,6 +35,15 @@ 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 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 +66,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 +93,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.
9 changes: 8 additions & 1 deletion apps/cli/src/legacy/commands/workers/new/new.command.ts
Original file line numberDiff line numberDiff 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 DownExpand Up@@ -51,6 +54,10 @@ export const legacyWorkersNewCommand = Command.make("new", config).pipe(
),
Command.withShortDescription("Scaffold a worker locally"),
Command.withExamples([
{
command: "supabase workers new",
description: "Prompt for the name, then for runtime and size",
},
{
command: "supabase 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/workers/new/new.handler.ts
Original file line numberDiff line numberDiff 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 workers new <name>` — scaffold `supabase/workers/<name>/` from the
* `supabase 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 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 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 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 DownExpand Up@@ -134,21 +202,19 @@ export const legacyWorkersNew = Effect.fn("legacy.workers.new")(function* (
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.workers.new")(function* (
}

// 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