@monster-orange/den 0.2.8 → 0.4.0

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 (158) hide show
  1. package/CHANGELOG.md +1731 -0
  2. package/README.md +4 -2
  3. package/bin/den +2 -1
  4. package/docs/distributions.md +730 -6
  5. package/make/den.mk +19 -4
  6. package/make/journeys.mk +2 -1
  7. package/make/local_dev.mk +6 -13
  8. package/make/secrets-1password.mk +18 -0
  9. package/make/test.mk +1 -1
  10. package/package.json +12 -2
  11. package/schemas/den.plugin.v1.json +156 -0
  12. package/schemas/den.theme.v1.json +269 -0
  13. package/scripts/auth-configure.ts +16 -8
  14. package/scripts/heal-config.ts +0 -5
  15. package/scripts/setup-1password.ts +41 -19
  16. package/scripts/setup-secrets.ts +67 -101
  17. package/src/pillars/api/global-setup.ts +1 -1
  18. package/src/pillars/journeys/sandbox.ts +23 -18
  19. package/src/pillars/journeys/tools/browser-session.ts +2 -2
  20. package/src/scripts/setup-default-workspace.ts +2 -2
  21. package/src/shared/agents/default-pack/be-dev.md +20 -0
  22. package/src/shared/agents/default-pack/devops.md +14 -0
  23. package/src/shared/agents/default-pack/fe-dev.md +20 -0
  24. package/src/shared/agents/default-pack/orchestrator.md +16 -0
  25. package/src/shared/agents/default-pack/planner.md +17 -0
  26. package/src/shared/agents/default-pack/reviewer.md +14 -0
  27. package/src/shared/agents/default-pack/ux-designer.md +15 -0
  28. package/src/shared/agents/den-claude.ts +173 -0
  29. package/src/shared/agents/generic.ts +52 -0
  30. package/src/shared/agents/personas.ts +218 -0
  31. package/src/shared/agents/records.ts +118 -0
  32. package/src/shared/agents/runtime.ts +192 -0
  33. package/src/shared/agents/runtimes.ts +47 -0
  34. package/src/shared/auth/headless-login.ts +1 -1
  35. package/src/shared/auth/sudo-vault.ts +93 -31
  36. package/src/shared/auth/token-providers/index.ts +7 -3
  37. package/src/shared/brand.ts +7 -0
  38. package/src/shared/config-error.ts +33 -3
  39. package/src/shared/constants.ts +3 -24
  40. package/src/shared/den-config.ts +257 -5
  41. package/src/shared/edge/config.ts +147 -21
  42. package/src/shared/edge/exec.ts +31 -0
  43. package/src/shared/edge/included.ts +169 -0
  44. package/src/shared/edge/render.ts +69 -14
  45. package/src/shared/edge/runtime.ts +128 -121
  46. package/src/shared/edge/status.ts +68 -8
  47. package/src/shared/edge/tls.ts +328 -0
  48. package/src/shared/edge/types.ts +25 -9
  49. package/src/shared/engine.ts +23 -0
  50. package/src/shared/environment-manager/default-workspace.ts +16 -1
  51. package/src/shared/environment-manager/health.ts +73 -38
  52. package/src/shared/environment-manager/scope.ts +168 -0
  53. package/src/shared/environment-manager/service-runner.ts +789 -43
  54. package/src/shared/environment-manager/up-targets.ts +132 -0
  55. package/src/shared/environment-manager/workspaces.ts +19 -0
  56. package/src/shared/health/rollup.ts +48 -0
  57. package/src/shared/open-url.ts +21 -2
  58. package/src/shared/org-sources.ts +78 -59
  59. package/src/shared/org.ts +53 -5
  60. package/src/shared/plugins/manifest.ts +406 -0
  61. package/src/shared/plugins/store.ts +358 -0
  62. package/src/shared/process/legacy-adopt.ts +285 -0
  63. package/src/shared/process/process-group.ts +202 -0
  64. package/src/shared/process/spawn-streaming.ts +7 -0
  65. package/src/shared/secret-env.ts +70 -0
  66. package/src/shared/secrets/doppler.ts +1 -1
  67. package/src/shared/secrets/index.ts +279 -50
  68. package/src/shared/secrets/none.ts +56 -0
  69. package/src/shared/secrets/strategies.ts +202 -0
  70. package/src/shared/secrets/types.ts +1 -1
  71. package/src/shared/sessions/client.ts +277 -0
  72. package/src/shared/sessions/den-pty.ts +97 -0
  73. package/src/shared/sessions/env.ts +79 -0
  74. package/src/shared/sessions/host.ts +667 -0
  75. package/src/shared/sessions/names.ts +85 -0
  76. package/src/shared/sessions/paths.ts +27 -0
  77. package/src/shared/sessions/protocol.ts +72 -0
  78. package/src/shared/sessions/pty.ts +141 -0
  79. package/src/shared/sessions/ring-buffer.ts +160 -0
  80. package/src/shared/sessions/types.ts +81 -0
  81. package/src/shared/smoke/git-remote.ts +40 -0
  82. package/src/shared/stacks/manifest-commands.ts +1 -1
  83. package/src/shared/stacks/namespace.ts +178 -0
  84. package/src/shared/stacks/owners.ts +303 -0
  85. package/src/shared/stacks/render.ts +8 -3
  86. package/src/shared/stacks/runtime.ts +100 -16
  87. package/src/shared/stacks/status.ts +63 -12
  88. package/src/shared/strategies/registry.ts +89 -0
  89. package/src/shared/theme/contract.ts +538 -0
  90. package/src/shared/types/index.ts +12 -3
  91. package/src/ui/cli/agents-command.ts +812 -0
  92. package/src/ui/cli/create.ts +24 -7
  93. package/src/ui/cli/doctor-command.ts +260 -0
  94. package/src/ui/cli/edge-command.ts +56 -10
  95. package/src/ui/cli/env-probe.ts +266 -0
  96. package/src/ui/cli/flag-parser.ts +18 -5
  97. package/src/ui/cli/health-display.ts +25 -0
  98. package/src/ui/cli/index.ts +580 -60
  99. package/src/ui/cli/notice.ts +20 -0
  100. package/src/ui/cli/plugin-command.ts +849 -0
  101. package/src/ui/cli/sessions-command.ts +718 -0
  102. package/src/ui/cli/stack-command.ts +63 -2
  103. package/src/ui/cli/test-runner.ts +11 -9
  104. package/src/ui/cli/theme-command.ts +136 -0
  105. package/src/ui/cli/web-command.ts +47 -23
  106. package/src/ui/mcp/api.ts +28 -2
  107. package/src/ui/mcp/scope.ts +145 -20
  108. package/src/ui/mcp/server.ts +27 -2
  109. package/src/ui/mcp/tools.ts +94 -12
  110. package/src/ui/server/appearance-prefs.ts +95 -0
  111. package/src/ui/server/audit.ts +35 -0
  112. package/src/ui/server/daemon-reaper.ts +29 -1
  113. package/src/ui/server/engine-stamp.ts +83 -0
  114. package/src/ui/server/github-service.ts +23 -44
  115. package/src/ui/server/graph-page.ts +20 -0
  116. package/src/ui/server/graph-prefs.ts +44 -1
  117. package/src/ui/server/http-errors.ts +293 -0
  118. package/src/ui/server/http-helpers.ts +22 -3
  119. package/src/ui/server/index.ts +539 -51
  120. package/src/ui/server/public/appearance.js +531 -0
  121. package/src/ui/server/public/edge-page.js +248 -0
  122. package/src/ui/server/public/edge.html +251 -0
  123. package/src/ui/server/public/environments.html +65 -83
  124. package/src/ui/server/public/error-page.js +247 -0
  125. package/src/ui/server/public/error.html +260 -0
  126. package/src/ui/server/public/graph-model.js +332 -14
  127. package/src/ui/server/public/graph.css +141 -47
  128. package/src/ui/server/public/graph.js +223 -44
  129. package/src/ui/server/public/header.js +396 -600
  130. package/src/ui/server/public/health-fit.js +248 -0
  131. package/src/ui/server/public/health-rollup.js +128 -0
  132. package/src/ui/server/public/i18n/es.js +287 -0
  133. package/src/ui/server/public/i18n/ja.js +276 -0
  134. package/src/ui/server/public/index.html +139 -438
  135. package/src/ui/server/public/local-dev.html +3468 -4044
  136. package/src/ui/server/public/logs.html +71 -208
  137. package/src/ui/server/public/panel.js +14 -2
  138. package/src/ui/server/public/release.html +175 -389
  139. package/src/ui/server/public/services.html +596 -770
  140. package/src/ui/server/public/settings-page.js +759 -0
  141. package/src/ui/server/public/settings-registry.js +521 -0
  142. package/src/ui/server/public/settings.html +590 -0
  143. package/src/ui/server/public/shell.js +3869 -0
  144. package/src/ui/server/public/topbar.js +1413 -0
  145. package/src/ui/server/public/version-label.js +12 -0
  146. package/src/ui/server/public/workspace-picker.js +74 -34
  147. package/src/ui/server/readonly-routes.ts +37 -3
  148. package/src/ui/server/services-list.ts +94 -17
  149. package/src/ui/server/settings-distribution.ts +74 -0
  150. package/src/ui/server/shell-prefs.ts +192 -0
  151. package/src/ui/server/theme-routes.ts +292 -0
  152. package/src/ui/server/theme-runtime.ts +407 -0
  153. package/src/ui/server/ui-assets.ts +16 -1
  154. package/src/ui/server/ui-language.ts +50 -0
  155. package/src/ui/server/workspace-routes.ts +48 -18
  156. package/themes/den/den-theme.json +72 -0
  157. package/themes/den/favicon.ico +0 -0
  158. package/themes/den/mark.svg +6 -0
@@ -11,6 +11,7 @@ import {
11
11
  runningWorkspaceStacks,
12
12
  } from "../../shared/stacks/status.js";
13
13
  import { isNamedTarget } from "./named-target.js";
14
+ import { isNotice, printNotice } from "./notice.js";
14
15
  import chalk from "chalk";
15
16
  import { noun, plural } from "../../shared/plural.js";
16
17
  // Spinners: progress on stderr (a terminal) or stdout (piped); plain lines
@@ -58,6 +59,13 @@ import {
58
59
  DEN_SERVICES_DIR,
59
60
  DEN_WORKSPACES_DIR,
60
61
  } from "../../shared/environment-manager/workspaces.js";
62
+ import {
63
+ ownersSuffix,
64
+ parseScope,
65
+ scopeLine,
66
+ scopeView,
67
+ takeScopeFlag,
68
+ } from "../../shared/environment-manager/scope.js";
61
69
  import {
62
70
  deleteWorkspaceDirectory,
63
71
  checkBehindCount,
@@ -70,9 +78,11 @@ import {
70
78
  markSetupSucceeded,
71
79
  fetchAndFastForward,
72
80
  } from "../../shared/environment-manager/workspace-manager.js";
81
+ import { stackNamespaceRows } from "../../shared/stacks/namespace.js";
73
82
  import {
74
83
  runServiceCommand,
75
84
  claimHealthyService,
85
+ daemonsOutsideWorkspaces,
76
86
  getWorkspaceServices,
77
87
  ensureDaemonRegistryLoaded,
78
88
  hasRunningOpctlContainers,
@@ -104,14 +114,18 @@ import { waitForReady } from "../../shared/environment-manager/readiness.js";
104
114
  import {
105
115
  canonicalServiceAddress,
106
116
  previewServiceCommand,
117
+ previewUpSettle,
107
118
  previewServiceSecretEnv,
108
119
  prepareServiceSecretEnv,
109
120
  resolveService,
121
+ type DownOutcome,
110
122
  type ServiceCommandPreview,
111
123
  } from "../../shared/environment-manager/service-runner.js";
112
124
  import { getGitHubCoords } from "../../shared/smoke/git-remote.js";
125
+ import { isConfigError } from "../../shared/config-error.js";
113
126
  import {
114
127
  assertDenConfigValid,
128
+ waiveInvalidOrgs,
115
129
  getReposRoot,
116
130
  mergeUserConfig,
117
131
  readDenConfig,
@@ -126,6 +140,11 @@ import {
126
140
  deriveStartTargets,
127
141
  } from "../../shared/environment-manager/derivation/derived-up.js";
128
142
  import type { DerivedGraph } from "../../shared/environment-manager/derivation/types.js";
143
+ import {
144
+ repoOfAddress,
145
+ resolveUpTargets,
146
+ type UpTargets,
147
+ } from "../../shared/environment-manager/up-targets.js";
129
148
  import { loadAliasResolver } from "../../shared/environment-manager/derivation/load-alias.js";
130
149
  import {
131
150
  cmdApps,
@@ -173,8 +192,13 @@ import {
173
192
  import { cleanupStaleTabs } from "../../shared/cli-tabs.js";
174
193
  import { cmdConfig } from "./config-command.js";
175
194
  import { cmdEdge } from "./edge-command.js";
195
+ import { cmdDoctor } from "./doctor-command.js";
196
+ import { cmdSessions, SESSIONS_HELP } from "./sessions-command.js";
197
+ import { AGENTS_HELP, cmdAgents } from "./agents-command.js";
176
198
  import { cmdService } from "./service-command.js";
177
199
  import { cmdStack } from "./stack-command.js";
200
+ import { cmdTheme } from "./theme-command.js";
201
+ import { cmdPlugin } from "./plugin-command.js";
178
202
  import {
179
203
  cmdOrg,
180
204
  orgFlag,
@@ -209,6 +233,9 @@ import {
209
233
  formatHealthDot,
210
234
  formatHealthLabel,
211
235
  formatHealthReason,
236
+ formatRollupLabel,
237
+ healthReasonText,
238
+ rollupReasonText,
212
239
  } from "./health-display.js";
213
240
  import { DEN_BANNER_LINES, DEN_DIVIDER } from "../../shared/banner.js";
214
241
 
@@ -300,9 +327,12 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
300
327
  ],
301
328
  },
302
329
  status: {
303
- usage: "status [-A <app>]",
330
+ usage: "status [-A <app>] [--scope <all|workspace>] [--verbose]",
304
331
  lines: [
305
- "Show service health for the active workspace (or one application).",
332
+ "Show service health for what is in view: every active workspace by",
333
+ "default (the web UI's scope), or --scope <workspace> for one. A",
334
+ "service several workspaces share is listed once, with its owners.",
335
+ "--verbose (or --all) shows one row per service instead of per repo.",
306
336
  ],
307
337
  },
308
338
  logs: {
@@ -319,7 +349,7 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
319
349
  lines: ["List all registered services in the catalog."],
320
350
  },
321
351
  register: {
322
- usage: "register [path] [--org <id>] [-w <workspace>]",
352
+ usage: "register [path] [-w <workspace>] [--org <id>]",
323
353
  lines: [
324
354
  "With a path: add that checkout (dev-managed) under its manifest name.",
325
355
  "Without one: pick the repo interactively. A managed clone is picked",
@@ -327,6 +357,15 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
327
357
  "-w writes that workspace (active or not) and leaves focus alone.",
328
358
  ],
329
359
  },
360
+ relocate: {
361
+ usage: "relocate <repo> <path> [--dry-run] [--force] [-w <workspace>]",
362
+ lines: [
363
+ "Point every workspace's dev-managed entries for <repo> at <path>.",
364
+ "Refuses a path that is missing, holds a different repo, or has",
365
+ "neither a manifest nor a remote naming this one (unless --force).",
366
+ "-w limits it to that workspace; focus never changes.",
367
+ ],
368
+ },
330
369
  setup: {
331
370
  usage:
332
371
  "setup [service...] [--force|-f] [--plan [-w <workspace>]... [--all-active]]",
@@ -417,7 +456,37 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
417
456
  edge: {
418
457
  usage: "edge [up|down|status|trust|untrust|routes]",
419
458
  lines: [
420
- "This org's HTTPS edge: own CA + cert, routes at <name>.<edge.domain>.",
459
+ "This org's edge: routes at <name>.<edge.domain>, TLS as edge.tls says",
460
+ "(none | mkcert | files). trust/untrust exist only under mkcert.",
461
+ ],
462
+ },
463
+ doctor: {
464
+ usage: "doctor [--probe]",
465
+ lines: [
466
+ "Each strategy kind (secrets, tls): the one selected, whether it was implicit,",
467
+ "and whether it can work here (a PATH check; nothing is run or prompted).",
468
+ "--probe: start a short probe session (through the session host) for a",
469
+ "plain session and each available runtime's agent env; key names only.",
470
+ "Exit 1 when a runtime's scrubEnv name or a credential-shaped variable",
471
+ "reaches one. Without the PTY module it says unavailable, not failed.",
472
+ ],
473
+ },
474
+ plugin: {
475
+ usage:
476
+ "plugin [list] | validate [dir] | add <repo>[/<subpath>]@<ref> [--track] [--yes] | update <id> @<ref> | remove <id>",
477
+ lines: [
478
+ "Plugins (den.plugin/v1) in plugins/<id>/: local (the distribution's own, no lock entry)",
479
+ "or added (vendored at an exact ref, pinned in plugins.lock.json). list checks each",
480
+ "against the lock; validate checks every manifest. add refuses file:// sources.",
481
+ ],
482
+ },
483
+ theme: {
484
+ usage:
485
+ "theme [list] | validate [dir] | new <id> | add <repo>[/<subpath>]@<ref> | remove <id>",
486
+ lines: [
487
+ "Web UI themes (den.theme/v1). add fetches one at an exact ref, validates it",
488
+ "(schema, contrast in both modes, files) and vendors it into plugins/<id>/",
489
+ "with a plugins.lock.json entry; commit both. list checks them against the lock.",
421
490
  ],
422
491
  },
423
492
  mcp: {
@@ -438,13 +507,15 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
438
507
  "local-dev": {
439
508
  usage: "local-dev [on|off]",
440
509
  lines: [
441
- "Toggle the local-dev experience (web Local Dev section + service mgmt).",
510
+ "Toggle the local-dev experience (web Workspaces page + service mgmt).",
442
511
  ],
443
512
  },
444
513
  menu: {
445
514
  usage: "menu",
446
515
  lines: ["Open the interactive menu (pillar/module/env picker)."],
447
516
  },
517
+ sessions: SESSIONS_HELP,
518
+ agents: AGENTS_HELP,
448
519
  };
449
520
 
450
521
  function resolveCliBaseApiUrl(explicitBaseApiUrl?: string): string | undefined {
@@ -466,6 +537,7 @@ function resolveCliBaseApiUrl(explicitBaseApiUrl?: string): string | undefined {
466
537
  const TELEMETRY_COMMANDS = new Set<string>([
467
538
  "run",
468
539
  "view-results",
540
+ "down",
469
541
  "menu",
470
542
  "web",
471
543
  "up",
@@ -476,6 +548,7 @@ const TELEMETRY_COMMANDS = new Set<string>([
476
548
  "services",
477
549
  "graph",
478
550
  "register",
551
+ "relocate",
479
552
  "use-local",
480
553
  `use-managed`,
481
554
  "setup",
@@ -488,19 +561,26 @@ const TELEMETRY_COMMANDS = new Set<string>([
488
561
  "local-dev",
489
562
  "config",
490
563
  "edge",
564
+ "doctor",
491
565
  "stack",
492
566
  "apps",
493
567
  "applications",
494
568
  "mcp",
569
+ "sessions",
570
+ "agents",
495
571
  "service",
572
+ "theme",
573
+ "plugin",
496
574
  ]);
497
575
 
498
576
  /**
499
577
  * Commands that run with a config that fails validation: the web and MCP
500
578
  * servers (each route or tool reports it), and those that read neither orgs,
501
579
  * the catalog nor clones (manifest validate, create, the per-engineer
502
- * config and local-dev toggles, saved results). Every other registered
503
- * command checks the config first.
580
+ * config and local-dev toggles, saved results), and `down` (0.3.0-dev.40:
581
+ * stopping is never stranded by a config that fails; it stops from the
582
+ * daemon records and compose projects, reading no org data). Every other
583
+ * registered command checks the config first.
504
584
  */
505
585
  const CONFIG_FREE_COMMANDS = new Set<string>([
506
586
  "web",
@@ -510,6 +590,11 @@ const CONFIG_FREE_COMMANDS = new Set<string>([
510
590
  "config",
511
591
  "local-dev",
512
592
  "view-results",
593
+ "down",
594
+ // The session host and its clients read no org data, and stopping or
595
+ // listing a session is never stranded by a config that fails (AG-3a).
596
+ "sessions",
597
+ "agents",
513
598
  ]);
514
599
 
515
600
  function configCheckedCommand(command: string): boolean {
@@ -743,6 +828,22 @@ class DenCLI {
743
828
  // anything: some (sync over existing clones, register, workspace
744
829
  // create) never read `orgs:` and used to carry on (ORG-1d).
745
830
  if (command && configCheckedCommand(command)) assertDenConfigValid();
831
+ // `down` is never stranded by an invalid config (0.3.0-dev.40): it
832
+ // says so and stops from the daemon records and compose projects,
833
+ // reading the config without its org data.
834
+ if (command === "down") {
835
+ try {
836
+ assertDenConfigValid();
837
+ } catch (e) {
838
+ if (!isConfigError(e)) throw e;
839
+ console.log(
840
+ chalk.yellow(
841
+ `\n ⚠ ${e.message}\n Stopping anyway, without the config's org data. Fix it, then \`${product().cli} validate\`.`,
842
+ ),
843
+ );
844
+ waiveInvalidOrgs();
845
+ }
846
+ }
746
847
 
747
848
  switch (command) {
748
849
  case "run":
@@ -798,6 +899,10 @@ class DenCLI {
798
899
  await this.cmdRegister(args.slice(1));
799
900
  break;
800
901
 
902
+ case "relocate":
903
+ await this.cmdRelocate(args.slice(1));
904
+ break;
905
+
801
906
  case "use-local":
802
907
  await this.cmdUseMode(args.slice(1), "dev-managed");
803
908
  break;
@@ -872,6 +977,10 @@ class DenCLI {
872
977
  await cmdEdge(args.slice(1));
873
978
  break;
874
979
 
980
+ case "doctor":
981
+ process.exitCode = await cmdDoctor(args.slice(1));
982
+ break;
983
+
875
984
  case "service":
876
985
  process.exitCode = await cmdService(args.slice(1));
877
986
  break;
@@ -880,12 +989,28 @@ class DenCLI {
880
989
  await cmdStack(args.slice(1));
881
990
  break;
882
991
 
992
+ case "theme":
993
+ process.exitCode = await cmdTheme(args.slice(1));
994
+ break;
995
+
996
+ case "plugin":
997
+ process.exitCode = await cmdPlugin(args.slice(1));
998
+ break;
999
+
883
1000
  case "apps":
884
1001
  case "applications":
885
1002
  await this.ensureWorkspaceSeeded();
886
1003
  cmdApps(args.slice(1));
887
1004
  break;
888
1005
 
1006
+ case "sessions":
1007
+ process.exitCode = await cmdSessions(args.slice(1));
1008
+ break;
1009
+
1010
+ case "agents":
1011
+ process.exitCode = await cmdAgents(args.slice(1));
1012
+ break;
1013
+
889
1014
  case "mcp": {
890
1015
  // Setup subcommands: register the server with agents.
891
1016
  const { MCP_CONNECT_SUBCOMMANDS, runMcpConnect } =
@@ -1597,7 +1722,15 @@ class DenCLI {
1597
1722
  // The same lookup a real run makes, so the dry run says what it does.
1598
1723
  const previews = ordered.map((a): ServiceCommandPreview => {
1599
1724
  const remote = remoteRefusal(a);
1600
- if (!remote) return previewServiceCommand(a, command, workspace);
1725
+ if (!remote) {
1726
+ const p = previewServiceCommand(a, command, workspace);
1727
+ // What up does first about a pre-dev.37 record or a held port.
1728
+ return (
1729
+ (command === "up" && p.kind === "run"
1730
+ ? previewUpSettle(a, workspace)
1731
+ : null) ?? p
1732
+ );
1733
+ }
1601
1734
  // As the loop below: a named remote target fails, any other is
1602
1735
  // said and skipped.
1603
1736
  return isNamedTarget(a, opts?.named ?? [])
@@ -1620,7 +1753,7 @@ class DenCLI {
1620
1753
  ),
1621
1754
  );
1622
1755
  for (const p of previews) {
1623
- if (p.kind === "via-repo" || p.kind === "nothing")
1756
+ if (p.kind !== "error" && p.note)
1624
1757
  console.log(chalk.gray(` ${p.note}`));
1625
1758
  if (p.kind === "error") console.log(chalk.red(` ✗ ${p.note}`));
1626
1759
  }
@@ -1698,12 +1831,27 @@ class DenCLI {
1698
1831
  const resolved = resolveService(address, workspace);
1699
1832
  if (resolved) {
1700
1833
  const health = await checkServiceHealth(resolved, "local");
1701
- if (health.health === "ok") {
1834
+ // `conflict`: a shared stack namespace runs another checkout's
1835
+ // project; the real up below refuses it, naming both.
1836
+ const claim =
1837
+ health.health === "ok" && workspace
1838
+ ? claimHealthyService(resolved, workspace, printNotice)
1839
+ : "untracked";
1840
+ // Healthy, but another process answers on its port: said (above),
1841
+ // never claimed, and nothing is started on top of it.
1842
+ if (health.health === "ok" && claim === "busy") {
1843
+ console.log(
1844
+ chalk.red(
1845
+ ` ✗ ${chalk.bold(address)} not started: its port is in use by a process ${product().cli} won't adopt`,
1846
+ ),
1847
+ );
1848
+ // A dependency only warns; the service you named failed.
1849
+ if (isNamedTarget(address, opts?.named ?? [])) process.exitCode = 1;
1850
+ continue;
1851
+ }
1852
+ if (health.health === "ok" && claim !== "conflict") {
1702
1853
  // Skipping must not lose the refcount — this workspace is an owner
1703
1854
  // of what's running, whether den tracked it or not.
1704
- const claim = workspace
1705
- ? claimHealthyService(resolved, workspace)
1706
- : "untracked";
1707
1855
  const suffix =
1708
1856
  claim === "adopted"
1709
1857
  ? " (adopted its running containers)"
@@ -1804,6 +1952,8 @@ class DenCLI {
1804
1952
  let result: ReturnType<typeof runServiceCommand> | null = null;
1805
1953
  let detachable = false;
1806
1954
  let interactive = false;
1955
+ // A down that found nothing to stop is said as such, never as ✔.
1956
+ let downOutcome: DownOutcome | null = null;
1807
1957
 
1808
1958
  const settleFromClose = (exitCode: number): void => {
1809
1959
  if (settled) return;
@@ -1820,6 +1970,17 @@ class DenCLI {
1820
1970
  ),
1821
1971
  );
1822
1972
  }
1973
+ } else if (exitCode === 0 && downOutcome === "nothing-recorded") {
1974
+ spinner.info(
1975
+ `${command} ${chalk.bold(address)} ${chalk.gray("(nothing to stop)")}`,
1976
+ );
1977
+ } else if (exitCode === 0 && downOutcome === "unstoppable") {
1978
+ spinner.warn(
1979
+ `${command} ${chalk.bold(address)} ${chalk.yellow("(could not stop it)")}`,
1980
+ );
1981
+ for (const l of lastLines) {
1982
+ console.log(chalk.gray(` ${l}`));
1983
+ }
1823
1984
  } else if (exitCode === 0) {
1824
1985
  spinner.succeed(`${command} ${chalk.bold(address)}`);
1825
1986
  } else {
@@ -1873,8 +2034,21 @@ class DenCLI {
1873
2034
  runtime,
1874
2035
  workspace,
1875
2036
  force: command === "down" ? force : false,
2037
+ // Down fails when it cannot stop a service the user named; one
2038
+ // reached as part of a workspace-wide down warns instead.
2039
+ explicit: isNamedTarget(address, opts?.named ?? []),
2040
+ onDownOutcome: (outcome) => {
2041
+ downOutcome = outcome;
2042
+ },
1876
2043
  secretEnv: secrets.secretEnv,
1877
2044
  onLine: (line) => {
2045
+ // What Den decided about a record or a port is said, not
2046
+ // only flashed in the spinner (0.3.0-dev.39).
2047
+ if (isNotice(line)) {
2048
+ spinner.clear();
2049
+ printNotice(line);
2050
+ if (spinner.isEnabled) spinner.render();
2051
+ }
1878
2052
  spinner.text = `${command} ${chalk.bold(address)}: ${chalk.gray(line.slice(0, 80))}`;
1879
2053
  const trimmed = line.trim();
1880
2054
  if (trimmed) {
@@ -2130,11 +2304,15 @@ class DenCLI {
2130
2304
  this.renderUpClonePlan(rawOrder);
2131
2305
  return;
2132
2306
  }
2307
+ // A repo with no up of its own starts its services, one with no up at
2308
+ // all is skipped: the decision `up -a` makes (resolveUpTargets).
2309
+ const targets = new Set(include ? [seed] : []);
2133
2310
  if (dryRun) {
2311
+ const up = this.upTargets(order, targets, graph);
2134
2312
  await this.runServicesCommand(
2135
- order,
2313
+ up.order,
2136
2314
  "up",
2137
- { order },
2315
+ { order: up.order },
2138
2316
  { named, dryRun: true },
2139
2317
  );
2140
2318
  return;
@@ -2158,12 +2336,18 @@ class DenCLI {
2158
2336
  );
2159
2337
  return;
2160
2338
  }
2339
+ const up = this.upTargets(order, targets, graph);
2340
+ if (up.order.length === 0) {
2341
+ console.log(chalk.yellow(" Nothing to start.\n"));
2342
+ return;
2343
+ }
2161
2344
  await this.runServicesCommand(
2162
- order,
2345
+ up.order,
2163
2346
  "up",
2164
2347
  {
2165
- order,
2166
- waitDepsFor: (address) => hardDepsOf(graph, address),
2348
+ order: up.order,
2349
+ waitDepsFor: (address) =>
2350
+ up.depsAs(hardDepsOf(graph, repoOfAddress(address))),
2167
2351
  failFast: true,
2168
2352
  skipHealthyAll: true,
2169
2353
  },
@@ -2171,6 +2355,37 @@ class DenCLI {
2171
2355
  );
2172
2356
  }
2173
2357
 
2358
+ /**
2359
+ * The start order `up` runs (`up <repo>` and `up -a` alike): a repo with
2360
+ * no up of its own replaced by its services' ups, one with no up at all
2361
+ * skipped (resolveUpTargets), said as it decides.
2362
+ */
2363
+ private upTargets(
2364
+ order: string[],
2365
+ targets: ReadonlySet<string>,
2366
+ graph: DerivedGraph,
2367
+ ): UpTargets {
2368
+ const up = resolveUpTargets(order, this.focusedWs(), { targets, graph });
2369
+ this.sayUpTargets(up);
2370
+ return up;
2371
+ }
2372
+
2373
+ /** What resolveUpTargets decided, in `up`'s words. */
2374
+ private sayUpTargets(up: UpTargets): void {
2375
+ for (const [repo, addrs] of up.expanded)
2376
+ console.log(
2377
+ chalk.gray(
2378
+ ` ${repo} has no up of its own: starting ${addrs.join(", ")}`,
2379
+ ),
2380
+ );
2381
+ if (up.skippedNoUp.length > 0)
2382
+ console.log(
2383
+ chalk.gray(
2384
+ ` Skipping (no local up command): ${up.skippedNoUp.join(", ")}\n`,
2385
+ ),
2386
+ );
2387
+ }
2388
+
2174
2389
  /**
2175
2390
  * Stop the seed repo and the dependencies it was started with — the same
2176
2391
  * closure `den up <seed>` brings up, torn down in reverse (seed first,
@@ -2388,19 +2603,18 @@ class DenCLI {
2388
2603
  // is skipped, not fatal. `-a` starts independent peers — one unrunnable
2389
2604
  // repo must not strand the rest (runServiceCommand exits 1 on a missing
2390
2605
  // command, and a fail-fast would turn that into a full abort). Now that
2391
- // clones exist, the manifest lookup is meaningful.
2392
- const order: string[] = [];
2393
- const skippedNoUp: string[] = [];
2394
- for (const repo of candidates) {
2395
- const resolved = resolveService(repo);
2396
- const upDef = resolved
2397
- ? localCommandsFor(resolved, this.focusedWs())["up"]
2398
- : undefined;
2399
- (upDef ? order : skippedNoUp).push(repo);
2400
- }
2606
+ // clones exist, the manifest lookup is meaningful. A repo with no up of
2607
+ // its own but per-service ups starts those services: the same decision
2608
+ // `up <repo>` makes for a dependency (upTargets).
2609
+ const up = resolveUpTargets(candidates, this.focusedWs(), {
2610
+ targets: new Set(candidates),
2611
+ graph,
2612
+ });
2613
+ const order = up.order;
2614
+ const systems = candidates.filter((r) => !up.skippedNoUp.includes(r));
2401
2615
  console.log(
2402
2616
  chalk.cyan(
2403
- `\n ${only ? "Application" : "Workspace-wide"} up — ${chalk.bold(String(order.length))} ${noun(order.length, "system")}`,
2617
+ `\n ${only ? "Application" : "Workspace-wide"} up — ${chalk.bold(String(systems.length))} ${noun(systems.length, "system")}`,
2404
2618
  ),
2405
2619
  );
2406
2620
  console.log(chalk.gray(` Order: ${order.join(" → ")}\n`));
@@ -2412,13 +2626,7 @@ class DenCLI {
2412
2626
  ),
2413
2627
  );
2414
2628
  }
2415
- if (skippedNoUp.length > 0) {
2416
- console.log(
2417
- chalk.gray(
2418
- ` Skipping (no local up command): ${skippedNoUp.join(", ")}\n`,
2419
- ),
2420
- );
2421
- }
2629
+ this.sayUpTargets(up);
2422
2630
  if (order.length === 0) {
2423
2631
  console.log(chalk.yellow(" Nothing to start.\n"));
2424
2632
  return;
@@ -2428,13 +2636,17 @@ class DenCLI {
2428
2636
  // healthy-skip. Unlike runDerivedUp there is NO failFast on the start
2429
2637
  // loop: `-a`'s systems are independent peers — one failure must not
2430
2638
  // strand the rest (the web master button continues too).
2431
- const repoSet = new Set(order);
2639
+ const repoSet = new Set(systems);
2432
2640
  await this.runServicesCommand(order, "up", {
2433
2641
  order,
2434
2642
  // Hard deps outside the workspace can't be waited on — filter to the set
2435
2643
  // we are actually starting.
2436
2644
  waitDepsFor: (address) =>
2437
- hardDepsOf(graph, address).filter((dep) => repoSet.has(dep)),
2645
+ up.depsAs(
2646
+ hardDepsOf(graph, repoOfAddress(address)).filter((dep) =>
2647
+ repoSet.has(dep),
2648
+ ),
2649
+ ),
2438
2650
  skipHealthyAll: true,
2439
2651
  });
2440
2652
  }
@@ -2470,6 +2682,14 @@ class DenCLI {
2470
2682
  return;
2471
2683
  }
2472
2684
  await this.runServicesCommand(order, "down", { order }, { force });
2685
+ // Records no workspace reaches: -a stops every workspace's services,
2686
+ // not these. Said, and left alone (0.3.0-dev.39, a consumer's finding).
2687
+ if (!only)
2688
+ for (const d of daemonsOutsideWorkspaces())
2689
+ printNotice(
2690
+ `Warning: ${d.address} is recorded as running (${d.repoPath}) but is in no active workspace: ` +
2691
+ `down -a left it alone. Stop it yourself if it still runs.`,
2692
+ );
2473
2693
  }
2474
2694
 
2475
2695
  private printUpHelp(): void {
@@ -2993,15 +3213,49 @@ class DenCLI {
2993
3213
 
2994
3214
  private async cmdServiceStatus(argv: string[] = []): Promise<void> {
2995
3215
  await this.ensureWorkspaceSeeded();
2996
- const { app, rest: args } = takeAppFlag(argv);
3216
+ const { app, rest: appRest } = takeAppFlag(argv);
3217
+ // The view scope (as the web UI's): every active workspace by default,
3218
+ // or `--scope <workspace>` for one. A view only: focus is unchanged.
3219
+ const scoped = takeScopeFlag(appRest);
3220
+ const args = scoped.rest;
3221
+ const scopeArg =
3222
+ scoped.scope === null
3223
+ ? null
3224
+ : parseScope(scoped.scope, getActiveWorkspaces());
3225
+ if (scoped.error || (scopeArg && !scopeArg.ok)) {
3226
+ console.log(
3227
+ chalk.red(
3228
+ `\n ${scoped.error ?? (scopeArg && !scopeArg.ok ? scopeArg.error : "")}\n`,
3229
+ ),
3230
+ );
3231
+ process.exit(1);
3232
+ }
3233
+ // `-w <workspace>` still means that workspace only (active or not).
3234
+ const named = process.env.DEN_WORKSPACE_OVERRIDE
3235
+ ? getActiveWorkspace()
3236
+ : null;
3237
+ const view =
3238
+ named && !scopeArg
3239
+ ? {
3240
+ scope: named.name,
3241
+ fellBack: false,
3242
+ workspaces: [named],
3243
+ focus: named.name,
3244
+ }
3245
+ : scopeView(scopeArg?.ok ? scopeArg.value : undefined);
2997
3246
  const only = app ? focusedAppRepos(app) : null;
2998
3247
  const verbose = args.includes("--verbose") || args.includes("--all");
2999
3248
  const spinner = ora("Checking service health...").start();
3000
- const raw = (await checkCatalogHealth()).filter(
3001
- (h) => !only || only.has(h.repoName),
3002
- );
3003
- // Roll multi-service repos up to their entrypoint so shared-infra sidecars
3004
- // (which read healthy off infra-stack) don't make a down app look up.
3249
+ const raw = (
3250
+ await checkCatalogHealth(
3251
+ undefined,
3252
+ "local",
3253
+ view.workspaces.filter((w) => w.config),
3254
+ )
3255
+ ).filter((h) => !only || only.has(h.repoName));
3256
+ // One row per repo, rolled up from every checked service by the rule the
3257
+ // web UI's header and graph use: entry down → down, another checked
3258
+ // service down → degraded; unchecked services never count.
3005
3259
  const rolled = verbose ? raw : rollupRepoHealth(raw);
3006
3260
  // Order top-down like the graph: entry repo first (shop-web) … foundation
3007
3261
  // last (infra-stack), subsystems grouped under their repo.
@@ -3019,10 +3273,7 @@ class DenCLI {
3019
3273
  return;
3020
3274
  }
3021
3275
 
3022
- const ws = getActiveWorkspace();
3023
- console.log(
3024
- chalk.cyan(`\n Workspace: ${chalk.bold(ws?.name ?? "none")}\n`),
3025
- );
3276
+ console.log(chalk.cyan(`\n ${scopeLine(view)}\n`));
3026
3277
 
3027
3278
  const table = newTable({
3028
3279
  head: ["Service", "Mode", "Branch", "Health", "Reason"],
@@ -3030,8 +3281,20 @@ class DenCLI {
3030
3281
  });
3031
3282
 
3032
3283
  for (const r of results) {
3033
- const healthStr = formatHealthLabel(r.health);
3034
- const reasonStr = formatHealthReason(r);
3284
+ const healthStr = r.rollup
3285
+ ? formatRollupLabel(r.rollup)
3286
+ : formatHealthLabel(r.health);
3287
+ const rolledWhy = r.rollup ? rollupReasonText(r.rollup) : "";
3288
+ const reasonStr =
3289
+ r.rollup && r.rollup.state !== "unknown"
3290
+ ? chalk.gray(
3291
+ [r.rollup.state === "down" ? healthReasonText(r) : "", rolledWhy]
3292
+ .filter(Boolean)
3293
+ .join("; ")
3294
+ .slice(0, 60),
3295
+ )
3296
+ : formatHealthReason(r) ||
3297
+ (rolledWhy ? chalk.gray(rolledWhy.slice(0, 60)) : "");
3035
3298
  const placed = placementSuffix({
3036
3299
  runs: r.runs ?? "none",
3037
3300
  target: r.target ?? null,
@@ -3039,6 +3302,7 @@ class DenCLI {
3039
3302
 
3040
3303
  table.push([
3041
3304
  this.labelWithAutostart(r.address, noAutostart) +
3305
+ chalk.gray(ownersSuffix(r.workspaces)) +
3042
3306
  (placed ? chalk.cyan(` ${placed}`) : ""),
3043
3307
  r.mode === "dev-managed"
3044
3308
  ? chalk.blue("dev-managed")
@@ -3060,7 +3324,7 @@ class DenCLI {
3060
3324
  if (!verbose && raw.length !== results.length) {
3061
3325
  console.log(
3062
3326
  chalk.gray(
3063
- ` ▪ multi-service repos show entrypoint health — \`${product().cli} status --verbose\` for all subsystems`,
3327
+ ` ▪ one row per repo, rolled up from its checked services — \`${product().cli} status --verbose\` for each`,
3064
3328
  ),
3065
3329
  );
3066
3330
  }
@@ -3231,13 +3495,14 @@ class DenCLI {
3231
3495
  private async cmdRegister(args: string[]): Promise<void> {
3232
3496
  if (args.includes("-h") || args.includes("--help")) {
3233
3497
  console.log(
3234
- `\n ${product().cli} register [path] [--name <repo>] [--gh <owner/repo>] [-w <workspace>]\n\n` +
3498
+ `\n ${product().cli} register [path] [--name <repo>] [--gh <owner/repo>] [-w <workspace>] [--org <id>]\n\n` +
3235
3499
  " With a path: add that checkout to the catalog and the focused\n" +
3236
3500
  " workspace as your own (dev-managed) copy, without prompts. The name\n" +
3237
3501
  " defaults to the manifest's name, then the folder name; the GitHub\n" +
3238
3502
  " slug to the git remote. -w writes that workspace (active or not)\n" +
3239
3503
  " and leaves focus alone.\n" +
3240
- " Without a path: choose a local checkout or a managed clone interactively.\n",
3504
+ " Without a path: choose a local checkout or a managed clone interactively;\n" +
3505
+ " a managed clone comes from the workspace's org, or --org <id>.\n",
3241
3506
  );
3242
3507
  return;
3243
3508
  }
@@ -3245,7 +3510,9 @@ class DenCLI {
3245
3510
  (a, i) =>
3246
3511
  !a.startsWith("-") && !/^--(name|gh|org)$/.test(args[i - 1] ?? ""),
3247
3512
  );
3248
- // Without a path, register prompts: with no terminal, say how to name one.
3513
+ // Without a path, register prompts: with no terminal (stdin closed or
3514
+ // piped, where readline would throw ERR_USE_AFTER_CLOSE mid-prompt), say
3515
+ // how to name one. Usage error: exit 2.
3249
3516
  if (!repoPathArg && !process.stdin.isTTY) {
3250
3517
  process.stderr.write(
3251
3518
  `register needs a terminal (stdin is not a TTY); without one: ` +
@@ -3282,7 +3549,157 @@ class DenCLI {
3282
3549
  }
3283
3550
  }
3284
3551
 
3285
- /** Non-interactive `register <path>`: a dev-managed entry in the focused workspace. */
3552
+ /**
3553
+ * `relocate <repo> <path>`: a checkout moved on disk. Rewrites the path of
3554
+ * every dev-managed entry for <repo> (bare and `<repo>/<svc>` addresses) in
3555
+ * every workspace, or only the `-w` one. Managed entries are Den's own
3556
+ * clones and are left alone. Focus and the active set never change.
3557
+ */
3558
+ private async cmdRelocate(args: string[]): Promise<void> {
3559
+ const usage = `${product().cli} relocate <repo> <path> [--dry-run] [--force] [-w <workspace>]`;
3560
+ if (args.includes("-h") || args.includes("--help")) {
3561
+ console.log(
3562
+ `\n ${usage}\n\n` +
3563
+ " A checkout moved: point every workspace's dev-managed entries for\n" +
3564
+ " <repo> at <path>. Refuses a missing path, one whose manifest\n" +
3565
+ " name or GitHub remote names a different repo, and one where\n" +
3566
+ " neither names this repo (--force accepts that last). --dry-run shows\n" +
3567
+ " the changes without writing. -w limits it to that workspace.\n" +
3568
+ " Focus and the active set never change.\n",
3569
+ );
3570
+ return;
3571
+ }
3572
+ const dryRun = args.includes("--dry-run") || args.includes("-n");
3573
+ const force = args.includes("--force");
3574
+ const unknown = args.find(
3575
+ (a) => a.startsWith("-") && !["--dry-run", "-n", "--force"].includes(a),
3576
+ );
3577
+ const positional = args.filter((a) => !a.startsWith("-"));
3578
+ if (unknown || positional.length !== 2) {
3579
+ console.error(
3580
+ chalk.red(
3581
+ `\n ❌ ${unknown ? `Unknown flag: ${unknown}` : "Need a repo and a path"}.\n Usage: ${usage}\n`,
3582
+ ),
3583
+ );
3584
+ process.exitCode = 1;
3585
+ return;
3586
+ }
3587
+ const [repoName, rawPath] = positional;
3588
+ const { existsSync, statSync } = await import("fs");
3589
+ const newPath = nodePath.resolve(rawPath);
3590
+ const fail = (msg: string) => {
3591
+ console.error(chalk.red(`\n ❌ ${msg}\n`));
3592
+ process.exitCode = 1;
3593
+ };
3594
+ const file = readWorkspacesFile();
3595
+ const only = process.env.DEN_WORKSPACE_OVERRIDE;
3596
+ const names = only ? [only] : Object.keys(file.workspaces);
3597
+ const belongs = (key: string) =>
3598
+ key === repoName || key.startsWith(`${repoName}/`);
3599
+ if (
3600
+ !names.some((ws) =>
3601
+ Object.keys(file.workspaces[ws]?.services ?? {}).some(belongs),
3602
+ )
3603
+ ) {
3604
+ fail(
3605
+ `"${repoName}" is not in ${only ? `workspace "${only}"` : "any workspace"}.`,
3606
+ );
3607
+ return;
3608
+ }
3609
+ if (!existsSync(newPath) || !statSync(newPath).isDirectory()) {
3610
+ fail(`Path does not exist: ${newPath}`);
3611
+ return;
3612
+ }
3613
+
3614
+ // The new path must hold this repo: its manifest name and GitHub remote,
3615
+ // when present, must agree with the repo and its catalog entry.
3616
+ const manifest = readManifest(newPath);
3617
+ const leaf = repoName.split("/").pop() ?? repoName;
3618
+ if (
3619
+ manifest?.name &&
3620
+ manifest.name !== repoName &&
3621
+ manifest.name !== leaf
3622
+ ) {
3623
+ fail(
3624
+ `${newPath} holds "${manifest.name}", not "${repoName}" (its manifest name).`,
3625
+ );
3626
+ return;
3627
+ }
3628
+ const catalogGh = readCatalog()[repoName]?.gh?.trim();
3629
+ const coords = getGitHubCoords(newPath);
3630
+ const remote = coords ? `${coords.owner}/${coords.repo}` : undefined;
3631
+ if (
3632
+ catalogGh &&
3633
+ remote &&
3634
+ catalogGh.toLowerCase() !== remote.toLowerCase()
3635
+ ) {
3636
+ fail(
3637
+ `${newPath} is a checkout of ${remote}; the catalog has "${repoName}" as ${catalogGh}.`,
3638
+ );
3639
+ return;
3640
+ }
3641
+ // Not contradicting is not enough: an empty or unrelated folder has no
3642
+ // manifest and no remote. Something must say this is the repo.
3643
+ const identified =
3644
+ manifest?.name === repoName ||
3645
+ manifest?.name === leaf ||
3646
+ (!!catalogGh &&
3647
+ !!remote &&
3648
+ catalogGh.toLowerCase() === remote.toLowerCase()) ||
3649
+ (!catalogGh && coords?.repo.toLowerCase() === leaf.toLowerCase());
3650
+ if (!identified && !force) {
3651
+ fail(
3652
+ `Nothing at ${newPath} says it is "${repoName}": no manifest named ` +
3653
+ `"${leaf}" and no GitHub remote ${catalogGh || `…/${leaf}`}.\n` +
3654
+ ` If it is, say so: ${product().cli} relocate ${repoName} ${rawPath} --force${dryRun ? " --dry-run" : ""}${only ? ` -w ${only}` : ""}`,
3655
+ );
3656
+ return;
3657
+ }
3658
+
3659
+ const changes: { ws: string; addr: string; from: string }[] = [];
3660
+ const managed: { ws: string; addr: string }[] = [];
3661
+ for (const ws of names) {
3662
+ const entries = file.workspaces[ws]?.services ?? {};
3663
+ for (const [addr, entry] of Object.entries(entries)) {
3664
+ if (!belongs(addr)) continue;
3665
+ if (entry.mode !== "dev-managed") {
3666
+ managed.push({ ws, addr });
3667
+ continue;
3668
+ }
3669
+ if (entry.path && nodePath.resolve(entry.path) === newPath) continue;
3670
+ changes.push({ ws, addr, from: entry.path ?? "(default)" });
3671
+ if (!dryRun) entry.path = newPath;
3672
+ }
3673
+ }
3674
+
3675
+ if (changes.length > 0 && !dryRun) writeWorkspacesFile(file);
3676
+
3677
+ const verb = dryRun ? "Would point" : "Pointed";
3678
+ if (changes.length === 0) {
3679
+ console.log(
3680
+ chalk.gray(
3681
+ `\n Nothing to change: "${repoName}" is already at ${newPath}.`,
3682
+ ),
3683
+ );
3684
+ } else {
3685
+ console.log(chalk.green(`\n ✓ ${verb} at ${newPath}:\n`));
3686
+ for (const c of changes) {
3687
+ console.log(
3688
+ ` ${chalk.bold(c.addr)} ${chalk.gray(`${c.ws} was ${c.from}`)}`,
3689
+ );
3690
+ }
3691
+ }
3692
+ for (const m of managed) {
3693
+ console.log(
3694
+ chalk.gray(
3695
+ ` ${m.addr} ${m.ws} managed (Den's clone): left alone`,
3696
+ ),
3697
+ );
3698
+ }
3699
+ console.log();
3700
+ }
3701
+
3702
+ /** Non-interactive `register <path>`: a dev-managed entry in the focused (or -w) workspace. */
3286
3703
  private async registerPath(
3287
3704
  ws: {
3288
3705
  name: string;
@@ -3751,14 +4168,32 @@ class DenCLI {
3751
4168
  const only = orgFlag(args.slice(1));
3752
4169
  const { resolved, warnings } = orgListRows();
3753
4170
  const orgOfWs = new Map(resolved.map((w) => [w.workspace, w.org]));
3754
- const table = newTable({
3755
- head: ["Workspace", "Org", "Active", "Focus", "Services", "Type"],
3756
- style: { head: ["cyan"] },
3757
- });
3758
4171
  const shown = Object.entries(file.workspaces).filter(
3759
4172
  ([name]) => !only || sameOrg(orgOfWs.get(name) ?? null, only),
3760
4173
  );
4174
+ // A Stacks column only when a shown workspace's stack namespace is
4175
+ // not its default suffix (or is invalid): the list is otherwise as before.
4176
+ const nsRows = new Map(
4177
+ stackNamespaceRows(shown.map(([n]) => n)).map((r) => [
4178
+ r.workspace,
4179
+ r,
4180
+ ]),
4181
+ );
4182
+ const nsCol = [...nsRows.values()].some((r) => r.differs || r.error);
4183
+ const table = newTable({
4184
+ head: [
4185
+ "Workspace",
4186
+ "Org",
4187
+ "Active",
4188
+ "Focus",
4189
+ "Services",
4190
+ "Type",
4191
+ ...(nsCol ? ["Stacks"] : []),
4192
+ ],
4193
+ style: { head: ["cyan"] },
4194
+ });
3761
4195
  for (const [name, cfg] of shown) {
4196
+ const ns = nsRows.get(name);
3762
4197
  table.push([
3763
4198
  chalk.bold(name),
3764
4199
  orgOfWs.get(name) ?? chalk.gray("none"),
@@ -3766,9 +4201,23 @@ class DenCLI {
3766
4201
  name === focus ? chalk.cyan("●") : "",
3767
4202
  Object.keys(cfg.services).join(", ") || chalk.gray("none"),
3768
4203
  cfg.directory ? chalk.magenta("feature") : chalk.blue("default"),
4204
+ ...(nsCol
4205
+ ? [
4206
+ ns?.error
4207
+ ? chalk.red("invalid")
4208
+ : ns?.differs
4209
+ ? `${ns.stack_namespace} ${chalk.gray(`(${ns.source})`)}`
4210
+ : chalk.gray(ns?.default ?? ""),
4211
+ ]
4212
+ : []),
3769
4213
  ]);
3770
4214
  }
3771
4215
  console.log(table.toString());
4216
+ printOrgWarnings(
4217
+ [...nsRows.values()].flatMap((r) =>
4218
+ r.error ? [`workspace ${r.workspace}: ${r.error}`] : r.warnings,
4219
+ ),
4220
+ );
3772
4221
  if (only && shown.length === 0)
3773
4222
  console.log(chalk.yellow(` No workspace in org ${only}.`));
3774
4223
  const names = new Set(shown.map(([n]) => n));
@@ -4075,6 +4524,58 @@ class DenCLI {
4075
4524
  // ── den validate ────────────────────────────────────────────────────────
4076
4525
 
4077
4526
  private async cmdValidate(args: string[]): Promise<void> {
4527
+ // The org config first (0.3.0-dev.40, a consumer's 0.2.8 nit: a
4528
+ // ConfigError went unreported): each problem with its file and key
4529
+ // path, a non-zero exit, and the manifests are still checked, reading
4530
+ // the config without its org data.
4531
+ try {
4532
+ assertDenConfigValid();
4533
+ } catch (e) {
4534
+ if (!isConfigError(e)) throw e;
4535
+ console.error(chalk.red(`\n ✗ ${product().configFile} is invalid:`));
4536
+ const issues = e.issues.length
4537
+ ? e.issues
4538
+ : [{ file: product().configFile, key: "", message: e.message }];
4539
+ for (const i of issues)
4540
+ console.error(
4541
+ chalk.red(` ✗ ${i.file} ${chalk.bold(i.key)} ${i.message}`),
4542
+ );
4543
+ process.exitCode = 1;
4544
+ waiveInvalidOrgs();
4545
+ }
4546
+ // The distribution's secrets config, once: a 1password backend with no
4547
+ // refs leaves the vault-backed features off (no built-in vault/item).
4548
+ try {
4549
+ const { secretsConfigWarnings } =
4550
+ await import("../../shared/secrets/index.js");
4551
+ for (const w of secretsConfigWarnings())
4552
+ console.warn(chalk.yellow(`\n ⚠ ${w.replace(/\n/g, "\n ")}`));
4553
+ } catch {
4554
+ /* an unreadable config is reported elsewhere */
4555
+ }
4556
+ // The edge's TLS strategy, when there is an edge: its config, the
4557
+ // implicit-mkcert warning (once), and detect() (mkcert on PATH).
4558
+ {
4559
+ const { hasEdgeBlock, readEdgeConfig } =
4560
+ await import("../../shared/edge/config.js");
4561
+ const { edgeTlsWarnings, tlsStrategyFor, warnImplicitTlsOnce } =
4562
+ await import("../../shared/edge/tls.js");
4563
+ let edge: ReturnType<typeof readEdgeConfig> | null = null;
4564
+ try {
4565
+ if (hasEdgeBlock()) edge = readEdgeConfig();
4566
+ } catch (e) {
4567
+ console.error(chalk.red(`\n ✗ edge: ${(e as Error).message}`));
4568
+ process.exitCode = 1;
4569
+ }
4570
+ if (edge) {
4571
+ const warn = (w: string) => console.warn(chalk.yellow(`\n ⚠ ${w}`));
4572
+ warnImplicitTlsOnce(edge, warn);
4573
+ for (const w of edgeTlsWarnings(edge))
4574
+ if (edge.tlsFrom !== "implicit") warn(w);
4575
+ const d = tlsStrategyFor(edge).detect();
4576
+ if (!d.available) warn(`edge.tls ${edge.tls.strategy}: ${d.reason}`);
4577
+ }
4578
+ }
4078
4579
  const positional = args.filter((a) => !a.startsWith("-"));
4079
4580
  const held = args.includes("--held");
4080
4581
  // Bare `validate` in the distribution root (which has no manifest of its
@@ -4279,7 +4780,7 @@ class DenCLI {
4279
4780
  " restart [service...] Restart services (-A <app> for a whole application)",
4280
4781
  );
4281
4782
  console.log(
4282
- " status Show service health for active workspace (-A <app>)",
4783
+ " status Show service health for the view scope (-A <app>, --scope <ws>)",
4283
4784
  );
4284
4785
  console.log(
4285
4786
  " apps Org > Application > Repo > Services (--json)",
@@ -4300,6 +4801,9 @@ class DenCLI {
4300
4801
  console.log(
4301
4802
  " register [path] Register a repo in the service catalog",
4302
4803
  );
4804
+ console.log(
4805
+ " relocate <repo> <path> Point a moved checkout's workspace entries at its new path",
4806
+ );
4303
4807
  console.log();
4304
4808
  console.log(
4305
4809
  " setup [service...] Run make setup for services (--plan: orgs + clone URLs, no changes)",
@@ -4317,6 +4821,22 @@ class DenCLI {
4317
4821
  console.log(
4318
4822
  " service install Run the web UI as a launchd service (status, print, uninstall)",
4319
4823
  );
4824
+ console.log(
4825
+ " theme add <repo>@<ref> Install a web UI theme (list, validate, remove)",
4826
+ );
4827
+ console.log(
4828
+ " plugin list Local and added plugins, checked (validate, add, update, remove)",
4829
+ );
4830
+ console.log(
4831
+ " sessions start -w <ws> --name <n> -- <argv…> A terminal that outlives the web server",
4832
+ );
4833
+ console.log(
4834
+ " sessions list|attach|kill List, attach to (ctrl-] detaches) or stop sessions",
4835
+ );
4836
+ console.log(" agents list -w <ws> The workspace's agents");
4837
+ console.log(
4838
+ " agents start -w <ws> --persona <p> Run a persona in the workspace (stop, resume, personas)",
4839
+ );
4320
4840
  console.log();
4321
4841
  console.log(
4322
4842
  " workspace list List workspaces (active set + focus)",