@monster-orange/den 0.2.2 → 0.2.4

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 (37) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/docs/distributions.md +51 -0
  3. package/package.json +1 -1
  4. package/scripts/setup-secrets.ts +11 -0
  5. package/service/install.sh +4 -2
  6. package/src/shared/edge/config.ts +16 -0
  7. package/src/shared/environment-manager/health.ts +36 -1
  8. package/src/shared/environment-manager/opctl.ts +83 -1
  9. package/src/shared/environment-manager/service-runner.ts +205 -8
  10. package/src/shared/git/origin-url.ts +77 -0
  11. package/src/shared/manifest/read-manifest.ts +39 -15
  12. package/src/shared/manifest/schema.ts +15 -0
  13. package/src/shared/paths.ts +42 -0
  14. package/src/shared/product.ts +7 -1
  15. package/src/shared/profile.ts +8 -0
  16. package/src/shared/read-cache.ts +77 -0
  17. package/src/shared/secrets/doppler.ts +10 -5
  18. package/src/shared/secrets/index.ts +1 -0
  19. package/src/shared/secrets/keys.ts +2 -0
  20. package/src/shared/secrets/secret-env.ts +149 -0
  21. package/src/shared/secrets/types.ts +10 -0
  22. package/src/shared/stacks/config.ts +6 -0
  23. package/src/shared/stacks/runtime.ts +51 -3
  24. package/src/shared/stacks/status.ts +109 -1
  25. package/src/shared/stacks/types.ts +8 -0
  26. package/src/shared/types/index.ts +15 -0
  27. package/src/ui/cli/index.ts +151 -10
  28. package/src/ui/cli/mcp-connect.ts +77 -15
  29. package/src/ui/cli/stack-command.ts +47 -5
  30. package/src/ui/cli/workspace-notes.ts +43 -0
  31. package/src/ui/mcp/connect.ts +3 -1
  32. package/src/ui/server/graph-order.ts +22 -0
  33. package/src/ui/server/index.ts +101 -183
  34. package/src/ui/server/lifecycle-exec.ts +23 -0
  35. package/src/ui/server/services-list.ts +210 -0
  36. package/src/ui/server/single-flight.ts +53 -0
  37. package/src/ui/server/workspace-routes.ts +272 -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,
@@ -99,6 +106,8 @@ import { waitForReady } from "../../shared/environment-manager/readiness.js";
99
106
  import {
100
107
  canonicalServiceAddress,
101
108
  previewServiceCommand,
109
+ previewServiceSecretEnv,
110
+ prepareServiceSecretEnv,
102
111
  resolveService,
103
112
  type ServiceCommandPreview,
104
113
  } from "../../shared/environment-manager/service-runner.js";
@@ -193,6 +202,58 @@ const DEN_HELP_DESCRIPTION = ` ${product().title} - local-first test, service,
193
202
  * Concise `<command> -h` help for commands without a dedicated help renderer.
194
203
  * Flag-heavy commands (up, graph, use-local, use-managed) have richer handlers.
195
204
  */
205
+ /** `workspace` subcommands, for `workspace --help` and `workspace <sub> --help`. */
206
+ const WORKSPACE_SUBCOMMANDS: {
207
+ name: string;
208
+ usage: string;
209
+ lines: string[];
210
+ }[] = [
211
+ {
212
+ name: "list",
213
+ usage: "list",
214
+ lines: ["every workspace, the active set and the focus"],
215
+ },
216
+ {
217
+ name: "activate",
218
+ usage: "activate <n>",
219
+ lines: ["add a workspace to the active set"],
220
+ },
221
+ {
222
+ name: "deactivate",
223
+ usage: "deactivate <n>",
224
+ lines: [
225
+ "remove it from the active set and stop the services",
226
+ "only it owns. Only services {cli} tracks: compose",
227
+ "stacks keep running; stop them with `{cli} down <repo>`",
228
+ ],
229
+ },
230
+ {
231
+ name: "focus",
232
+ usage: "focus <n>",
233
+ lines: ["make an active workspace the default for commands"],
234
+ },
235
+ {
236
+ name: "use",
237
+ usage: "use <n>",
238
+ lines: ["activate and focus a workspace"],
239
+ },
240
+ {
241
+ name: "create",
242
+ usage: "create [n]",
243
+ lines: ["create an empty workspace"],
244
+ },
245
+ {
246
+ name: "delete",
247
+ usage: "delete <n>",
248
+ lines: ["delete a workspace (and its directory)"],
249
+ },
250
+ {
251
+ name: "code",
252
+ usage: "code [n]",
253
+ lines: ["open it in your editor (VS Code: a .code-workspace)"],
254
+ },
255
+ ];
256
+
196
257
  const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
197
258
  down: {
198
259
  usage: "down [repo] [flags]",
@@ -250,8 +311,16 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
250
311
  lines: ["Check managed repos for upstream updates and offer to pull."],
251
312
  },
252
313
  workspace: {
253
- usage: "workspace <list|use|create|delete|code> [name]",
254
- lines: ["Manage workspaces (named sets of services + their modes)."],
314
+ usage: `workspace <${WORKSPACE_SUBCOMMANDS.map((c) => c.name).join("|")}> [name]`,
315
+ lines: [
316
+ "Manage workspaces (named sets of services + their modes).",
317
+ "",
318
+ ...WORKSPACE_SUBCOMMANDS.flatMap((c) =>
319
+ c.lines.map(
320
+ (line, i) => ` ${(i === 0 ? c.usage : "").padEnd(16)} ${line}`,
321
+ ),
322
+ ),
323
+ ],
255
324
  },
256
325
  run: {
257
326
  usage: "run [options]",
@@ -581,7 +650,7 @@ class DenCLI {
581
650
  !["-h", "--help", "-v", "--version"].includes(command) &&
582
651
  (ownArgs.includes("-h") || ownArgs.includes("--help"))
583
652
  ) {
584
- this.printCommandHelp(command);
653
+ this.printCommandHelp(command, ownArgs[1]);
585
654
  return;
586
655
  }
587
656
 
@@ -1453,6 +1522,20 @@ class DenCLI {
1453
1522
  console.log(chalk.gray(` ${p.note}`));
1454
1523
  if (p.kind === "error") console.log(chalk.red(` ✗ ${p.note}`));
1455
1524
  }
1525
+ // R16: key names only. A dry run never asks the secrets provider.
1526
+ for (const a of new Set(runs)) {
1527
+ const sec = previewServiceSecretEnv(a, command, workspace);
1528
+ if (!sec) continue;
1529
+ const parts = [
1530
+ ...(sec.service.length ? [sec.service.join(", ")] : []),
1531
+ ...(sec.compose.length ? [`compose: ${sec.compose.join(", ")}`] : []),
1532
+ ];
1533
+ console.log(
1534
+ chalk.gray(
1535
+ ` ${sec.address}: would inject secret_env ${parts.join("; ")} (names only; not resolved in a dry run)`,
1536
+ ),
1537
+ );
1538
+ }
1456
1539
  if (previews.some((p) => p.kind === "error")) process.exitCode = 1;
1457
1540
  console.log(chalk.gray(` (--dry-run: not ${doing} anything)\n`));
1458
1541
  return;
@@ -1590,7 +1673,22 @@ class DenCLI {
1590
1673
  }
1591
1674
  }
1592
1675
 
1676
+ // R16: resolve the service's secret_env before anything spawns. A
1677
+ // missing key fails this target; nothing starts with a partial set.
1678
+ const secrets = await prepareServiceSecretEnv(
1679
+ address,
1680
+ command,
1681
+ workspace,
1682
+ );
1683
+
1593
1684
  const started = await new Promise<boolean>((resolve) => {
1685
+ if (!secrets.ok) {
1686
+ console.log(chalk.red(` ✗ ${command} ${chalk.bold(address)}`));
1687
+ for (const l of secrets.message.split("\n"))
1688
+ console.log(chalk.gray(` ${l}`));
1689
+ resolve(false);
1690
+ return;
1691
+ }
1594
1692
  const spinner = ora(`${command} ${chalk.bold(address)}...`).start();
1595
1693
  let detachTimer: ReturnType<typeof setTimeout> | null = null;
1596
1694
  let settled = false;
@@ -1672,6 +1770,7 @@ class DenCLI {
1672
1770
  runtime,
1673
1771
  workspace,
1674
1772
  force: command === "down" ? force : false,
1773
+ secretEnv: secrets.secretEnv,
1675
1774
  onLine: (line) => {
1676
1775
  spinner.text = `${command} ${chalk.bold(address)}: ${chalk.gray(line.slice(0, 80))}`;
1677
1776
  const trimmed = line.trim();
@@ -2343,7 +2442,19 @@ class DenCLI {
2343
2442
  }
2344
2443
 
2345
2444
  /** Help for `<command> -h`: dedicated renderer for flag-heavy commands, else the table. */
2346
- private printCommandHelp(command: string): void {
2445
+ private printCommandHelp(command: string, sub?: string): void {
2446
+ // `workspace <sub> --help`: that subcommand only.
2447
+ const wsSub =
2448
+ command === "workspace"
2449
+ ? WORKSPACE_SUBCOMMANDS.find((c) => c.name === sub)
2450
+ : undefined;
2451
+ if (wsSub) {
2452
+ console.log(`\n ${product().cli} workspace ${wsSub.usage}\n`);
2453
+ for (const line of wsSub.lines)
2454
+ console.log(` ${line.replaceAll("{cli}", product().cli)}`);
2455
+ console.log();
2456
+ return;
2457
+ }
2347
2458
  switch (command) {
2348
2459
  case "up":
2349
2460
  return this.printUpHelp();
@@ -2366,7 +2477,8 @@ class DenCLI {
2366
2477
  return;
2367
2478
  }
2368
2479
  console.log(`\n ${product().cli} ${entry.usage}\n`);
2369
- for (const line of entry.lines) console.log(` ${line}`);
2480
+ for (const line of entry.lines)
2481
+ console.log(` ${line.replaceAll("{cli}", product().cli)}`.trimEnd());
2370
2482
  console.log();
2371
2483
  }
2372
2484
 
@@ -3469,9 +3581,36 @@ class DenCLI {
3469
3581
  }
3470
3582
  }
3471
3583
  }
3584
+ // Deactivate releases tracked daemons only; say what it left up.
3585
+ // One docker ps for this workspace and every other active one.
3586
+ let up: ReturnType<typeof runningComposeProjects> | undefined;
3587
+ const running = () =>
3588
+ up === undefined ? (up = runningComposeProjects()) : up;
3589
+ const stacks = runningWorkspaceStacks(name, running);
3590
+ const heldBy = new Map<string, string[]>();
3591
+ if (stacks?.length)
3592
+ for (const other of getActiveWorkspaces())
3593
+ for (const s of runningWorkspaceStacks(other, running) ?? [])
3594
+ heldBy.set(s.project, [...(heldBy.get(s.project) ?? []), other]);
3595
+ const stacksNote = composeStacksNotice(product().cli, stacks, heldBy);
3596
+ if (stacksNote) console.log(chalk.yellow(` ${stacksNote}`));
3472
3597
  console.log("");
3473
3598
  break;
3474
3599
  }
3600
+ case "focus": {
3601
+ const name = args[1];
3602
+ if (!name) {
3603
+ console.log(
3604
+ chalk.red(`Usage: ${product().cli} workspace focus <name>`),
3605
+ );
3606
+ return;
3607
+ }
3608
+ setFocusedWorkspace(name);
3609
+ console.log(
3610
+ chalk.green(`\n ✓ Focused workspace: ${chalk.bold(name)}\n`),
3611
+ );
3612
+ break;
3613
+ }
3475
3614
  case "use": {
3476
3615
  const name = args[1];
3477
3616
  if (!name) {
@@ -3579,7 +3718,7 @@ class DenCLI {
3579
3718
  default:
3580
3719
  console.log(
3581
3720
  chalk.gray(
3582
- `Usage: ${product().cli} workspace <list|activate|deactivate|use|create|delete|code> [name]`,
3721
+ `Usage: ${product().cli} workspace <list|activate|deactivate|focus|use|create|delete|code> [name]`,
3583
3722
  ),
3584
3723
  );
3585
3724
  }
@@ -3839,7 +3978,6 @@ class DenCLI {
3839
3978
  " service install Run the web UI as a launchd service (status, print, uninstall)",
3840
3979
  );
3841
3980
  console.log();
3842
- console.log(" workspace list List workspaces");
3843
3981
  console.log(
3844
3982
  " workspace list List workspaces (active set + focus)",
3845
3983
  );
@@ -3847,11 +3985,14 @@ class DenCLI {
3847
3985
  console.log(
3848
3986
  " workspace deactivate <n> Remove workspace from the active set",
3849
3987
  );
3988
+ console.log(
3989
+ " workspace focus <n> Focus an active workspace (the default)",
3990
+ );
3850
3991
  console.log(" workspace use <n> Activate and focus a workspace");
3851
- console.log(" workspace create [n] Create a new workspace");
3852
- console.log(" workspace delete <n> Delete a workspace");
3992
+ console.log(" workspace create [n] Create a new workspace");
3993
+ console.log(" workspace delete <n> Delete a workspace");
3853
3994
  console.log(
3854
- " workspace code [n] Open workspace in VS Code (generates .code-workspace)",
3995
+ " workspace code [n] Open workspace in VS Code (generates .code-workspace)",
3855
3996
  );
3856
3997
  console.log("\nTest commands:");
3857
3998
  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 {
@@ -8,8 +8,11 @@
8
8
  import chalk from "chalk";
9
9
  import { routeUrl } from "../../shared/edge/render.js";
10
10
  import { edgeUp } from "../../shared/edge/runtime.js";
11
+ import { getSecretsProvider } from "../../shared/secrets/index.js";
12
+ import type { SecretsProvider } from "../../shared/secrets/types.js";
11
13
  import { projectName, startOrder } from "../../shared/stacks/render.js";
12
14
  import {
15
+ resolveStackSecretEnv,
13
16
  stackDown,
14
17
  stackHealth,
15
18
  stackLogs,
@@ -18,6 +21,7 @@ import {
18
21
  stackUp,
19
22
  } from "../../shared/stacks/runtime.js";
20
23
  import { loadStackContext, stackStatuses } from "../../shared/stacks/status.js";
24
+ import type { StackConfig } from "../../shared/stacks/types.js";
21
25
 
22
26
  function serviceFlag(args: string[]): { service?: string; rest: string[] } {
23
27
  const rest: string[] = [];
@@ -29,6 +33,29 @@ function serviceFlag(args: string[]): { service?: string; rest: string[] } {
29
33
  return { service, rest };
30
34
  }
31
35
 
36
+ /**
37
+ * Every named stack's `secret_env`, resolved before anything starts or
38
+ * stops: one missing key fails the whole command with nothing changed.
39
+ * The provider is only asked when some stack declares secret_env.
40
+ */
41
+ export async function resolveStacksSecretEnv(
42
+ targets: StackConfig[],
43
+ provider: () => SecretsProvider = getSecretsProvider,
44
+ ): Promise<Map<string, Record<string, string>>> {
45
+ const out = new Map<string, Record<string, string>>();
46
+ const needing = targets.filter((s) => Object.keys(s.secretEnv ?? {}).length);
47
+ if (!needing.length) return out;
48
+ const p = provider();
49
+ const failures: string[] = [];
50
+ for (const s of needing) {
51
+ const r = await resolveStackSecretEnv(s, p);
52
+ if (r.ok) out.set(s.name, r.env);
53
+ else failures.push(r.message);
54
+ }
55
+ if (failures.length) throw new Error(failures.join("\n"));
56
+ return out;
57
+ }
58
+
32
59
  export async function cmdStack(argv: string[]): Promise<void> {
33
60
  const { service, rest } = serviceFlag(argv);
34
61
  const [sub = "ls", ...args] = rest;
@@ -62,21 +89,32 @@ export async function cmdStack(argv: string[]): Promise<void> {
62
89
  return;
63
90
  case "up": {
64
91
  // Re-render the edge so its cert covers every stack, then start the stacks.
92
+ const order = startOrder(pick(), stacks);
93
+ const secrets = await resolveStacksSecretEnv(order.map((n) => stacks[n]));
65
94
  await edgeUp(edge);
66
- for (const n of startOrder(pick(), stacks)) {
95
+ for (const n of order) {
67
96
  console.log(chalk.cyan(`\n▶ ${n} (${projectName(n, ctx.workspace)})`));
68
- stackUp(ctx, stacks[n], { build: args.includes("--build") });
97
+ stackUp(ctx, stacks[n], {
98
+ build: args.includes("--build"),
99
+ secretEnv: secrets.get(n),
100
+ });
69
101
  for (const route of Object.keys(stacks[n].routes))
70
102
  console.log(chalk.green(` ✓ ${routeUrl(`${route}.${n}`, edge)}`));
71
103
  }
72
104
  return;
73
105
  }
74
- case "restart":
106
+ case "restart": {
107
+ // Resolved before the down: a missing secret leaves the stack running.
108
+ const secrets = await resolveStacksSecretEnv(
109
+ pick().map((n) => stacks[n]),
110
+ );
75
111
  if (service) {
76
112
  // One compose service, in place: no edge re-render, no recreate.
77
113
  for (const n of pick()) {
78
114
  console.log(chalk.cyan(`↻ ${n} ${service}`));
79
- stackRestartService(ctx, stacks[n], service);
115
+ stackRestartService(ctx, stacks[n], service, {
116
+ secretEnv: secrets.get(n),
117
+ });
80
118
  }
81
119
  return;
82
120
  }
@@ -84,9 +122,13 @@ export async function cmdStack(argv: string[]): Promise<void> {
84
122
  for (const n of pick()) {
85
123
  console.log(chalk.cyan(`↻ ${n}`));
86
124
  stackDown(ctx, stacks[n]);
87
- stackUp(ctx, stacks[n], { build: args.includes("--build") });
125
+ stackUp(ctx, stacks[n], {
126
+ build: args.includes("--build"),
127
+ secretEnv: secrets.get(n),
128
+ });
88
129
  }
89
130
  return;
131
+ }
90
132
  case "health": {
91
133
  let ok = true;
92
134
  for (const n of pick()) {
@@ -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
+ }
@@ -107,10 +107,12 @@ export const MCP_CLIENTS: readonly McpClient[] = [
107
107
  "claude",
108
108
  "mcp",
109
109
  "add",
110
+ // The name first: claude's -e takes several values, so a name
111
+ // after the env pairs is read as one more pair.
112
+ s.name,
110
113
  "--scope",
111
114
  "user",
112
115
  ...envFlags("-e", s.env),
113
- s.name,
114
116
  "--",
115
117
  s.command,
116
118
  ...s.args,
@@ -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
+ }