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
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { Primitive, type Command } from "effect/unstable/cli";
import {
legacyCommandInternals,
legacyFlattenSubcommands,
legacyUserGlobalFlagParams,
} from "../docs/legacy-docs-introspection.ts";
import { legacyUnwrapParam } from "../shared/legacy-param-introspection.ts";
import { legacyRoot } from "./root.ts";

/**
* `Flag.boolean(name)` builds a bare `Single` param, and a bare `Single` is
* *required* — omitting it fails the whole command with a missing-flag error
* before the handler ever runs. Every boolean flag therefore has to be closed
* off with `Flag.withDefault(false)` or `Flag.optional`.
*
* Nothing else catches this: handler integration tests build their flags record
* directly, so they never touch the parser, and the required-ness is invisible
* to the type checker because a required boolean flag still infers as
* `boolean`. The flag only misbehaves when a real invocation omits it, which is
* precisely the invocation no handler test makes — so the guard walks the
* command tree instead of waiting for a command to be exercised end to end.
*/

/**
* The published getter for a primitive's kind — `Primitive.getTypeName`, whose
* own doc example pins `Primitive.boolean` to `"boolean"`. Reading
* `primitiveType._tag` instead would couple this guard to effect's runtime
* representation, which this repo forbids in tests as well as in source.
*
* Derived from `Primitive.boolean` rather than written as the literal
* `"boolean"`: were that name to change upstream, a hardcoded literal would
* match nothing and leave the guard silently passing every command, which is
* the one failure mode a regression test must not have.
*/
const BOOLEAN_TYPE_NAME = Primitive.getTypeName(Primitive.boolean);

function booleanFlagsRequiringAValue(command: Command.Command.Any): ReadonlyArray<string> {
const internals = legacyCommandInternals(command);
// All three parameter sets a command can be parsed with, not just its own:
// `Command.withSharedFlags` puts inherited flags on `contextConfig`, and the
// root's persistent flags arrive as `globalFlags`. A bare boolean introduced
// through either would break every command that inherits it while a guard
// reading only `config.flags` stayed green.
const params = [
...internals.config.flags,
...internals.contextConfig.flags,
...legacyUserGlobalFlagParams(command),
];

// Throws rather than skipping if effect's internal shape moves, so this
// cannot quietly degrade into a test that inspects nothing.
const own = params.flatMap((flag) => {
const unwrapped = legacyUnwrapParam(flag);
if (unwrapped === undefined) {
throw new Error(`Unrecognizable flag param on "${command.name}".`);
}
const { single, isOptional } = unwrapped;
return Primitive.getTypeName(single.primitiveType) === BOOLEAN_TYPE_NAME && !isOptional
? [`${command.name} --${single.name}`]
: [];
});

return [...own, ...legacyFlattenSubcommands(command).flatMap(booleanFlagsRequiringAValue)];
}

describe("legacy boolean flag wiring", () => {
it("gives every boolean flag a default, so omitting it is not a parse error", () => {
expect(booleanFlagsRequiringAValue(legacyRoot)).toEqual([]);
});
});
16 changes: 8 additions & 8 deletions apps/cli/src/legacy/commands/workers/delete/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,11 @@ wrapper emits for every command.

## Output Formats

| Mode | stdout | stderr |
| ----------------------------- | --------------------------------------------------------------------------------------------- | --------------------------------------------- |
| text (default) | the confirmation prompt, then what was deleted and kept | that nothing local was kept, when nothing was |
| `--output-format json` | one structured result carrying `worker_name`, `project_ref`, `kept_*` | as above |
| `--output-format stream-json` | the same result as a single terminal event | as above |
| `-o json` / `yaml` / `toml` | the same payload in that encoding, and nothing else | as above |
| `-o pretty` / `table` / `csv` | the text rendering — these fall through rather than encoding | as above |
| `-o env` | refused **before** the DELETE; discovering it at emit time deleted the worker and then failed | the error |
| Mode | stdout | stderr |
| ----------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| text (default) | the confirmation prompt, then what was deleted and kept | that nothing local was kept when nothing was, and the redeploy hint |
| `--output-format json` | one structured result carrying `worker_name`, `project_ref`, `kept_*` | as above |
| `--output-format stream-json` | the same result as a single terminal event | as above |
| `-o json` / `yaml` / `toml` | the same payload in that encoding, and nothing else | as above |
| `-o pretty` / `table` / `csv` | the text rendering — these fall through rather than encoding | as above |
| `-o env` | refused **before** the DELETE; discovering it at emit time deleted the worker and then failed | the error |
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Effect, Option } from "effect";
import { Output } from "../../../../shared/output/output.service.ts";
import { emitSuccessTrailer } from "../../../../shared/cli/success-trailer.ts";
import { legacyAqua } from "../../../shared/legacy-colors.ts";
import { legacyRenderWorkerDetails } from "../workers.format.ts";
import {
Expand Down Expand Up @@ -232,7 +233,10 @@ export const legacyWorkersDelete = Effect.fn("legacy.workers.delete")(function*
// 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}${refSuffix}.\n`);
// Trailer, like every other "what to run next" line in this shell.
yield* emitSuccessTrailer(
`Redeploy it with ${legacyAqua(`supabase workers push ${name}${refSuffix}`)}.\n`,
);
}
} else {
yield* output.raw(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ describe("legacy workers delete", () => {
// 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");
// The redeploy hint is a success trailer, which lands on stderr.
expect(out.stderrText).toContain("supabase workers push api");
}).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup)));
});

Expand Down Expand Up @@ -541,6 +542,63 @@ describe("legacy workers delete", () => {
}).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup)));
});

it.live("pluralizes the live instance count in the confirmation", () => {
const repo = project();
const { layer, out } = setupLegacyWorkers({
workdir: repo.dir,
promptTextResponses: ["api"],
routes: {
...routes,
[getRoute]: {
status: 200,
body: {
data: workerResource({
name: "api",
instances: 3,
instanceCounts: { declared: 3, live: 2, ready: 2, stale: 0 },
}),
},
},
},
});

return Effect.gen(function* () {
yield* legacyWorkersDelete({ name: "api", projectRef: Option.none() });

expect(out.stdoutText).toContain("2 running instances will be terminated");
}).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup)));
});

// Scaled to zero: there is a tally, and it says nothing is running. Warning
// about terminated instances there would invent a consequence.
it.live("promises no terminations when nothing is running", () => {
const repo = project();
const { layer, out } = setupLegacyWorkers({
workdir: repo.dir,
promptTextResponses: ["api"],
routes: {
...routes,
[getRoute]: {
status: 200,
body: {
data: workerResource({
name: "api",
instances: 2,
instanceCounts: { declared: 2, live: 0, ready: 0, stale: 0 },
}),
},
},
},
});

return Effect.gen(function* () {
yield* legacyWorkersDelete({ name: "api", projectRef: Option.none() });

expect(out.stdoutText).toContain("permanently deletes");
expect(out.stdoutText).not.toContain("will be terminated");
}).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup)));
});

// An orphan — deployed from another checkout — has no local entry and no local
// directory, so there is nothing that was "kept" and `push` has no source to
// redeploy from.
Expand Down
4 changes: 4 additions & 0 deletions apps/cli/src/legacy/commands/workers/list/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,7 @@ wrapper emits for every command.
| `-o json` / `yaml` / `toml` | the same payload in that encoding, and nothing else | as above |
| `-o pretty` / `table` / `csv` | the text rendering — these fall through rather than encoding | as above |
| `-o env` | refused before any request; the payload carries a `workers` array a flat `KEY=value` list cannot express | the error |

The text table omits each worker's URL — it is the same host and prefix on
every row, and carrying it made the table 137 columns wide. Every machine
format still carries `url` per worker, and `workers status` renders it.
42 changes: 33 additions & 9 deletions apps/cli/src/legacy/commands/workers/list/list.handler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Effect } from "effect";
import { Output } from "../../../../shared/output/output.service.ts";
import { legacyAqua, legacyYellow } from "../../../shared/legacy-colors.ts";
import { displayPath } from "../../../../shared/workers/worker-paths.ts";
import { renderGlamourTable } from "../../../output/legacy-glamour-table.ts";
import { legacyEmitWorkersMachineOutput, legacyRejectWorkersEnvOutput } from "../workers.output.ts";
import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts";
Expand Down Expand Up @@ -28,7 +30,15 @@ import type { LegacyWorkersListFlags } from "./list.command.ts";
* count from the spec. `status` is where the live tally lives.
*/

const HEADERS = ["NAME", "RUNTIME", "SIZE", "STATE", "INSTANCES", "URL"] as const;
/**
* No URL column. Every worker's URL is the same 40-odd characters of host and
* prefix with the name on the end, which pushed the table past 130 columns to
* carry one derivable field — `renderGlamourTable` sizes each column to its
* widest cell and never wraps. `workers status` renders it, vertically, for the
* same reason (see `workers.format.ts`), and every machine format still carries
* `url` per worker.
*/
const HEADERS = ["NAME", "RUNTIME", "SIZE", "STATE", "INSTANCES"] as const;

interface WorkerRow {
readonly name: string;
Expand Down Expand Up @@ -68,14 +78,21 @@ function runtimeLabel(row: WorkerRow): string {
return runtimeLabelFor(row) ?? "-";
}

/**
* `api is` / `api, box are` — the subject of both advisories below, which only
* ever differ in the verb.
*/
function nameList(names: ReadonlyArray<string>): string {
return `${names.join(", ")} ${names.length === 1 ? "is" : "are"}`;
}

function toCells(row: WorkerRow): ReadonlyArray<string> {
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 ?? "-",
];
}

Expand Down Expand Up @@ -164,7 +181,9 @@ export const legacyWorkersList = Effect.fn("legacy.workers.list")(function* (
}

if (rows.length === 0) {
yield* output.raw("No workers found. Scaffold one with supabase workers new <name>.\n");
yield* output.raw(
`No workers found. Scaffold one with ${legacyAqua("supabase workers new <name>", process.stdout)}.\n`,
);
return;
}

Expand All @@ -176,14 +195,20 @@ export const legacyWorkersList = Effect.fn("legacy.workers.list")(function* (
// the source directory *before* inferring a runtime and fails with
// `WorkerSourceMissingError`, so telling that user about runtime guessing
// points them at the wrong prerequisite.
//
// Both are written the way this shell writes every other heads-up that is
// not a failure: a yellow `WARNING:` prefix, then the consequence on its own
// line (`start`'s Docker-on-Windows notice is the same two-line shape). The
// single long sentence each of these used to be re-flowed differently at
// every terminal width, right under a table that lines its columns up.
const unconfigured = rows
.filter((row) => row.deployed !== undefined && !row.configured && row.local)
.map((row) => row.name);
if (unconfigured.length > 0) {
const configDisplay = displayPath(project.projectRoot, project.configPath);
yield* output.raw(
`${unconfigured.join(", ")} ${
unconfigured.length === 1 ? "is" : "are"
} deployed but absent from supabase/config.toml: pushing from here would have to guess the runtime.\n`,
`${legacyYellow("WARNING:")} ${nameList(unconfigured)} deployed but not in ${configDisplay}.\n` +
`Pushing from here would have to guess the runtime.\n`,
"stderr",
);
}
Expand All @@ -193,9 +218,8 @@ export const legacyWorkersList = Effect.fn("legacy.workers.list")(function* (
.map((row) => row.name);
if (remoteOnly.length > 0) {
yield* output.raw(
`${remoteOnly.join(", ")} ${
remoteOnly.length === 1 ? "is" : "are"
} deployed but ${remoteOnly.length === 1 ? "has" : "have"} no source in this project: scaffold or restore it before pushing from here.\n`,
`${legacyYellow("WARNING:")} ${nameList(remoteOnly)} deployed with no source in this project.\n` +
`Scaffold or restore before pushing from here.\n`,
"stderr",
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,9 @@ describe("legacy workers list", () => {
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`);
// The URL is deliberately not a column: one derivable field pushed the
// table past 130 columns. The machine payload still carries it.
expect(stdout).not.toContain("https://");
expect(rows[1]).toContain("sandbox");
expect(rows[2]).toContain("not deployed");
}).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup)));
Expand Down Expand Up @@ -122,6 +124,63 @@ describe("legacy workers list", () => {
}).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup)));
});

// Two of them, so the advisory has to read as a list rather than as one name
// with a stray verb.
it.live("calls out every deployed worker config.toml does not know about", () => {
const created = makeWorkersProject({
"supabase/config.toml": `project_id = "demo"\n`,
"supabase/workers/stray/index.js": "export default {};\n",
"supabase/workers/spare/index.js": "export default {};\n",
});
const repo = {
dir: created.dir,
cleanup: () => rmSync(created.dir, { recursive: true, force: true }),
};
const { layer, out } = setupLegacyWorkers({
workdir: repo.dir,
routes: {
[listRoute]: {
status: 200,
body: {
data: [
workerResource({ name: "stray", runtime: "node" }),
workerResource({ name: "spare", runtime: "node" }),
],
},
},
},
});

return Effect.gen(function* () {
yield* legacyWorkersList({ projectRef: Option.none() });

expect(out.stderrText).toContain("spare, stray are deployed but not in");
expect(out.stderrText).toContain("guess the runtime");
}).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup)));
});

// Deletion is asynchronous, so a worker can be listed while it is being torn
// down. Reporting its build state would show `active` for something on its
// way out.
it.live("shows a worker being torn down as deleting", () => {
const repo = project(`project_id = "demo"\n\n[workers.api]\nruntime = "node"\n`);
const { layer, out } = setupLegacyWorkers({
workdir: repo.dir,
routes: {
[listRoute]: {
status: 200,
body: { data: [workerResource({ name: "api", runtime: "node", deleting: true })] },
},
},
});

return Effect.gen(function* () {
yield* legacyWorkersList({ projectRef: Option.none() });

expect(out.stdoutText).toContain("deleting");
}).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup)));
});

// Nothing local at all: `deployOneWorker` checks the source directory before
// it ever infers a runtime, so "would have to guess the runtime" named the
// wrong prerequisite for this one.
Expand Down Expand Up @@ -386,6 +445,29 @@ describe("legacy workers list", () => {
}).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup)));
});

it.live("encodes YAML when -o yaml asks for it", () => {
const repo = project();
const { layer, out } = setupLegacyWorkers({
workdir: repo.dir,
goOutput: "yaml",
routes: {
[listRoute]: {
status: 200,
body: { data: [workerResource({ name: "api", runtime: "node" })] },
},
},
});

return Effect.gen(function* () {
yield* legacyWorkersList({ projectRef: Option.none() });

expect(out.stdoutText).toContain("project_ref:");
expect(out.stdoutText).toContain("name: api");
// The table would have gone to stdout too, and broken the document.
expect(out.stdoutText).not.toContain("NAME");
}).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup)));
});

// `pretty` is the human default; `table` and `csv` are accepted by the global
// flag for `db query`'s benefit, and every resource command is meant to ignore
// them and render text. All three used to fall through to the TOML encoder,
Expand Down
Loading
Loading