@monster-orange/den 0.2.7 → 0.3.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 (139) hide show
  1. package/CHANGELOG.md +1655 -0
  2. package/README.md +4 -2
  3. package/bin/den +2 -1
  4. package/docs/distributions.md +619 -3
  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 +9 -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/agent-actor.ts +38 -3
  22. package/src/shared/auth/headless-login.ts +1 -1
  23. package/src/shared/auth/sudo-vault.ts +93 -31
  24. package/src/shared/auth/token-providers/index.ts +7 -3
  25. package/src/shared/brand.ts +7 -0
  26. package/src/shared/config-error.ts +63 -0
  27. package/src/shared/constants.ts +3 -24
  28. package/src/shared/den-config.ts +185 -7
  29. package/src/shared/edge/config.ts +147 -21
  30. package/src/shared/edge/exec.ts +31 -0
  31. package/src/shared/edge/included.ts +169 -0
  32. package/src/shared/edge/render.ts +69 -14
  33. package/src/shared/edge/runtime.ts +128 -121
  34. package/src/shared/edge/status.ts +68 -8
  35. package/src/shared/edge/tls.ts +328 -0
  36. package/src/shared/edge/types.ts +25 -9
  37. package/src/shared/environment-manager/catalog.ts +52 -1
  38. package/src/shared/environment-manager/default-workspace.ts +26 -3
  39. package/src/shared/environment-manager/git-remote.ts +49 -0
  40. package/src/shared/environment-manager/health.ts +73 -38
  41. package/src/shared/environment-manager/scope.ts +168 -0
  42. package/src/shared/environment-manager/service-runner.ts +789 -43
  43. package/src/shared/environment-manager/up-targets.ts +132 -0
  44. package/src/shared/environment-manager/workspace-manager.ts +67 -25
  45. package/src/shared/environment-manager/workspace-org.ts +26 -11
  46. package/src/shared/health/rollup.ts +48 -0
  47. package/src/shared/manifest/service-links.ts +20 -8
  48. package/src/shared/open-url.ts +21 -2
  49. package/src/shared/org-sources.ts +328 -0
  50. package/src/shared/org.ts +53 -5
  51. package/src/shared/plugins/manifest.ts +406 -0
  52. package/src/shared/plugins/store.ts +358 -0
  53. package/src/shared/process/legacy-adopt.ts +285 -0
  54. package/src/shared/process/process-group.ts +202 -0
  55. package/src/shared/process/spawn-streaming.ts +7 -0
  56. package/src/shared/secrets/doppler.ts +1 -1
  57. package/src/shared/secrets/index.ts +279 -50
  58. package/src/shared/secrets/none.ts +56 -0
  59. package/src/shared/secrets/strategies.ts +202 -0
  60. package/src/shared/secrets/types.ts +1 -1
  61. package/src/shared/smoke/git-remote.ts +40 -0
  62. package/src/shared/stacks/manifest-commands.ts +1 -1
  63. package/src/shared/stacks/namespace.ts +178 -0
  64. package/src/shared/stacks/owners.ts +303 -0
  65. package/src/shared/stacks/render.ts +8 -3
  66. package/src/shared/stacks/runtime.ts +100 -16
  67. package/src/shared/stacks/status.ts +63 -12
  68. package/src/shared/strategies/registry.ts +89 -0
  69. package/src/shared/theme/contract.ts +538 -0
  70. package/src/shared/types/index.ts +24 -5
  71. package/src/ui/cli/clone-plan.ts +154 -23
  72. package/src/ui/cli/create.ts +24 -7
  73. package/src/ui/cli/doctor-command.ts +131 -0
  74. package/src/ui/cli/edge-command.ts +56 -10
  75. package/src/ui/cli/flag-parser.ts +62 -3
  76. package/src/ui/cli/health-display.ts +25 -0
  77. package/src/ui/cli/index.ts +649 -72
  78. package/src/ui/cli/notice.ts +20 -0
  79. package/src/ui/cli/org-command.ts +12 -2
  80. package/src/ui/cli/plugin-command.ts +849 -0
  81. package/src/ui/cli/stack-command.ts +63 -2
  82. package/src/ui/cli/test-runner.ts +11 -9
  83. package/src/ui/cli/theme-command.ts +136 -0
  84. package/src/ui/cli/up-args.ts +15 -0
  85. package/src/ui/cli/web-command.ts +47 -23
  86. package/src/ui/mcp/api.ts +28 -2
  87. package/src/ui/mcp/scope.ts +298 -0
  88. package/src/ui/mcp/server.ts +70 -8
  89. package/src/ui/mcp/tools.ts +151 -24
  90. package/src/ui/server/appearance-prefs.ts +95 -0
  91. package/src/ui/server/audit.ts +90 -3
  92. package/src/ui/server/daemon-reaper.ts +29 -1
  93. package/src/ui/server/engine-stamp.ts +83 -0
  94. package/src/ui/server/github-service.ts +29 -45
  95. package/src/ui/server/graph-page.ts +20 -0
  96. package/src/ui/server/graph-prefs.ts +44 -1
  97. package/src/ui/server/http-errors.ts +293 -0
  98. package/src/ui/server/http-helpers.ts +22 -3
  99. package/src/ui/server/index.ts +590 -45
  100. package/src/ui/server/public/appearance.js +531 -0
  101. package/src/ui/server/public/edge-page.js +248 -0
  102. package/src/ui/server/public/edge.html +251 -0
  103. package/src/ui/server/public/environments.html +65 -83
  104. package/src/ui/server/public/error-page.js +247 -0
  105. package/src/ui/server/public/error.html +260 -0
  106. package/src/ui/server/public/graph-model.js +332 -14
  107. package/src/ui/server/public/graph.css +141 -47
  108. package/src/ui/server/public/graph.js +223 -44
  109. package/src/ui/server/public/header.js +396 -600
  110. package/src/ui/server/public/health-fit.js +248 -0
  111. package/src/ui/server/public/health-rollup.js +128 -0
  112. package/src/ui/server/public/i18n/es.js +287 -0
  113. package/src/ui/server/public/i18n/ja.js +276 -0
  114. package/src/ui/server/public/index.html +139 -438
  115. package/src/ui/server/public/local-dev.html +3468 -4044
  116. package/src/ui/server/public/logs.html +71 -208
  117. package/src/ui/server/public/panel.js +15 -3
  118. package/src/ui/server/public/release.html +175 -389
  119. package/src/ui/server/public/services.html +596 -770
  120. package/src/ui/server/public/settings-page.js +759 -0
  121. package/src/ui/server/public/settings-registry.js +521 -0
  122. package/src/ui/server/public/settings.html +590 -0
  123. package/src/ui/server/public/shell.js +3869 -0
  124. package/src/ui/server/public/topbar.js +1413 -0
  125. package/src/ui/server/public/version-label.js +12 -0
  126. package/src/ui/server/public/workspace-picker.js +74 -34
  127. package/src/ui/server/readonly-routes.ts +24 -4
  128. package/src/ui/server/release-service.ts +25 -3
  129. package/src/ui/server/services-list.ts +94 -17
  130. package/src/ui/server/settings-distribution.ts +74 -0
  131. package/src/ui/server/shell-prefs.ts +192 -0
  132. package/src/ui/server/theme-routes.ts +292 -0
  133. package/src/ui/server/theme-runtime.ts +407 -0
  134. package/src/ui/server/ui-assets.ts +16 -1
  135. package/src/ui/server/ui-language.ts +50 -0
  136. package/src/ui/server/workspace-routes.ts +55 -9
  137. package/themes/den/den-theme.json +72 -0
  138. package/themes/den/favicon.ico +0 -0
  139. 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
@@ -32,10 +33,12 @@ import { moduleScanner } from "../../shared/test-discovery/module-scanner.js";
32
33
  import { resolvePerformanceModules } from "./module-selection.js";
33
34
  import {
34
35
  readCatalog,
35
- resolveRepoGhSlug,
36
+ needsGhRepair,
37
+ resolveRepoCloneSource,
36
38
  upsertCatalogEntry,
37
39
  } from "../../shared/environment-manager/catalog.js";
38
40
  import { seedSecretsFromSibling } from "../../shared/environment-manager/secrets-check.js";
41
+ import { upNeedsTarget } from "./up-args.js";
39
42
  import {
40
43
  readWorkspacesFile,
41
44
  writeWorkspacesFile,
@@ -56,6 +59,13 @@ import {
56
59
  DEN_SERVICES_DIR,
57
60
  DEN_WORKSPACES_DIR,
58
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";
59
69
  import {
60
70
  deleteWorkspaceDirectory,
61
71
  checkBehindCount,
@@ -68,9 +78,11 @@ import {
68
78
  markSetupSucceeded,
69
79
  fetchAndFastForward,
70
80
  } from "../../shared/environment-manager/workspace-manager.js";
81
+ import { stackNamespaceRows } from "../../shared/stacks/namespace.js";
71
82
  import {
72
83
  runServiceCommand,
73
84
  claimHealthyService,
85
+ daemonsOutsideWorkspaces,
74
86
  getWorkspaceServices,
75
87
  ensureDaemonRegistryLoaded,
76
88
  hasRunningOpctlContainers,
@@ -102,13 +114,18 @@ import { waitForReady } from "../../shared/environment-manager/readiness.js";
102
114
  import {
103
115
  canonicalServiceAddress,
104
116
  previewServiceCommand,
117
+ previewUpSettle,
105
118
  previewServiceSecretEnv,
106
119
  prepareServiceSecretEnv,
107
120
  resolveService,
121
+ type DownOutcome,
108
122
  type ServiceCommandPreview,
109
123
  } from "../../shared/environment-manager/service-runner.js";
110
124
  import { getGitHubCoords } from "../../shared/smoke/git-remote.js";
125
+ import { isConfigError } from "../../shared/config-error.js";
111
126
  import {
127
+ assertDenConfigValid,
128
+ waiveInvalidOrgs,
112
129
  getReposRoot,
113
130
  mergeUserConfig,
114
131
  readDenConfig,
@@ -123,6 +140,11 @@ import {
123
140
  deriveStartTargets,
124
141
  } from "../../shared/environment-manager/derivation/derived-up.js";
125
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";
126
148
  import { loadAliasResolver } from "../../shared/environment-manager/derivation/load-alias.js";
127
149
  import {
128
150
  cmdApps,
@@ -170,8 +192,11 @@ import {
170
192
  import { cleanupStaleTabs } from "../../shared/cli-tabs.js";
171
193
  import { cmdConfig } from "./config-command.js";
172
194
  import { cmdEdge } from "./edge-command.js";
195
+ import { cmdDoctor } from "./doctor-command.js";
173
196
  import { cmdService } from "./service-command.js";
174
197
  import { cmdStack } from "./stack-command.js";
198
+ import { cmdTheme } from "./theme-command.js";
199
+ import { cmdPlugin } from "./plugin-command.js";
175
200
  import {
176
201
  cmdOrg,
177
202
  orgFlag,
@@ -191,6 +216,7 @@ import { cmdGraph } from "./graph-command.js";
191
216
  import { resolveLogsTarget, tailServiceLogs } from "./logs-command.js";
192
217
  import {
193
218
  parseCliArgs,
219
+ takeWorkspaceFlags,
194
220
  unknownCommandFlags,
195
221
  workspaceFlagRequiresActive,
196
222
  type ParsedCliArgs,
@@ -205,6 +231,9 @@ import {
205
231
  formatHealthDot,
206
232
  formatHealthLabel,
207
233
  formatHealthReason,
234
+ formatRollupLabel,
235
+ healthReasonText,
236
+ rollupReasonText,
208
237
  } from "./health-display.js";
209
238
  import { DEN_BANNER_LINES, DEN_DIVIDER } from "../../shared/banner.js";
210
239
 
@@ -296,9 +325,12 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
296
325
  ],
297
326
  },
298
327
  status: {
299
- usage: "status [-A <app>]",
328
+ usage: "status [-A <app>] [--scope <all|workspace>] [--verbose]",
300
329
  lines: [
301
- "Show service health for the active workspace (or one application).",
330
+ "Show service health for what is in view: every active workspace by",
331
+ "default (the web UI's scope), or --scope <workspace> for one. A",
332
+ "service several workspaces share is listed once, with its owners.",
333
+ "--verbose (or --all) shows one row per service instead of per repo.",
302
334
  ],
303
335
  },
304
336
  logs: {
@@ -315,15 +347,26 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
315
347
  lines: ["List all registered services in the catalog."],
316
348
  },
317
349
  register: {
318
- usage: "register [path] [--org <id>]",
350
+ usage: "register [path] [-w <workspace>] [--org <id>]",
319
351
  lines: [
320
352
  "With a path: add that checkout (dev-managed) under its manifest name.",
321
353
  "Without one: pick the repo interactively. A managed clone is picked",
322
354
  "from the focused workspace's org; --org <id> picks from another.",
355
+ "-w writes that workspace (active or not) and leaves focus alone.",
356
+ ],
357
+ },
358
+ relocate: {
359
+ usage: "relocate <repo> <path> [--dry-run] [--force] [-w <workspace>]",
360
+ lines: [
361
+ "Point every workspace's dev-managed entries for <repo> at <path>.",
362
+ "Refuses a path that is missing, holds a different repo, or has",
363
+ "neither a manifest nor a remote naming this one (unless --force).",
364
+ "-w limits it to that workspace; focus never changes.",
323
365
  ],
324
366
  },
325
367
  setup: {
326
- usage: "setup [service...] [--force|-f] [--plan]",
368
+ usage:
369
+ "setup [service...] [--force|-f] [--plan [-w <workspace>]... [--all-active]]",
327
370
  lines: [
328
371
  "Run 'make setup' once per system (clones missing managed repos first).",
329
372
  "Default targets MANAGED clones only — setup's codegen/installs",
@@ -331,6 +374,8 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
331
374
  "--force (-f) includes dev-managed repos; naming a service always works.",
332
375
  "--plan prints each repo's org and clone URL and stops: nothing is",
333
376
  "cloned, seeded or written (on a fresh machine: the blueprint's plan).",
377
+ "It plans the focused workspace; -w <workspace> (repeatable) plans those,",
378
+ "--all-active every active one. Repos only other workspaces hold are named.",
334
379
  ],
335
380
  },
336
381
  org: {
@@ -409,7 +454,33 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
409
454
  edge: {
410
455
  usage: "edge [up|down|status|trust|untrust|routes]",
411
456
  lines: [
412
- "This org's HTTPS edge: own CA + cert, routes at <name>.<edge.domain>.",
457
+ "This org's edge: routes at <name>.<edge.domain>, TLS as edge.tls says",
458
+ "(none | mkcert | files). trust/untrust exist only under mkcert.",
459
+ ],
460
+ },
461
+ doctor: {
462
+ usage: "doctor",
463
+ lines: [
464
+ "Each strategy kind (secrets, tls): the one selected, whether it was implicit,",
465
+ "and whether it can work here (a PATH check; nothing is run or prompted).",
466
+ ],
467
+ },
468
+ plugin: {
469
+ usage:
470
+ "plugin [list] | validate [dir] | add <repo>[/<subpath>]@<ref> [--track] [--yes] | update <id> @<ref> | remove <id>",
471
+ lines: [
472
+ "Plugins (den.plugin/v1) in plugins/<id>/: local (the distribution's own, no lock entry)",
473
+ "or added (vendored at an exact ref, pinned in plugins.lock.json). list checks each",
474
+ "against the lock; validate checks every manifest. add refuses file:// sources.",
475
+ ],
476
+ },
477
+ theme: {
478
+ usage:
479
+ "theme [list] | validate [dir] | new <id> | add <repo>[/<subpath>]@<ref> | remove <id>",
480
+ lines: [
481
+ "Web UI themes (den.theme/v1). add fetches one at an exact ref, validates it",
482
+ "(schema, contrast in both modes, files) and vendors it into plugins/<id>/",
483
+ "with a plugins.lock.json entry; commit both. list checks them against the lock.",
413
484
  ],
414
485
  },
415
486
  mcp: {
@@ -430,7 +501,7 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
430
501
  "local-dev": {
431
502
  usage: "local-dev [on|off]",
432
503
  lines: [
433
- "Toggle the local-dev experience (web Local Dev section + service mgmt).",
504
+ "Toggle the local-dev experience (web Workspaces page + service mgmt).",
434
505
  ],
435
506
  },
436
507
  menu: {
@@ -458,6 +529,7 @@ function resolveCliBaseApiUrl(explicitBaseApiUrl?: string): string | undefined {
458
529
  const TELEMETRY_COMMANDS = new Set<string>([
459
530
  "run",
460
531
  "view-results",
532
+ "down",
461
533
  "menu",
462
534
  "web",
463
535
  "up",
@@ -468,6 +540,7 @@ const TELEMETRY_COMMANDS = new Set<string>([
468
540
  "services",
469
541
  "graph",
470
542
  "register",
543
+ "relocate",
471
544
  "use-local",
472
545
  `use-managed`,
473
546
  "setup",
@@ -480,13 +553,40 @@ const TELEMETRY_COMMANDS = new Set<string>([
480
553
  "local-dev",
481
554
  "config",
482
555
  "edge",
556
+ "doctor",
483
557
  "stack",
484
558
  "apps",
485
559
  "applications",
486
560
  "mcp",
487
561
  "service",
562
+ "theme",
563
+ "plugin",
564
+ ]);
565
+
566
+ /**
567
+ * Commands that run with a config that fails validation: the web and MCP
568
+ * servers (each route or tool reports it), and those that read neither orgs,
569
+ * the catalog nor clones (manifest validate, create, the per-engineer
570
+ * config and local-dev toggles, saved results), and `down` (0.3.0-dev.40:
571
+ * stopping is never stranded by a config that fails; it stops from the
572
+ * daemon records and compose projects, reading no org data). Every other
573
+ * registered command checks the config first.
574
+ */
575
+ const CONFIG_FREE_COMMANDS = new Set<string>([
576
+ "web",
577
+ "mcp",
578
+ "validate",
579
+ "create",
580
+ "config",
581
+ "local-dev",
582
+ "view-results",
583
+ "down",
488
584
  ]);
489
585
 
586
+ function configCheckedCommand(command: string): boolean {
587
+ return TELEMETRY_COMMANDS.has(command) && !CONFIG_FREE_COMMANDS.has(command);
588
+ }
589
+
490
590
  /** Map a raw command token to a PII-safe, registered telemetry command name. */
491
591
  function telemetryCommandName(command?: string): string {
492
592
  if (!command) return "none";
@@ -709,6 +809,28 @@ class DenCLI {
709
809
  }
710
810
 
711
811
  try {
812
+ // A config that fails validation stops every command that touches
813
+ // orgs, the catalog, clones or releases before it reads or writes
814
+ // anything: some (sync over existing clones, register, workspace
815
+ // create) never read `orgs:` and used to carry on (ORG-1d).
816
+ if (command && configCheckedCommand(command)) assertDenConfigValid();
817
+ // `down` is never stranded by an invalid config (0.3.0-dev.40): it
818
+ // says so and stops from the daemon records and compose projects,
819
+ // reading the config without its org data.
820
+ if (command === "down") {
821
+ try {
822
+ assertDenConfigValid();
823
+ } catch (e) {
824
+ if (!isConfigError(e)) throw e;
825
+ console.log(
826
+ chalk.yellow(
827
+ `\n ⚠ ${e.message}\n Stopping anyway, without the config's org data. Fix it, then \`${product().cli} validate\`.`,
828
+ ),
829
+ );
830
+ waiveInvalidOrgs();
831
+ }
832
+ }
833
+
712
834
  switch (command) {
713
835
  case "run":
714
836
  await this.runTestsCommand(args.slice(1));
@@ -763,6 +885,10 @@ class DenCLI {
763
885
  await this.cmdRegister(args.slice(1));
764
886
  break;
765
887
 
888
+ case "relocate":
889
+ await this.cmdRelocate(args.slice(1));
890
+ break;
891
+
766
892
  case "use-local":
767
893
  await this.cmdUseMode(args.slice(1), "dev-managed");
768
894
  break;
@@ -837,6 +963,10 @@ class DenCLI {
837
963
  await cmdEdge(args.slice(1));
838
964
  break;
839
965
 
966
+ case "doctor":
967
+ process.exitCode = cmdDoctor(args.slice(1));
968
+ break;
969
+
840
970
  case "service":
841
971
  process.exitCode = await cmdService(args.slice(1));
842
972
  break;
@@ -845,6 +975,14 @@ class DenCLI {
845
975
  await cmdStack(args.slice(1));
846
976
  break;
847
977
 
978
+ case "theme":
979
+ process.exitCode = await cmdTheme(args.slice(1));
980
+ break;
981
+
982
+ case "plugin":
983
+ process.exitCode = await cmdPlugin(args.slice(1));
984
+ break;
985
+
848
986
  case "apps":
849
987
  case "applications":
850
988
  await this.ensureWorkspaceSeeded();
@@ -1142,10 +1280,11 @@ class DenCLI {
1142
1280
  try {
1143
1281
  // An entry with `gh: ""` clones from the workspace's org, as `up`
1144
1282
  // does (and `setup --plan` says), instead of failing on an empty slug.
1145
- const ghSlug = resolveRepoGhSlug(repoName, catalog, () =>
1283
+ // ORG-1d: a repo with no source and a `remote:` clones from it.
1284
+ const ghSlug = resolveRepoCloneSource(repoName, catalog, () =>
1146
1285
  githubOrgFor(ws.name),
1147
1286
  );
1148
- if (!catalogEntry.gh?.trim())
1287
+ if (needsGhRepair(catalogEntry, ghSlug))
1149
1288
  upsertCatalogEntry(repoName, { ...catalogEntry, gh: ghSlug });
1150
1289
  ensureManagedServiceClone(repoName, ghSlug, branch);
1151
1290
  } catch (err) {
@@ -1248,10 +1387,12 @@ class DenCLI {
1248
1387
  // Catalog entries written by the dev-managed add flow carry `gh: ""`, so
1249
1388
  // an entry can exist yet be unclonable. Derive the conventional slug and
1250
1389
  // persist the repair rather than cloning from an empty URL.
1251
- const ghSlug = resolveRepoGhSlug(repoName, catalog, () =>
1390
+ // ORG-1d: a repo with no source and a `remote:` clones from it.
1391
+ const ghSlug = resolveRepoCloneSource(repoName, catalog, () =>
1252
1392
  githubOrgFor(ws.name),
1253
1393
  );
1254
- if (!catalog[repoName]?.gh) upsertCatalogEntry(repoName, { gh: ghSlug });
1394
+ if (needsGhRepair(catalog[repoName], ghSlug))
1395
+ upsertCatalogEntry(repoName, { ...catalog[repoName], gh: ghSlug });
1255
1396
  try {
1256
1397
  // runSetup=false: clone only — setup is run below, hidden + non-interactive.
1257
1398
  ensureManagedServiceClone(repoName, ghSlug, branch, false);
@@ -1488,6 +1629,8 @@ class DenCLI {
1488
1629
  * in order, and run nothing. Read-only on every path.
1489
1630
  */
1490
1631
  dryRun?: boolean;
1632
+ /** `--plan`: a preview that would run nothing exits 1. */
1633
+ plan?: boolean;
1491
1634
  },
1492
1635
  ): Promise<void> {
1493
1636
  // How long to show startup output before handing control back to the menu
@@ -1557,7 +1700,15 @@ class DenCLI {
1557
1700
  // The same lookup a real run makes, so the dry run says what it does.
1558
1701
  const previews = ordered.map((a): ServiceCommandPreview => {
1559
1702
  const remote = remoteRefusal(a);
1560
- if (!remote) return previewServiceCommand(a, command, workspace);
1703
+ if (!remote) {
1704
+ const p = previewServiceCommand(a, command, workspace);
1705
+ // What up does first about a pre-dev.37 record or a held port.
1706
+ return (
1707
+ (command === "up" && p.kind === "run"
1708
+ ? previewUpSettle(a, workspace)
1709
+ : null) ?? p
1710
+ );
1711
+ }
1561
1712
  // As the loop below: a named remote target fails, any other is
1562
1713
  // said and skipped.
1563
1714
  return isNamedTarget(a, opts?.named ?? [])
@@ -1580,7 +1731,7 @@ class DenCLI {
1580
1731
  ),
1581
1732
  );
1582
1733
  for (const p of previews) {
1583
- if (p.kind === "via-repo" || p.kind === "nothing")
1734
+ if (p.kind !== "error" && p.note)
1584
1735
  console.log(chalk.gray(` ${p.note}`));
1585
1736
  if (p.kind === "error") console.log(chalk.red(` ✗ ${p.note}`));
1586
1737
  }
@@ -1599,6 +1750,7 @@ class DenCLI {
1599
1750
  );
1600
1751
  }
1601
1752
  if (previews.some((p) => p.kind === "error")) process.exitCode = 1;
1753
+ if (opts?.plan && runs.length === 0) process.exitCode = 1;
1602
1754
  console.log(chalk.gray(` (--dry-run: not ${doing} anything)\n`));
1603
1755
  return;
1604
1756
  }
@@ -1657,12 +1809,27 @@ class DenCLI {
1657
1809
  const resolved = resolveService(address, workspace);
1658
1810
  if (resolved) {
1659
1811
  const health = await checkServiceHealth(resolved, "local");
1660
- if (health.health === "ok") {
1812
+ // `conflict`: a shared stack namespace runs another checkout's
1813
+ // project; the real up below refuses it, naming both.
1814
+ const claim =
1815
+ health.health === "ok" && workspace
1816
+ ? claimHealthyService(resolved, workspace, printNotice)
1817
+ : "untracked";
1818
+ // Healthy, but another process answers on its port: said (above),
1819
+ // never claimed, and nothing is started on top of it.
1820
+ if (health.health === "ok" && claim === "busy") {
1821
+ console.log(
1822
+ chalk.red(
1823
+ ` ✗ ${chalk.bold(address)} not started: its port is in use by a process ${product().cli} won't adopt`,
1824
+ ),
1825
+ );
1826
+ // A dependency only warns; the service you named failed.
1827
+ if (isNamedTarget(address, opts?.named ?? [])) process.exitCode = 1;
1828
+ continue;
1829
+ }
1830
+ if (health.health === "ok" && claim !== "conflict") {
1661
1831
  // Skipping must not lose the refcount — this workspace is an owner
1662
1832
  // of what's running, whether den tracked it or not.
1663
- const claim = workspace
1664
- ? claimHealthyService(resolved, workspace)
1665
- : "untracked";
1666
1833
  const suffix =
1667
1834
  claim === "adopted"
1668
1835
  ? " (adopted its running containers)"
@@ -1763,6 +1930,8 @@ class DenCLI {
1763
1930
  let result: ReturnType<typeof runServiceCommand> | null = null;
1764
1931
  let detachable = false;
1765
1932
  let interactive = false;
1933
+ // A down that found nothing to stop is said as such, never as ✔.
1934
+ let downOutcome: DownOutcome | null = null;
1766
1935
 
1767
1936
  const settleFromClose = (exitCode: number): void => {
1768
1937
  if (settled) return;
@@ -1779,6 +1948,17 @@ class DenCLI {
1779
1948
  ),
1780
1949
  );
1781
1950
  }
1951
+ } else if (exitCode === 0 && downOutcome === "nothing-recorded") {
1952
+ spinner.info(
1953
+ `${command} ${chalk.bold(address)} ${chalk.gray("(nothing to stop)")}`,
1954
+ );
1955
+ } else if (exitCode === 0 && downOutcome === "unstoppable") {
1956
+ spinner.warn(
1957
+ `${command} ${chalk.bold(address)} ${chalk.yellow("(could not stop it)")}`,
1958
+ );
1959
+ for (const l of lastLines) {
1960
+ console.log(chalk.gray(` ${l}`));
1961
+ }
1782
1962
  } else if (exitCode === 0) {
1783
1963
  spinner.succeed(`${command} ${chalk.bold(address)}`);
1784
1964
  } else {
@@ -1832,8 +2012,21 @@ class DenCLI {
1832
2012
  runtime,
1833
2013
  workspace,
1834
2014
  force: command === "down" ? force : false,
2015
+ // Down fails when it cannot stop a service the user named; one
2016
+ // reached as part of a workspace-wide down warns instead.
2017
+ explicit: isNamedTarget(address, opts?.named ?? []),
2018
+ onDownOutcome: (outcome) => {
2019
+ downOutcome = outcome;
2020
+ },
1835
2021
  secretEnv: secrets.secretEnv,
1836
2022
  onLine: (line) => {
2023
+ // What Den decided about a record or a port is said, not
2024
+ // only flashed in the spinner (0.3.0-dev.39).
2025
+ if (isNotice(line)) {
2026
+ spinner.clear();
2027
+ printNotice(line);
2028
+ if (spinner.isEnabled) spinner.render();
2029
+ }
1837
2030
  spinner.text = `${command} ${chalk.bold(address)}: ${chalk.gray(line.slice(0, 80))}`;
1838
2031
  const trimmed = line.trim();
1839
2032
  if (trimmed) {
@@ -2042,6 +2235,7 @@ class DenCLI {
2042
2235
  `\n ${chalk.bold(seed)} has no derived dependencies to start.\n`,
2043
2236
  ),
2044
2237
  );
2238
+ if (plan) process.exitCode = 1;
2045
2239
  return;
2046
2240
  }
2047
2241
  // Systems the user toggled to auto-start OFF in this workspace are not
@@ -2088,11 +2282,15 @@ class DenCLI {
2088
2282
  this.renderUpClonePlan(rawOrder);
2089
2283
  return;
2090
2284
  }
2285
+ // A repo with no up of its own starts its services, one with no up at
2286
+ // all is skipped: the decision `up -a` makes (resolveUpTargets).
2287
+ const targets = new Set(include ? [seed] : []);
2091
2288
  if (dryRun) {
2289
+ const up = this.upTargets(order, targets, graph);
2092
2290
  await this.runServicesCommand(
2093
- order,
2291
+ up.order,
2094
2292
  "up",
2095
- { order },
2293
+ { order: up.order },
2096
2294
  { named, dryRun: true },
2097
2295
  );
2098
2296
  return;
@@ -2116,12 +2314,18 @@ class DenCLI {
2116
2314
  );
2117
2315
  return;
2118
2316
  }
2317
+ const up = this.upTargets(order, targets, graph);
2318
+ if (up.order.length === 0) {
2319
+ console.log(chalk.yellow(" Nothing to start.\n"));
2320
+ return;
2321
+ }
2119
2322
  await this.runServicesCommand(
2120
- order,
2323
+ up.order,
2121
2324
  "up",
2122
2325
  {
2123
- order,
2124
- waitDepsFor: (address) => hardDepsOf(graph, address),
2326
+ order: up.order,
2327
+ waitDepsFor: (address) =>
2328
+ up.depsAs(hardDepsOf(graph, repoOfAddress(address))),
2125
2329
  failFast: true,
2126
2330
  skipHealthyAll: true,
2127
2331
  },
@@ -2129,6 +2333,37 @@ class DenCLI {
2129
2333
  );
2130
2334
  }
2131
2335
 
2336
+ /**
2337
+ * The start order `up` runs (`up <repo>` and `up -a` alike): a repo with
2338
+ * no up of its own replaced by its services' ups, one with no up at all
2339
+ * skipped (resolveUpTargets), said as it decides.
2340
+ */
2341
+ private upTargets(
2342
+ order: string[],
2343
+ targets: ReadonlySet<string>,
2344
+ graph: DerivedGraph,
2345
+ ): UpTargets {
2346
+ const up = resolveUpTargets(order, this.focusedWs(), { targets, graph });
2347
+ this.sayUpTargets(up);
2348
+ return up;
2349
+ }
2350
+
2351
+ /** What resolveUpTargets decided, in `up`'s words. */
2352
+ private sayUpTargets(up: UpTargets): void {
2353
+ for (const [repo, addrs] of up.expanded)
2354
+ console.log(
2355
+ chalk.gray(
2356
+ ` ${repo} has no up of its own: starting ${addrs.join(", ")}`,
2357
+ ),
2358
+ );
2359
+ if (up.skippedNoUp.length > 0)
2360
+ console.log(
2361
+ chalk.gray(
2362
+ ` Skipping (no local up command): ${up.skippedNoUp.join(", ")}\n`,
2363
+ ),
2364
+ );
2365
+ }
2366
+
2132
2367
  /**
2133
2368
  * Stop the seed repo and the dependencies it was started with — the same
2134
2369
  * closure `den up <seed>` brings up, torn down in reverse (seed first,
@@ -2283,6 +2518,7 @@ class DenCLI {
2283
2518
  "\n No services in the active workspace — nothing to start.\n",
2284
2519
  ),
2285
2520
  );
2521
+ if (plan) process.exitCode = 1;
2286
2522
  return;
2287
2523
  }
2288
2524
  const { rawOrder, graph } = target;
@@ -2345,19 +2581,18 @@ class DenCLI {
2345
2581
  // is skipped, not fatal. `-a` starts independent peers — one unrunnable
2346
2582
  // repo must not strand the rest (runServiceCommand exits 1 on a missing
2347
2583
  // command, and a fail-fast would turn that into a full abort). Now that
2348
- // clones exist, the manifest lookup is meaningful.
2349
- const order: string[] = [];
2350
- const skippedNoUp: string[] = [];
2351
- for (const repo of candidates) {
2352
- const resolved = resolveService(repo);
2353
- const upDef = resolved
2354
- ? localCommandsFor(resolved, this.focusedWs())["up"]
2355
- : undefined;
2356
- (upDef ? order : skippedNoUp).push(repo);
2357
- }
2584
+ // clones exist, the manifest lookup is meaningful. A repo with no up of
2585
+ // its own but per-service ups starts those services: the same decision
2586
+ // `up <repo>` makes for a dependency (upTargets).
2587
+ const up = resolveUpTargets(candidates, this.focusedWs(), {
2588
+ targets: new Set(candidates),
2589
+ graph,
2590
+ });
2591
+ const order = up.order;
2592
+ const systems = candidates.filter((r) => !up.skippedNoUp.includes(r));
2358
2593
  console.log(
2359
2594
  chalk.cyan(
2360
- `\n ${only ? "Application" : "Workspace-wide"} up — ${chalk.bold(String(order.length))} ${noun(order.length, "system")}`,
2595
+ `\n ${only ? "Application" : "Workspace-wide"} up — ${chalk.bold(String(systems.length))} ${noun(systems.length, "system")}`,
2361
2596
  ),
2362
2597
  );
2363
2598
  console.log(chalk.gray(` Order: ${order.join(" → ")}\n`));
@@ -2369,13 +2604,7 @@ class DenCLI {
2369
2604
  ),
2370
2605
  );
2371
2606
  }
2372
- if (skippedNoUp.length > 0) {
2373
- console.log(
2374
- chalk.gray(
2375
- ` Skipping (no local up command): ${skippedNoUp.join(", ")}\n`,
2376
- ),
2377
- );
2378
- }
2607
+ this.sayUpTargets(up);
2379
2608
  if (order.length === 0) {
2380
2609
  console.log(chalk.yellow(" Nothing to start.\n"));
2381
2610
  return;
@@ -2385,13 +2614,17 @@ class DenCLI {
2385
2614
  // healthy-skip. Unlike runDerivedUp there is NO failFast on the start
2386
2615
  // loop: `-a`'s systems are independent peers — one failure must not
2387
2616
  // strand the rest (the web master button continues too).
2388
- const repoSet = new Set(order);
2617
+ const repoSet = new Set(systems);
2389
2618
  await this.runServicesCommand(order, "up", {
2390
2619
  order,
2391
2620
  // Hard deps outside the workspace can't be waited on — filter to the set
2392
2621
  // we are actually starting.
2393
2622
  waitDepsFor: (address) =>
2394
- hardDepsOf(graph, address).filter((dep) => repoSet.has(dep)),
2623
+ up.depsAs(
2624
+ hardDepsOf(graph, repoOfAddress(address)).filter((dep) =>
2625
+ repoSet.has(dep),
2626
+ ),
2627
+ ),
2395
2628
  skipHealthyAll: true,
2396
2629
  });
2397
2630
  }
@@ -2427,6 +2660,14 @@ class DenCLI {
2427
2660
  return;
2428
2661
  }
2429
2662
  await this.runServicesCommand(order, "down", { order }, { force });
2663
+ // Records no workspace reaches: -a stops every workspace's services,
2664
+ // not these. Said, and left alone (0.3.0-dev.39, a consumer's finding).
2665
+ if (!only)
2666
+ for (const d of daemonsOutsideWorkspaces())
2667
+ printNotice(
2668
+ `Warning: ${d.address} is recorded as running (${d.repoPath}) but is in no active workspace: ` +
2669
+ `down -a left it alone. Stop it yourself if it still runs.`,
2670
+ );
2430
2671
  }
2431
2672
 
2432
2673
  private printUpHelp(): void {
@@ -2714,10 +2955,10 @@ class DenCLI {
2714
2955
  }
2715
2956
 
2716
2957
  private async cmdServiceUp(args: string[]): Promise<void> {
2717
- await this.ensureWorkspaceSeeded();
2718
2958
  // Tokens after a `--` terminator belong to a child command — they must
2719
2959
  // never be read as up's own flags (`den up -- -a` is NOT `up -a`).
2720
2960
  const terminator = args.indexOf("--");
2961
+ await this.ensureWorkspaceSeeded();
2721
2962
  const { app, rest } = takeAppFlag(
2722
2963
  terminator === -1 ? args : args.slice(0, terminator),
2723
2964
  );
@@ -2781,6 +3022,7 @@ class DenCLI {
2781
3022
  await this.cmdServiceUpManifest(
2782
3023
  ownArgs.filter((a) => !a.startsWith("-")),
2783
3024
  dryRun || plan,
3025
+ plan,
2784
3026
  );
2785
3027
  }
2786
3028
 
@@ -2788,6 +3030,7 @@ class DenCLI {
2788
3030
  private async cmdServiceUpManifest(
2789
3031
  addresses: string[],
2790
3032
  dryRun = false,
3033
+ plan = false,
2791
3034
  ): Promise<void> {
2792
3035
  // Resolve targets BEFORE the secrets preflight — otherwise running
2793
3036
  // `den up` from a non-service dir (e.g. the den repo root) triggers
@@ -2806,6 +3049,16 @@ class DenCLI {
2806
3049
  ` or run \`${product().cli} graph\` to see the workspace.\n`,
2807
3050
  ),
2808
3051
  );
3052
+ } else if (process.env.DEN_WORKSPACE_OVERRIDE) {
3053
+ // `up -w <ws>` with nothing to start: -w picks the workspace, not
3054
+ // what in it to start. A usage error, not an empty plan.
3055
+ console.error(
3056
+ chalk.red(
3057
+ `\n ${upNeedsTarget(product().cli, process.env.DEN_WORKSPACE_OVERRIDE, plan)}\n`,
3058
+ ),
3059
+ );
3060
+ process.exitCode = 2;
3061
+ return;
2809
3062
  } else {
2810
3063
  console.log(
2811
3064
  chalk.yellow(
@@ -2813,12 +3066,15 @@ class DenCLI {
2813
3066
  ),
2814
3067
  );
2815
3068
  }
3069
+ // A plan that resolves nothing is a failed plan (scripts check it).
3070
+ if (plan) process.exitCode = 1;
2816
3071
  return;
2817
3072
  }
2818
3073
  if (!dryRun) preflightSecrets();
2819
3074
  await this.runServicesCommand(targets, "up", undefined, {
2820
3075
  named: addresses,
2821
3076
  dryRun,
3077
+ plan,
2822
3078
  });
2823
3079
  }
2824
3080
 
@@ -2935,15 +3191,49 @@ class DenCLI {
2935
3191
 
2936
3192
  private async cmdServiceStatus(argv: string[] = []): Promise<void> {
2937
3193
  await this.ensureWorkspaceSeeded();
2938
- const { app, rest: args } = takeAppFlag(argv);
3194
+ const { app, rest: appRest } = takeAppFlag(argv);
3195
+ // The view scope (as the web UI's): every active workspace by default,
3196
+ // or `--scope <workspace>` for one. A view only: focus is unchanged.
3197
+ const scoped = takeScopeFlag(appRest);
3198
+ const args = scoped.rest;
3199
+ const scopeArg =
3200
+ scoped.scope === null
3201
+ ? null
3202
+ : parseScope(scoped.scope, getActiveWorkspaces());
3203
+ if (scoped.error || (scopeArg && !scopeArg.ok)) {
3204
+ console.log(
3205
+ chalk.red(
3206
+ `\n ${scoped.error ?? (scopeArg && !scopeArg.ok ? scopeArg.error : "")}\n`,
3207
+ ),
3208
+ );
3209
+ process.exit(1);
3210
+ }
3211
+ // `-w <workspace>` still means that workspace only (active or not).
3212
+ const named = process.env.DEN_WORKSPACE_OVERRIDE
3213
+ ? getActiveWorkspace()
3214
+ : null;
3215
+ const view =
3216
+ named && !scopeArg
3217
+ ? {
3218
+ scope: named.name,
3219
+ fellBack: false,
3220
+ workspaces: [named],
3221
+ focus: named.name,
3222
+ }
3223
+ : scopeView(scopeArg?.ok ? scopeArg.value : undefined);
2939
3224
  const only = app ? focusedAppRepos(app) : null;
2940
3225
  const verbose = args.includes("--verbose") || args.includes("--all");
2941
3226
  const spinner = ora("Checking service health...").start();
2942
- const raw = (await checkCatalogHealth()).filter(
2943
- (h) => !only || only.has(h.repoName),
2944
- );
2945
- // Roll multi-service repos up to their entrypoint so shared-infra sidecars
2946
- // (which read healthy off infra-stack) don't make a down app look up.
3227
+ const raw = (
3228
+ await checkCatalogHealth(
3229
+ undefined,
3230
+ "local",
3231
+ view.workspaces.filter((w) => w.config),
3232
+ )
3233
+ ).filter((h) => !only || only.has(h.repoName));
3234
+ // One row per repo, rolled up from every checked service by the rule the
3235
+ // web UI's header and graph use: entry down → down, another checked
3236
+ // service down → degraded; unchecked services never count.
2947
3237
  const rolled = verbose ? raw : rollupRepoHealth(raw);
2948
3238
  // Order top-down like the graph: entry repo first (shop-web) … foundation
2949
3239
  // last (infra-stack), subsystems grouped under their repo.
@@ -2961,10 +3251,7 @@ class DenCLI {
2961
3251
  return;
2962
3252
  }
2963
3253
 
2964
- const ws = getActiveWorkspace();
2965
- console.log(
2966
- chalk.cyan(`\n Workspace: ${chalk.bold(ws?.name ?? "none")}\n`),
2967
- );
3254
+ console.log(chalk.cyan(`\n ${scopeLine(view)}\n`));
2968
3255
 
2969
3256
  const table = newTable({
2970
3257
  head: ["Service", "Mode", "Branch", "Health", "Reason"],
@@ -2972,8 +3259,20 @@ class DenCLI {
2972
3259
  });
2973
3260
 
2974
3261
  for (const r of results) {
2975
- const healthStr = formatHealthLabel(r.health);
2976
- const reasonStr = formatHealthReason(r);
3262
+ const healthStr = r.rollup
3263
+ ? formatRollupLabel(r.rollup)
3264
+ : formatHealthLabel(r.health);
3265
+ const rolledWhy = r.rollup ? rollupReasonText(r.rollup) : "";
3266
+ const reasonStr =
3267
+ r.rollup && r.rollup.state !== "unknown"
3268
+ ? chalk.gray(
3269
+ [r.rollup.state === "down" ? healthReasonText(r) : "", rolledWhy]
3270
+ .filter(Boolean)
3271
+ .join("; ")
3272
+ .slice(0, 60),
3273
+ )
3274
+ : formatHealthReason(r) ||
3275
+ (rolledWhy ? chalk.gray(rolledWhy.slice(0, 60)) : "");
2977
3276
  const placed = placementSuffix({
2978
3277
  runs: r.runs ?? "none",
2979
3278
  target: r.target ?? null,
@@ -2981,6 +3280,7 @@ class DenCLI {
2981
3280
 
2982
3281
  table.push([
2983
3282
  this.labelWithAutostart(r.address, noAutostart) +
3283
+ chalk.gray(ownersSuffix(r.workspaces)) +
2984
3284
  (placed ? chalk.cyan(` ${placed}`) : ""),
2985
3285
  r.mode === "dev-managed"
2986
3286
  ? chalk.blue("dev-managed")
@@ -3002,7 +3302,7 @@ class DenCLI {
3002
3302
  if (!verbose && raw.length !== results.length) {
3003
3303
  console.log(
3004
3304
  chalk.gray(
3005
- ` ▪ multi-service repos show entrypoint health — \`${product().cli} status --verbose\` for all subsystems`,
3305
+ ` ▪ one row per repo, rolled up from its checked services — \`${product().cli} status --verbose\` for each`,
3006
3306
  ),
3007
3307
  );
3008
3308
  }
@@ -3173,15 +3473,32 @@ class DenCLI {
3173
3473
  private async cmdRegister(args: string[]): Promise<void> {
3174
3474
  if (args.includes("-h") || args.includes("--help")) {
3175
3475
  console.log(
3176
- `\n ${product().cli} register [path] [--name <repo>] [--gh <owner/repo>]\n\n` +
3476
+ `\n ${product().cli} register [path] [--name <repo>] [--gh <owner/repo>] [-w <workspace>] [--org <id>]\n\n` +
3177
3477
  " With a path: add that checkout to the catalog and the focused\n" +
3178
3478
  " workspace as your own (dev-managed) copy, without prompts. The name\n" +
3179
3479
  " defaults to the manifest's name, then the folder name; the GitHub\n" +
3180
- " slug to the git remote.\n" +
3181
- " Without a path: choose a local checkout or a managed clone interactively.\n",
3480
+ " slug to the git remote. -w writes that workspace (active or not)\n" +
3481
+ " and leaves focus alone.\n" +
3482
+ " Without a path: choose a local checkout or a managed clone interactively;\n" +
3483
+ " a managed clone comes from the workspace's org, or --org <id>.\n",
3182
3484
  );
3183
3485
  return;
3184
3486
  }
3487
+ const repoPathArg = args.find(
3488
+ (a, i) =>
3489
+ !a.startsWith("-") && !/^--(name|gh|org)$/.test(args[i - 1] ?? ""),
3490
+ );
3491
+ // Without a path, register prompts: with no terminal (stdin closed or
3492
+ // piped, where readline would throw ERR_USE_AFTER_CLOSE mid-prompt), say
3493
+ // how to name one. Usage error: exit 2.
3494
+ if (!repoPathArg && !process.stdin.isTTY) {
3495
+ process.stderr.write(
3496
+ `register needs a terminal (stdin is not a TTY); without one: ` +
3497
+ `${product().cli} register <path> [--name <repo>] [--gh <owner/repo>] [-w <workspace>]\n`,
3498
+ );
3499
+ process.exitCode = 2;
3500
+ return;
3501
+ }
3185
3502
  const ws = getActiveWorkspace();
3186
3503
  if (!ws) {
3187
3504
  console.log(
@@ -3192,10 +3509,6 @@ class DenCLI {
3192
3509
  return;
3193
3510
  }
3194
3511
 
3195
- const repoPathArg = args.find(
3196
- (a, i) =>
3197
- !a.startsWith("-") && !/^--(name|gh|org)$/.test(args[i - 1] ?? ""),
3198
- );
3199
3512
  if (repoPathArg) {
3200
3513
  const flag = (n: string) => {
3201
3514
  const i = args.indexOf(n);
@@ -3214,7 +3527,157 @@ class DenCLI {
3214
3527
  }
3215
3528
  }
3216
3529
 
3217
- /** Non-interactive `register <path>`: a dev-managed entry in the focused workspace. */
3530
+ /**
3531
+ * `relocate <repo> <path>`: a checkout moved on disk. Rewrites the path of
3532
+ * every dev-managed entry for <repo> (bare and `<repo>/<svc>` addresses) in
3533
+ * every workspace, or only the `-w` one. Managed entries are Den's own
3534
+ * clones and are left alone. Focus and the active set never change.
3535
+ */
3536
+ private async cmdRelocate(args: string[]): Promise<void> {
3537
+ const usage = `${product().cli} relocate <repo> <path> [--dry-run] [--force] [-w <workspace>]`;
3538
+ if (args.includes("-h") || args.includes("--help")) {
3539
+ console.log(
3540
+ `\n ${usage}\n\n` +
3541
+ " A checkout moved: point every workspace's dev-managed entries for\n" +
3542
+ " <repo> at <path>. Refuses a missing path, one whose manifest\n" +
3543
+ " name or GitHub remote names a different repo, and one where\n" +
3544
+ " neither names this repo (--force accepts that last). --dry-run shows\n" +
3545
+ " the changes without writing. -w limits it to that workspace.\n" +
3546
+ " Focus and the active set never change.\n",
3547
+ );
3548
+ return;
3549
+ }
3550
+ const dryRun = args.includes("--dry-run") || args.includes("-n");
3551
+ const force = args.includes("--force");
3552
+ const unknown = args.find(
3553
+ (a) => a.startsWith("-") && !["--dry-run", "-n", "--force"].includes(a),
3554
+ );
3555
+ const positional = args.filter((a) => !a.startsWith("-"));
3556
+ if (unknown || positional.length !== 2) {
3557
+ console.error(
3558
+ chalk.red(
3559
+ `\n ❌ ${unknown ? `Unknown flag: ${unknown}` : "Need a repo and a path"}.\n Usage: ${usage}\n`,
3560
+ ),
3561
+ );
3562
+ process.exitCode = 1;
3563
+ return;
3564
+ }
3565
+ const [repoName, rawPath] = positional;
3566
+ const { existsSync, statSync } = await import("fs");
3567
+ const newPath = nodePath.resolve(rawPath);
3568
+ const fail = (msg: string) => {
3569
+ console.error(chalk.red(`\n ❌ ${msg}\n`));
3570
+ process.exitCode = 1;
3571
+ };
3572
+ const file = readWorkspacesFile();
3573
+ const only = process.env.DEN_WORKSPACE_OVERRIDE;
3574
+ const names = only ? [only] : Object.keys(file.workspaces);
3575
+ const belongs = (key: string) =>
3576
+ key === repoName || key.startsWith(`${repoName}/`);
3577
+ if (
3578
+ !names.some((ws) =>
3579
+ Object.keys(file.workspaces[ws]?.services ?? {}).some(belongs),
3580
+ )
3581
+ ) {
3582
+ fail(
3583
+ `"${repoName}" is not in ${only ? `workspace "${only}"` : "any workspace"}.`,
3584
+ );
3585
+ return;
3586
+ }
3587
+ if (!existsSync(newPath) || !statSync(newPath).isDirectory()) {
3588
+ fail(`Path does not exist: ${newPath}`);
3589
+ return;
3590
+ }
3591
+
3592
+ // The new path must hold this repo: its manifest name and GitHub remote,
3593
+ // when present, must agree with the repo and its catalog entry.
3594
+ const manifest = readManifest(newPath);
3595
+ const leaf = repoName.split("/").pop() ?? repoName;
3596
+ if (
3597
+ manifest?.name &&
3598
+ manifest.name !== repoName &&
3599
+ manifest.name !== leaf
3600
+ ) {
3601
+ fail(
3602
+ `${newPath} holds "${manifest.name}", not "${repoName}" (its manifest name).`,
3603
+ );
3604
+ return;
3605
+ }
3606
+ const catalogGh = readCatalog()[repoName]?.gh?.trim();
3607
+ const coords = getGitHubCoords(newPath);
3608
+ const remote = coords ? `${coords.owner}/${coords.repo}` : undefined;
3609
+ if (
3610
+ catalogGh &&
3611
+ remote &&
3612
+ catalogGh.toLowerCase() !== remote.toLowerCase()
3613
+ ) {
3614
+ fail(
3615
+ `${newPath} is a checkout of ${remote}; the catalog has "${repoName}" as ${catalogGh}.`,
3616
+ );
3617
+ return;
3618
+ }
3619
+ // Not contradicting is not enough: an empty or unrelated folder has no
3620
+ // manifest and no remote. Something must say this is the repo.
3621
+ const identified =
3622
+ manifest?.name === repoName ||
3623
+ manifest?.name === leaf ||
3624
+ (!!catalogGh &&
3625
+ !!remote &&
3626
+ catalogGh.toLowerCase() === remote.toLowerCase()) ||
3627
+ (!catalogGh && coords?.repo.toLowerCase() === leaf.toLowerCase());
3628
+ if (!identified && !force) {
3629
+ fail(
3630
+ `Nothing at ${newPath} says it is "${repoName}": no manifest named ` +
3631
+ `"${leaf}" and no GitHub remote ${catalogGh || `…/${leaf}`}.\n` +
3632
+ ` If it is, say so: ${product().cli} relocate ${repoName} ${rawPath} --force${dryRun ? " --dry-run" : ""}${only ? ` -w ${only}` : ""}`,
3633
+ );
3634
+ return;
3635
+ }
3636
+
3637
+ const changes: { ws: string; addr: string; from: string }[] = [];
3638
+ const managed: { ws: string; addr: string }[] = [];
3639
+ for (const ws of names) {
3640
+ const entries = file.workspaces[ws]?.services ?? {};
3641
+ for (const [addr, entry] of Object.entries(entries)) {
3642
+ if (!belongs(addr)) continue;
3643
+ if (entry.mode !== "dev-managed") {
3644
+ managed.push({ ws, addr });
3645
+ continue;
3646
+ }
3647
+ if (entry.path && nodePath.resolve(entry.path) === newPath) continue;
3648
+ changes.push({ ws, addr, from: entry.path ?? "(default)" });
3649
+ if (!dryRun) entry.path = newPath;
3650
+ }
3651
+ }
3652
+
3653
+ if (changes.length > 0 && !dryRun) writeWorkspacesFile(file);
3654
+
3655
+ const verb = dryRun ? "Would point" : "Pointed";
3656
+ if (changes.length === 0) {
3657
+ console.log(
3658
+ chalk.gray(
3659
+ `\n Nothing to change: "${repoName}" is already at ${newPath}.`,
3660
+ ),
3661
+ );
3662
+ } else {
3663
+ console.log(chalk.green(`\n ✓ ${verb} at ${newPath}:\n`));
3664
+ for (const c of changes) {
3665
+ console.log(
3666
+ ` ${chalk.bold(c.addr)} ${chalk.gray(`${c.ws} was ${c.from}`)}`,
3667
+ );
3668
+ }
3669
+ }
3670
+ for (const m of managed) {
3671
+ console.log(
3672
+ chalk.gray(
3673
+ ` ${m.addr} ${m.ws} managed (Den's clone): left alone`,
3674
+ ),
3675
+ );
3676
+ }
3677
+ console.log();
3678
+ }
3679
+
3680
+ /** Non-interactive `register <path>`: a dev-managed entry in the focused (or -w) workspace. */
3218
3681
  private async registerPath(
3219
3682
  ws: {
3220
3683
  name: string;
@@ -3306,7 +3769,8 @@ class DenCLI {
3306
3769
  console.log(
3307
3770
  chalk.red(
3308
3771
  `\n ❌ No GitHub org to list: set github_org in ${product().configFile}, ` +
3309
- `org: on workspace ${ws.name}, or pass --org <id>\n`,
3772
+ `org: on workspace ${ws.name}, or pass --org <id> ` +
3773
+ `(an org with no GitHub source in orgs.<id>.sources has none)\n`,
3310
3774
  ),
3311
3775
  );
3312
3776
  return;
@@ -3559,18 +4023,38 @@ class DenCLI {
3559
4023
  console.log(chalk.gray(` revert: ${revert}\n`));
3560
4024
  }
3561
4025
 
3562
- private async cmdSetup(args: string[]): Promise<void> {
4026
+ private async cmdSetup(rawArgs: string[]): Promise<void> {
4027
+ // `-w <ws>` (repeatable) and `--all-active` pick the workspaces to plan.
4028
+ const { workspaces: planWs, rest: args } = takeWorkspaceFlags(rawArgs);
4029
+ const allActive = args.includes("--all-active");
3563
4030
  const force = args.includes("--force") || args.includes("-f");
3564
4031
  const names = args.filter((a) => !a.startsWith("-"));
3565
4032
 
3566
4033
  // `--plan`: each repo's org and clone URL, then stop. Read-only.
3567
4034
  if (args.includes("--plan")) {
4035
+ let plan: ReturnType<typeof buildSetupPlan>;
4036
+ try {
4037
+ plan = buildSetupPlan(names, { workspaces: planWs, allActive });
4038
+ } catch (e) {
4039
+ console.error(chalk.red(`\n ${(e as Error).message}\n`));
4040
+ process.exitCode = 1;
4041
+ return;
4042
+ }
3568
4043
  console.log(chalk.bold("\n Setup plan (no changes will be made):\n"));
3569
- for (const line of renderClonePlan(buildSetupPlan(names), product().cli))
4044
+ for (const line of renderClonePlan(plan, product().cli))
3570
4045
  console.log(` ${line}`.trimEnd());
3571
4046
  console.log();
3572
4047
  return;
3573
4048
  }
4049
+ if (planWs.length || allActive) {
4050
+ console.error(
4051
+ chalk.red(
4052
+ `\n -w and --all-active go with --plan: setup itself works on the focused workspace.\n`,
4053
+ ),
4054
+ );
4055
+ process.exitCode = 1;
4056
+ return;
4057
+ }
3574
4058
 
3575
4059
  // Clone any managed repos that aren't on disk yet before reading manifests.
3576
4060
  this.cloneMissingManagedRepos();
@@ -3662,14 +4146,32 @@ class DenCLI {
3662
4146
  const only = orgFlag(args.slice(1));
3663
4147
  const { resolved, warnings } = orgListRows();
3664
4148
  const orgOfWs = new Map(resolved.map((w) => [w.workspace, w.org]));
3665
- const table = newTable({
3666
- head: ["Workspace", "Org", "Active", "Focus", "Services", "Type"],
3667
- style: { head: ["cyan"] },
3668
- });
3669
4149
  const shown = Object.entries(file.workspaces).filter(
3670
4150
  ([name]) => !only || sameOrg(orgOfWs.get(name) ?? null, only),
3671
4151
  );
4152
+ // A Stacks column only when a shown workspace's stack namespace is
4153
+ // not its default suffix (or is invalid): the list is otherwise as before.
4154
+ const nsRows = new Map(
4155
+ stackNamespaceRows(shown.map(([n]) => n)).map((r) => [
4156
+ r.workspace,
4157
+ r,
4158
+ ]),
4159
+ );
4160
+ const nsCol = [...nsRows.values()].some((r) => r.differs || r.error);
4161
+ const table = newTable({
4162
+ head: [
4163
+ "Workspace",
4164
+ "Org",
4165
+ "Active",
4166
+ "Focus",
4167
+ "Services",
4168
+ "Type",
4169
+ ...(nsCol ? ["Stacks"] : []),
4170
+ ],
4171
+ style: { head: ["cyan"] },
4172
+ });
3672
4173
  for (const [name, cfg] of shown) {
4174
+ const ns = nsRows.get(name);
3673
4175
  table.push([
3674
4176
  chalk.bold(name),
3675
4177
  orgOfWs.get(name) ?? chalk.gray("none"),
@@ -3677,9 +4179,23 @@ class DenCLI {
3677
4179
  name === focus ? chalk.cyan("●") : "",
3678
4180
  Object.keys(cfg.services).join(", ") || chalk.gray("none"),
3679
4181
  cfg.directory ? chalk.magenta("feature") : chalk.blue("default"),
4182
+ ...(nsCol
4183
+ ? [
4184
+ ns?.error
4185
+ ? chalk.red("invalid")
4186
+ : ns?.differs
4187
+ ? `${ns.stack_namespace} ${chalk.gray(`(${ns.source})`)}`
4188
+ : chalk.gray(ns?.default ?? ""),
4189
+ ]
4190
+ : []),
3680
4191
  ]);
3681
4192
  }
3682
4193
  console.log(table.toString());
4194
+ printOrgWarnings(
4195
+ [...nsRows.values()].flatMap((r) =>
4196
+ r.error ? [`workspace ${r.workspace}: ${r.error}`] : r.warnings,
4197
+ ),
4198
+ );
3683
4199
  if (only && shown.length === 0)
3684
4200
  console.log(chalk.yellow(` No workspace in org ${only}.`));
3685
4201
  const names = new Set(shown.map(([n]) => n));
@@ -3986,6 +4502,58 @@ class DenCLI {
3986
4502
  // ── den validate ────────────────────────────────────────────────────────
3987
4503
 
3988
4504
  private async cmdValidate(args: string[]): Promise<void> {
4505
+ // The org config first (0.3.0-dev.40, a consumer's 0.2.8 nit: a
4506
+ // ConfigError went unreported): each problem with its file and key
4507
+ // path, a non-zero exit, and the manifests are still checked, reading
4508
+ // the config without its org data.
4509
+ try {
4510
+ assertDenConfigValid();
4511
+ } catch (e) {
4512
+ if (!isConfigError(e)) throw e;
4513
+ console.error(chalk.red(`\n ✗ ${product().configFile} is invalid:`));
4514
+ const issues = e.issues.length
4515
+ ? e.issues
4516
+ : [{ file: product().configFile, key: "", message: e.message }];
4517
+ for (const i of issues)
4518
+ console.error(
4519
+ chalk.red(` ✗ ${i.file} ${chalk.bold(i.key)} ${i.message}`),
4520
+ );
4521
+ process.exitCode = 1;
4522
+ waiveInvalidOrgs();
4523
+ }
4524
+ // The distribution's secrets config, once: a 1password backend with no
4525
+ // refs leaves the vault-backed features off (no built-in vault/item).
4526
+ try {
4527
+ const { secretsConfigWarnings } =
4528
+ await import("../../shared/secrets/index.js");
4529
+ for (const w of secretsConfigWarnings())
4530
+ console.warn(chalk.yellow(`\n ⚠ ${w.replace(/\n/g, "\n ")}`));
4531
+ } catch {
4532
+ /* an unreadable config is reported elsewhere */
4533
+ }
4534
+ // The edge's TLS strategy, when there is an edge: its config, the
4535
+ // implicit-mkcert warning (once), and detect() (mkcert on PATH).
4536
+ {
4537
+ const { hasEdgeBlock, readEdgeConfig } =
4538
+ await import("../../shared/edge/config.js");
4539
+ const { edgeTlsWarnings, tlsStrategyFor, warnImplicitTlsOnce } =
4540
+ await import("../../shared/edge/tls.js");
4541
+ let edge: ReturnType<typeof readEdgeConfig> | null = null;
4542
+ try {
4543
+ if (hasEdgeBlock()) edge = readEdgeConfig();
4544
+ } catch (e) {
4545
+ console.error(chalk.red(`\n ✗ edge: ${(e as Error).message}`));
4546
+ process.exitCode = 1;
4547
+ }
4548
+ if (edge) {
4549
+ const warn = (w: string) => console.warn(chalk.yellow(`\n ⚠ ${w}`));
4550
+ warnImplicitTlsOnce(edge, warn);
4551
+ for (const w of edgeTlsWarnings(edge))
4552
+ if (edge.tlsFrom !== "implicit") warn(w);
4553
+ const d = tlsStrategyFor(edge).detect();
4554
+ if (!d.available) warn(`edge.tls ${edge.tls.strategy}: ${d.reason}`);
4555
+ }
4556
+ }
3989
4557
  const positional = args.filter((a) => !a.startsWith("-"));
3990
4558
  const held = args.includes("--held");
3991
4559
  // Bare `validate` in the distribution root (which has no manifest of its
@@ -4190,7 +4758,7 @@ class DenCLI {
4190
4758
  " restart [service...] Restart services (-A <app> for a whole application)",
4191
4759
  );
4192
4760
  console.log(
4193
- " status Show service health for active workspace (-A <app>)",
4761
+ " status Show service health for the view scope (-A <app>, --scope <ws>)",
4194
4762
  );
4195
4763
  console.log(
4196
4764
  " apps Org > Application > Repo > Services (--json)",
@@ -4211,6 +4779,9 @@ class DenCLI {
4211
4779
  console.log(
4212
4780
  " register [path] Register a repo in the service catalog",
4213
4781
  );
4782
+ console.log(
4783
+ " relocate <repo> <path> Point a moved checkout's workspace entries at its new path",
4784
+ );
4214
4785
  console.log();
4215
4786
  console.log(
4216
4787
  " setup [service...] Run make setup for services (--plan: orgs + clone URLs, no changes)",
@@ -4228,6 +4799,12 @@ class DenCLI {
4228
4799
  console.log(
4229
4800
  " service install Run the web UI as a launchd service (status, print, uninstall)",
4230
4801
  );
4802
+ console.log(
4803
+ " theme add <repo>@<ref> Install a web UI theme (list, validate, remove)",
4804
+ );
4805
+ console.log(
4806
+ " plugin list Local and added plugins, checked (validate, add, update, remove)",
4807
+ );
4231
4808
  console.log();
4232
4809
  console.log(
4233
4810
  " workspace list List workspaces (active set + focus)",