@monster-orange/den 0.2.3 → 0.2.5

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 (35) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/docs/distributions.md +64 -16
  3. package/package.json +1 -1
  4. package/service/install.sh +4 -2
  5. package/src/shared/constants.ts +7 -0
  6. package/src/shared/den-config.ts +130 -12
  7. package/src/shared/edge/config.ts +16 -0
  8. package/src/shared/engine.ts +18 -0
  9. package/src/shared/environment-manager/default-workspace.ts +182 -87
  10. package/src/shared/environment-manager/health.ts +229 -30
  11. package/src/shared/environment-manager/opctl.ts +83 -1
  12. package/src/shared/environment-manager/service-runner.ts +49 -4
  13. package/src/shared/git/origin-url.ts +77 -0
  14. package/src/shared/manifest/read-manifest.ts +39 -15
  15. package/src/shared/paths.ts +42 -0
  16. package/src/shared/product.ts +7 -1
  17. package/src/shared/profile.ts +8 -0
  18. package/src/shared/read-cache.ts +77 -0
  19. package/src/shared/stacks/compose-health.ts +150 -0
  20. package/src/shared/stacks/runtime.ts +71 -24
  21. package/src/shared/stacks/status.ts +112 -3
  22. package/src/ui/cli/health-display.ts +1 -0
  23. package/src/ui/cli/index.ts +181 -31
  24. package/src/ui/cli/mcp-connect.ts +77 -15
  25. package/src/ui/cli/stack-command.ts +40 -12
  26. package/src/ui/cli/web-command.ts +25 -7
  27. package/src/ui/cli/workspace-notes.ts +43 -0
  28. package/src/ui/server/graph-order.ts +22 -0
  29. package/src/ui/server/index.ts +174 -197
  30. package/src/ui/server/public/changelog-view.js +16 -0
  31. package/src/ui/server/public/header.js +121 -12
  32. package/src/ui/server/services-list.ts +210 -0
  33. package/src/ui/server/single-flight.ts +53 -0
  34. package/src/ui/server/ui-assets.ts +1 -0
  35. package/src/ui/server/workspace-routes.ts +247 -181
@@ -5,6 +5,11 @@
5
5
  * Main entry point for the interactive CLI
6
6
  */
7
7
 
8
+ import { composeStacksNotice } from "./workspace-notes.js";
9
+ import {
10
+ runningComposeProjects,
11
+ runningWorkspaceStacks,
12
+ } from "../../shared/stacks/status.js";
8
13
  import { isNamedTarget } from "./named-target.js";
9
14
  import chalk from "chalk";
10
15
  import oraSpinner from "ora";
@@ -46,7 +51,9 @@ import {
46
51
  isWorkspaceActive,
47
52
  activateWorkspace,
48
53
  deactivateWorkspace,
54
+ getActiveWorkspaces,
49
55
  setActiveWorkspace,
56
+ setFocusedWorkspace,
50
57
  createWorkspace,
51
58
  deleteWorkspaceConfig,
52
59
  parseAddress,
@@ -78,9 +85,11 @@ import {
78
85
  resolveServiceRuntime,
79
86
  } from "../../shared/environment-manager/service-runner.js";
80
87
  import {
81
- defaultWorkspaceRepos,
88
+ blueprintWorkspaces,
82
89
  defaultWorkspaceNeedsSeeding,
83
90
  defaultWorkspaceHasServices,
91
+ isMultiBlueprint,
92
+ seedBlueprintWorkspaces,
84
93
  seedDefaultWorkspace,
85
94
  topUpDefaultWorkspace,
86
95
  markDefaultWorkspaceDeclined,
@@ -195,6 +204,66 @@ const DEN_HELP_DESCRIPTION = ` ${product().title} - local-first test, service,
195
204
  * Concise `<command> -h` help for commands without a dedicated help renderer.
196
205
  * Flag-heavy commands (up, graph, use-local, use-managed) have richer handlers.
197
206
  */
207
+ /** `workspace` subcommands, for `workspace --help` and `workspace <sub> --help`. */
208
+ const WORKSPACE_SUBCOMMANDS: {
209
+ name: string;
210
+ usage: string;
211
+ lines: string[];
212
+ }[] = [
213
+ {
214
+ name: "list",
215
+ usage: "list",
216
+ lines: ["every workspace, the active set and the focus"],
217
+ },
218
+ {
219
+ name: "activate",
220
+ usage: "activate <n>",
221
+ lines: ["add a workspace to the active set"],
222
+ },
223
+ {
224
+ name: "deactivate",
225
+ usage: "deactivate <n>",
226
+ lines: [
227
+ "remove it from the active set and stop the services",
228
+ "only it owns. Only services {cli} tracks: compose",
229
+ "stacks keep running; stop them with `{cli} down <repo>`",
230
+ ],
231
+ },
232
+ {
233
+ name: "focus",
234
+ usage: "focus <n>",
235
+ lines: ["make an active workspace the default for commands"],
236
+ },
237
+ {
238
+ name: "use",
239
+ usage: "use <n>",
240
+ lines: ["activate and focus a workspace"],
241
+ },
242
+ {
243
+ name: "create",
244
+ usage: "create [n]",
245
+ lines: ["create an empty workspace"],
246
+ },
247
+ {
248
+ name: "delete",
249
+ usage: "delete <n>",
250
+ lines: ["delete a workspace (and its directory)"],
251
+ },
252
+ {
253
+ name: "code",
254
+ usage: "code [n]",
255
+ lines: ["open it in your editor (VS Code: a .code-workspace)"],
256
+ },
257
+ {
258
+ name: "seed",
259
+ usage: "seed",
260
+ lines: [
261
+ "fill workspaces from the distribution's blueprint",
262
+ "(defaults.workspace / defaults.workspaces), all managed",
263
+ ],
264
+ },
265
+ ];
266
+
198
267
  const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
199
268
  down: {
200
269
  usage: "down [repo] [flags]",
@@ -252,8 +321,16 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
252
321
  lines: ["Check managed repos for upstream updates and offer to pull."],
253
322
  },
254
323
  workspace: {
255
- usage: "workspace <list|use|create|delete|code> [name]",
256
- lines: ["Manage workspaces (named sets of services + their modes)."],
324
+ usage: `workspace <${WORKSPACE_SUBCOMMANDS.map((c) => c.name).join("|")}> [name]`,
325
+ lines: [
326
+ "Manage workspaces (named sets of services + their modes).",
327
+ "",
328
+ ...WORKSPACE_SUBCOMMANDS.flatMap((c) =>
329
+ c.lines.map(
330
+ (line, i) => ` ${(i === 0 ? c.usage : "").padEnd(16)} ${line}`,
331
+ ),
332
+ ),
333
+ ],
257
334
  },
258
335
  run: {
259
336
  usage: "run [options]",
@@ -583,7 +660,7 @@ class DenCLI {
583
660
  !["-h", "--help", "-v", "--version"].includes(command) &&
584
661
  (ownArgs.includes("-h") || ownArgs.includes("--help"))
585
662
  ) {
586
- this.printCommandHelp(command);
663
+ this.printCommandHelp(command, ownArgs[1]);
587
664
  return;
588
665
  }
589
666
 
@@ -2375,7 +2452,19 @@ class DenCLI {
2375
2452
  }
2376
2453
 
2377
2454
  /** Help for `<command> -h`: dedicated renderer for flag-heavy commands, else the table. */
2378
- private printCommandHelp(command: string): void {
2455
+ private printCommandHelp(command: string, sub?: string): void {
2456
+ // `workspace <sub> --help`: that subcommand only.
2457
+ const wsSub =
2458
+ command === "workspace"
2459
+ ? WORKSPACE_SUBCOMMANDS.find((c) => c.name === sub)
2460
+ : undefined;
2461
+ if (wsSub) {
2462
+ console.log(`\n ${product().cli} workspace ${wsSub.usage}\n`);
2463
+ for (const line of wsSub.lines)
2464
+ console.log(` ${line.replaceAll("{cli}", product().cli)}`);
2465
+ console.log();
2466
+ return;
2467
+ }
2379
2468
  switch (command) {
2380
2469
  case "up":
2381
2470
  return this.printUpHelp();
@@ -2398,41 +2487,48 @@ class DenCLI {
2398
2487
  return;
2399
2488
  }
2400
2489
  console.log(`\n ${product().cli} ${entry.usage}\n`);
2401
- for (const line of entry.lines) console.log(` ${line}`);
2490
+ for (const line of entry.lines)
2491
+ console.log(` ${line.replaceAll("{cli}", product().cli)}`.trimEnd());
2402
2492
  console.log();
2403
2493
  }
2404
2494
 
2405
2495
  /**
2406
- * Seed (or reset) the default workspace from the committed blueprint
2407
- * (config/den.yml `defaults.workspace`), all repos managed. Prints
2408
- * clear guidance and, on a TTY, asks first. `reason: "auto"` is the one-time
2409
- * offer on a fresh clone; `reason: "init"` is the explicit `den init`.
2410
- * Returns true if the workspace was seeded.
2496
+ * Seed (or reset) the blueprint's workspaces from the committed config:
2497
+ * `defaults.workspace` fills `default`; `defaults.workspaces` creates each
2498
+ * named workspace, activates them all and focuses the marked one. All
2499
+ * repos managed. Prints clear guidance and, on a TTY, asks first.
2500
+ * `reason: "auto"` is the one-time offer on a genuine fresh setup (TTY
2501
+ * only: see ensureWorkspaceSeeded); `reason: "init"` is the explicit
2502
+ * `<cli> workspace seed` / `<cli> init` in the root. Returns true if seeded.
2411
2503
  */
2412
2504
  private async seedDefaultWorkspaceFlow(
2413
2505
  reason: "auto" | "init",
2414
2506
  ): Promise<boolean> {
2415
- const repos = defaultWorkspaceRepos();
2416
- if (repos.length === 0) {
2507
+ const bps = blueprintWorkspaces();
2508
+ if (bps.length === 0) {
2417
2509
  if (reason === "init") {
2418
2510
  console.log(
2419
2511
  chalk.yellow(
2420
- `\n No defaults.workspace.repos in ${product().configFile} — nothing to seed.\n`,
2512
+ `\n No defaults.workspace.repos or defaults.workspaces in ${product().configFile} — nothing to seed.\n`,
2421
2513
  ),
2422
2514
  );
2423
2515
  }
2424
2516
  return false;
2425
2517
  }
2518
+ const multi = isMultiBlueprint();
2426
2519
  const willReset = defaultWorkspaceHasServices();
2427
2520
  const interactive = !!(process.stdin.isTTY && process.stdout.isTTY);
2428
2521
 
2429
- console.log(
2430
- chalk.cyan(`\n Default workspace — ${repos.length} repos, all managed:`),
2431
- );
2432
- console.log(chalk.gray(` ${repos.join(", ")}`));
2522
+ for (const bp of bps) {
2523
+ const label = multi ? `Workspace ${bp.name}` : "Default workspace";
2524
+ console.log(
2525
+ chalk.cyan(`\n ${label} — ${bp.repos.length} repos, all managed:`),
2526
+ );
2527
+ console.log(chalk.gray(` ${bp.repos.join(", ")}`));
2528
+ }
2433
2529
  console.log(
2434
2530
  chalk.gray(
2435
- ` ${product().title} clones + tracks them on main under ${product().servicesDir}/. Switch any`,
2531
+ ` ${product().title} clones + tracks them on their branch under ${product().servicesDir}/. Switch any`,
2436
2532
  ),
2437
2533
  );
2438
2534
  console.log(
@@ -2442,16 +2538,17 @@ class DenCLI {
2442
2538
  );
2443
2539
 
2444
2540
  if (interactive) {
2541
+ const what = multi ? "these workspaces" : "the default workspace";
2445
2542
  const question = willReset
2446
- ? " Reset the default workspace to this shipped set?"
2447
- : " Seed the default workspace now?";
2543
+ ? ` Reset ${what} to this shipped set?`
2544
+ : ` Seed ${what} now?`;
2448
2545
  const ok = await cliPrompts.confirm(question, !willReset);
2449
2546
  if (!ok) {
2450
2547
  if (reason === "auto") {
2451
2548
  markDefaultWorkspaceDeclined(); // don't ask again
2452
2549
  console.log(
2453
2550
  chalk.gray(
2454
- ` Skipped — run \`${product().cli} init\` to set it up later.\n`,
2551
+ ` Skipped — run \`${product().cli} workspace seed\` to set it up later.\n`,
2455
2552
  ),
2456
2553
  );
2457
2554
  } else {
@@ -2461,6 +2558,17 @@ class DenCLI {
2461
2558
  }
2462
2559
  }
2463
2560
 
2561
+ if (multi) {
2562
+ const seeded = seedBlueprintWorkspaces();
2563
+ const focus = readWorkspacesFile().focus;
2564
+ console.log(
2565
+ chalk.green(
2566
+ ` ✓ ${seeded.length} workspaces ready (${seeded.map((w) => w.name).join(", ")}), all active; focus: ${focus}. ` +
2567
+ `Run \`${product().cli} up <repo>\` to bring up its closure.\n`,
2568
+ ),
2569
+ );
2570
+ return true;
2571
+ }
2464
2572
  const seeded = seedDefaultWorkspace();
2465
2573
  console.log(
2466
2574
  chalk.green(
@@ -2472,13 +2580,22 @@ class DenCLI {
2472
2580
  }
2473
2581
 
2474
2582
  /**
2475
- * One-time offer to seed the default workspace on a fresh clone; afterwards
2476
- * keep it converged on the blueprint (repos added to defaults later — e.g.
2477
- * tasks-api — join the existing workspace managed).
2583
+ * On a genuine fresh setup (nothing but an empty, never-seeded `default`),
2584
+ * offer to seed from the blueprint: on a TTY, ask; otherwise print a
2585
+ * one-line hint and change nothing. Afterwards keep a lone `default`
2586
+ * converged on the blueprint (repos added to defaults later join it
2587
+ * managed); never once the user has other workspaces.
2478
2588
  */
2479
2589
  private async ensureWorkspaceSeeded(): Promise<void> {
2480
2590
  if (defaultWorkspaceNeedsSeeding()) {
2481
- await this.seedDefaultWorkspaceFlow("auto");
2591
+ if (process.stdin.isTTY && process.stdout.isTTY) {
2592
+ await this.seedDefaultWorkspaceFlow("auto");
2593
+ } else {
2594
+ const hint = isMultiBlueprint()
2595
+ ? "no workspaces yet: run `{cli} workspace seed` to create them from the blueprint"
2596
+ : "default workspace is empty: run `{cli} workspace seed` to fill it from the blueprint";
2597
+ console.error(chalk.gray(` ${hint.replace("{cli}", product().cli)}`));
2598
+ }
2482
2599
  return;
2483
2600
  }
2484
2601
  const added = topUpDefaultWorkspace();
@@ -3501,9 +3618,36 @@ class DenCLI {
3501
3618
  }
3502
3619
  }
3503
3620
  }
3621
+ // Deactivate releases tracked daemons only; say what it left up.
3622
+ // One docker ps for this workspace and every other active one.
3623
+ let up: ReturnType<typeof runningComposeProjects> | undefined;
3624
+ const running = () =>
3625
+ up === undefined ? (up = runningComposeProjects()) : up;
3626
+ const stacks = runningWorkspaceStacks(name, running);
3627
+ const heldBy = new Map<string, string[]>();
3628
+ if (stacks?.length)
3629
+ for (const other of getActiveWorkspaces())
3630
+ for (const s of runningWorkspaceStacks(other, running) ?? [])
3631
+ heldBy.set(s.project, [...(heldBy.get(s.project) ?? []), other]);
3632
+ const stacksNote = composeStacksNotice(product().cli, stacks, heldBy);
3633
+ if (stacksNote) console.log(chalk.yellow(` ${stacksNote}`));
3504
3634
  console.log("");
3505
3635
  break;
3506
3636
  }
3637
+ case "focus": {
3638
+ const name = args[1];
3639
+ if (!name) {
3640
+ console.log(
3641
+ chalk.red(`Usage: ${product().cli} workspace focus <name>`),
3642
+ );
3643
+ return;
3644
+ }
3645
+ setFocusedWorkspace(name);
3646
+ console.log(
3647
+ chalk.green(`\n ✓ Focused workspace: ${chalk.bold(name)}\n`),
3648
+ );
3649
+ break;
3650
+ }
3507
3651
  case "use": {
3508
3652
  const name = args[1];
3509
3653
  if (!name) {
@@ -3520,6 +3664,10 @@ class DenCLI {
3520
3664
  );
3521
3665
  break;
3522
3666
  }
3667
+ case "seed": {
3668
+ await this.seedDefaultWorkspaceFlow("init");
3669
+ break;
3670
+ }
3523
3671
  case "create": {
3524
3672
  const name =
3525
3673
  args[1] ??
@@ -3611,7 +3759,7 @@ class DenCLI {
3611
3759
  default:
3612
3760
  console.log(
3613
3761
  chalk.gray(
3614
- `Usage: ${product().cli} workspace <list|activate|deactivate|use|create|delete|code> [name]`,
3762
+ `Usage: ${product().cli} workspace <${WORKSPACE_SUBCOMMANDS.map((c) => c.name).join("|")}> [name]`,
3615
3763
  ),
3616
3764
  );
3617
3765
  }
@@ -3871,7 +4019,6 @@ class DenCLI {
3871
4019
  " service install Run the web UI as a launchd service (status, print, uninstall)",
3872
4020
  );
3873
4021
  console.log();
3874
- console.log(" workspace list List workspaces");
3875
4022
  console.log(
3876
4023
  " workspace list List workspaces (active set + focus)",
3877
4024
  );
@@ -3879,11 +4026,14 @@ class DenCLI {
3879
4026
  console.log(
3880
4027
  " workspace deactivate <n> Remove workspace from the active set",
3881
4028
  );
4029
+ console.log(
4030
+ " workspace focus <n> Focus an active workspace (the default)",
4031
+ );
3882
4032
  console.log(" workspace use <n> Activate and focus a workspace");
3883
- console.log(" workspace create [n] Create a new workspace");
3884
- console.log(" workspace delete <n> Delete a workspace");
4033
+ console.log(" workspace create [n] Create a new workspace");
4034
+ console.log(" workspace delete <n> Delete a workspace");
3885
4035
  console.log(
3886
- " workspace code [n] Open workspace in VS Code (generates .code-workspace)",
4036
+ " workspace code [n] Open workspace in VS Code (generates .code-workspace)",
3887
4037
  );
3888
4038
  console.log("\nTest commands:");
3889
4039
  console.log(
@@ -25,7 +25,7 @@ import { product } from "../../shared/product.js";
25
25
  import { getDistRoot } from "../../shared/paths.js";
26
26
  import { readWebPort } from "../../shared/edge/config.js";
27
27
  import { DEFAULT_PORT } from "../../shared/constants.js";
28
- import { readMcpConfig } from "../../shared/den-config.js";
28
+ import { readDenConfig, readMcpConfig } from "../../shared/den-config.js";
29
29
 
30
30
  export const MCP_CONNECT_SUBCOMMANDS = [
31
31
  "connect",
@@ -199,6 +199,78 @@ function status(
199
199
  : chalk.gray("not connected");
200
200
  }
201
201
 
202
+ /** How long `mcp connect` waits for /api/server-info before giving up. */
203
+ export const WEB_PROBE_TIMEOUT_MS = 1500;
204
+
205
+ /** What a probe of the web server's /api/server-info found. */
206
+ export type WebProbe =
207
+ | { kind: "up"; version?: string }
208
+ /** Something accepted the connection but sent no answer in time. */
209
+ | { kind: "timeout" }
210
+ /** Nothing answered: connection refused, or not the web server. */
211
+ | { kind: "down" };
212
+
213
+ export async function probeWebServer(
214
+ url: string,
215
+ timeoutMs: number = WEB_PROBE_TIMEOUT_MS,
216
+ fetchImpl: typeof fetch = fetch,
217
+ ): Promise<WebProbe> {
218
+ try {
219
+ const r = await fetchImpl(`${url}/api/server-info`, {
220
+ signal: AbortSignal.timeout(timeoutMs),
221
+ });
222
+ const info = (await r.json()) as { version?: string };
223
+ return { kind: "up", version: info.version };
224
+ } catch (e) {
225
+ return (e as Error).name === "TimeoutError"
226
+ ? { kind: "timeout" }
227
+ : { kind: "down" };
228
+ }
229
+ }
230
+
231
+ /** Whether the org config runs the web UI as a service (`web.service`). */
232
+ function webRunsAsService(): boolean {
233
+ try {
234
+ return readDenConfig().web?.service !== undefined;
235
+ } catch {
236
+ return false;
237
+ }
238
+ }
239
+
240
+ /**
241
+ * The status line for a probe. A server that is up but slow is "not
242
+ * answering", never "not running"; and when the web UI runs as a service
243
+ * (`web.service`), the advice is the service's status, not `web up`.
244
+ */
245
+ export function webServerMessage(
246
+ probe: WebProbe,
247
+ url: string,
248
+ opts: { cli: string; asService: boolean; timeoutMs?: number },
249
+ ): string {
250
+ if (probe.kind === "up")
251
+ return (
252
+ chalk.green(`web server up at ${url}`) +
253
+ (probe.version ? chalk.gray(` (Den ${probe.version})`) : "")
254
+ );
255
+ const svc = `\`${opts.cli} service status\``;
256
+ if (probe.kind === "timeout") {
257
+ const secs = (opts.timeoutMs ?? WEB_PROBE_TIMEOUT_MS) / 1000;
258
+ return chalk.yellow(
259
+ `web server at ${url} is not answering within ${secs}s (busy or stuck): ` +
260
+ `agents' reads wait on it too; if it stays slow, ` +
261
+ (opts.asService
262
+ ? `check ${svc}`
263
+ : `restart it with \`${opts.cli} web restart\``),
264
+ );
265
+ }
266
+ return chalk.yellow(
267
+ `web server not running at ${url}: agents get read errors until ` +
268
+ (opts.asService
269
+ ? `the service is up (check ${svc})`
270
+ : `\`${opts.cli} web up\``),
271
+ );
272
+ }
273
+
202
274
  async function webServerLine(port?: number): Promise<string> {
203
275
  let p = port;
204
276
  if (p === undefined) {
@@ -209,20 +281,10 @@ async function webServerLine(port?: number): Promise<string> {
209
281
  }
210
282
  }
211
283
  const url = `http://localhost:${p}`;
212
- try {
213
- const r = await fetch(`${url}/api/server-info`, {
214
- signal: AbortSignal.timeout(1500),
215
- });
216
- const info = (await r.json()) as { version?: string };
217
- return (
218
- chalk.green(`web server up at ${url}`) +
219
- (info.version ? chalk.gray(` (Den ${info.version})`) : "")
220
- );
221
- } catch {
222
- return chalk.yellow(
223
- `web server not running at ${url}: agents get read errors until \`${product().cli} web up\``,
224
- );
225
- }
284
+ return webServerMessage(await probeWebServer(url), url, {
285
+ cli: product().cli,
286
+ asService: webRunsAsService(),
287
+ });
226
288
  }
227
289
 
228
290
  function usage(): string {
@@ -22,6 +22,7 @@ import {
22
22
  } from "../../shared/stacks/runtime.js";
23
23
  import { loadStackContext, stackStatuses } from "../../shared/stacks/status.js";
24
24
  import type { StackConfig } from "../../shared/stacks/types.js";
25
+ import { STACK_HEALTH_UNKNOWN_EXIT } from "../../shared/constants.js";
25
26
 
26
27
  function serviceFlag(args: string[]): { service?: string; rest: string[] } {
27
28
  const rest: string[] = [];
@@ -56,9 +57,48 @@ export async function resolveStacksSecretEnv(
56
57
  return out;
57
58
  }
58
59
 
60
+ /**
61
+ * `stack health <name…>`: exit 0 when every stack is healthy, 1 when one is
62
+ * not, and STACK_HEALTH_UNKNOWN_EXIT when none failed but one could not be
63
+ * told (docker did not answer, the config could not be read). The web
64
+ * server's health row reads that as unknown, never as down.
65
+ */
66
+ function stackHealthCommand(args: string[], service?: string): void {
67
+ let failed = false;
68
+ let unknown = false;
69
+ try {
70
+ const ctx = loadStackContext();
71
+ const names = args.filter((a) => !a.startsWith("-"));
72
+ if (!names.length)
73
+ throw new Error(
74
+ `Usage: stack health <name…> (known: ${Object.keys(ctx.stacks).join(", ")})`,
75
+ );
76
+ for (const n of names) {
77
+ const s = ctx.stacks[n];
78
+ const h = s
79
+ ? stackHealth(ctx, s, service)
80
+ : { ok: false, detail: `unknown stack "${n}"` };
81
+ if (h.ok === false) failed = true;
82
+ if (h.ok === null) unknown = true;
83
+ const mark =
84
+ h.ok === true
85
+ ? chalk.green("✓")
86
+ : h.ok === false
87
+ ? chalk.red("✗")
88
+ : chalk.gray("?");
89
+ console.log(`${mark} ${n} ${h.detail}`);
90
+ }
91
+ } catch (e) {
92
+ console.error(chalk.gray(`? ${(e as Error).message}`));
93
+ unknown = true;
94
+ }
95
+ process.exitCode = failed ? 1 : unknown ? STACK_HEALTH_UNKNOWN_EXIT : 0;
96
+ }
97
+
59
98
  export async function cmdStack(argv: string[]): Promise<void> {
60
99
  const { service, rest } = serviceFlag(argv);
61
100
  const [sub = "ls", ...args] = rest;
101
+ if (sub === "health") return stackHealthCommand(args, service);
62
102
  // The global -w/--workspace is parsed (and checked) before we run and
63
103
  // arrives as DEN_WORKSPACE_OVERRIDE; loadStackContext reads it.
64
104
  const ctx = loadStackContext();
@@ -129,18 +169,6 @@ export async function cmdStack(argv: string[]): Promise<void> {
129
169
  }
130
170
  return;
131
171
  }
132
- case "health": {
133
- let ok = true;
134
- for (const n of pick()) {
135
- const h = stackHealth(ctx, stacks[n], service);
136
- ok &&= h.ok;
137
- console.log(
138
- `${h.ok ? chalk.green("✓") : chalk.red("✗")} ${n} ${h.detail}`,
139
- );
140
- }
141
- process.exitCode = ok ? 0 : 1;
142
- return;
143
- }
144
172
  case "down":
145
173
  for (const n of startOrder(pick(), stacks)
146
174
  .reverse()
@@ -3,6 +3,7 @@ import { execFileSync, spawn, spawnSync } from "child_process";
3
3
  import * as fs from "fs";
4
4
  import * as http from "http";
5
5
  import * as path from "path";
6
+ import { pathToFileURL } from "url";
6
7
  import {
7
8
  CLI_SPAWN_TIMEOUT_MS,
8
9
  DEFAULT_PORT,
@@ -10,7 +11,7 @@ import {
10
11
  } from "../../shared/constants.js";
11
12
  import { getDistRoot, getEngineRoot, getStateDir } from "../../shared/paths.js";
12
13
  import { hasDenUiMarker } from "../../shared/ui-marker.js";
13
- import { engineBin } from "../../shared/engine.js";
14
+ import { engineBin, engineModuleFile } from "../../shared/engine.js";
14
15
  import { readWebPort } from "../../shared/edge/config.js";
15
16
 
16
17
  import { product } from "../../shared/product.js";
@@ -294,8 +295,19 @@ async function waitForDenWeb(port: number): Promise<boolean> {
294
295
  return false;
295
296
  }
296
297
 
297
- /** How to run the web server: Den's own files, from the engine root. */
298
- function resolveServerEntrypoint(engine: string = getEngineRoot()): {
298
+ /**
299
+ * How to run the web server: Den's own files, from the engine root.
300
+ *
301
+ * The TypeScript source runs as `node --import <tsx loader> index.ts`, never
302
+ * through the `tsx` CLI. The CLI is a parent process that relays signals to
303
+ * the server and, if the server has not acknowledged a SIGTERM within about
304
+ * 60ms (two 30ms windows), SIGKILLs it and exits 143. Under load the server
305
+ * misses that window, so `<cli> web down` killed it without its shutdown.
306
+ * With the loader there is one process, and the signal reaches the
307
+ * server's own handler. (The launchd service, service/install.sh, already
308
+ * runs this way.)
309
+ */
310
+ export function resolveServerEntrypoint(engine: string = getEngineRoot()): {
299
311
  cmd: string;
300
312
  args: string[];
301
313
  } {
@@ -313,17 +325,23 @@ function resolveServerEntrypoint(engine: string = getEngineRoot()): {
313
325
  // take effect. The server resolves PUBLIC_DIR relative to its own module, so
314
326
  // running from src/ serves src/ui/server/public directly. Fall back to the
315
327
  // compiled build only when devDependencies (tsx) aren't installed.
316
- const tsxBin = engineBin(process.platform === "win32" ? "tsx.cmd" : "tsx");
328
+ const loader = engineModuleFile("tsx/dist/loader.mjs", engine);
317
329
  const srcServer = path.join(engine, "src/ui/server/index.ts");
318
- if (fs.existsSync(tsxBin) && fs.existsSync(srcServer)) {
319
- return { cmd: tsxBin, args: [srcServer] };
330
+ if (loader && fs.existsSync(srcServer)) {
331
+ return {
332
+ cmd: process.execPath,
333
+ args: ["--import", pathToFileURL(loader).href, srcServer],
334
+ };
320
335
  }
321
336
 
322
337
  if (fs.existsSync(builtServer)) {
323
338
  return { cmd: process.execPath, args: [builtServer] };
324
339
  }
325
340
 
326
- return { cmd: tsxBin, args: [srcServer] };
341
+ return {
342
+ cmd: engineBin(process.platform === "win32" ? "tsx.cmd" : "tsx", engine),
343
+ args: [srcServer],
344
+ };
327
345
  }
328
346
 
329
347
  function startDenWebServer(options: WebCommandOptions): StartedServer {
@@ -0,0 +1,43 @@
1
+ /**
2
+ * What `<cli> workspace deactivate` says about what it leaves running: it
3
+ * releases only the daemons Den tracks, so a compose stack started from one
4
+ * of the workspace's repos keeps running until `<cli> down <repo>`. A stack
5
+ * another active workspace still holds is named as in use, never with a
6
+ * command that would stop it.
7
+ */
8
+ import type { WorkspaceStack } from "../../shared/stacks/status.js";
9
+
10
+ /**
11
+ * The note for `stacks`: the running ones (null when docker could not be
12
+ * asked, so it is said generically), or nothing when none is running.
13
+ * `heldBy` maps a Compose project to the other active workspaces that hold
14
+ * it.
15
+ */
16
+ export function composeStacksNotice(
17
+ cli: string,
18
+ stacks: WorkspaceStack[] | null,
19
+ heldBy: ReadonlyMap<string, string[]> = new Map(),
20
+ ): string | null {
21
+ if (stacks === null)
22
+ return `compose stacks keep running: stop them with \`${cli} down <repo>\``;
23
+ if (stacks.length === 0) return null;
24
+ const held = stacks.filter((s) => heldBy.get(s.project)?.length);
25
+ // `down <repo>` stops all of a repo's stacks: never suggest it for a repo
26
+ // with a stack still in use.
27
+ const heldRepos = new Set(held.map((s) => s.repo));
28
+ const free = stacks.filter((s) => !heldRepos.has(s.repo));
29
+ const inUse = held.map(
30
+ (s) => `${s.project} (still in ${heldBy.get(s.project)!.join(", ")})`,
31
+ );
32
+ const parts: string[] = [];
33
+ if (free.length) {
34
+ const repos = [...new Set(free.map((s) => s.repo))];
35
+ parts.push(
36
+ `compose stacks keep running (${free.map((s) => s.project).join(", ")}): ` +
37
+ `stop them with ${repos.map((r) => `\`${cli} down ${r}\``).join(", ")}`,
38
+ );
39
+ }
40
+ if (inUse.length)
41
+ parts.push(`in use by another workspace: ${inUse.join(", ")}`);
42
+ return parts.join("; ");
43
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The dependency graph's top-down repo order (entry repo first, infra last),
3
+ * which the health row and /api/services rank by.
4
+ *
5
+ * Deriving the graph walks every repo's sources (fingerprints, scans), so it
6
+ * is far too slow to redo on each 5s poll. In the web server it is kept for
7
+ * 30s and then recomputed after the request that noticed, never inside it
8
+ * (read-cache.ts); a stale order only means a briefly stale sort.
9
+ */
10
+ import { deriveGraph } from "../../shared/environment-manager/derivation/derive-graph.js";
11
+ import { orderForDown } from "../../shared/environment-manager/derivation/derived-up.js";
12
+ import { cachedReadStale } from "../../shared/read-cache.js";
13
+
14
+ const GRAPH_ORDER_MAX_AGE_MS = 30_000;
15
+
16
+ export function graphOrder(workspace: string | undefined): string[] {
17
+ return cachedReadStale(
18
+ `graph-order:${workspace ?? ""}`,
19
+ GRAPH_ORDER_MAX_AGE_MS,
20
+ () => orderForDown(deriveGraph({ scope: "all", workspace })),
21
+ );
22
+ }