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
69 changes: 69 additions & 0 deletions apps/cli/src/legacy/commands/workers/delete/SIDE_EFFECTS.md
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) |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document the live tally used by deletion

When the single-worker response contains an instance tally, the confirmation uses instances.live, but this checklist records only spec.instances as a consumed response field. That omission hides the live-versus-declared behavior from the compatibility document that drives E2E coverage; include instances.live and its fallback semantics in the response description.

AGENTS.md reference: apps/cli/AGENTS.md:L359-L366

Useful? React with 👍 / 👎.

| `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.
43 changes: 43 additions & 0 deletions apps/cli/src/legacy/commands/workers/delete/delete.command.ts
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"])),
);
193 changes: 193 additions & 0 deletions apps/cli/src/legacy/commands/workers/delete/delete.handler.ts
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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep local config failures from blocking remote deletion

When supabase/config.toml is malformed or its [workers] section is invalid, this mandatory load fails before either API request, even with an explicit valid --project-ref. The config is used only to describe optional local files that deletion deliberately does not modify, so this can strand a deployed worker until the user repairs unrelated local configuration; load the reporting metadata best-effort or perform it independently of the remote delete.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid requiring read scope to delete a worker

For an OAuth credential granted edge_functions:write without edge_functions:read, this preliminary GET returns 403 and the DELETE is never attempted, including with --yes. The generated API contract assigns the retrieval endpoint edge_functions:read (packages/api/src/generated/openapi.json:12408-12414) but the deletion endpoint only edge_functions:write (packages/api/src/generated/openapi.json:12488-12494), so the command imposes a permission the requested operation does not require; skip this lookup when confirmation metadata is unnecessary or degrade gracefully when it is unavailable.

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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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. output.promptText still reads stdin, so with a TTY stdout but redirected or piped stdin, input containing the worker name satisfies confirmation and reaches deleteWorker, enabling unattended destructive deletion without --yes.
Helpful? Add 👍 / 👎

💡 Fix Suggestion

Suggestion: The root cause is that output.interactive in apps/cli/src/shared/output/output.layer.ts (line 205) is set to tty.stdoutIsTty only, ignoring whether stdin is a TTY. This means that when stdout is a terminal but stdin is piped, output.interactive is true, the guard at line 93 of delete.handler.ts passes, and the confirmation prompt (output.promptText) can be satisfied by piped stdin input — enabling unattended destructive deletion without --yes.

The fix should be applied in apps/cli/src/shared/output/output.layer.ts at line 205. Change:

interactive: tty.stdoutIsTty,

to:

interactive: tty.stdoutIsTty && tty.stdinIsTty,

The Tty service already exposes stdinIsTty alongside stdoutIsTty, so this is a minimal, safe change that fixes the issue consistently for all commands that rely on output.interactive, not just the workers delete command. Additionally, update the doc comment in delete.handler.ts (lines 44–45) that states 'interactive tracks stdout' to reflect that it now tracks both stdin and stdout.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require an interactive stdin before prompting

When stdout is still a TTY but stdin is piped, such as printf 'api\n' | supabase workers delete api, output.interactive remains true because it only reflects stdout. This guard therefore permits promptText to consume the piped worker name and proceeds with the DELETE without --yes, contrary to the command's non-interactive safety rule; also require Tty.stdinIsTty before entering the prompt.

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.`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the project ref in confirmation retry commands

When deletion is attempted with an explicit --project-ref from a checkout linked to another project, copying this suggested retry drops the selected ref; workers delete <name> --yes then resolves the checkout's linked project and can irreversibly delete a same-named worker there. Include --project-ref ${projectRef} here and in the confirmation-mismatch retry guidance.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject -o env before deleting the worker

With --yes -o env, the handler performs the DELETE here and only afterward reaches legacyEmitWorkersMachineOutput, which raises LegacyWorkersEnvNotSupportedError. The worker is therefore deleted even though the command exits with an error and emits no success payload; validate the requested output format before making any API calls.

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`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the selected project in redeploy guidance

When deletion is run with --project-ref from a checkout linked to a different project—or from an unlinked checkout—this printed command drops the resolved ref. Following the suggested command therefore either fails project resolution or redeploys the worker into the checkout's linked project instead of the project it was just deleted from; include --project-ref ${projectRef} in the guidance.

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),
);
});
Loading
Loading