diff --git a/apps/cli/src/legacy/commands/workers/delete/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/workers/delete/SIDE_EFFECTS.md new file mode 100644 index 0000000000..aaa605e9ab --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/delete/SIDE_EFFECTS.md @@ -0,0 +1,54 @@ +# `supabase workers delete ` + +> **TS-only command.** `supabase workers` has no Go counterpart — there is no +> `apps/cli-go/internal/workers` to match, and nothing is proxied. See +> `docs/go-cli-divergences.md`. + +## Files Read + +| Path | Format | When | +| -------------------------------- | ------ | ---------------------------------------------- | +| `/supabase/config.toml` | TOML | always, to report the source directory it kept | + +## Files Written + +| Path | Format | When | +| ---- | ------ | ---- | +| — | — | — | + +The worker's directory and its `[workers.]` entry are deliberately left +on disk; only the remote worker is deleted. + +## 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` | 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) | + +## 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. `workers` has no Go counterpart, so there is no +`phtelemetry.*` call to reproduce. diff --git a/apps/cli/src/legacy/commands/workers/delete/delete.command.ts b/apps/cli/src/legacy/commands/workers/delete/delete.command.ts new file mode 100644 index 0000000000..f785e7624c --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/delete/delete.command.ts @@ -0,0 +1,44 @@ +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"; + +const config = { + name: Argument.string("name").pipe(Argument.withDescription("Worker to delete.")), + yes: Flag.boolean("yes").pipe( + Flag.withAlias("y"), + Flag.withDescription("Skip the confirmation prompt."), + ), + 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; + +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"])), +); diff --git a/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts b/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts new file mode 100644 index 0000000000..7b9f4b2a87 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/delete/delete.handler.ts @@ -0,0 +1,128 @@ +import { Effect, Option } from "effect"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { renderWorkerDetails } from "../workers.format.ts"; +import { legacyEmitWorkersGoOutput } 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 { + WorkerDeleteNotConfirmedError, + WorkerNotDeployedError, +} from "../../../../shared/workers/workers.errors.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 { + legacyDescribeWorker, + 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 ` 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` skips it for scripts, as does a non-interactive session, where there + * would be nothing to read. + */ +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; + + const project = yield* legacyLoadWorkersProject(); + const name = yield* legacyValidateWorkerName(flags.name); + const worker = legacyDescribeWorker(project, name); + const projectRef = yield* resolver.resolve(flags.projectRef); + + // Go writes the linked-project cache and flushes telemetry in + // `PersistentPostRun`, so both happen whether the command succeeds or fails. + yield* Effect.gen(function* () { + const fetching = yield* output.task(`Reading "${name}"...`); + const found = yield* getWorker(api, projectRef, name).pipe( + Effect.tapError(() => fetching.fail()), + ); + 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}\`.`, + }), + ); + } + + if (!flags.yes && output.format === "text" && output.interactive) { + const instances = found.value.spec.instances; + yield* output.raw( + `This permanently deletes "${name}" from project ${projectRef}.` + + (instances > 0 + ? ` ${instances} running instance${instances === 1 ? "" : "s"} will be terminated.` + : "") + + "\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 "${name}"...`); + yield* deleteWorker(api, projectRef, name).pipe(Effect.tapError(() => deleting.fail())); + yield* deleting.succeed(`Deleted "${name}".`); + + const sourceDisplay = displayPath(project.projectRoot, worker.sourceDir); + + const payload = { + worker_name: name, + project_ref: projectRef, + kept_source: sourceDisplay, + }; + + // `-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* legacyEmitWorkersGoOutput(payload)) { + return; + } + + if (output.format !== "text") { + yield* output.success("", payload); + return; + } + + { + // "Deleted" reads more final than it is: the source and its config entry + // are still here, and redeploying is one command away. + yield* output.raw( + renderWorkerDetails([ + ["kept", `${sourceDisplay} · its supabase/config.toml entry`], + ["next", `supabase workers push ${name}`], + ]), + ); + } + }).pipe( + Effect.ensuring(linkedProjectCache.cache(projectRef)), + Effect.ensuring(telemetryState.flush), + ); +}); diff --git a/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts b/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts new file mode 100644 index 0000000000..4c7aaf8e09 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/delete/delete.integration.test.ts @@ -0,0 +1,181 @@ +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Option } from "effect"; +import { + makeWorkersProject, + setupLegacyWorkers, + workerResource, + workersRoute, + WORKERS_PROJECT_REF, +} from "../../../../../tests/helpers/legacy-workers.ts"; +import { + WorkerDeleteNotConfirmedError, + WorkerNotDeployedError, +} from "../../../../shared/workers/workers.errors.ts"; +import { legacyWorkersDelete } from "./delete.handler.ts"; + +const CONFIG = `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsize = "2gb"\n`; + +function project() { + const created = makeWorkersProject({ + "supabase/config.toml": CONFIG, + "supabase/workers/api/index.js": "export default {};\n", + }); + return { + dir: created.dir, + cleanup: () => rmSync(created.dir, { recursive: true, force: true }), + }; +} + +const getRoute = `GET ${workersRoute("/api")}`; +const deleteRoute = `DELETE ${workersRoute("/api")}`; + +const routes = { + [getRoute]: { + status: 200, + body: { data: workerResource({ name: "api", runtime: "node", instances: 3 }) }, + }, + [deleteRoute]: { status: 204 }, +}; + +describe("legacy workers delete", () => { + it.live("deletes after the name is typed back, and keeps the local files", () => { + const repo = project(); + const { layer, out, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes, + promptTextResponses: ["api"], + }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ + name: "api", + yes: false, + projectRef: Option.none(), + }); + + expect(http.routeKeys).toEqual([getRoute, deleteRoute]); + expect(out.stdoutText).toContain("permanently deletes"); + expect(out.stdoutText).toContain("3 running instances"); + expect(out.stdoutText).toContain("kept"); + + // Nothing local is touched — that is what makes `push` a one-command undo. + expect(existsSync(join(repo.dir, "supabase", "workers", "api", "index.js"))).toBe(true); + expect(readFileSync(join(repo.dir, "supabase", "config.toml"), "utf8")).toBe(CONFIG); + expect(out.stdoutText).toContain("supabase workers push api"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("deletes nothing when the confirmation does not match", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + routes, + promptTextResponses: ["nope"], + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersDelete({ + name: "api", + yes: false, + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerDeleteNotConfirmedError); + expect(http.routeKeys).toEqual([getRoute]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("skips the confirmation with --yes", () => { + const repo = project(); + const { layer, out, http } = setupLegacyWorkers({ workdir: repo.dir, routes }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "api", yes: true, projectRef: Option.none() }); + + expect(http.routeKeys).toEqual([getRoute, deleteRoute]); + expect(out.stdoutText).not.toContain("permanently deletes"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("skips the confirmation when there is no interactive session to read from", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, format: "json", routes }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "api", yes: false, projectRef: Option.none() }); + + expect(http.routeKeys).toEqual([getRoute, deleteRoute]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("fails with `not deployed` before asking anything", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { [getRoute]: { status: 404, body: { message: "worker not found" } } }, + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersDelete({ + name: "api", + yes: false, + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerNotDeployedError); + expect(out.messages.filter((message) => message.type === "warn")).toHaveLength(0); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("treats a delete that races another one as done", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { ...routes, [deleteRoute]: { status: 404, body: { message: "already gone" } } }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "api", yes: true, projectRef: Option.none() }); + + expect(out.stdoutText).toContain("kept"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("surfaces an unexpected delete status", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { ...routes, [deleteRoute]: { status: 500, body: { message: "boom" } } }, + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersDelete({ + name: "api", + yes: true, + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error._tag).toBe("WorkersApiUnexpectedStatusError"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("emits a structured result in json mode", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ workdir: repo.dir, format: "json", routes }); + + return Effect.gen(function* () { + yield* legacyWorkersDelete({ name: "api", yes: true, projectRef: Option.none() }); + + const success = out.messages.findLast( + (message) => message.type === "success" && message.data !== undefined, + ); + expect(success?.data).toEqual({ + worker_name: "api", + project_ref: WORKERS_PROJECT_REF, + kept_source: join("supabase", "workers", "api"), + }); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); +}); diff --git a/apps/cli/src/legacy/commands/workers/list/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/workers/list/SIDE_EFFECTS.md new file mode 100644 index 0000000000..655d85f811 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/list/SIDE_EFFECTS.md @@ -0,0 +1,47 @@ +# `supabase workers list` + +> **TS-only command.** `supabase workers` has no Go counterpart — there is no +> `apps/cli-go/internal/workers` to match, and nothing is proxied. See +> `docs/go-cli-divergences.md`. + +## Files Read + +| Path | Format | When | +| -------------------------------- | ------ | ------------------------------------- | +| `/supabase/config.toml` | TOML | always, for the `[workers.*]` entries | + +## Files Written + +| Path | Format | When | +| ---- | ------ | ---- | +| — | — | — | + +## API Routes + +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---------------------------- | ------------ | ------------ | ---------------------------------------------------------- | +| `GET` | `/v2/projects/{ref}/workers` | Bearer token | none | `data[].id`, `data[].attributes.spec/build_state/deleting` | + +## Exit Codes + +| Code | Condition | +| ---- | ----------------------------------------------- | +| `0` | success, including when the project has none | +| `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) | + +## 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. `workers` has no Go counterpart, so there is no +`phtelemetry.*` call to reproduce. diff --git a/apps/cli/src/legacy/commands/workers/list/list.command.ts b/apps/cli/src/legacy/commands/workers/list/list.command.ts new file mode 100644 index 0000000000..3cde9c8e1c --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/list/list.command.ts @@ -0,0 +1,35 @@ +import { 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 { legacyWorkersList } from "./list.handler.ts"; + +const config = { + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, + ), +} as const; + +export type LegacyWorkersListFlags = CliCommand.Command.Config.Infer; + +export const legacyWorkersListCommand = Command.make("list", config).pipe( + Command.withDescription( + "List this project's workers, deployed or not — the union of supabase/config.toml's entries and what the Workers API reports.", + ), + Command.withShortDescription("List this project's workers"), + Command.withExamples([ + { + command: "supabase workers list", + description: "See every worker in the linked project", + }, + ]), + Command.withHandler((flags) => + legacyWorkersList(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["workers", "list"])), +); diff --git a/apps/cli/src/legacy/commands/workers/list/list.handler.ts b/apps/cli/src/legacy/commands/workers/list/list.handler.ts new file mode 100644 index 0000000000..16338323cc --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/list/list.handler.ts @@ -0,0 +1,161 @@ +import { Effect } from "effect"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { renderWorkersTable } from "../workers.format.ts"; +import { legacyEmitWorkersGoOutput } from "../workers.output.ts"; +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { formatApiSize } from "../../../../shared/workers/worker-runtimes.ts"; +import { workerUrl } from "../../../../shared/workers/worker-url.ts"; +import { listWorkers, type WorkerRecord } from "../../../../shared/workers/workers-api.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 { legacyLoadWorkersProject } from "../workers.shared.ts"; +import type { LegacyWorkersListFlags } from "./list.command.ts"; + +/** + * `supabase workers list` — every worker in this project, deployed or not. + * + * A union of two sources, because either half alone is misleading: the + * project's `[workers.*]` entries (scaffolded, maybe never deployed) and what + * the API reports as deployed (including anything deployed from elsewhere, or + * from a directory since deleted). A worker in the config with nothing deployed + * shows as `not deployed`; a deployed worker with no local entry is called out, + * since pushing it from here would have to guess its runtime. + * + * The list endpoint deliberately makes no per-worker backend call, so it + * carries no live instance tally — the `INSTANCES` column is the declared + * count from the spec. `status` is where the live tally lives. + */ + +const HEADERS = ["NAME", "RUNTIME", "SIZE", "STATE", "INSTANCES", "URL"] as const; + +interface WorkerRow { + readonly name: string; + readonly configured: boolean; + readonly deployed: WorkerRecord | undefined; + readonly localRuntime: string | undefined; + readonly url: string | undefined; +} + +function stateLabel(row: WorkerRow): string { + if (row.deployed === undefined) { + return "not deployed"; + } + if (row.deployed.deleting === true) { + return "deleting"; + } + return row.deployed.buildState; +} + +/** + * The API omits `spec.runtime` only for a context-only build, so for a deployed + * worker its absence *is* "dockerfile". For one that has never been deployed + * there is nothing to infer from — `push` would guess from marker files — so say + * unknown rather than assert a runtime it may not have. + */ +function runtimeLabel(row: WorkerRow): string { + if (row.deployed !== undefined) { + return row.deployed.spec.runtime ?? "dockerfile"; + } + return row.localRuntime ?? "-"; +} + +function toCells(row: WorkerRow): ReadonlyArray { + return [ + row.name, + runtimeLabel(row), + row.deployed === undefined ? "-" : formatApiSize(row.deployed.spec.size), + stateLabel(row), + row.deployed === undefined ? "-" : String(row.deployed.spec.instances), + row.url ?? "-", + ]; +} + +export const legacyWorkersList = Effect.fn("legacy.workers.list")(function* ( + flags: LegacyWorkersListFlags, +) { + const output = yield* Output; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + const cliConfig = yield* LegacyCliConfig; + + const project = yield* legacyLoadWorkersProject(); + const projectRef = yield* resolver.resolve(flags.projectRef); + + // Go writes the linked-project cache and flushes telemetry in + // `PersistentPostRun`, so both happen whether the command succeeds or fails. + yield* Effect.gen(function* () { + const fetching = yield* output.task("Listing workers..."); + const deployed = yield* listWorkers(api, projectRef).pipe( + Effect.tapError(() => fetching.fail()), + ); + yield* fetching.clear(); + + const byName = new Map(deployed.map((worker) => [worker.name, worker])); + const configuredNames = Object.keys(project.section.workers); + const names = [...new Set([...configuredNames, ...byName.keys()])].sort(); + + const rows: Array = names.map((name) => { + const record = byName.get(name); + return { + name, + configured: configuredNames.includes(name), + deployed: record, + localRuntime: project.section.workers[name]?.runtime, + url: + record !== undefined && record.spec.exposure === "public" + ? workerUrl(projectRef, cliConfig.projectHost, name) + : undefined, + }; + }); + + const payload = { + project_ref: projectRef, + workers: rows.map((row) => ({ + name: row.name, + configured: row.configured, + deployed: row.deployed !== undefined, + runtime: row.deployed?.spec.runtime ?? row.localRuntime, + size: row.deployed?.spec.size, + state: stateLabel(row), + instances: row.deployed?.spec.instances, + url: row.url, + })), + }; + + // `-o` is independent of `--output-format`: it leaves `output.format` as + // `text`, so this has to be checked before the text branch below, not + // inside the structured one. + if (yield* legacyEmitWorkersGoOutput(payload)) { + return; + } + + if (output.format !== "text") { + yield* output.success("", payload); + return; + } + + if (rows.length === 0) { + yield* output.raw("No workers yet. Scaffold one with `supabase workers new `.\n"); + return; + } + + yield* output.raw(renderWorkersTable([...HEADERS], rows.map(toCells))); + + const orphans = rows.filter((row) => !row.configured).map((row) => row.name); + if (orphans.length > 0) { + yield* output.raw( + `${orphans.join(", ")} ${ + orphans.length === 1 ? "is" : "are" + } deployed but absent from supabase/config.toml — pushing from here would have to guess the runtime.\n`, + "stderr", + ); + } + }).pipe( + Effect.ensuring(linkedProjectCache.cache(projectRef)), + Effect.ensuring(telemetryState.flush), + ); +}); diff --git a/apps/cli/src/legacy/commands/workers/list/list.integration.test.ts b/apps/cli/src/legacy/commands/workers/list/list.integration.test.ts new file mode 100644 index 0000000000..d673f843ec --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/list/list.integration.test.ts @@ -0,0 +1,277 @@ +import { rmSync } from "node:fs"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Option } from "effect"; +import { + makeWorkersProject, + setupLegacyWorkers, + workerResource, + workersRoute, + WORKERS_PROJECT_REF, +} from "../../../../../tests/helpers/legacy-workers.ts"; +import { LegacyProjectNotLinkedError } from "../../../config/legacy-project-ref.errors.ts"; +import { LegacyWorkersEnvNotSupportedError } from "../workers.errors.ts"; +import { WorkersUnavailableError } from "../../../../shared/workers/workers.errors.ts"; +import { legacyWorkersList } from "./list.handler.ts"; + +const CONFIG = `project_id = "demo" + +[workers.api] +runtime = "node" +size = "2gb" + +[workers.old] +runtime = "deno" +`; + +function project(config = CONFIG) { + const created = makeWorkersProject({ "supabase/config.toml": config }); + return { + dir: created.dir, + cleanup: () => rmSync(created.dir, { recursive: true, force: true }), + }; +} + +const listRoute = `GET ${workersRoute()}`; + +describe("legacy workers list", () => { + it.live("shows configured and deployed workers as one inventory", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [listRoute]: { + status: 200, + body: { + data: [ + workerResource({ name: "api", runtime: "node", imageVersion: "v3" }), + workerResource({ + name: "box", + runtime: "sandbox", + exposure: "private", + instances: 2, + }), + ], + }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + const stdout = out.stdoutText; + expect(stdout).toContain("NAME"); + + const rows = stdout.split("\n").filter((line) => /\|/.test(line) && /api|box|old/.test(line)); + expect(rows).toHaveLength(3); + // Sorted by name, so `api`, `box`, then the scaffolded-but-undeployed `old`. + expect(rows[0]).toContain("2gb · 1 vCPU"); + expect(rows[0]).toContain(`https://${WORKERS_PROJECT_REF}.supabase.co/workers/v1/api`); + expect(rows[1]).toContain("sandbox"); + expect(rows[2]).toContain("not deployed"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("does not assert a runtime for a worker that has never been deployed", () => { + const repo = project(`project_id = "demo"\n\n[workers.ghost]\n`); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { [listRoute]: { status: 200, body: { data: [] } } }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + const row = out.stdoutText.split("\n").find((line) => line.includes("ghost")); + expect(row).toBeDefined(); + expect(row).not.toContain("dockerfile"); + expect(row).toContain("not deployed"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("calls out a deployed worker that config.toml does not know about", () => { + const repo = project(`project_id = "demo"\n`); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [listRoute]: { + status: 200, + body: { data: [workerResource({ name: "stray", runtime: "node" })] }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + expect(out.stderrText).toContain("stray"); + expect(out.stderrText).toContain("guess the runtime"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("says so when the project has no workers at all", () => { + const repo = project(`project_id = "demo"\n`); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { [listRoute]: { status: 200, body: { data: [] } } }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + expect(out.stdoutText).toContain( + "No workers yet. Scaffold one with `supabase workers new `.", + ); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("emits the inventory as structured data in json mode", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + format: "json", + routes: { + [listRoute]: { + status: 200, + body: { data: [workerResource({ name: "api", runtime: "node" })] }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + const success = out.messages.findLast( + (message) => message.type === "success" && message.data !== undefined, + ); + expect(success?.data).toMatchObject({ project_ref: WORKERS_PROJECT_REF }); + expect(success?.data?.["workers"]).toEqual([ + { + name: "api", + configured: true, + deployed: true, + runtime: "node", + size: "2gb-1vcpu", + state: "active", + instances: 1, + url: `https://${WORKERS_PROJECT_REF}.supabase.co/workers/v1/api`, + }, + { + name: "old", + configured: true, + deployed: false, + runtime: "deno", + size: undefined, + state: "not deployed", + instances: undefined, + url: undefined, + }, + ]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("serialises the inventory for the Go -o flag", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + goOutput: "json", + routes: { + [listRoute]: { + status: 200, + body: { data: [workerResource({ name: "api", runtime: "node" })] }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.none() }); + + // `-o` payloads own stdout outright: no clack success line may share it. + const parsed = JSON.parse(out.stdoutText); + expect(parsed.project_ref).toBe(WORKERS_PROJECT_REF); + expect(parsed.workers).toHaveLength(2); + expect(out.messages.filter((m) => m.type === "success")).toHaveLength(0); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("refuses -o env, which cannot represent the worker list", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + goOutput: "env", + routes: { [listRoute]: { status: 200, body: { data: [] } } }, + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersList({ projectRef: Option.none() }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(LegacyWorkersEnvNotSupportedError); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("reports a project outside the alpha as unavailable", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [listRoute]: { + status: 404, + body: { + error: { + code: "generic_not_found", + message: "Workers are not available for this project", + }, + }, + }, + }, + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersList({ projectRef: Option.none() }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkersUnavailableError); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("surfaces an unexpected status rather than showing an empty list", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { [listRoute]: { status: 500, body: { message: "boom" } } }, + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersList({ projectRef: Option.none() }).pipe(Effect.flip); + + expect(error._tag).toBe("WorkersApiUnexpectedStatusError"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("uses an explicit --project-ref without a linked project", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ + workdir: repo.dir, + linked: false, + routes: { + "GET /v2/projects/qrstuvwxyzabcdefghij/workers": { status: 200, body: { data: [] } }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersList({ projectRef: Option.some("qrstuvwxyzabcdefghij") }); + + expect(http.routeKeys).toEqual(["GET /v2/projects/qrstuvwxyzabcdefghij/workers"]); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("requires a linked project when no ref is given", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ workdir: repo.dir, linked: false }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersList({ projectRef: Option.none() }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(LegacyProjectNotLinkedError); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); +}); diff --git a/apps/cli/src/legacy/commands/workers/status/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/workers/status/SIDE_EFFECTS.md new file mode 100644 index 0000000000..ce6ee2cb23 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/status/SIDE_EFFECTS.md @@ -0,0 +1,49 @@ +# `supabase workers status ` + +> **TS-only command.** `supabase workers` has no Go counterpart — there is no +> `apps/cli-go/internal/workers` to match, and nothing is proxied. See +> `docs/go-cli-divergences.md`. + +## Files Read + +| Path | Format | When | +| -------------------------------- | ------ | ----------------------------------------------- | +| `/supabase/config.toml` | TOML | always, to report the worker's source directory | + +## Files Written + +| Path | Format | When | +| ---- | ------ | ---- | +| — | — | — | + +## API Routes + +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ----------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------- | +| `GET` | `/v2/projects/{ref}/workers/{name}` | Bearer token | none | `spec`, `build_state`, `state_reason`, `image_version`, `instances`, `instances_error`, `deleting` | + +## Exit Codes + +| Code | Condition | +| ---- | ----------------------------------------------- | +| `0` | success | +| `1` | invalid worker name | +| `1` | nothing deployed under that name | +| `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) | + +## 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. `workers` has no Go counterpart, so there is no +`phtelemetry.*` call to reproduce. diff --git a/apps/cli/src/legacy/commands/workers/status/status.command.ts b/apps/cli/src/legacy/commands/workers/status/status.command.ts new file mode 100644 index 0000000000..15f4e5c23c --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/status/status.command.ts @@ -0,0 +1,36 @@ +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 { legacyWorkersStatus } from "./status.handler.ts"; + +const config = { + name: Argument.string("name").pipe(Argument.withDescription("Worker to inspect.")), + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project."), + Flag.optional, + ), +} as const; + +export type LegacyWorkersStatusFlags = CliCommand.Command.Config.Infer; + +export const legacyWorkersStatusCommand = Command.make("status", config).pipe( + Command.withDescription( + "Show one worker in detail: build state, size, access, image, live instance tally and source directory.", + ), + Command.withShortDescription("Show a worker in detail"), + Command.withExamples([ + { + command: "supabase workers status api", + description: "Inspect a specific worker", + }, + ]), + Command.withHandler((flags) => + legacyWorkersStatus(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["workers", "status"])), +); diff --git a/apps/cli/src/legacy/commands/workers/status/status.handler.ts b/apps/cli/src/legacy/commands/workers/status/status.handler.ts new file mode 100644 index 0000000000..4eb5ee16fe --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/status/status.handler.ts @@ -0,0 +1,133 @@ +import { Effect, Option } from "effect"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { renderWorkerDetails } from "../workers.format.ts"; +import { legacyEmitWorkersGoOutput } from "../workers.output.ts"; +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { displayPath } from "../../../../shared/workers/worker-paths.ts"; +import { formatApiSize } from "../../../../shared/workers/worker-runtimes.ts"; +import { workerUrl } from "../../../../shared/workers/worker-url.ts"; +import { getWorker } from "../../../../shared/workers/workers-api.ts"; +import { WorkerNotDeployedError } from "../../../../shared/workers/workers.errors.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 { + legacyDescribeWorker, + legacyLoadWorkersProject, + legacyValidateWorkerName, +} from "../workers.shared.ts"; +import type { LegacyWorkersStatusFlags } from "./status.command.ts"; + +/** + * `supabase workers status [name]` — everything known about one worker. + * + * `list`'s companion: the size, image and URL a `push` printed once and then + * scrolled away, plus the live instance tally, which is the only place it is + * available — the list endpoint stays free of per-worker backend calls. + */ +export const legacyWorkersStatus = Effect.fn("legacy.workers.status")(function* ( + flags: LegacyWorkersStatusFlags, +) { + const output = yield* Output; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + const cliConfig = yield* LegacyCliConfig; + + const project = yield* legacyLoadWorkersProject(); + const name = yield* legacyValidateWorkerName(flags.name); + const worker = legacyDescribeWorker(project, name); + const projectRef = yield* resolver.resolve(flags.projectRef); + + // Go writes the linked-project cache and flushes telemetry in + // `PersistentPostRun`, so both happen whether the command succeeds or fails. + yield* Effect.gen(function* () { + const fetching = yield* output.task(`Reading "${name}"...`); + const found = yield* getWorker(api, projectRef, name).pipe( + Effect.tapError(() => fetching.fail()), + ); + 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 record = found.value; + const url = + record.spec.exposure === "public" + ? workerUrl(projectRef, cliConfig.projectHost, name) + : undefined; + const sourceDisplay = displayPath(project.projectRoot, worker.sourceDir); + + const payload = { + worker_name: name, + project_ref: projectRef, + runtime: record.spec.runtime ?? "dockerfile", + size: record.spec.size, + exposure: record.spec.exposure, + build_state: record.buildState, + state_reason: record.stateReason, + image_version: record.imageVersion, + deleting: record.deleting, + declared_instances: record.spec.instances, + instances: record.instances, + instances_error: record.instancesError, + source: sourceDisplay, + ...(url === undefined ? {} : { url }), + }; + + // `-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* legacyEmitWorkersGoOutput(payload)) { + return; + } + + yield* output.success("Read worker.", payload); + + if (output.format !== "text") { + yield* output.success("", payload); + return; + } + + const details: Array = [ + ["state", record.deleting === true ? "deleting" : record.buildState], + ...(record.stateReason === undefined + ? [] + : ([["reason", record.stateReason]] as Array)), + ["runtime", record.spec.runtime ?? "dockerfile"], + ["size", formatApiSize(record.spec.size)], + ["access", record.spec.exposure], + ["project", projectRef], + ...(record.imageVersion === undefined + ? [] + : ([["image", record.imageVersion]] as Array)), + [ + "instances", + record.instances !== undefined + ? `${record.instances.ready}/${record.spec.instances} ready · ${record.instances.live} live · ${record.instances.stale} stale` + : `${record.spec.instances} declared`, + ], + ...(url === undefined ? [] : ([["url", url]] as Array)), + ["source", sourceDisplay], + ]; + + yield* output.raw(renderWorkerDetails(details)); + + if (record.instances === undefined && record.instancesError !== undefined) { + yield* output.raw(`Instance counts could not be read: ${record.instancesError}\n`, "stderr"); + } + if (record.buildState === "failed") { + yield* output.raw(` → try again: supabase workers push ${name}\n`); + } + }).pipe( + Effect.ensuring(linkedProjectCache.cache(projectRef)), + Effect.ensuring(telemetryState.flush), + ); +}); diff --git a/apps/cli/src/legacy/commands/workers/status/status.integration.test.ts b/apps/cli/src/legacy/commands/workers/status/status.integration.test.ts new file mode 100644 index 0000000000..efdba77155 --- /dev/null +++ b/apps/cli/src/legacy/commands/workers/status/status.integration.test.ts @@ -0,0 +1,284 @@ +import { rmSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Option } from "effect"; +import { + makeWorkersProject, + setupLegacyWorkers, + workerResource, + workersRoute, + WORKERS_PROJECT_REF, +} from "../../../../../tests/helpers/legacy-workers.ts"; +import { + InvalidWorkerNameError, + WorkerNotDeployedError, +} from "../../../../shared/workers/workers.errors.ts"; +import { legacyWorkersStatus } from "./status.handler.ts"; + +const CONFIG = `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsize = "2gb"\n`; + +function project(files: Readonly> = {}) { + const created = makeWorkersProject({ + "supabase/config.toml": CONFIG, + "supabase/workers/api/index.js": "export default {};\n", + ...files, + }); + return { + dir: created.dir, + cleanup: () => rmSync(created.dir, { recursive: true, force: true }), + }; +} + +const getRoute = `GET ${workersRoute("/api")}`; + +describe("legacy workers status", () => { + it.live("reports the deployment facts and the live instance tally", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 200, + body: { + data: workerResource({ + name: "api", + runtime: "node", + imageVersion: "v3", + instances: 3, + instanceCounts: { declared: 3, live: 3, ready: 2, stale: 1 }, + }), + }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + const stdout = out.stdoutText; + expect(stdout).toContain("state"); + expect(stdout).toContain("active"); + expect(stdout).toContain("node"); + expect(stdout).toContain("2gb · 1 vCPU"); + expect(stdout).toContain("public"); + expect(stdout).toContain(WORKERS_PROJECT_REF); + expect(stdout).toContain("v3"); + expect(stdout).toContain("2/3 ready · 3 live · 1 stale"); + expect(stdout).toContain(`https://${WORKERS_PROJECT_REF}.supabase.co/workers/v1/api`); + expect(stdout).toContain(join("supabase", "workers", "api")); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("reports the deployed runtime, not a stale config.toml entry", () => { + // config.toml says node; the deployment carries no spec.runtime, which the + // API only omits for a context-only (Dockerfile) build. + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 200, + body: { data: workerResource({ name: "api" }) }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + const runtimeLine = out.stdoutText + .split("\n") + .find((line) => line.trim().startsWith("runtime")); + expect(runtimeLine).toContain("dockerfile"); + expect(runtimeLine).not.toContain("node"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("falls back to the declared count when no tally came back", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 200, + body: { data: workerResource({ name: "api", runtime: "node", instances: 2 }) }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("2 declared"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("warns rather than lying when the instance read-through failed", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 200, + body: { + data: workerResource({ + name: "api", + runtime: "node", + instancesError: "backend unreachable", + }), + }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + expect(out.stderrText).toContain("backend unreachable"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("points a failed build at the retry, with the reason", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 200, + body: { + data: workerResource({ + name: "api", + runtime: "node", + buildState: "failed", + stateReason: "exit status 1", + }), + }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("failed"); + expect(out.stdoutText).toContain("exit status 1"); + expect(out.stdoutText).toContain("supabase workers push api"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("shows a worker being torn down as deleting", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 200, + body: { data: workerResource({ name: "api", runtime: "node", deleting: true }) }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + expect(out.stdoutText).toContain("deleting"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("fails with `not deployed` and points at push", () => { + const repo = project(); + const { layer } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { [getRoute]: { status: 404, body: { message: "worker not found" } } }, + }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersStatus({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkerNotDeployedError); + expect((error as WorkerNotDeployedError).suggestion).toContain("supabase workers push api"); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("refuses a name that could never have been written", () => { + const repo = project(); + const { layer, http } = setupLegacyWorkers({ workdir: repo.dir }); + + return Effect.gen(function* () { + const error = yield* legacyWorkersStatus({ + name: "My_Worker", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(InvalidWorkerNameError); + expect(http.requests).toHaveLength(0); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("reports the worker's source directory even when it lives outside supabase/", () => { + const repo = project({ + "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsource = "packages/api"\n`, + "packages/api/index.js": "export default {};\n", + }); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 200, + body: { data: workerResource({ name: "api", runtime: "node" }) }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + expect(out.stdoutText).toContain(join("packages", "api")); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); + + it.live("emits the same facts as structured data in json mode", () => { + const repo = project(); + const { layer, out } = setupLegacyWorkers({ + workdir: repo.dir, + format: "json", + routes: { + [getRoute]: { + status: 200, + body: { + data: workerResource({ + name: "api", + runtime: "node", + imageVersion: "v3", + instanceCounts: { declared: 1, live: 1, ready: 1, stale: 0 }, + }), + }, + }, + }, + }); + + return Effect.gen(function* () { + yield* legacyWorkersStatus({ name: "api", projectRef: Option.none() }); + + const success = out.messages.findLast( + (message) => message.type === "success" && message.data !== undefined, + ); + expect(success?.data).toMatchObject({ + worker_name: "api", + project_ref: WORKERS_PROJECT_REF, + runtime: "node", + size: "2gb-1vcpu", + exposure: "public", + build_state: "active", + image_version: "v3", + declared_instances: 1, + instances: { declared: 1, live: 1, ready: 1, stale: 0 }, + }); + // The detail lines are text-mode only. + expect(out.stdoutText).toBe(""); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); + }); +}); diff --git a/apps/cli/src/legacy/commands/workers/workers.command.ts b/apps/cli/src/legacy/commands/workers/workers.command.ts index ac8defa4d2..898a50bded 100644 --- a/apps/cli/src/legacy/commands/workers/workers.command.ts +++ b/apps/cli/src/legacy/commands/workers/workers.command.ts @@ -1,11 +1,20 @@ import { Command } from "effect/unstable/cli"; +import { legacyWorkersDeleteCommand } from "./delete/delete.command.ts"; +import { legacyWorkersListCommand } from "./list/list.command.ts"; import { legacyWorkersNewCommand } from "./new/new.command.ts"; import { legacyWorkersPushCommand } from "./push/push.command.ts"; +import { legacyWorkersStatusCommand } from "./status/status.command.ts"; export const legacyWorkersCommand = Command.make("workers").pipe( Command.withDescription( "Manage Supabase Workers — containers that run your code next to your project, deployed from supabase/workers//.", ), Command.withShortDescription("Manage Supabase Workers"), - Command.withSubcommands([legacyWorkersNewCommand, legacyWorkersPushCommand]), + Command.withSubcommands([ + legacyWorkersNewCommand, + legacyWorkersPushCommand, + legacyWorkersListCommand, + legacyWorkersStatusCommand, + legacyWorkersDeleteCommand, + ]), ); diff --git a/apps/cli/src/shared/workers/workers-api.ts b/apps/cli/src/shared/workers/workers-api.ts index eb40a60542..a0d57478f3 100644 --- a/apps/cli/src/shared/workers/workers-api.ts +++ b/apps/cli/src/shared/workers/workers-api.ts @@ -5,6 +5,7 @@ import { V2CreateWorkerUploadOutput, V2DeployAWorkerOutput, V2GetAWorkerOutput, + V2ListAllWorkersOutput, type ApiClient, } from "@supabase/api/effect"; import { Effect, Option, Schedule, Schema } from "effect"; @@ -26,11 +27,11 @@ import { * * The routes are deliberately few — list, get, mint an upload slot, deploy, * delete — so this module is thin, and what it mostly adds is status handling. - * The alpha's allow-list answers 404 for a project that is not enrolled, which - * at the transport level is indistinguishable from "no such worker"; so a 404 - * on a collection endpoint (where no worker name could have been wrong) becomes - * {@link WorkersUnavailableError}, and a 404 on a named worker is reported by - * the caller as "not deployed". + * A 404 is overloaded on these routes: it is the answer for a project outside + * the alpha's allow-list, for a project ref that names nothing, and for a + * worker that is not deployed. A 404 on a named worker is reported by the + * caller as "not deployed"; one on a collection endpoint, where no worker name + * could have been wrong, is split by its body — see {@link projectScoped404}. */ /** The worker shape the API returns, flattened out of its JSON:API envelope. */ @@ -194,12 +195,43 @@ const decodeBody = ( ), ); +export const listWorkers = Effect.fnUntraced(function* (api: ApiClient, projectRef: string) { + const operation = "list workers"; + const response = yield* api + .executeRaw(operationDefinitions.v2ListAllWorkers, { ref: projectRef }) + .pipe(Effect.mapError(mapRequestError(operation))); + + if (response.status === 404) { + return yield* Effect.fail( + yield* projectScoped404({ + projectRef, + body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), + }), + ); + } + if (response.status !== 200) { + return yield* unexpectedStatus({ + operation, + status: response.status, + body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), + }); + } + + const body = yield* response.json.pipe(Effect.mapError(mapRequestError(operation))); + const decoded = yield* decodeBody(V2ListAllWorkersOutput, operation, body, response.status); + return decoded.data.map(toWorkerRecord); +}); + /** * One worker, or `None` when the API has no record of it — which is also what a * project outside the alpha's allow-list answers, so callers report it as "not * deployed" and point at `push` rather than guessing which of the two it was. */ -const getWorker = Effect.fnUntraced(function* (api: ApiClient, projectRef: string, name: string) { +export const getWorker = Effect.fnUntraced(function* ( + api: ApiClient, + projectRef: string, + name: string, +) { const operation = `read worker "${name}"`; const response = yield* api .executeRaw(operationDefinitions.v2GetAWorker, { ref: projectRef, name }) @@ -346,6 +378,29 @@ export const deployWorker = Effect.fnUntraced(function* ( return toWorkerRecord(decoded.data); }); +export const deleteWorker = Effect.fnUntraced(function* ( + api: ApiClient, + projectRef: string, + name: string, +) { + const operation = `delete worker "${name}"`; + const response = yield* api + .executeRaw(operationDefinitions.v2DeleteAWorker, { ref: projectRef, name }) + .pipe(Effect.mapError(mapRequestError(operation))); + + // 404 is the caller's own "not deployed" verdict to report; a delete that + // races another one is still a delete that happened. + if (response.status === 204 || response.status === 200 || response.status === 404) { + return; + } + + return yield* unexpectedStatus({ + operation, + status: response.status, + body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), + }); +}); + /** * The build runs asynchronously — deploy answers 202 and the worker reaches * `active` or `failed` later — so `push` polls `get` until `build_state` leaves diff --git a/apps/cli/src/shared/workers/workers.errors.ts b/apps/cli/src/shared/workers/workers.errors.ts index c0cbe967c9..b43ced0f7e 100644 --- a/apps/cli/src/shared/workers/workers.errors.ts +++ b/apps/cli/src/shared/workers/workers.errors.ts @@ -132,6 +132,19 @@ export class WorkersApiNetworkError extends Data.TaggedError("WorkersApiNetworkE } } +/** + * The named worker is not deployed. `status`/`delete` share this verbatim: the + * question "does this exist?" is asked of the API, never of a local directory. + */ +export class WorkerNotDeployedError extends Data.TaggedError("WorkerNotDeployedError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + /** * Workers are in private alpha: the routes answer 404 for a project that is not * enrolled, which is indistinguishable from an unknown worker at the transport @@ -175,3 +188,15 @@ export class WorkersApiUnexpectedStatusError extends Data.TaggedError( return actionability.apiStatus; } } + +/** The user answered the `delete` confirmation with something other than the name. */ +export class WorkerDeleteNotConfirmedError extends Data.TaggedError( + "WorkerDeleteNotConfirmedError", +)<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +}