Skip to content

Commit c636947

Browse files
authored
feat(cli): prompt for worker name if not provided (#6349)
## Summary Makes the `name` argument to `supabase experimental workers new` optional and prompts for it when it is omitted, so a bare `supabase experimental workers new` walks through name, runtime and size rather than failing the parse. The name is the one input this command cannot default — it is the directory, the `[workers.<name>]` key and the hostname all at once. So where the runtime and size prompts fall back to a default when there is nowhere to ask, the name prompt has nothing to fall back to: with `-o json|yaml|toml|env` or no interactive terminal, the command fails with a new `MissingWorkerNameError` pointing at `supabase experimental workers new api`. The prompt validates against everything the command would otherwise refuse a moment later — a non-DNS-label name, and a name `config.toml` already records — so a typo is corrected in place instead of ending the run. That also means the project has to be loaded before the first prompt, and the machine-output check moves up with it: `-o` leaves `output.format` as `text`, and Clack writes its terminal UI to stdout, so a name prompt would land in front of the payload for the same reason the runtime prompt would. The handler's inline name validation is replaced by the shared `legacyValidateWorkerName`, which the rest of the command family already uses, so an explicitly-passed name and a prompted one are refused on identical terms. `mockOutput` now records `promptTextCalls` so tests can assert on the prompt's message and exercise its `validate` callback. ## Stack Bottom of the workers stack, on `develop`. Above it: output polish (#6389), `workers logs` (#6410), and `push --wait` (#6371). ## Linked issue FUNC-840 (Linear). Supabase maintainer, exempt from the `open-for-contribution` flow. ## Checklist - [x] The PR title follows [Conventional Commits](https://www.conventionalcommits.org/)
1 parent c326986 commit c636947

11 files changed

Lines changed: 292 additions & 80 deletions

File tree

apps/cli/src/legacy/commands/experimental/workers/new/SIDE_EFFECTS.md

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# `supabase experimental workers new <name>`
1+
# `supabase experimental workers new [name]`
22

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

38+
The name is prompted for when the command line does not carry one, and the
39+
prompt refuses a name that is not a DNS label or that `config.toml` already
40+
records — so nothing is asked, and nothing written, for a name the command was
41+
going to refuse. With `-o json|yaml|toml|env`, a redirected stdout, or a stdin
42+
that is not a terminal, there is nowhere to ask, and the command fails instead
43+
of defaulting: unlike the runtime and size, the name has no default to fall back
44+
on. Every prompt is gated on both streams, so
45+
`printf 'api\n' | supabase experimental workers new` takes that failure path
46+
rather than reading the worker name off the pipe.
47+
3848
Writes to `config.toml` are append-only. A worker already recorded under
3949
`[workers.<name>]` is refused outright — before the runtime and size prompts,
4050
and before anything reaches disk — because editing an entry the user owns is
@@ -57,14 +67,15 @@ root.
5767

5868
## Exit Codes
5969

60-
| Code | Condition |
61-
| ---- | ----------------------------------------------------------------------------------- |
62-
| `0` | success |
63-
| `1` | invalid worker name — the name must be a DNS label |
64-
| `1` | bad `--source`: outside the project, or a path the CLI owns |
65-
| `1` | destination exists and is not empty |
66-
| `1` | the worker is already recorded in `config.toml`, in any form |
67-
| `1` | the rendered `config.toml` would not parse, or `[workers]` is a sealed inline table |
70+
| Code | Condition |
71+
| ---- | -------------------------------------------------------------------------------------------------- |
72+
| `0` | success |
73+
| `1` | invalid worker name — the name must be a DNS label |
74+
| `1` | no name given, and nowhere to ask for one — stdin or stdout is not a terminal, or `-o` is in force |
75+
| `1` | bad `--source`: outside the project, or a path the CLI owns |
76+
| `1` | destination exists and is not empty |
77+
| `1` | the worker is already recorded in `config.toml`, in any form |
78+
| `1` | the rendered `config.toml` would not parse, or `[workers]` is a sealed inline table |
6879

6980
## Environment Variables
7081

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

86-
Nothing is emitted for a failure the parser catches, such as a missing worker
87-
name or a `--runtime`/`--size` value outside the choice list. The wrapper is
88-
installed by `Command.withHandler`, so a command that never reaches its handler
89-
never reaches the instrumentation either — and `telemetry.json` is not written.
97+
Nothing is emitted for a failure the parser catches, such as a
98+
`--runtime`/`--size` value outside the choice list. The wrapper is installed by
99+
`Command.withHandler`, so a command that never reaches its handler never reaches
100+
the instrumentation either — and `telemetry.json` is not written. A missing name
101+
is _not_ one of those: the argument is optional, so a bare `workers new` reaches
102+
the handler, which asks for the name or fails for want of anywhere to ask.

apps/cli/src/legacy/commands/experimental/workers/new/new.command.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ import { legacyWorkersNew } from "./new.handler.ts";
1212

1313
const config = {
1414
name: Argument.string("name").pipe(
15-
Argument.withDescription("Worker name. Doubles as its directory, and its hostname."),
15+
Argument.withDescription(
16+
"Worker name. Doubles as its directory, and its hostname. Prompted when omitted.",
17+
),
18+
Argument.optional,
1619
),
1720
runtime: Flag.choice("runtime", WORKER_RUNTIMES).pipe(
1821
Flag.withDescription(
@@ -51,6 +54,10 @@ export const legacyWorkersNewCommand = Command.make("new", config).pipe(
5154
),
5255
Command.withShortDescription("Scaffold a worker locally"),
5356
Command.withExamples([
57+
{
58+
command: "supabase experimental workers new",
59+
description: "Prompt for the name, then for runtime and size",
60+
},
5461
{
5562
command: "supabase experimental workers new api",
5663
description: "Scaffold supabase/workers/api, prompting for runtime and size",

apps/cli/src/legacy/commands/experimental/workers/new/new.handler.ts

Lines changed: 93 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from "../workers.output.ts";
99
import { LegacyTelemetryState } from "../../../../telemetry/legacy-telemetry-state.service.ts";
1010
import { RuntimeInfo } from "../../../../../shared/runtime/runtime-info.service.ts";
11+
import { Tty } from "../../../../../shared/runtime/tty.service.ts";
1112
import {
1213
commitWorkerEntry,
1314
planWorkerEntry,
@@ -33,18 +34,22 @@ import {
3334
} from "../../../../../shared/workers/worker-runtimes.ts";
3435
import { WORKER_STACKS } from "../../../../../shared/workers/worker-stacks.ts";
3536
import {
36-
InvalidWorkerNameError,
37+
MissingWorkerNameError,
3738
WorkerDirectoryExistsError,
3839
} from "../../../../../shared/workers/workers.errors.ts";
39-
import { legacyLoadWorkersProjectForEntryWrite } from "../workers.shared.ts";
40+
import {
41+
legacyLoadWorkersProjectForEntryWrite,
42+
legacyValidateWorkerName,
43+
type LegacyWorkersProject,
44+
} from "../workers.shared.ts";
4045
import type { LegacyWorkersNewFlags } from "./new.command.ts";
4146

4247
/**
43-
* `supabase experimental workers new <name>` — scaffold `supabase/workers/<name>/` from the
48+
* `supabase experimental workers new [name]` — scaffold `supabase/workers/<name>/` from the
4449
* chosen runtime's starter files and record the choice in `config.toml`.
4550
* Nothing is deployed; this is entirely local-disk work.
4651
*
47-
* The runtime and size are resolved *before* anything is written, so a
52+
* The name, runtime and size are all resolved *before* anything is written, so a
4853
* cancelled prompt leaves nothing behind for this worker at all.
4954
*/
5055

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

61+
/**
62+
* Whether this run has a terminal to ask on.
63+
*
64+
* `-o json|yaml|toml|env` leaves `output.format` as `text`, and the prompts go
65+
* through Clack, which writes its terminal UI to stdout with no stream
66+
* override — so a machine format is as non-interactive as a redirected stdout,
67+
* whichever flag asked for it.
68+
*
69+
* `output.interactive` only tracks *stdout*, so on its own it still let
70+
* `printf 'api\n' | supabase experimental workers new` feed the pipe straight
71+
* into the name prompt instead of taking the documented non-interactive path. A
72+
* prompt is only answerable from a keyboard, so stdin has to be a terminal too
73+
* — the same pair `workers delete` guards its confirmation with.
74+
*/
75+
const canPromptFor = Effect.fnUntraced(function* (machineOutput: boolean) {
76+
const output = yield* Output;
77+
const tty = yield* Tty;
78+
return output.format === "text" && output.interactive && !machineOutput && tty.stdinIsTty;
79+
});
80+
81+
/**
82+
* The worker name, asked for when the command line did not carry one.
83+
*
84+
* The name is the one input here that cannot be defaulted — it is the
85+
* directory, the `config.toml` key and the hostname — so a bare
86+
* `supabase experimental workers new` asks rather than failing the parse. The
87+
* prompt validates against everything the command would otherwise refuse a
88+
* moment later, so a mistyped or already-recorded name is corrected in place
89+
* instead of ending the run.
90+
*/
91+
const resolveName = Effect.fnUntraced(function* (options: {
92+
readonly explicit: Option.Option<string>;
93+
/** Whether there is a terminal to ask on — see `canPromptFor`. */
94+
readonly canPrompt: boolean;
95+
readonly project: LegacyWorkersProject;
96+
}) {
97+
if (Option.isSome(options.explicit)) {
98+
return options.explicit.value;
99+
}
100+
101+
if (options.canPrompt) {
102+
const output = yield* Output;
103+
return yield* output.promptText("What should this worker be called?", {
104+
validate: (value) => {
105+
const invalid = validateWorkerNameMessage(value);
106+
if (invalid !== undefined) {
107+
return invalid;
108+
}
109+
return options.project.section.workers[value] === undefined
110+
? undefined
111+
: `"${value}" is already configured in ${options.project.configPath}.`;
112+
},
113+
});
114+
}
115+
116+
return yield* Effect.fail(
117+
new MissingWorkerNameError({
118+
detail: "Worker name is required in non-interactive mode.",
119+
suggestion: "Pass a worker name, for example `supabase experimental workers new api`.",
120+
}),
121+
);
122+
});
123+
56124
const resolveRuntime = Effect.fnUntraced(function* (options: {
57125
readonly explicit: Option.Option<WorkerRuntime>;
58-
/** `-o json|yaml|toml|env` — stdout belongs to the payload, so do not prompt. */
59-
readonly machineOutput: boolean;
126+
/** Whether there is a terminal to ask on — see `canPromptFor`. */
127+
readonly canPrompt: boolean;
60128
}) {
61129
// `--runtime` is a choice flag, so the parser has already rejected anything
62130
// outside the catalog by the time it gets here.
63131
if (Option.isSome(options.explicit)) {
64132
return options.explicit.value;
65133
}
66134

67-
const output = yield* Output;
68-
if (output.format === "text" && output.interactive && !options.machineOutput) {
135+
if (options.canPrompt) {
136+
const output = yield* Output;
69137
const selected = yield* output.promptSelect(
70138
"Which runtime should this worker use?",
71139
defaultFirst([...WORKER_RUNTIMES], DEFAULT_WORKER_RUNTIME).map((runtime) => ({
@@ -82,15 +150,15 @@ const resolveRuntime = Effect.fnUntraced(function* (options: {
82150

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

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

137-
const name = flags.name;
138-
const invalid = validateWorkerNameMessage(name);
139-
if (invalid !== undefined) {
140-
return yield* Effect.fail(
141-
new InvalidWorkerNameError({
142-
detail: `"${name}" is not a valid worker name. ${invalid}`,
143-
suggestion: "Worker names become hostnames, so they must be DNS labels.",
144-
}),
145-
);
146-
}
205+
// Decided once, before the first prompt rather than beside the last, since
206+
// the name is now asked for too — every prompt below shares the answer.
207+
const machineOutput = yield* legacyWorkersMachineOutputRequested();
208+
const canPrompt = yield* canPromptFor(machineOutput);
209+
210+
const name = yield* resolveName({ explicit: flags.name, canPrompt, project });
211+
yield* legacyValidateWorkerName(name);
147212

148213
// Refused before anything is asked or written. `new` creates a worker;
149214
// changing one that already exists is a `config.toml` edit, and the file is
150215
// the user's. Checking here rather than only in `planWorkerEntry` means the
151-
// prompts never run for a name that was going to be refused anyway.
216+
// runtime and size prompts never run for a name that was going to be
217+
// refused anyway; the name prompt rejects it up front for the same reason.
152218
if (project.section.workers[name] !== undefined) {
153219
return yield* Effect.fail(
154220
new WorkerAlreadyConfiguredError({
@@ -159,14 +225,10 @@ export const legacyWorkersNew = Effect.fn("legacy.experimental.workers.new")(fun
159225
}
160226

161227
// Resolved before anything is written, so cancelling either prompt leaves
162-
// nothing behind — the name included.
163-
// `-o` leaves `output.format` as `text`, and `promptSelect` goes through
164-
// Clack, which writes its terminal UI to stdout with no stream override — so
165-
// a prompt would land in front of the payload just as the notices did. With a
166-
// machine format requested there is nowhere to ask, so the defaults stand.
167-
const machineOutput = yield* legacyWorkersMachineOutputRequested();
168-
const runtime = yield* resolveRuntime({ explicit: flags.runtime, machineOutput });
169-
const size = yield* resolveSize({ explicit: flags.size, machineOutput });
228+
// nothing behind — the name included. With nowhere to ask, the defaults
229+
// stand; only the name has nothing to fall back to.
230+
const runtime = yield* resolveRuntime({ explicit: flags.runtime, canPrompt });
231+
const size = yield* resolveSize({ explicit: flags.size, canPrompt });
170232

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

0 commit comments

Comments
 (0)