@monster-orange/den 0.2.8 → 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 (127) hide show
  1. package/CHANGELOG.md +1547 -0
  2. package/README.md +4 -2
  3. package/bin/den +2 -1
  4. package/docs/distributions.md +507 -4
  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/auth/headless-login.ts +1 -1
  22. package/src/shared/auth/sudo-vault.ts +93 -31
  23. package/src/shared/auth/token-providers/index.ts +7 -3
  24. package/src/shared/brand.ts +7 -0
  25. package/src/shared/config-error.ts +33 -3
  26. package/src/shared/constants.ts +3 -24
  27. package/src/shared/den-config.ts +141 -5
  28. package/src/shared/edge/config.ts +147 -21
  29. package/src/shared/edge/exec.ts +31 -0
  30. package/src/shared/edge/included.ts +169 -0
  31. package/src/shared/edge/render.ts +69 -14
  32. package/src/shared/edge/runtime.ts +128 -121
  33. package/src/shared/edge/status.ts +68 -8
  34. package/src/shared/edge/tls.ts +328 -0
  35. package/src/shared/edge/types.ts +25 -9
  36. package/src/shared/environment-manager/default-workspace.ts +16 -1
  37. package/src/shared/environment-manager/health.ts +73 -38
  38. package/src/shared/environment-manager/scope.ts +168 -0
  39. package/src/shared/environment-manager/service-runner.ts +789 -43
  40. package/src/shared/environment-manager/up-targets.ts +132 -0
  41. package/src/shared/health/rollup.ts +48 -0
  42. package/src/shared/open-url.ts +21 -2
  43. package/src/shared/org-sources.ts +78 -59
  44. package/src/shared/org.ts +53 -5
  45. package/src/shared/plugins/manifest.ts +406 -0
  46. package/src/shared/plugins/store.ts +358 -0
  47. package/src/shared/process/legacy-adopt.ts +285 -0
  48. package/src/shared/process/process-group.ts +202 -0
  49. package/src/shared/process/spawn-streaming.ts +7 -0
  50. package/src/shared/secrets/doppler.ts +1 -1
  51. package/src/shared/secrets/index.ts +279 -50
  52. package/src/shared/secrets/none.ts +56 -0
  53. package/src/shared/secrets/strategies.ts +202 -0
  54. package/src/shared/secrets/types.ts +1 -1
  55. package/src/shared/smoke/git-remote.ts +40 -0
  56. package/src/shared/stacks/manifest-commands.ts +1 -1
  57. package/src/shared/stacks/namespace.ts +178 -0
  58. package/src/shared/stacks/owners.ts +303 -0
  59. package/src/shared/stacks/render.ts +8 -3
  60. package/src/shared/stacks/runtime.ts +100 -16
  61. package/src/shared/stacks/status.ts +63 -12
  62. package/src/shared/strategies/registry.ts +89 -0
  63. package/src/shared/theme/contract.ts +538 -0
  64. package/src/shared/types/index.ts +12 -3
  65. package/src/ui/cli/create.ts +24 -7
  66. package/src/ui/cli/doctor-command.ts +131 -0
  67. package/src/ui/cli/edge-command.ts +56 -10
  68. package/src/ui/cli/flag-parser.ts +7 -2
  69. package/src/ui/cli/health-display.ts +25 -0
  70. package/src/ui/cli/index.ts +548 -60
  71. package/src/ui/cli/notice.ts +20 -0
  72. package/src/ui/cli/plugin-command.ts +849 -0
  73. package/src/ui/cli/stack-command.ts +63 -2
  74. package/src/ui/cli/test-runner.ts +11 -9
  75. package/src/ui/cli/theme-command.ts +136 -0
  76. package/src/ui/cli/web-command.ts +47 -23
  77. package/src/ui/mcp/api.ts +28 -2
  78. package/src/ui/mcp/scope.ts +22 -1
  79. package/src/ui/mcp/tools.ts +50 -7
  80. package/src/ui/server/appearance-prefs.ts +95 -0
  81. package/src/ui/server/daemon-reaper.ts +29 -1
  82. package/src/ui/server/engine-stamp.ts +83 -0
  83. package/src/ui/server/github-service.ts +23 -44
  84. package/src/ui/server/graph-page.ts +20 -0
  85. package/src/ui/server/graph-prefs.ts +44 -1
  86. package/src/ui/server/http-errors.ts +293 -0
  87. package/src/ui/server/http-helpers.ts +22 -3
  88. package/src/ui/server/index.ts +532 -51
  89. package/src/ui/server/public/appearance.js +531 -0
  90. package/src/ui/server/public/edge-page.js +248 -0
  91. package/src/ui/server/public/edge.html +251 -0
  92. package/src/ui/server/public/environments.html +65 -83
  93. package/src/ui/server/public/error-page.js +247 -0
  94. package/src/ui/server/public/error.html +260 -0
  95. package/src/ui/server/public/graph-model.js +332 -14
  96. package/src/ui/server/public/graph.css +141 -47
  97. package/src/ui/server/public/graph.js +223 -44
  98. package/src/ui/server/public/header.js +396 -600
  99. package/src/ui/server/public/health-fit.js +248 -0
  100. package/src/ui/server/public/health-rollup.js +128 -0
  101. package/src/ui/server/public/i18n/es.js +287 -0
  102. package/src/ui/server/public/i18n/ja.js +276 -0
  103. package/src/ui/server/public/index.html +139 -438
  104. package/src/ui/server/public/local-dev.html +3468 -4044
  105. package/src/ui/server/public/logs.html +71 -208
  106. package/src/ui/server/public/panel.js +14 -2
  107. package/src/ui/server/public/release.html +175 -389
  108. package/src/ui/server/public/services.html +596 -770
  109. package/src/ui/server/public/settings-page.js +759 -0
  110. package/src/ui/server/public/settings-registry.js +521 -0
  111. package/src/ui/server/public/settings.html +590 -0
  112. package/src/ui/server/public/shell.js +3869 -0
  113. package/src/ui/server/public/topbar.js +1413 -0
  114. package/src/ui/server/public/version-label.js +12 -0
  115. package/src/ui/server/public/workspace-picker.js +74 -34
  116. package/src/ui/server/readonly-routes.ts +12 -1
  117. package/src/ui/server/services-list.ts +94 -17
  118. package/src/ui/server/settings-distribution.ts +74 -0
  119. package/src/ui/server/shell-prefs.ts +192 -0
  120. package/src/ui/server/theme-routes.ts +292 -0
  121. package/src/ui/server/theme-runtime.ts +407 -0
  122. package/src/ui/server/ui-assets.ts +16 -1
  123. package/src/ui/server/ui-language.ts +50 -0
  124. package/src/ui/server/workspace-routes.ts +45 -3
  125. package/themes/den/den-theme.json +72 -0
  126. package/themes/den/favicon.ico +0 -0
  127. 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,11 @@ 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";
176
196
  import { cmdService } from "./service-command.js";
177
197
  import { cmdStack } from "./stack-command.js";
198
+ import { cmdTheme } from "./theme-command.js";
199
+ import { cmdPlugin } from "./plugin-command.js";
178
200
  import {
179
201
  cmdOrg,
180
202
  orgFlag,
@@ -209,6 +231,9 @@ import {
209
231
  formatHealthDot,
210
232
  formatHealthLabel,
211
233
  formatHealthReason,
234
+ formatRollupLabel,
235
+ healthReasonText,
236
+ rollupReasonText,
212
237
  } from "./health-display.js";
213
238
  import { DEN_BANNER_LINES, DEN_DIVIDER } from "../../shared/banner.js";
214
239
 
@@ -300,9 +325,12 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
300
325
  ],
301
326
  },
302
327
  status: {
303
- usage: "status [-A <app>]",
328
+ usage: "status [-A <app>] [--scope <all|workspace>] [--verbose]",
304
329
  lines: [
305
- "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.",
306
334
  ],
307
335
  },
308
336
  logs: {
@@ -319,7 +347,7 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
319
347
  lines: ["List all registered services in the catalog."],
320
348
  },
321
349
  register: {
322
- usage: "register [path] [--org <id>] [-w <workspace>]",
350
+ usage: "register [path] [-w <workspace>] [--org <id>]",
323
351
  lines: [
324
352
  "With a path: add that checkout (dev-managed) under its manifest name.",
325
353
  "Without one: pick the repo interactively. A managed clone is picked",
@@ -327,6 +355,15 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
327
355
  "-w writes that workspace (active or not) and leaves focus alone.",
328
356
  ],
329
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.",
365
+ ],
366
+ },
330
367
  setup: {
331
368
  usage:
332
369
  "setup [service...] [--force|-f] [--plan [-w <workspace>]... [--all-active]]",
@@ -417,7 +454,33 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
417
454
  edge: {
418
455
  usage: "edge [up|down|status|trust|untrust|routes]",
419
456
  lines: [
420
- "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.",
421
484
  ],
422
485
  },
423
486
  mcp: {
@@ -438,7 +501,7 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
438
501
  "local-dev": {
439
502
  usage: "local-dev [on|off]",
440
503
  lines: [
441
- "Toggle the local-dev experience (web Local Dev section + service mgmt).",
504
+ "Toggle the local-dev experience (web Workspaces page + service mgmt).",
442
505
  ],
443
506
  },
444
507
  menu: {
@@ -466,6 +529,7 @@ function resolveCliBaseApiUrl(explicitBaseApiUrl?: string): string | undefined {
466
529
  const TELEMETRY_COMMANDS = new Set<string>([
467
530
  "run",
468
531
  "view-results",
532
+ "down",
469
533
  "menu",
470
534
  "web",
471
535
  "up",
@@ -476,6 +540,7 @@ const TELEMETRY_COMMANDS = new Set<string>([
476
540
  "services",
477
541
  "graph",
478
542
  "register",
543
+ "relocate",
479
544
  "use-local",
480
545
  `use-managed`,
481
546
  "setup",
@@ -488,19 +553,24 @@ const TELEMETRY_COMMANDS = new Set<string>([
488
553
  "local-dev",
489
554
  "config",
490
555
  "edge",
556
+ "doctor",
491
557
  "stack",
492
558
  "apps",
493
559
  "applications",
494
560
  "mcp",
495
561
  "service",
562
+ "theme",
563
+ "plugin",
496
564
  ]);
497
565
 
498
566
  /**
499
567
  * Commands that run with a config that fails validation: the web and MCP
500
568
  * servers (each route or tool reports it), and those that read neither orgs,
501
569
  * 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.
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.
504
574
  */
505
575
  const CONFIG_FREE_COMMANDS = new Set<string>([
506
576
  "web",
@@ -510,6 +580,7 @@ const CONFIG_FREE_COMMANDS = new Set<string>([
510
580
  "config",
511
581
  "local-dev",
512
582
  "view-results",
583
+ "down",
513
584
  ]);
514
585
 
515
586
  function configCheckedCommand(command: string): boolean {
@@ -743,6 +814,22 @@ class DenCLI {
743
814
  // anything: some (sync over existing clones, register, workspace
744
815
  // create) never read `orgs:` and used to carry on (ORG-1d).
745
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
+ }
746
833
 
747
834
  switch (command) {
748
835
  case "run":
@@ -798,6 +885,10 @@ class DenCLI {
798
885
  await this.cmdRegister(args.slice(1));
799
886
  break;
800
887
 
888
+ case "relocate":
889
+ await this.cmdRelocate(args.slice(1));
890
+ break;
891
+
801
892
  case "use-local":
802
893
  await this.cmdUseMode(args.slice(1), "dev-managed");
803
894
  break;
@@ -872,6 +963,10 @@ class DenCLI {
872
963
  await cmdEdge(args.slice(1));
873
964
  break;
874
965
 
966
+ case "doctor":
967
+ process.exitCode = cmdDoctor(args.slice(1));
968
+ break;
969
+
875
970
  case "service":
876
971
  process.exitCode = await cmdService(args.slice(1));
877
972
  break;
@@ -880,6 +975,14 @@ class DenCLI {
880
975
  await cmdStack(args.slice(1));
881
976
  break;
882
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
+
883
986
  case "apps":
884
987
  case "applications":
885
988
  await this.ensureWorkspaceSeeded();
@@ -1597,7 +1700,15 @@ class DenCLI {
1597
1700
  // The same lookup a real run makes, so the dry run says what it does.
1598
1701
  const previews = ordered.map((a): ServiceCommandPreview => {
1599
1702
  const remote = remoteRefusal(a);
1600
- 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
+ }
1601
1712
  // As the loop below: a named remote target fails, any other is
1602
1713
  // said and skipped.
1603
1714
  return isNamedTarget(a, opts?.named ?? [])
@@ -1620,7 +1731,7 @@ class DenCLI {
1620
1731
  ),
1621
1732
  );
1622
1733
  for (const p of previews) {
1623
- if (p.kind === "via-repo" || p.kind === "nothing")
1734
+ if (p.kind !== "error" && p.note)
1624
1735
  console.log(chalk.gray(` ${p.note}`));
1625
1736
  if (p.kind === "error") console.log(chalk.red(` ✗ ${p.note}`));
1626
1737
  }
@@ -1698,12 +1809,27 @@ class DenCLI {
1698
1809
  const resolved = resolveService(address, workspace);
1699
1810
  if (resolved) {
1700
1811
  const health = await checkServiceHealth(resolved, "local");
1701
- 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") {
1702
1831
  // Skipping must not lose the refcount — this workspace is an owner
1703
1832
  // of what's running, whether den tracked it or not.
1704
- const claim = workspace
1705
- ? claimHealthyService(resolved, workspace)
1706
- : "untracked";
1707
1833
  const suffix =
1708
1834
  claim === "adopted"
1709
1835
  ? " (adopted its running containers)"
@@ -1804,6 +1930,8 @@ class DenCLI {
1804
1930
  let result: ReturnType<typeof runServiceCommand> | null = null;
1805
1931
  let detachable = false;
1806
1932
  let interactive = false;
1933
+ // A down that found nothing to stop is said as such, never as ✔.
1934
+ let downOutcome: DownOutcome | null = null;
1807
1935
 
1808
1936
  const settleFromClose = (exitCode: number): void => {
1809
1937
  if (settled) return;
@@ -1820,6 +1948,17 @@ class DenCLI {
1820
1948
  ),
1821
1949
  );
1822
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
+ }
1823
1962
  } else if (exitCode === 0) {
1824
1963
  spinner.succeed(`${command} ${chalk.bold(address)}`);
1825
1964
  } else {
@@ -1873,8 +2012,21 @@ class DenCLI {
1873
2012
  runtime,
1874
2013
  workspace,
1875
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
+ },
1876
2021
  secretEnv: secrets.secretEnv,
1877
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
+ }
1878
2030
  spinner.text = `${command} ${chalk.bold(address)}: ${chalk.gray(line.slice(0, 80))}`;
1879
2031
  const trimmed = line.trim();
1880
2032
  if (trimmed) {
@@ -2130,11 +2282,15 @@ class DenCLI {
2130
2282
  this.renderUpClonePlan(rawOrder);
2131
2283
  return;
2132
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] : []);
2133
2288
  if (dryRun) {
2289
+ const up = this.upTargets(order, targets, graph);
2134
2290
  await this.runServicesCommand(
2135
- order,
2291
+ up.order,
2136
2292
  "up",
2137
- { order },
2293
+ { order: up.order },
2138
2294
  { named, dryRun: true },
2139
2295
  );
2140
2296
  return;
@@ -2158,12 +2314,18 @@ class DenCLI {
2158
2314
  );
2159
2315
  return;
2160
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
+ }
2161
2322
  await this.runServicesCommand(
2162
- order,
2323
+ up.order,
2163
2324
  "up",
2164
2325
  {
2165
- order,
2166
- waitDepsFor: (address) => hardDepsOf(graph, address),
2326
+ order: up.order,
2327
+ waitDepsFor: (address) =>
2328
+ up.depsAs(hardDepsOf(graph, repoOfAddress(address))),
2167
2329
  failFast: true,
2168
2330
  skipHealthyAll: true,
2169
2331
  },
@@ -2171,6 +2333,37 @@ class DenCLI {
2171
2333
  );
2172
2334
  }
2173
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
+
2174
2367
  /**
2175
2368
  * Stop the seed repo and the dependencies it was started with — the same
2176
2369
  * closure `den up <seed>` brings up, torn down in reverse (seed first,
@@ -2388,19 +2581,18 @@ class DenCLI {
2388
2581
  // is skipped, not fatal. `-a` starts independent peers — one unrunnable
2389
2582
  // repo must not strand the rest (runServiceCommand exits 1 on a missing
2390
2583
  // 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
- }
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));
2401
2593
  console.log(
2402
2594
  chalk.cyan(
2403
- `\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")}`,
2404
2596
  ),
2405
2597
  );
2406
2598
  console.log(chalk.gray(` Order: ${order.join(" → ")}\n`));
@@ -2412,13 +2604,7 @@ class DenCLI {
2412
2604
  ),
2413
2605
  );
2414
2606
  }
2415
- if (skippedNoUp.length > 0) {
2416
- console.log(
2417
- chalk.gray(
2418
- ` Skipping (no local up command): ${skippedNoUp.join(", ")}\n`,
2419
- ),
2420
- );
2421
- }
2607
+ this.sayUpTargets(up);
2422
2608
  if (order.length === 0) {
2423
2609
  console.log(chalk.yellow(" Nothing to start.\n"));
2424
2610
  return;
@@ -2428,13 +2614,17 @@ class DenCLI {
2428
2614
  // healthy-skip. Unlike runDerivedUp there is NO failFast on the start
2429
2615
  // loop: `-a`'s systems are independent peers — one failure must not
2430
2616
  // strand the rest (the web master button continues too).
2431
- const repoSet = new Set(order);
2617
+ const repoSet = new Set(systems);
2432
2618
  await this.runServicesCommand(order, "up", {
2433
2619
  order,
2434
2620
  // Hard deps outside the workspace can't be waited on — filter to the set
2435
2621
  // we are actually starting.
2436
2622
  waitDepsFor: (address) =>
2437
- hardDepsOf(graph, address).filter((dep) => repoSet.has(dep)),
2623
+ up.depsAs(
2624
+ hardDepsOf(graph, repoOfAddress(address)).filter((dep) =>
2625
+ repoSet.has(dep),
2626
+ ),
2627
+ ),
2438
2628
  skipHealthyAll: true,
2439
2629
  });
2440
2630
  }
@@ -2470,6 +2660,14 @@ class DenCLI {
2470
2660
  return;
2471
2661
  }
2472
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
+ );
2473
2671
  }
2474
2672
 
2475
2673
  private printUpHelp(): void {
@@ -2993,15 +3191,49 @@ class DenCLI {
2993
3191
 
2994
3192
  private async cmdServiceStatus(argv: string[] = []): Promise<void> {
2995
3193
  await this.ensureWorkspaceSeeded();
2996
- 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);
2997
3224
  const only = app ? focusedAppRepos(app) : null;
2998
3225
  const verbose = args.includes("--verbose") || args.includes("--all");
2999
3226
  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.
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.
3005
3237
  const rolled = verbose ? raw : rollupRepoHealth(raw);
3006
3238
  // Order top-down like the graph: entry repo first (shop-web) … foundation
3007
3239
  // last (infra-stack), subsystems grouped under their repo.
@@ -3019,10 +3251,7 @@ class DenCLI {
3019
3251
  return;
3020
3252
  }
3021
3253
 
3022
- const ws = getActiveWorkspace();
3023
- console.log(
3024
- chalk.cyan(`\n Workspace: ${chalk.bold(ws?.name ?? "none")}\n`),
3025
- );
3254
+ console.log(chalk.cyan(`\n ${scopeLine(view)}\n`));
3026
3255
 
3027
3256
  const table = newTable({
3028
3257
  head: ["Service", "Mode", "Branch", "Health", "Reason"],
@@ -3030,8 +3259,20 @@ class DenCLI {
3030
3259
  });
3031
3260
 
3032
3261
  for (const r of results) {
3033
- const healthStr = formatHealthLabel(r.health);
3034
- 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)) : "");
3035
3276
  const placed = placementSuffix({
3036
3277
  runs: r.runs ?? "none",
3037
3278
  target: r.target ?? null,
@@ -3039,6 +3280,7 @@ class DenCLI {
3039
3280
 
3040
3281
  table.push([
3041
3282
  this.labelWithAutostart(r.address, noAutostart) +
3283
+ chalk.gray(ownersSuffix(r.workspaces)) +
3042
3284
  (placed ? chalk.cyan(` ${placed}`) : ""),
3043
3285
  r.mode === "dev-managed"
3044
3286
  ? chalk.blue("dev-managed")
@@ -3060,7 +3302,7 @@ class DenCLI {
3060
3302
  if (!verbose && raw.length !== results.length) {
3061
3303
  console.log(
3062
3304
  chalk.gray(
3063
- ` ▪ 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`,
3064
3306
  ),
3065
3307
  );
3066
3308
  }
@@ -3231,13 +3473,14 @@ class DenCLI {
3231
3473
  private async cmdRegister(args: string[]): Promise<void> {
3232
3474
  if (args.includes("-h") || args.includes("--help")) {
3233
3475
  console.log(
3234
- `\n ${product().cli} register [path] [--name <repo>] [--gh <owner/repo>] [-w <workspace>]\n\n` +
3476
+ `\n ${product().cli} register [path] [--name <repo>] [--gh <owner/repo>] [-w <workspace>] [--org <id>]\n\n` +
3235
3477
  " With a path: add that checkout to the catalog and the focused\n" +
3236
3478
  " workspace as your own (dev-managed) copy, without prompts. The name\n" +
3237
3479
  " defaults to the manifest's name, then the folder name; the GitHub\n" +
3238
3480
  " slug to the git remote. -w writes that workspace (active or not)\n" +
3239
3481
  " and leaves focus alone.\n" +
3240
- " Without a path: choose a local checkout or a managed clone interactively.\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",
3241
3484
  );
3242
3485
  return;
3243
3486
  }
@@ -3245,7 +3488,9 @@ class DenCLI {
3245
3488
  (a, i) =>
3246
3489
  !a.startsWith("-") && !/^--(name|gh|org)$/.test(args[i - 1] ?? ""),
3247
3490
  );
3248
- // Without a path, register prompts: with no terminal, say how to name one.
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.
3249
3494
  if (!repoPathArg && !process.stdin.isTTY) {
3250
3495
  process.stderr.write(
3251
3496
  `register needs a terminal (stdin is not a TTY); without one: ` +
@@ -3282,7 +3527,157 @@ class DenCLI {
3282
3527
  }
3283
3528
  }
3284
3529
 
3285
- /** 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. */
3286
3681
  private async registerPath(
3287
3682
  ws: {
3288
3683
  name: string;
@@ -3751,14 +4146,32 @@ class DenCLI {
3751
4146
  const only = orgFlag(args.slice(1));
3752
4147
  const { resolved, warnings } = orgListRows();
3753
4148
  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
4149
  const shown = Object.entries(file.workspaces).filter(
3759
4150
  ([name]) => !only || sameOrg(orgOfWs.get(name) ?? null, only),
3760
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
+ });
3761
4173
  for (const [name, cfg] of shown) {
4174
+ const ns = nsRows.get(name);
3762
4175
  table.push([
3763
4176
  chalk.bold(name),
3764
4177
  orgOfWs.get(name) ?? chalk.gray("none"),
@@ -3766,9 +4179,23 @@ class DenCLI {
3766
4179
  name === focus ? chalk.cyan("●") : "",
3767
4180
  Object.keys(cfg.services).join(", ") || chalk.gray("none"),
3768
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
+ : []),
3769
4191
  ]);
3770
4192
  }
3771
4193
  console.log(table.toString());
4194
+ printOrgWarnings(
4195
+ [...nsRows.values()].flatMap((r) =>
4196
+ r.error ? [`workspace ${r.workspace}: ${r.error}`] : r.warnings,
4197
+ ),
4198
+ );
3772
4199
  if (only && shown.length === 0)
3773
4200
  console.log(chalk.yellow(` No workspace in org ${only}.`));
3774
4201
  const names = new Set(shown.map(([n]) => n));
@@ -4075,6 +4502,58 @@ class DenCLI {
4075
4502
  // ── den validate ────────────────────────────────────────────────────────
4076
4503
 
4077
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
+ }
4078
4557
  const positional = args.filter((a) => !a.startsWith("-"));
4079
4558
  const held = args.includes("--held");
4080
4559
  // Bare `validate` in the distribution root (which has no manifest of its
@@ -4279,7 +4758,7 @@ class DenCLI {
4279
4758
  " restart [service...] Restart services (-A <app> for a whole application)",
4280
4759
  );
4281
4760
  console.log(
4282
- " status Show service health for active workspace (-A <app>)",
4761
+ " status Show service health for the view scope (-A <app>, --scope <ws>)",
4283
4762
  );
4284
4763
  console.log(
4285
4764
  " apps Org > Application > Repo > Services (--json)",
@@ -4300,6 +4779,9 @@ class DenCLI {
4300
4779
  console.log(
4301
4780
  " register [path] Register a repo in the service catalog",
4302
4781
  );
4782
+ console.log(
4783
+ " relocate <repo> <path> Point a moved checkout's workspace entries at its new path",
4784
+ );
4303
4785
  console.log();
4304
4786
  console.log(
4305
4787
  " setup [service...] Run make setup for services (--plan: orgs + clone URLs, no changes)",
@@ -4317,6 +4799,12 @@ class DenCLI {
4317
4799
  console.log(
4318
4800
  " service install Run the web UI as a launchd service (status, print, uninstall)",
4319
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
+ );
4320
4808
  console.log();
4321
4809
  console.log(
4322
4810
  " workspace list List workspaces (active set + focus)",