-
Notifications
You must be signed in to change notification settings - Fork 511
feat(cli): add supabase workers list, status and delete #6263
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| # `supabase workers delete <name>` | ||
|
|
||
| > **No live test yet.** `workers` runs against the v2 Management API, which the | ||
| > supabase/cli-e2e-ci supabox stack is not expected to serve, so a `*.live.test.ts` | ||
| > here would be permanently skipped or permanently red. Revisit when the v2 | ||
| > Workers routes are available on that stack. | ||
|
|
||
| ## Files Read | ||
|
|
||
| | Path | Format | When | | ||
| | ---------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------- | | ||
| | `<workdir>/supabase/config.toml` | TOML | always, to report the source directory it kept | | ||
| | `<SUPABASE_HOME or ~/.supabase>/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` | | ||
| | `<SUPABASE_PROFILE>` (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_HOME or ~/.supabase>/telemetry.json` | JSON | always — flushed on success and on failure | | ||
| | `<workdir>/supabase/.temp/linked-project.json` | JSON | after the project ref resolves, when the cache does not hold it | | ||
|
|
||
| The worker's directory and its `[workers.<name>]` entry are deliberately left | ||
| on disk; only the remote worker is deleted. | ||
|
|
||
| ## Confirmation | ||
|
|
||
| Interactively, the worker's name has to be typed back before anything is | ||
| deleted. `--yes` (the root persistent flag) or `SUPABASE_YES` skips that. With | ||
| neither — and no interactive terminal to prompt on, which includes a redirected | ||
| stdout and any `--output-format json`/`stream-json` run — the command refuses | ||
| rather than deleting unasked. | ||
|
|
||
| ## API Routes | ||
|
|
||
| | Method | Path | Auth | Request body | Response (used fields) | | ||
| | -------- | ----------------------------------- | ------------ | ------------ | --------------------------------------- | | ||
| | `GET` | `/v2/projects/{ref}/workers/{name}` | Bearer token | none | `spec.instances` (for the confirmation) | | ||
| | `DELETE` | `/v2/projects/{ref}/workers/{name}` | Bearer token | none | status only | | ||
|
|
||
| ## Exit Codes | ||
|
|
||
| | Code | Condition | | ||
| | ---- | ------------------------------------------------------------------------- | | ||
| | `0` | success (a `404` on DELETE counts — it is already gone) | | ||
| | `1` | invalid worker name | | ||
| | `1` | nothing deployed under that name | | ||
| | `1` | the typed confirmation did not match the worker's name | | ||
| | `1` | confirmation needed but no interactive terminal to ask on, and no `--yes` | | ||
| | `1` | API error, or project not enrolled in the alpha | | ||
|
|
||
| ## 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`) | | ||
| | `SUPABASE_YES` | auto-confirms the deletion, as `--yes` does | no (defaults to prompting) | | ||
|
|
||
| ## 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. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| 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 { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; | ||
| import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; | ||
| import { legacyWorkersDelete } from "./delete.handler.ts"; | ||
|
|
||
| // No local `--yes`: it is a root persistent flag every other confirming command | ||
| // reads through `legacyResolveYes`, so redeclaring it here would shadow the | ||
| // global, list `--yes` twice in `--help`, and quietly ignore `SUPABASE_YES`. | ||
| const config = { | ||
| name: Argument.string("name").pipe(Argument.withDescription("Worker to delete.")), | ||
| projectRef: Flag.string("project-ref").pipe( | ||
| Flag.withDescription("Project ref of the Supabase project."), | ||
| Flag.optional, | ||
| ), | ||
| } as const; | ||
|
|
||
| export type LegacyWorkersDeleteFlags = CliCommand.Command.Config.Infer<typeof config>; | ||
|
|
||
| export const legacyWorkersDeleteCommand = Command.make("delete", config).pipe( | ||
| Command.withDescription( | ||
| "Delete a worker from the linked Supabase project. Irreversible; its local directory and supabase/config.toml entry are kept.", | ||
| ), | ||
| Command.withShortDescription("Delete a worker from Supabase"), | ||
| Command.withExamples([ | ||
| { | ||
| command: "supabase workers delete api", | ||
| description: "Delete a worker, confirming by typing its name", | ||
| }, | ||
| { | ||
| command: "supabase workers delete api --yes", | ||
| description: "Skip the confirmation prompt (scripts and CI)", | ||
| }, | ||
| ]), | ||
| Command.withHandler((flags) => | ||
| legacyWorkersDelete(flags).pipe( | ||
| withLegacyCommandInstrumentation({ flags }), | ||
| withJsonErrorHandling, | ||
| ), | ||
| ), | ||
| Command.provide(legacyManagementApiRuntimeLayer(["workers", "delete"])), | ||
| ); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,193 @@ | ||
| import { Effect, Option } from "effect"; | ||
| import { Output } from "../../../../shared/output/output.service.ts"; | ||
| import { legacyAqua } from "../../../shared/legacy-colors.ts"; | ||
| import { legacyRenderWorkerDetails } from "../workers.format.ts"; | ||
| import { | ||
| legacyEmitWorkersMachineOutput, | ||
| legacyWorkersMachineOutputRequested, | ||
| } from "../workers.output.ts"; | ||
| import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; | ||
| import { displayPath } from "../../../../shared/workers/worker-paths.ts"; | ||
| import { deleteWorker, getWorker } from "../../../../shared/workers/workers-api.ts"; | ||
| import { | ||
| WorkerDeleteConfirmationRequiredError, | ||
| WorkerDeleteNotConfirmedError, | ||
| WorkerNotDeployedError, | ||
| } from "../../../../shared/workers/workers.errors.ts"; | ||
| import { legacyResolveYes } from "../../../../shared/legacy/global-flags.ts"; | ||
| import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; | ||
| import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; | ||
| import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; | ||
| import { | ||
| legacyDescribeWorkerForReporting, | ||
| legacyLoadWorkersProject, | ||
| legacyValidateWorkerName, | ||
| } from "../workers.shared.ts"; | ||
| import type { LegacyWorkersDeleteFlags } from "./delete.command.ts"; | ||
|
|
||
| /** | ||
| * `supabase workers delete [name]` — delete the worker; its instances and image | ||
| * are torn down asynchronously. Whether it exists is asked of the API, never of | ||
| * a local file. | ||
| * | ||
| * Note what it does *not* remove: the worker's directory and its `config.toml` | ||
| * entry stay on disk, so `push <name>` brings it straight back — which is why | ||
| * the command says so. | ||
| * | ||
| * Being irreversible, an interactive session has to type the worker's name back | ||
| * to proceed — the same "confirm by typing it" pattern as GitHub's own repo | ||
| * deletion, rather than a bare y/n that is too easy to reflexively confirm. | ||
| * `--yes`/`SUPABASE_YES` skips it for scripts, resolved through | ||
| * `legacyResolveYes` like every other confirming command rather than through a | ||
| * local flag that would shadow the root one. | ||
| * | ||
| * Without a terminal to prompt on there is no third option: `interactive` tracks | ||
| * stdout, so merely redirecting output would otherwise delete unattended. This | ||
| * refuses instead, and says which flag would have authorised it. | ||
| */ | ||
| export const legacyWorkersDelete = Effect.fn("legacy.workers.delete")(function* ( | ||
| flags: LegacyWorkersDeleteFlags, | ||
| ) { | ||
| const output = yield* Output; | ||
| const api = yield* LegacyPlatformApi; | ||
| const resolver = yield* LegacyProjectRefResolver; | ||
| const linkedProjectCache = yield* LegacyLinkedProjectCache; | ||
| const telemetryState = yield* LegacyTelemetryState; | ||
| // `--yes` OR `SUPABASE_YES`, matching `projects delete` and every other | ||
| // command that guards a destructive step behind a prompt. | ||
| const yes = yield* legacyResolveYes; | ||
|
|
||
| // The ref is resolved outside the finalizers because caching it is one of | ||
| // them; everything that can fail on its own — loading `config.toml`, | ||
| // validating the name, resolving the worker — belongs inside, so those | ||
| // failures still flush telemetry. Same shape as `config/push`. | ||
| const projectRef = yield* resolver.resolve(flags.projectRef); | ||
|
|
||
| yield* Effect.gen(function* () { | ||
| const project = yield* legacyLoadWorkersProject(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| const name = yield* legacyValidateWorkerName(flags.name); | ||
| const worker = yield* legacyDescribeWorkerForReporting(project, name); | ||
|
|
||
| const fetching = yield* output.task("Fetching worker..."); | ||
| const found = yield* getWorker(api, projectRef, name).pipe( | ||
| Effect.tapError(() => fetching.fail()), | ||
| ); | ||
|
Comment on lines
+71
to
+74
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For an OAuth credential granted Useful? React with 👍 / 👎. |
||
| yield* fetching.clear(); | ||
|
|
||
| if (Option.isNone(found)) { | ||
| return yield* Effect.fail( | ||
| new WorkerNotDeployedError({ | ||
| detail: `Nothing is deployed for "${name}" in project ${projectRef}.`, | ||
| suggestion: `Deploy it with \`supabase workers push ${name}\`.`, | ||
| }), | ||
| ); | ||
| } | ||
|
|
||
| const machineOutput = yield* legacyWorkersMachineOutputRequested(); | ||
|
|
||
| if (!yes) { | ||
| // `-o json` leaves `output.format` as `text`, so the format check alone | ||
| // still let the warning and the prompt run — onto the stdout the user had | ||
| // asked to carry a payload. A machine format is as non-interactive as a | ||
| // redirected stdout, whichever flag asked for it. | ||
| if (output.format !== "text" || machineOutput || !output.interactive) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Severity: MEDIUM The non-interactive safeguard checks only whether stdout is a TTY. 💡 Fix SuggestionSuggestion: The root cause is that The fix should be applied in to: The There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When stdout is still a TTY but stdin is piped, such as Useful? React with 👍 / 👎. |
||
| return yield* Effect.fail( | ||
| new WorkerDeleteConfirmationRequiredError({ | ||
| detail: `Deleting "${name}" from project ${projectRef} needs confirmation, and there is no interactive terminal to ask on.`, | ||
| suggestion: `Re-run \`supabase workers delete ${name} --yes\` to confirm without a prompt.`, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When deletion is attempted with an explicit Useful? React with 👍 / 👎. |
||
| }), | ||
| ); | ||
| } | ||
|
|
||
| // The live tally when the API reports one, labelled "declared" when it | ||
| // does not. `spec.instances` is the target, which for a worker still | ||
| // provisioning differs from what is running — and a destructive prompt is | ||
| // the wrong place to overstate. | ||
| const live = found.value.instances?.live; | ||
| const declared = found.value.spec.instances; | ||
| const terminating = | ||
| live !== undefined | ||
| ? live > 0 | ||
| ? ` ${live} running instance${live === 1 ? "" : "s"} will be terminated.` | ||
| : "" | ||
| : declared > 0 | ||
| ? ` ${declared} declared instance${declared === 1 ? "" : "s"} will be terminated.` | ||
| : ""; | ||
| yield* output.raw( | ||
| `This permanently deletes "${name}" from project ${projectRef}.${terminating}\n`, | ||
| ); | ||
| const typed = yield* output.promptText(`Type ${name} to confirm`); | ||
| // Trimmed: a trailing space from a paste is not a different answer, and | ||
| // making someone re-run a destructive command over one is just friction. | ||
| if (typed.trim() !== name) { | ||
| return yield* Effect.fail( | ||
| new WorkerDeleteNotConfirmedError({ | ||
| detail: `The confirmation did not match "${name}", so nothing was deleted.`, | ||
| suggestion: `Re-run \`supabase workers delete ${name}\` and type the name exactly, or pass --yes.`, | ||
| }), | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| const deleting = yield* output.task("Deleting worker..."); | ||
| yield* deleteWorker(api, projectRef, name).pipe(Effect.tapError(() => deleting.fail())); | ||
|
Comment on lines
+132
to
+133
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
With AGENTS.md reference: apps/cli/AGENTS.md:L300-L300 Useful? React with 👍 / 👎. |
||
| yield* deleting.clear(); | ||
|
|
||
| // A worker deployed from another checkout has neither a local entry nor a | ||
| // local directory, so there is nothing here that was kept. | ||
| const keptSource = worker.sourceExists | ||
| ? displayPath(project.projectRoot, worker.sourceDir) | ||
| : undefined; | ||
| const keptEntry = worker.entry !== undefined; | ||
|
|
||
| const payload = { | ||
| worker_name: name, | ||
| project_ref: projectRef, | ||
| ...(keptSource === undefined ? {} : { kept_source: keptSource }), | ||
| kept_config_entry: keptEntry, | ||
| }; | ||
|
|
||
| // `-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; | ||
| } | ||
|
|
||
| { | ||
| yield* output.raw( | ||
| `Deleted Worker ${legacyAqua(name, process.stdout)} from project ${projectRef}\n`, | ||
| ); | ||
|
|
||
| // "Deleted" reads more final than it is *when there is something left* — | ||
| // so only say so when there is. For an orphan there is nothing local to | ||
| // keep, and pointing at `push` would send the user at a command that has | ||
| // no source to deploy. | ||
| const kept = [ | ||
| ...(keptSource === undefined ? [] : [keptSource]), | ||
| ...(keptEntry ? ["its supabase/config.toml entry"] : []), | ||
| ]; | ||
| if (kept.length > 0) { | ||
| yield* output.raw(legacyRenderWorkerDetails([["Kept", kept.join(", ")]])); | ||
| // Only when the source is still there: a retained `config.toml` entry | ||
| // alone is not enough to redeploy from, so `push` would fail on the very | ||
| // command this line recommends. | ||
| if (keptSource !== undefined) { | ||
| yield* output.raw(`Redeploy it with supabase workers push ${name}.\n`); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When deletion is run with Useful? React with 👍 / 👎. |
||
| } | ||
| } else { | ||
| yield* output.raw( | ||
| `Nothing for "${name}" exists in this project on disk, so nothing was kept.\n`, | ||
| "stderr", | ||
| ); | ||
| } | ||
| } | ||
| }).pipe( | ||
| Effect.ensuring(linkedProjectCache.cache(projectRef)), | ||
| Effect.ensuring(telemetryState.flush), | ||
| ); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the single-worker response contains an instance tally, the confirmation uses
instances.live, but this checklist records onlyspec.instancesas a consumed response field. That omission hides the live-versus-declared behavior from the compatibility document that drives E2E coverage; includeinstances.liveand its fallback semantics in the response description.AGENTS.md reference: apps/cli/AGENTS.md:L359-L366
Useful? React with 👍 / 👎.