@monster-orange/den 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +195 -0
  2. package/docs/distributions.md +158 -16
  3. package/package.json +1 -1
  4. package/src/pillars/security/discover.ts +1 -1
  5. package/src/pillars/smoke/setup-global.ts +2 -1
  6. package/src/scripts/configure-aws-profiles.ts +2 -1
  7. package/src/scripts/register-repos.ts +4 -3
  8. package/src/shared/constants.ts +7 -0
  9. package/src/shared/den-config.ts +130 -12
  10. package/src/shared/engine.ts +18 -0
  11. package/src/shared/environment-manager/default-workspace.ts +229 -87
  12. package/src/shared/environment-manager/health.ts +235 -35
  13. package/src/shared/manifest/placement.ts +1 -1
  14. package/src/shared/manifest/schema.ts +47 -0
  15. package/src/shared/manifest/service-links.ts +1 -1
  16. package/src/shared/manifest/validate-manifest.ts +1 -1
  17. package/src/shared/plural.ts +13 -0
  18. package/src/shared/service-url.ts +2 -5
  19. package/src/shared/stacks/compose-health.ts +242 -0
  20. package/src/shared/stacks/runtime.ts +73 -24
  21. package/src/shared/stacks/status.ts +3 -2
  22. package/src/shared/types/index.ts +13 -1
  23. package/src/ui/cli/create.ts +2 -1
  24. package/src/ui/cli/health-display.ts +1 -0
  25. package/src/ui/cli/index.ts +86 -41
  26. package/src/ui/cli/mcp-connect.ts +36 -5
  27. package/src/ui/cli/server-identity.ts +233 -0
  28. package/src/ui/cli/service-command.ts +136 -12
  29. package/src/ui/cli/spinner.ts +48 -0
  30. package/src/ui/cli/stack-command.ts +40 -12
  31. package/src/ui/cli/web-command.ts +101 -32
  32. package/src/ui/mcp/api.ts +94 -1
  33. package/src/ui/mcp/server.ts +6 -1
  34. package/src/ui/mcp/tools.ts +6 -2
  35. package/src/ui/server/health-row.ts +6 -3
  36. package/src/ui/server/index.ts +120 -40
  37. package/src/ui/server/public/changelog-view.js +16 -0
  38. package/src/ui/server/public/header.js +121 -12
  39. package/src/ui/server/ui-assets.ts +1 -0
  40. package/src/ui/server/workspace-routes.ts +7 -0
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Compose-stack health answered in-process, from one `docker ps` listing
3
+ * shared by every check that runs at the same time.
4
+ *
5
+ * Den gives a compose repo with no declared check a `<cli> stack health`
6
+ * shell check (composeHealthCmd), one per manifest service. Spawned, each
7
+ * was a full CLI start: 17-19 tsx processes per health cycle for three
8
+ * stacks, which on a loaded machine (load 35-55) ran past their budget and
9
+ * read as down. The checks Den generates itself are now judged here, with
10
+ * the verdict `stack health` gives; a distribution's own shell checks still
11
+ * run as shell commands.
12
+ */
13
+ import { execFile } from "child_process";
14
+ import type { RawManifest } from "../types/index.js";
15
+ import { manifestStackName } from "./config.js";
16
+ import { composeHealthCmd } from "./manifest-commands.js";
17
+ import { projectName } from "./render.js";
18
+ import { judgeStackRows, type ComposeRow } from "./runtime.js";
19
+ import { resolveStackWorkspaces } from "./status.js";
20
+
21
+ export { judgeStackRows, type ComposeRow };
22
+
23
+ /** A Den-generated compose health check: what to look for in the listing. */
24
+ export interface ComposeHealthTarget {
25
+ /** The Compose project (`<stack>-<stack workspace>`). */
26
+ project: string;
27
+ /** A compose service to check on its own; unset: the whole stack. */
28
+ service?: string;
29
+ /** The equivalent `<cli> stack health` command (shown as the check). */
30
+ cmd: string;
31
+ }
32
+
33
+ /** A health verdict; `ok: null` when docker could not be asked. */
34
+ export interface ComposeVerdict {
35
+ ok: boolean | null;
36
+ detail: string;
37
+ /** With ok null: docker gave no answer within the budget. */
38
+ pending?: boolean;
39
+ }
40
+
41
+ /** The compose health check Den generates for this service, or null. */
42
+ export function composeHealthTarget(
43
+ manifest: RawManifest,
44
+ subsystem: string,
45
+ repoName: string,
46
+ workspace: string,
47
+ ): ComposeHealthTarget | null {
48
+ const cmd = composeHealthCmd(manifest, subsystem, repoName, workspace);
49
+ if (!cmd) return null;
50
+ const project = projectName(
51
+ manifestStackName(manifest, repoName),
52
+ resolveStackWorkspaces(workspace).stackWorkspace,
53
+ );
54
+ const service = cmd.includes(" --service ") ? subsystem : undefined;
55
+ return { project, ...(service ? { service } : {}), cmd };
56
+ }
57
+
58
+ /** Budget for the shared listing; past it the checks are pending. */
59
+ export const COMPOSE_LISTING_TIMEOUT_MS = 5_000;
60
+
61
+ /** The shared listing: every container, or why docker could not answer. */
62
+ export type ComposeListing =
63
+ | { rows: ComposeRow[] }
64
+ | { error: string; pending?: boolean };
65
+ type Listing = ComposeListing;
66
+
67
+ let inFlight: Promise<Listing> | null = null;
68
+
69
+ /**
70
+ * Every container, from one `docker ps -a`. Concurrent callers share the
71
+ * call in flight, so a health cycle lists once however many checks it has
72
+ * (compose checks and `type: container` checks alike). Not filtered to
73
+ * compose containers: a container check may name a plain container.
74
+ */
75
+ export function composeListing(): Promise<Listing> {
76
+ return (inFlight ??= listOnce().finally(() => {
77
+ inFlight = null;
78
+ }));
79
+ }
80
+
81
+ function listOnce(): Promise<Listing> {
82
+ return new Promise((done) => {
83
+ // Backstop: execFile reports only once the pipes close, which a killed
84
+ // process's children can hold open; the checks must not wait on that.
85
+ const guard = setTimeout(
86
+ () =>
87
+ resolve({
88
+ error: `docker gave no answer within ${COMPOSE_LISTING_TIMEOUT_MS / 1000}s`,
89
+ pending: true,
90
+ }),
91
+ COMPOSE_LISTING_TIMEOUT_MS + 500,
92
+ );
93
+ let settled = false;
94
+ const resolve = (l: Listing): void => {
95
+ if (settled) return;
96
+ settled = true;
97
+ clearTimeout(guard);
98
+ done(l);
99
+ };
100
+ execFile(
101
+ "docker",
102
+ [
103
+ "ps",
104
+ "-a",
105
+ "--format",
106
+ '{{.Label "com.docker.compose.project"}}\t{{.Label "com.docker.compose.service"}}\t{{.State}}\t{{.Status}}\t{{.Names}}',
107
+ ],
108
+ {
109
+ encoding: "utf-8",
110
+ timeout: COMPOSE_LISTING_TIMEOUT_MS,
111
+ maxBuffer: 16 * 1024 * 1024,
112
+ },
113
+ (err, stdout, stderr) => {
114
+ if (err) {
115
+ const killed = (err as { killed?: boolean }).killed === true;
116
+ return resolve(
117
+ killed
118
+ ? {
119
+ error: `docker gave no answer within ${COMPOSE_LISTING_TIMEOUT_MS / 1000}s`,
120
+ pending: true,
121
+ }
122
+ : {
123
+ error:
124
+ String(stderr || err.message).trim() || "docker ps failed",
125
+ },
126
+ );
127
+ }
128
+ const rows = stdout
129
+ .split("\n")
130
+ .filter(Boolean)
131
+ .map((line) => {
132
+ const [
133
+ project = "",
134
+ service = "",
135
+ state = "",
136
+ status = "",
137
+ name = "",
138
+ ] = line.split("\t");
139
+ return { project, service, state, status, name };
140
+ });
141
+ resolve({ rows });
142
+ },
143
+ );
144
+ });
145
+ }
146
+
147
+ /**
148
+ * The verdict when docker gave no listing, shared by compose and container
149
+ * checks so the two cannot drift: container state unknown is not "not
150
+ * running" (`ok: null`, never down, however often it repeats). Only a
151
+ * listing past its budget is `pending` (no answer in time), which, like any
152
+ * timeout, is down when it repeats on the next cycle.
153
+ */
154
+ export function unknownListingVerdict(listing: {
155
+ error: string;
156
+ pending?: boolean;
157
+ }): ComposeVerdict {
158
+ return {
159
+ ok: null,
160
+ detail: `container state unknown: ${listing.error}`,
161
+ ...(listing.pending ? { pending: true } : {}),
162
+ };
163
+ }
164
+
165
+ /** The health of one Den-generated compose check, from the shared listing. */
166
+ export async function composeHealth(
167
+ target: ComposeHealthTarget,
168
+ ): Promise<ComposeVerdict> {
169
+ const listing = await composeListing();
170
+ if ("error" in listing) return unknownListingVerdict(listing);
171
+ return judgeStackRows(listing.rows, target.project, target.service);
172
+ }
173
+
174
+ // ── `health: { type: container }` ───────────────────────────────────────────
175
+
176
+ /**
177
+ * A declared container check: a compose container by its project and service
178
+ * labels, or any container by name (`container_name`).
179
+ */
180
+ export type ContainerHealthTarget =
181
+ | { project: string; service: string; container_name?: undefined }
182
+ | { container_name: string; project?: undefined; service?: undefined };
183
+
184
+ /** What a container check shows as its check: `container <project>/<service>`. */
185
+ export function containerCheckLabel(t: ContainerHealthTarget): string {
186
+ return `container ${containerTargetName(t)}`;
187
+ }
188
+
189
+ function containerTargetName(t: ContainerHealthTarget): string {
190
+ return t.container_name ?? `${t.project}/${t.service}`;
191
+ }
192
+
193
+ function matchesTarget(r: ComposeRow, t: ContainerHealthTarget): boolean {
194
+ if (t.container_name)
195
+ return (r.name ?? "")
196
+ .split(",")
197
+ .map((n) => n.trim().replace(/^\//, ""))
198
+ .includes(t.container_name);
199
+ return r.project === t.project && r.service === t.service;
200
+ }
201
+
202
+ /** One container's verdict: its healthcheck when it has one, else running. */
203
+ function rowVerdict(r: ComposeRow): ComposeVerdict & { rank: number } {
204
+ const what = `${r.name || `${r.project}/${r.service}`}\t${r.state}\t${r.status}`;
205
+ if (r.state !== "running") return { ok: false, detail: what, rank: 4 };
206
+ if (/\(unhealthy\)/.test(r.status))
207
+ return { ok: false, detail: what, rank: 3 };
208
+ // Starting is pending, but not "no answer": it never turns down on repeat.
209
+ if (/\(health: starting\)/.test(r.status))
210
+ return { ok: null, detail: what, rank: 2 };
211
+ if (/\(healthy\)/.test(r.status)) return { ok: true, detail: what, rank: 0 };
212
+ return { ok: true, detail: what, rank: 1 };
213
+ }
214
+
215
+ /**
216
+ * The verdict of a container check over the shared listing. More than one
217
+ * match: the best one (healthy, then running). No match: down. No listing:
218
+ * as a compose check (unknownListingVerdict), the reason in `detail`.
219
+ */
220
+ export function judgeContainerListing(
221
+ listing: ComposeListing,
222
+ target: ContainerHealthTarget,
223
+ ): ComposeVerdict {
224
+ if ("error" in listing) return unknownListingVerdict(listing);
225
+ const best = listing.rows
226
+ .filter((r) => matchesTarget(r, target))
227
+ .map(rowVerdict)
228
+ .sort((a, b) => a.rank - b.rank)[0];
229
+ if (!best)
230
+ return {
231
+ ok: false,
232
+ detail: `${containerTargetName(target)}: no container`,
233
+ };
234
+ return { ok: best.ok, detail: best.detail };
235
+ }
236
+
237
+ /** The health of a declared container check, from the shared listing. */
238
+ export async function containerHealth(
239
+ target: ContainerHealthTarget,
240
+ ): Promise<ComposeVerdict> {
241
+ return judgeContainerListing(await composeListing(), target);
242
+ }
@@ -364,39 +364,88 @@ export function stackLogs(
364
364
 
365
365
  /**
366
366
  * Health of a running stack: ok when it has containers, all are running, and
367
- * none reports a failing or pending Docker healthcheck.
367
+ * none reports a failing or pending Docker healthcheck. `ok: null` when
368
+ * docker could not be asked: container state unknown is not "not running".
368
369
  */
369
370
  export function stackHealth(
370
371
  ctx: StackContext,
371
372
  s: StackConfig,
372
373
  service?: string,
373
- ): { ok: boolean; detail: string } {
374
+ ): { ok: boolean | null; detail: string } {
374
375
  const project = projectName(s.name, ctx.workspace);
375
- const r = run("docker", [
376
- "ps",
377
- "-a",
378
- "--filter",
379
- `label=com.docker.compose.project=${project}`,
380
- ...(service
381
- ? ["--filter", `label=com.docker.compose.service=${service}`]
382
- : []),
383
- "--format",
384
- '{{.Label "com.docker.compose.service"}}\t{{.State}}\t{{.Status}}',
385
- ]);
386
- if (r.code !== 0) return { ok: false, detail: r.err || "docker ps failed" };
387
- const rows = r.out.split("\n").filter(Boolean);
388
- if (!rows.length)
376
+ let r: ReturnType<typeof run>;
377
+ try {
378
+ r = run("docker", [
379
+ "ps",
380
+ "-a",
381
+ "--filter",
382
+ `label=com.docker.compose.project=${project}`,
383
+ ...(service
384
+ ? ["--filter", `label=com.docker.compose.service=${service}`]
385
+ : []),
386
+ "--format",
387
+ '{{.Label "com.docker.compose.service"}}\t{{.State}}\t{{.Status}}',
388
+ ]);
389
+ } catch (e) {
390
+ return {
391
+ ok: null,
392
+ detail: `container state unknown: ${(e as Error).message}`,
393
+ };
394
+ }
395
+ if (r.code !== 0)
396
+ return {
397
+ ok: null,
398
+ detail: `container state unknown: ${r.err.trim() || "docker ps failed"}`,
399
+ };
400
+ const rows = r.out
401
+ .split("\n")
402
+ .filter(Boolean)
403
+ .map((line) => {
404
+ const [svc = "", state = "", status = ""] = line.split("\t");
405
+ return { project, service: svc, state, status };
406
+ });
407
+ return judgeStackRows(rows, project, service);
408
+ }
409
+
410
+ /** One compose container, as a `docker ps` listing reports it. */
411
+ export interface ComposeRow {
412
+ project: string;
413
+ service: string;
414
+ state: string;
415
+ status: string;
416
+ /** The container's name(s), comma-separated as docker lists them. */
417
+ name?: string;
418
+ }
419
+
420
+ /**
421
+ * The verdict `stack health` gives for `project` (and `service`): ok when it
422
+ * has containers, all running, none with a failing or starting Docker
423
+ * healthcheck. Shared by the CLI and the in-process check (compose-health).
424
+ */
425
+ export function judgeStackRows(
426
+ rows: ComposeRow[],
427
+ project: string,
428
+ service?: string,
429
+ ): { ok: boolean; detail: string } {
430
+ const mine = rows.filter(
431
+ (r) => r.project === project && (!service || r.service === service),
432
+ );
433
+ if (!mine.length)
389
434
  return {
390
435
  ok: false,
391
436
  detail: `${project}${service ? `/${service}` : ""}: not running`,
392
437
  };
393
- const bad = rows.filter((row) => {
394
- const [, state, status] = row.split("\t");
395
- return (
396
- state !== "running" || /\((unhealthy|health: starting)\)/.test(status)
397
- );
398
- });
438
+ const bad = mine.filter(
439
+ (r) =>
440
+ r.state !== "running" ||
441
+ /\((unhealthy|health: starting)\)/.test(r.status),
442
+ );
399
443
  return bad.length
400
- ? { ok: false, detail: bad.join("; ") }
401
- : { ok: true, detail: `${rows.length} container(s) running` };
444
+ ? {
445
+ ok: false,
446
+ detail: bad
447
+ .map((r) => `${r.service}\t${r.state}\t${r.status}`)
448
+ .join("; "),
449
+ }
450
+ : { ok: true, detail: `${mine.length} container(s) running` };
402
451
  }
@@ -34,7 +34,8 @@ export interface StackStatus {
34
34
  routes: StackRoute[];
35
35
  uses: string[];
36
36
  /** Container health (only when asked for: it runs `docker ps`). */
37
- health?: { ok: boolean; detail: string };
37
+ /** `ok: null`: docker could not be asked, so the state is unknown. */
38
+ health?: { ok: boolean | null; detail: string };
38
39
  }
39
40
 
40
41
  const WS_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
@@ -96,7 +97,7 @@ export function stackStatuses(
96
97
  try {
97
98
  status.health = healthOf(ctx, s);
98
99
  } catch (e) {
99
- status.health = { ok: false, detail: (e as Error).message };
100
+ status.health = { ok: null, detail: (e as Error).message };
100
101
  }
101
102
  }
102
103
  return status;
@@ -210,10 +210,22 @@ export type LogSource =
210
210
  * type: shell
211
211
  * cmd: "docker exec $(docker ps --filter ancestor=postgres:16 --format '{{.ID}}' | head -1) pg_isready -U postgres"
212
212
  * label: "postgres" # optional — shown in UI instead of cmd
213
+ *
214
+ * Container (answered from the one `docker ps` a health cycle shares):
215
+ * type: container
216
+ * project: shop-main # com.docker.compose.project label
217
+ * service: db # com.docker.compose.service label
218
+ * -- or, for a container not run by compose --
219
+ * type: container
220
+ * container_name: shop-db
213
221
  */
214
222
  export type ServiceHealthConfig =
215
223
  | { type?: "http"; url: string; label?: string }
216
- | { type: "shell"; cmd: string; label?: string };
224
+ | { type: "shell"; cmd: string; label?: string }
225
+ | ({ type: "container"; label?: string } & (
226
+ | { project: string; service: string; container_name?: undefined }
227
+ | { container_name: string; project?: undefined; service?: undefined }
228
+ ));
217
229
 
218
230
  /**
219
231
  * A command can be a plain shell string (detached by default) or an object
@@ -15,6 +15,7 @@ import * as fs from "node:fs";
15
15
  import * as path from "node:path";
16
16
  import { ENGINE_PACKAGE, getEngineRoot } from "../../shared/paths.js";
17
17
  import { profileFrom } from "../../shared/profile.js";
18
+ import { plural } from "../../shared/plural.js";
18
19
 
19
20
  export interface CreateOptions {
20
21
  /** Product name: lower-case `[a-z][a-z0-9-]*`. */
@@ -345,7 +346,7 @@ export function runCreate(argv: string[], cwd: string): number {
345
346
  const cli = args.cli ?? args.name;
346
347
  const rel = path.relative(cwd, args.dir) || ".";
347
348
  console.log(
348
- `Created ${args.name} in ${args.dir} (${written.length} files).`,
349
+ `Created ${args.name} in ${args.dir} (${plural(written.length, "file")}).`,
349
350
  );
350
351
  console.log("");
351
352
  console.log("Next:");
@@ -13,6 +13,7 @@ export function healthLabelText(health: HealthState): string {
13
13
  export function formatHealthLabel(health: HealthState): string {
14
14
  if (health === "ok") return chalk.green("● ok");
15
15
  if (health === "error") return chalk.red("● down");
16
+ if (health === "pending") return chalk.gray("● pending");
16
17
  return chalk.gray("● unknown");
17
18
  }
18
19
 
@@ -12,18 +12,10 @@ import {
12
12
  } from "../../shared/stacks/status.js";
13
13
  import { isNamedTarget } from "./named-target.js";
14
14
  import chalk from "chalk";
15
- import oraSpinner from "ora";
16
-
17
- /**
18
- * Spinners are progress, not diagnostics: on a terminal they animate on
19
- * stderr as usual; piped (CI, scripts) their lines go to stdout so stderr
20
- * carries only real warnings and errors.
21
- */
22
- const ora = (text: string) =>
23
- oraSpinner({
24
- text,
25
- stream: process.stderr.isTTY ? process.stderr : process.stdout,
26
- });
15
+ import { noun, plural } from "../../shared/plural.js";
16
+ // Spinners: progress on stderr (a terminal) or stdout (piped); plain lines
17
+ // where they cannot animate. See spinner.ts.
18
+ import { spinner as ora } from "./spinner.js";
27
19
  import Table from "cli-table3";
28
20
  import { cliPrompts } from "./prompts.js";
29
21
  import { resultsViewer } from "./results-viewer.js";
@@ -85,9 +77,12 @@ import {
85
77
  resolveServiceRuntime,
86
78
  } from "../../shared/environment-manager/service-runner.js";
87
79
  import {
88
- defaultWorkspaceRepos,
80
+ blueprintWorkspaces,
89
81
  defaultWorkspaceNeedsSeeding,
82
+ blueprintAlreadySeeded,
90
83
  defaultWorkspaceHasServices,
84
+ isMultiBlueprint,
85
+ seedBlueprintWorkspaces,
91
86
  seedDefaultWorkspace,
92
87
  topUpDefaultWorkspace,
93
88
  markDefaultWorkspaceDeclined,
@@ -252,6 +247,14 @@ const WORKSPACE_SUBCOMMANDS: {
252
247
  usage: "code [n]",
253
248
  lines: ["open it in your editor (VS Code: a .code-workspace)"],
254
249
  },
250
+ {
251
+ name: "seed",
252
+ usage: "seed",
253
+ lines: [
254
+ "fill workspaces from the distribution's blueprint",
255
+ "(defaults.workspace / defaults.workspaces), all managed",
256
+ ],
257
+ },
255
258
  ];
256
259
 
257
260
  const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
@@ -486,8 +489,6 @@ function printValidation(
486
489
  layer?: string,
487
490
  ): void {
488
491
  const tag = layer ? `${layer}, ` : "";
489
- const plural = (n: number, word: string): string =>
490
- `${n} ${word}${n === 1 ? "" : "s"}`;
491
492
  if (result.valid && result.warnings.length === 0) {
492
493
  console.log(
493
494
  chalk.green(` ✓ ${label}`) +
@@ -2274,7 +2275,7 @@ class DenCLI {
2274
2275
  }
2275
2276
  console.log(
2276
2277
  chalk.cyan(
2277
- `\n ${only ? "Application" : "Workspace-wide"} up — ${chalk.bold(String(order.length))} systems`,
2278
+ `\n ${only ? "Application" : "Workspace-wide"} up — ${chalk.bold(String(order.length))} ${noun(order.length, "system")}`,
2278
2279
  ),
2279
2280
  );
2280
2281
  console.log(chalk.gray(` Order: ${order.join(" → ")}\n`));
@@ -2335,7 +2336,7 @@ class DenCLI {
2335
2336
  const order = [...target.rawOrder].reverse();
2336
2337
  console.log(
2337
2338
  chalk.cyan(
2338
- `\n ${only ? "Application" : "Workspace-wide"} down — ${chalk.bold(String(order.length))} systems`,
2339
+ `\n ${only ? "Application" : "Workspace-wide"} down — ${chalk.bold(String(order.length))} ${noun(order.length, "system")}`,
2339
2340
  ),
2340
2341
  );
2341
2342
  console.log(chalk.gray(` Order: ${order.join(" → ")}\n`));
@@ -2483,36 +2484,55 @@ class DenCLI {
2483
2484
  }
2484
2485
 
2485
2486
  /**
2486
- * Seed (or reset) the default workspace from the committed blueprint
2487
- * (config/den.yml `defaults.workspace`), all repos managed. Prints
2488
- * clear guidance and, on a TTY, asks first. `reason: "auto"` is the one-time
2489
- * offer on a fresh clone; `reason: "init"` is the explicit `den init`.
2490
- * Returns true if the workspace was seeded.
2487
+ * Seed (or reset) the blueprint's workspaces from the committed config:
2488
+ * `defaults.workspace` fills `default`; `defaults.workspaces` creates each
2489
+ * named workspace, activates them all and focuses the marked one. All
2490
+ * repos managed. Prints clear guidance and, on a TTY, asks first.
2491
+ * `reason: "auto"` is the one-time offer on a genuine fresh setup (TTY
2492
+ * only: see ensureWorkspaceSeeded); `reason: "init"` is the explicit
2493
+ * `<cli> workspace seed` / `<cli> init` in the root. Returns true if seeded.
2491
2494
  */
2492
2495
  private async seedDefaultWorkspaceFlow(
2493
2496
  reason: "auto" | "init",
2494
2497
  ): Promise<boolean> {
2495
- const repos = defaultWorkspaceRepos();
2496
- if (repos.length === 0) {
2498
+ const bps = blueprintWorkspaces();
2499
+ if (bps.length === 0) {
2497
2500
  if (reason === "init") {
2498
2501
  console.log(
2499
2502
  chalk.yellow(
2500
- `\n No defaults.workspace.repos in ${product().configFile} — nothing to seed.\n`,
2503
+ `\n No defaults.workspace.repos or defaults.workspaces in ${product().configFile} — nothing to seed.\n`,
2501
2504
  ),
2502
2505
  );
2503
2506
  }
2504
2507
  return false;
2505
2508
  }
2509
+ const multi = isMultiBlueprint();
2510
+ // A repeat seed: say so, and touch nothing.
2511
+ if (blueprintAlreadySeeded()) {
2512
+ console.log(
2513
+ chalk.green(
2514
+ multi
2515
+ ? `\n ✓ ${plural(bps.length, "workspace")} already seeded (${bps.map((b) => b.name).join(", ")}): nothing changed.\n`
2516
+ : `\n ✓ Default workspace already seeded — ${plural(bps[0].repos.length, "repo")} (managed): nothing changed.\n`,
2517
+ ),
2518
+ );
2519
+ return false;
2520
+ }
2506
2521
  const willReset = defaultWorkspaceHasServices();
2507
2522
  const interactive = !!(process.stdin.isTTY && process.stdout.isTTY);
2508
2523
 
2509
- console.log(
2510
- chalk.cyan(`\n Default workspace — ${repos.length} repos, all managed:`),
2511
- );
2512
- console.log(chalk.gray(` ${repos.join(", ")}`));
2524
+ for (const bp of bps) {
2525
+ const label = multi ? `Workspace ${bp.name}` : "Default workspace";
2526
+ console.log(
2527
+ chalk.cyan(
2528
+ `\n ${label} — ${plural(bp.repos.length, "repo")}, all managed:`,
2529
+ ),
2530
+ );
2531
+ console.log(chalk.gray(` ${bp.repos.join(", ")}`));
2532
+ }
2513
2533
  console.log(
2514
2534
  chalk.gray(
2515
- ` ${product().title} clones + tracks them on main under ${product().servicesDir}/. Switch any`,
2535
+ ` ${product().title} clones + tracks them on their branch under ${product().servicesDir}/. Switch any`,
2516
2536
  ),
2517
2537
  );
2518
2538
  console.log(
@@ -2522,16 +2542,17 @@ class DenCLI {
2522
2542
  );
2523
2543
 
2524
2544
  if (interactive) {
2545
+ const what = multi ? "these workspaces" : "the default workspace";
2525
2546
  const question = willReset
2526
- ? " Reset the default workspace to this shipped set?"
2527
- : " Seed the default workspace now?";
2547
+ ? ` Reset ${what} to this shipped set?`
2548
+ : ` Seed ${what} now?`;
2528
2549
  const ok = await cliPrompts.confirm(question, !willReset);
2529
2550
  if (!ok) {
2530
2551
  if (reason === "auto") {
2531
2552
  markDefaultWorkspaceDeclined(); // don't ask again
2532
2553
  console.log(
2533
2554
  chalk.gray(
2534
- ` Skipped — run \`${product().cli} init\` to set it up later.\n`,
2555
+ ` Skipped — run \`${product().cli} workspace seed\` to set it up later.\n`,
2535
2556
  ),
2536
2557
  );
2537
2558
  } else {
@@ -2541,10 +2562,21 @@ class DenCLI {
2541
2562
  }
2542
2563
  }
2543
2564
 
2565
+ if (multi) {
2566
+ const seeded = seedBlueprintWorkspaces();
2567
+ const focus = readWorkspacesFile().focus;
2568
+ console.log(
2569
+ chalk.green(
2570
+ ` ✓ ${plural(seeded.length, "workspace")} ${willReset ? "reset to the blueprint" : "ready"} (${seeded.map((w) => w.name).join(", ")}), all active; focus: ${focus}. ` +
2571
+ `Run \`${product().cli} up <repo>\` to bring up its closure.\n`,
2572
+ ),
2573
+ );
2574
+ return true;
2575
+ }
2544
2576
  const seeded = seedDefaultWorkspace();
2545
2577
  console.log(
2546
2578
  chalk.green(
2547
- ` ✓ Default workspace ready — ${seeded.length} repos (managed). ` +
2579
+ ` ✓ Default workspace ${willReset ? "reset to the blueprint" : "ready"} — ${plural(seeded.length, "repo")} (managed). ` +
2548
2580
  `Run \`${product().cli} up <repo>\` to bring up its closure.\n`,
2549
2581
  ),
2550
2582
  );
@@ -2552,20 +2584,29 @@ class DenCLI {
2552
2584
  }
2553
2585
 
2554
2586
  /**
2555
- * One-time offer to seed the default workspace on a fresh clone; afterwards
2556
- * keep it converged on the blueprint (repos added to defaults later — e.g.
2557
- * tasks-api — join the existing workspace managed).
2587
+ * On a genuine fresh setup (nothing but an empty, never-seeded `default`),
2588
+ * offer to seed from the blueprint: on a TTY, ask; otherwise print a
2589
+ * one-line hint and change nothing. Afterwards keep a lone `default`
2590
+ * converged on the blueprint (repos added to defaults later join it
2591
+ * managed); never once the user has other workspaces.
2558
2592
  */
2559
2593
  private async ensureWorkspaceSeeded(): Promise<void> {
2560
2594
  if (defaultWorkspaceNeedsSeeding()) {
2561
- await this.seedDefaultWorkspaceFlow("auto");
2595
+ if (process.stdin.isTTY && process.stdout.isTTY) {
2596
+ await this.seedDefaultWorkspaceFlow("auto");
2597
+ } else {
2598
+ const hint = isMultiBlueprint()
2599
+ ? "no workspaces yet: run `{cli} workspace seed` to create them from the blueprint"
2600
+ : "default workspace is empty: run `{cli} workspace seed` to fill it from the blueprint";
2601
+ console.error(chalk.gray(` ${hint.replace("{cli}", product().cli)}`));
2602
+ }
2562
2603
  return;
2563
2604
  }
2564
2605
  const added = topUpDefaultWorkspace();
2565
2606
  if (added.length > 0) {
2566
2607
  console.log(
2567
2608
  chalk.gray(
2568
- ` default workspace: added standard repo(s) ${added.join(", ")} (managed)`,
2609
+ ` default workspace: added standard ${noun(added.length, "repo")} ${added.join(", ")} (managed)`,
2569
2610
  ),
2570
2611
  );
2571
2612
  }
@@ -3627,6 +3668,10 @@ class DenCLI {
3627
3668
  );
3628
3669
  break;
3629
3670
  }
3671
+ case "seed": {
3672
+ await this.seedDefaultWorkspaceFlow("init");
3673
+ break;
3674
+ }
3630
3675
  case "create": {
3631
3676
  const name =
3632
3677
  args[1] ??
@@ -3718,7 +3763,7 @@ class DenCLI {
3718
3763
  default:
3719
3764
  console.log(
3720
3765
  chalk.gray(
3721
- `Usage: ${product().cli} workspace <list|activate|deactivate|focus|use|create|delete|code> [name]`,
3766
+ `Usage: ${product().cli} workspace <${WORKSPACE_SUBCOMMANDS.map((c) => c.name).join("|")}> [name]`,
3722
3767
  ),
3723
3768
  );
3724
3769
  }
@@ -3761,7 +3806,7 @@ class DenCLI {
3761
3806
  if (missing.length > 0) {
3762
3807
  console.log(
3763
3808
  chalk.cyan(
3764
- `\n Cloning ${missing.length} managed repo(s) not on disk yet: ${missing.join(", ")}`,
3809
+ `\n Cloning ${plural(missing.length, "managed repo")} not on disk yet: ${missing.join(", ")}`,
3765
3810
  ),
3766
3811
  );
3767
3812
  await this.ensureManagedClonesFor(missing);