@monster-orange/den 0.2.6 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +225 -0
  2. package/bin/den +9 -0
  3. package/docs/distributions.md +205 -6
  4. package/package.json +1 -1
  5. package/service/install.sh +41 -2
  6. package/src/shared/agent-actor.ts +38 -3
  7. package/src/shared/config-error.ts +33 -0
  8. package/src/shared/den-config.ts +84 -5
  9. package/src/shared/environment-manager/catalog.ts +61 -3
  10. package/src/shared/environment-manager/default-workspace.ts +53 -14
  11. package/src/shared/environment-manager/git-remote.ts +49 -0
  12. package/src/shared/environment-manager/health.ts +51 -8
  13. package/src/shared/environment-manager/service-runner.ts +51 -5
  14. package/src/shared/environment-manager/workspace-manager.ts +69 -26
  15. package/src/shared/environment-manager/workspace-org.ts +279 -0
  16. package/src/shared/manifest/service-links.ts +20 -8
  17. package/src/shared/org-sources.ts +309 -0
  18. package/src/shared/types/index.ts +19 -2
  19. package/src/ui/cli/clone-plan.ts +374 -0
  20. package/src/ui/cli/flag-parser.ts +57 -3
  21. package/src/ui/cli/index.ts +346 -48
  22. package/src/ui/cli/org-command.ts +172 -0
  23. package/src/ui/cli/results-viewer.ts +5 -5
  24. package/src/ui/cli/table.ts +20 -0
  25. package/src/ui/cli/up-args.ts +15 -0
  26. package/src/ui/cli/validate-all.ts +171 -0
  27. package/src/ui/mcp/scope.ts +277 -0
  28. package/src/ui/mcp/server.ts +94 -9
  29. package/src/ui/mcp/tools.ts +101 -17
  30. package/src/ui/server/audit.ts +90 -3
  31. package/src/ui/server/daemon-reaper.ts +22 -4
  32. package/src/ui/server/github-service.ts +6 -1
  33. package/src/ui/server/index.ts +96 -5
  34. package/src/ui/server/public/panel.js +1 -1
  35. package/src/ui/server/readonly-routes.ts +12 -3
  36. package/src/ui/server/release-service.ts +40 -5
  37. package/src/ui/server/workspace-routes.ts +13 -6
@@ -16,7 +16,8 @@ import { noun, plural } from "../../shared/plural.js";
16
16
  // Spinners: progress on stderr (a terminal) or stdout (piped); plain lines
17
17
  // where they cannot animate. See spinner.ts.
18
18
  import { spinner as ora } from "./spinner.js";
19
- import Table from "cli-table3";
19
+ import { newTable } from "./table.js";
20
+ import { validateAll, validateAllFailed } from "./validate-all.js";
20
21
  import { cliPrompts } from "./prompts.js";
21
22
  import { resultsViewer } from "./results-viewer.js";
22
23
  import { testRunner } from "./test-runner.js";
@@ -31,10 +32,12 @@ import { moduleScanner } from "../../shared/test-discovery/module-scanner.js";
31
32
  import { resolvePerformanceModules } from "./module-selection.js";
32
33
  import {
33
34
  readCatalog,
34
- resolveRepoGhSlug,
35
+ needsGhRepair,
36
+ resolveRepoCloneSource,
35
37
  upsertCatalogEntry,
36
38
  } from "../../shared/environment-manager/catalog.js";
37
39
  import { seedSecretsFromSibling } from "../../shared/environment-manager/secrets-check.js";
40
+ import { upNeedsTarget } from "./up-args.js";
38
41
  import {
39
42
  readWorkspacesFile,
40
43
  writeWorkspacesFile,
@@ -108,6 +111,7 @@ import {
108
111
  } from "../../shared/environment-manager/service-runner.js";
109
112
  import { getGitHubCoords } from "../../shared/smoke/git-remote.js";
110
113
  import {
114
+ assertDenConfigValid,
111
115
  getReposRoot,
112
116
  mergeUserConfig,
113
117
  readDenConfig,
@@ -139,7 +143,10 @@ import {
139
143
  } from "../../shared/environment-manager/up-plan.js";
140
144
  import { execSync } from "child_process";
141
145
  import * as fs from "fs";
142
- import { readManifest } from "../../shared/manifest/read-manifest.js";
146
+ import {
147
+ heldManifestsDir,
148
+ readManifest,
149
+ } from "../../shared/manifest/read-manifest.js";
143
150
  import * as nodePath from "path";
144
151
  import { runInit } from "./init.js";
145
152
  import {
@@ -168,11 +175,26 @@ import { cmdConfig } from "./config-command.js";
168
175
  import { cmdEdge } from "./edge-command.js";
169
176
  import { cmdService } from "./service-command.js";
170
177
  import { cmdStack } from "./stack-command.js";
178
+ import {
179
+ cmdOrg,
180
+ orgFlag,
181
+ orgListRows,
182
+ printOrgWarnings,
183
+ registerOrg,
184
+ sameOrg,
185
+ } from "./org-command.js";
186
+ import {
187
+ buildSetupPlan,
188
+ renderClonePlan,
189
+ workspaceClonePlan,
190
+ } from "./clone-plan.js";
191
+ import { githubOrgFor } from "../../shared/environment-manager/workspace-org.js";
171
192
  import { cmdWeb, displayWebHelp } from "./web-command.js";
172
193
  import { cmdGraph } from "./graph-command.js";
173
194
  import { resolveLogsTarget, tailServiceLogs } from "./logs-command.js";
174
195
  import {
175
196
  parseCliArgs,
197
+ takeWorkspaceFlags,
176
198
  unknownCommandFlags,
177
199
  workspaceFlagRequiresActive,
178
200
  type ParsedCliArgs,
@@ -205,8 +227,11 @@ const WORKSPACE_SUBCOMMANDS: {
205
227
  }[] = [
206
228
  {
207
229
  name: "list",
208
- usage: "list",
209
- lines: ["every workspace, the active set and the focus"],
230
+ usage: "list [--org <id>]",
231
+ lines: [
232
+ "every workspace, the active set, the focus and its org;",
233
+ "--org <id> lists one org's workspaces",
234
+ ],
210
235
  },
211
236
  {
212
237
  name: "activate",
@@ -294,19 +319,35 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
294
319
  lines: ["List all registered services in the catalog."],
295
320
  },
296
321
  register: {
297
- usage: "register [path]",
322
+ usage: "register [path] [--org <id>] [-w <workspace>]",
298
323
  lines: [
299
324
  "With a path: add that checkout (dev-managed) under its manifest name.",
300
- "Without one: pick the repo interactively.",
325
+ "Without one: pick the repo interactively. A managed clone is picked",
326
+ "from the focused workspace's org; --org <id> picks from another.",
327
+ "-w writes that workspace (active or not) and leaves focus alone.",
301
328
  ],
302
329
  },
303
330
  setup: {
304
- usage: "setup [service...] [--force|-f]",
331
+ usage:
332
+ "setup [service...] [--force|-f] [--plan [-w <workspace>]... [--all-active]]",
305
333
  lines: [
306
334
  "Run 'make setup' once per system (clones missing managed repos first).",
307
335
  "Default targets MANAGED clones only — setup's codegen/installs",
308
336
  "shouldn't touch your own dev-managed checkouts uninvited.",
309
337
  "--force (-f) includes dev-managed repos; naming a service always works.",
338
+ "--plan prints each repo's org and clone URL and stops: nothing is",
339
+ "cloned, seeded or written (on a fresh machine: the blueprint's plan).",
340
+ "It plans the focused workspace; -w <workspace> (repeatable) plans those,",
341
+ "--all-active every active one. Repos only other workspaces hold are named.",
342
+ ],
343
+ },
344
+ org: {
345
+ usage: "org list [--org <id>]",
346
+ lines: [
347
+ "Every org: its label, its workspaces, whether it is the default",
348
+ "(github_org), and any org warnings. A workspace's org is its own org:,",
349
+ "then the blueprint's org:, then its repos' one catalog owner, then",
350
+ "github_org.",
310
351
  ],
311
352
  },
312
353
  sync: {
@@ -439,6 +480,7 @@ const TELEMETRY_COMMANDS = new Set<string>([
439
480
  `use-managed`,
440
481
  "setup",
441
482
  "workspace",
483
+ "org",
442
484
  "sync",
443
485
  "init",
444
486
  "validate",
@@ -453,6 +495,27 @@ const TELEMETRY_COMMANDS = new Set<string>([
453
495
  "service",
454
496
  ]);
455
497
 
498
+ /**
499
+ * Commands that run with a config that fails validation: the web and MCP
500
+ * servers (each route or tool reports it), and those that read neither orgs,
501
+ * 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.
504
+ */
505
+ const CONFIG_FREE_COMMANDS = new Set<string>([
506
+ "web",
507
+ "mcp",
508
+ "validate",
509
+ "create",
510
+ "config",
511
+ "local-dev",
512
+ "view-results",
513
+ ]);
514
+
515
+ function configCheckedCommand(command: string): boolean {
516
+ return TELEMETRY_COMMANDS.has(command) && !CONFIG_FREE_COMMANDS.has(command);
517
+ }
518
+
456
519
  /** Map a raw command token to a PII-safe, registered telemetry command name. */
457
520
  function telemetryCommandName(command?: string): string {
458
521
  if (!command) return "none";
@@ -675,6 +738,12 @@ class DenCLI {
675
738
  }
676
739
 
677
740
  try {
741
+ // A config that fails validation stops every command that touches
742
+ // orgs, the catalog, clones or releases before it reads or writes
743
+ // anything: some (sync over existing clones, register, workspace
744
+ // create) never read `orgs:` and used to carry on (ORG-1d).
745
+ if (command && configCheckedCommand(command)) assertDenConfigValid();
746
+
678
747
  switch (command) {
679
748
  case "run":
680
749
  await this.runTestsCommand(args.slice(1));
@@ -745,6 +814,10 @@ class DenCLI {
745
814
  await this.cmdWorkspace(args.slice(1));
746
815
  break;
747
816
 
817
+ case "org":
818
+ cmdOrg(args.slice(1));
819
+ break;
820
+
748
821
  case "sync":
749
822
  await this.cmdSync(args.slice(1));
750
823
  break;
@@ -752,9 +825,23 @@ class DenCLI {
752
825
  case "init": {
753
826
  // In the den root, `init` seeds/resets the default workspace; pointed
754
827
  // at a service repo it scaffolds that repo's .den.yml (manifest init).
755
- const initTarget = args[1] ?? getInvokeCwd();
756
- if (nodePath.resolve(initTarget) === getDistRoot()) {
757
- await this.seedDefaultWorkspaceFlow("init");
828
+ const initTarget =
829
+ args.slice(1).find((a) => !a.startsWith("-")) ?? getInvokeCwd();
830
+ // Compare real paths: a symlinked tmp or home (/var → /private/var)
831
+ // must not turn a seed into a manifest scaffold.
832
+ const real = (p: string) => {
833
+ try {
834
+ return fs.realpathSync(nodePath.resolve(p));
835
+ } catch {
836
+ return nodePath.resolve(p);
837
+ }
838
+ };
839
+ if (real(initTarget) === real(getDistRoot())) {
840
+ await this.seedDefaultWorkspaceFlow(
841
+ "init",
842
+ args.slice(1).includes("--reset"),
843
+ "init",
844
+ );
758
845
  } else {
759
846
  await runInit(initTarget);
760
847
  }
@@ -1088,7 +1175,15 @@ class DenCLI {
1088
1175
  if (!catalogEntry) continue;
1089
1176
  const branch = wsEntry.branch ?? "main";
1090
1177
  try {
1091
- ensureManagedServiceClone(repoName, catalogEntry.gh, branch);
1178
+ // An entry with `gh: ""` clones from the workspace's org, as `up`
1179
+ // does (and `setup --plan` says), instead of failing on an empty slug.
1180
+ // ORG-1d: a repo with no source and a `remote:` clones from it.
1181
+ const ghSlug = resolveRepoCloneSource(repoName, catalog, () =>
1182
+ githubOrgFor(ws.name),
1183
+ );
1184
+ if (needsGhRepair(catalogEntry, ghSlug))
1185
+ upsertCatalogEntry(repoName, { ...catalogEntry, gh: ghSlug });
1186
+ ensureManagedServiceClone(repoName, ghSlug, branch);
1092
1187
  } catch (err) {
1093
1188
  console.log(
1094
1189
  chalk.red(
@@ -1189,8 +1284,12 @@ class DenCLI {
1189
1284
  // Catalog entries written by the dev-managed add flow carry `gh: ""`, so
1190
1285
  // an entry can exist yet be unclonable. Derive the conventional slug and
1191
1286
  // persist the repair rather than cloning from an empty URL.
1192
- const ghSlug = resolveRepoGhSlug(repoName, catalog);
1193
- if (!catalog[repoName]?.gh) upsertCatalogEntry(repoName, { gh: ghSlug });
1287
+ // ORG-1d: a repo with no source and a `remote:` clones from it.
1288
+ const ghSlug = resolveRepoCloneSource(repoName, catalog, () =>
1289
+ githubOrgFor(ws.name),
1290
+ );
1291
+ if (needsGhRepair(catalog[repoName], ghSlug))
1292
+ upsertCatalogEntry(repoName, { ...catalog[repoName], gh: ghSlug });
1194
1293
  try {
1195
1294
  // runSetup=false: clone only — setup is run below, hidden + non-interactive.
1196
1295
  ensureManagedServiceClone(repoName, ghSlug, branch, false);
@@ -1427,6 +1526,8 @@ class DenCLI {
1427
1526
  * in order, and run nothing. Read-only on every path.
1428
1527
  */
1429
1528
  dryRun?: boolean;
1529
+ /** `--plan`: a preview that would run nothing exits 1. */
1530
+ plan?: boolean;
1430
1531
  },
1431
1532
  ): Promise<void> {
1432
1533
  // How long to show startup output before handing control back to the menu
@@ -1538,6 +1639,7 @@ class DenCLI {
1538
1639
  );
1539
1640
  }
1540
1641
  if (previews.some((p) => p.kind === "error")) process.exitCode = 1;
1642
+ if (opts?.plan && runs.length === 0) process.exitCode = 1;
1541
1643
  console.log(chalk.gray(` (--dry-run: not ${doing} anything)\n`));
1542
1644
  return;
1543
1645
  }
@@ -1892,6 +1994,25 @@ class DenCLI {
1892
1994
  return seed && knownRepos.has(seed) ? seed : undefined;
1893
1995
  }
1894
1996
 
1997
+ /**
1998
+ * `up --plan`'s clone half (ORG-1a): each repo's org and the URL a missing
1999
+ * managed clone would come from. Read-only, like the rest of the plan.
2000
+ */
2001
+ private renderUpClonePlan(repos: readonly string[]): void {
2002
+ const ws = getActiveWorkspace();
2003
+ if (!ws) return;
2004
+ const lines = renderClonePlan(
2005
+ {
2006
+ fromBlueprint: false,
2007
+ workspaces: [workspaceClonePlan(ws.name, repos)],
2008
+ },
2009
+ product().cli,
2010
+ );
2011
+ console.log(chalk.bold(" Repos and clone URLs:\n"));
2012
+ for (const line of lines) console.log(chalk.gray(` ${line}`.trimEnd()));
2013
+ console.log();
2014
+ }
2015
+
1895
2016
  /** Render the read-only `den up --plan` view: per-node command + flags. */
1896
2017
  private renderUpPlan(nodes: NodePlan[]): void {
1897
2018
  const tag = (n: NodePlan): string => {
@@ -1962,6 +2083,7 @@ class DenCLI {
1962
2083
  `\n ${chalk.bold(seed)} has no derived dependencies to start.\n`,
1963
2084
  ),
1964
2085
  );
2086
+ if (plan) process.exitCode = 1;
1965
2087
  return;
1966
2088
  }
1967
2089
  // Systems the user toggled to auto-start OFF in this workspace are not
@@ -2005,6 +2127,7 @@ class DenCLI {
2005
2127
  this.renderUpPlan(
2006
2128
  buildUpPlan(graph, rawOrder, { noAutostart: noAutostartSet }),
2007
2129
  );
2130
+ this.renderUpClonePlan(rawOrder);
2008
2131
  return;
2009
2132
  }
2010
2133
  if (dryRun) {
@@ -2202,6 +2325,7 @@ class DenCLI {
2202
2325
  "\n No services in the active workspace — nothing to start.\n",
2203
2326
  ),
2204
2327
  );
2328
+ if (plan) process.exitCode = 1;
2205
2329
  return;
2206
2330
  }
2207
2331
  const { rawOrder, graph } = target;
@@ -2216,6 +2340,7 @@ class DenCLI {
2216
2340
  this.renderUpPlan(
2217
2341
  buildUpPlan(graph, rawOrder, { noAutostart: noAutostartSet }),
2218
2342
  );
2343
+ this.renderUpClonePlan(rawOrder);
2219
2344
  return;
2220
2345
  }
2221
2346
  if (dryRun) {
@@ -2494,6 +2619,8 @@ class DenCLI {
2494
2619
  */
2495
2620
  private async seedDefaultWorkspaceFlow(
2496
2621
  reason: "auto" | "init",
2622
+ reset = false,
2623
+ via: "init" | "workspace seed" = "workspace seed",
2497
2624
  ): Promise<boolean> {
2498
2625
  const bps = blueprintWorkspaces();
2499
2626
  if (bps.length === 0) {
@@ -2520,6 +2647,23 @@ class DenCLI {
2520
2647
  }
2521
2648
  const willReset = defaultWorkspaceHasServices();
2522
2649
  const interactive = !!(process.stdin.isTTY && process.stdout.isTTY);
2650
+ // A reset replaces every entry, dev-managed checkouts included. On a TTY
2651
+ // the user is asked (default No); without one there is nobody to ask, so
2652
+ // only an explicit --reset may do it (SEED-0).
2653
+ if (willReset && !interactive && !reset) {
2654
+ const what = multi
2655
+ ? "the blueprint's workspaces"
2656
+ : "the default workspace";
2657
+ console.error(
2658
+ chalk.red(
2659
+ `\n ❌ Not resetting ${what}: it has entries, and a reset replaces them all\n` +
2660
+ ` (your own dev-managed checkouts included) with managed clones.\n` +
2661
+ ` Nothing changed. To reset anyway: ${product().cli} ${via} --reset\n`,
2662
+ ),
2663
+ );
2664
+ process.exitCode = 1;
2665
+ return false;
2666
+ }
2523
2667
 
2524
2668
  for (const bp of bps) {
2525
2669
  const label = multi ? `Workspace ${bp.name}` : "Default workspace";
@@ -2613,10 +2757,10 @@ class DenCLI {
2613
2757
  }
2614
2758
 
2615
2759
  private async cmdServiceUp(args: string[]): Promise<void> {
2616
- await this.ensureWorkspaceSeeded();
2617
2760
  // Tokens after a `--` terminator belong to a child command — they must
2618
2761
  // never be read as up's own flags (`den up -- -a` is NOT `up -a`).
2619
2762
  const terminator = args.indexOf("--");
2763
+ await this.ensureWorkspaceSeeded();
2620
2764
  const { app, rest } = takeAppFlag(
2621
2765
  terminator === -1 ? args : args.slice(0, terminator),
2622
2766
  );
@@ -2680,6 +2824,7 @@ class DenCLI {
2680
2824
  await this.cmdServiceUpManifest(
2681
2825
  ownArgs.filter((a) => !a.startsWith("-")),
2682
2826
  dryRun || plan,
2827
+ plan,
2683
2828
  );
2684
2829
  }
2685
2830
 
@@ -2687,6 +2832,7 @@ class DenCLI {
2687
2832
  private async cmdServiceUpManifest(
2688
2833
  addresses: string[],
2689
2834
  dryRun = false,
2835
+ plan = false,
2690
2836
  ): Promise<void> {
2691
2837
  // Resolve targets BEFORE the secrets preflight — otherwise running
2692
2838
  // `den up` from a non-service dir (e.g. the den repo root) triggers
@@ -2705,6 +2851,16 @@ class DenCLI {
2705
2851
  ` or run \`${product().cli} graph\` to see the workspace.\n`,
2706
2852
  ),
2707
2853
  );
2854
+ } else if (process.env.DEN_WORKSPACE_OVERRIDE) {
2855
+ // `up -w <ws>` with nothing to start: -w picks the workspace, not
2856
+ // what in it to start. A usage error, not an empty plan.
2857
+ console.error(
2858
+ chalk.red(
2859
+ `\n ${upNeedsTarget(product().cli, process.env.DEN_WORKSPACE_OVERRIDE, plan)}\n`,
2860
+ ),
2861
+ );
2862
+ process.exitCode = 2;
2863
+ return;
2708
2864
  } else {
2709
2865
  console.log(
2710
2866
  chalk.yellow(
@@ -2712,12 +2868,15 @@ class DenCLI {
2712
2868
  ),
2713
2869
  );
2714
2870
  }
2871
+ // A plan that resolves nothing is a failed plan (scripts check it).
2872
+ if (plan) process.exitCode = 1;
2715
2873
  return;
2716
2874
  }
2717
2875
  if (!dryRun) preflightSecrets();
2718
2876
  await this.runServicesCommand(targets, "up", undefined, {
2719
2877
  named: addresses,
2720
2878
  dryRun,
2879
+ plan,
2721
2880
  });
2722
2881
  }
2723
2882
 
@@ -2865,7 +3024,7 @@ class DenCLI {
2865
3024
  chalk.cyan(`\n Workspace: ${chalk.bold(ws?.name ?? "none")}\n`),
2866
3025
  );
2867
3026
 
2868
- const table = new Table({
3027
+ const table = newTable({
2869
3028
  head: ["Service", "Mode", "Branch", "Health", "Reason"],
2870
3029
  style: { head: ["cyan"] },
2871
3030
  });
@@ -3027,7 +3186,7 @@ class DenCLI {
3027
3186
  return;
3028
3187
  }
3029
3188
 
3030
- const table = new Table({
3189
+ const table = newTable({
3031
3190
  head: ["Service", "Mode", "Health", "Commands"],
3032
3191
  style: { head: ["cyan"] },
3033
3192
  });
@@ -3072,15 +3231,29 @@ class DenCLI {
3072
3231
  private async cmdRegister(args: string[]): Promise<void> {
3073
3232
  if (args.includes("-h") || args.includes("--help")) {
3074
3233
  console.log(
3075
- `\n ${product().cli} register [path] [--name <repo>] [--gh <owner/repo>]\n\n` +
3234
+ `\n ${product().cli} register [path] [--name <repo>] [--gh <owner/repo>] [-w <workspace>]\n\n` +
3076
3235
  " With a path: add that checkout to the catalog and the focused\n" +
3077
3236
  " workspace as your own (dev-managed) copy, without prompts. The name\n" +
3078
3237
  " defaults to the manifest's name, then the folder name; the GitHub\n" +
3079
- " slug to the git remote.\n" +
3238
+ " slug to the git remote. -w writes that workspace (active or not)\n" +
3239
+ " and leaves focus alone.\n" +
3080
3240
  " Without a path: choose a local checkout or a managed clone interactively.\n",
3081
3241
  );
3082
3242
  return;
3083
3243
  }
3244
+ const repoPathArg = args.find(
3245
+ (a, i) =>
3246
+ !a.startsWith("-") && !/^--(name|gh|org)$/.test(args[i - 1] ?? ""),
3247
+ );
3248
+ // Without a path, register prompts: with no terminal, say how to name one.
3249
+ if (!repoPathArg && !process.stdin.isTTY) {
3250
+ process.stderr.write(
3251
+ `register needs a terminal (stdin is not a TTY); without one: ` +
3252
+ `${product().cli} register <path> [--name <repo>] [--gh <owner/repo>] [-w <workspace>]\n`,
3253
+ );
3254
+ process.exitCode = 2;
3255
+ return;
3256
+ }
3084
3257
  const ws = getActiveWorkspace();
3085
3258
  if (!ws) {
3086
3259
  console.log(
@@ -3091,9 +3264,6 @@ class DenCLI {
3091
3264
  return;
3092
3265
  }
3093
3266
 
3094
- const repoPathArg = args.find(
3095
- (a, i) => !a.startsWith("-") && !/^--(name|gh)$/.test(args[i - 1] ?? ""),
3096
- );
3097
3267
  if (repoPathArg) {
3098
3268
  const flag = (n: string) => {
3099
3269
  const i = args.indexOf(n);
@@ -3108,7 +3278,7 @@ class DenCLI {
3108
3278
  if (source === "local") {
3109
3279
  await this.registerLinked(ws);
3110
3280
  } else {
3111
- await this.registerManaged(ws);
3281
+ await this.registerManaged(ws, orgFlag(args));
3112
3282
  }
3113
3283
  }
3114
3284
 
@@ -3187,26 +3357,25 @@ class DenCLI {
3187
3357
  );
3188
3358
  }
3189
3359
 
3190
- /** List org repos via `gh`, let user pick one + branch → managed service. */
3191
- private async registerManaged(ws: {
3192
- name: string;
3193
- config: import("../../shared/types/index.js").WorkspaceConfig;
3194
- }): Promise<void> {
3195
- // Derive org from den's own git remote
3196
- const { fileURLToPath: ftu } = await import("url");
3197
- const { default: pathMod } = await import("path");
3198
- const distRoot = pathMod.resolve(
3199
- pathMod.dirname(ftu(import.meta.url)),
3200
- "../../..",
3201
- );
3202
- const denCoords = getGitHubCoords(distRoot);
3203
- // The instance's config names its org; the checkout's own remote is only a
3204
- // fallback (inside a package it would name the engine's repo, not the org's).
3205
- const org = readDenConfig().github_org ?? denCoords?.owner;
3206
- if (!org || !/^[A-Za-z0-9][A-Za-z0-9-]*$/.test(org)) {
3360
+ /**
3361
+ * List an org's repos via `gh`, let the user pick one + branch → managed
3362
+ * service. The org is `--org <id>`, else the focused workspace's resolved
3363
+ * org (ORG-1a). Never Den's own checkout or remote (SEC-0).
3364
+ */
3365
+ private async registerManaged(
3366
+ ws: {
3367
+ name: string;
3368
+ config: import("../../shared/types/index.js").WorkspaceConfig;
3369
+ },
3370
+ orgArg?: string,
3371
+ ): Promise<void> {
3372
+ const org = registerOrg(ws.name, orgArg);
3373
+ if (!org) {
3207
3374
  console.log(
3208
3375
  chalk.red(
3209
- `\n ❌ No GitHub org to list: set github_org in ${product().configFile}\n`,
3376
+ `\n ❌ No GitHub org to list: set github_org in ${product().configFile}, ` +
3377
+ `org: on workspace ${ws.name}, or pass --org <id> ` +
3378
+ `(an org with no GitHub source in orgs.<id>.sources has none)\n`,
3210
3379
  ),
3211
3380
  );
3212
3381
  return;
@@ -3459,10 +3628,39 @@ class DenCLI {
3459
3628
  console.log(chalk.gray(` revert: ${revert}\n`));
3460
3629
  }
3461
3630
 
3462
- private async cmdSetup(args: string[]): Promise<void> {
3631
+ private async cmdSetup(rawArgs: string[]): Promise<void> {
3632
+ // `-w <ws>` (repeatable) and `--all-active` pick the workspaces to plan.
3633
+ const { workspaces: planWs, rest: args } = takeWorkspaceFlags(rawArgs);
3634
+ const allActive = args.includes("--all-active");
3463
3635
  const force = args.includes("--force") || args.includes("-f");
3464
3636
  const names = args.filter((a) => !a.startsWith("-"));
3465
3637
 
3638
+ // `--plan`: each repo's org and clone URL, then stop. Read-only.
3639
+ if (args.includes("--plan")) {
3640
+ let plan: ReturnType<typeof buildSetupPlan>;
3641
+ try {
3642
+ plan = buildSetupPlan(names, { workspaces: planWs, allActive });
3643
+ } catch (e) {
3644
+ console.error(chalk.red(`\n ${(e as Error).message}\n`));
3645
+ process.exitCode = 1;
3646
+ return;
3647
+ }
3648
+ console.log(chalk.bold("\n Setup plan (no changes will be made):\n"));
3649
+ for (const line of renderClonePlan(plan, product().cli))
3650
+ console.log(` ${line}`.trimEnd());
3651
+ console.log();
3652
+ return;
3653
+ }
3654
+ if (planWs.length || allActive) {
3655
+ console.error(
3656
+ chalk.red(
3657
+ `\n -w and --all-active go with --plan: setup itself works on the focused workspace.\n`,
3658
+ ),
3659
+ );
3660
+ process.exitCode = 1;
3661
+ return;
3662
+ }
3663
+
3466
3664
  // Clone any managed repos that aren't on disk yet before reading manifests.
3467
3665
  this.cloneMissingManagedRepos();
3468
3666
 
@@ -3550,13 +3748,20 @@ class DenCLI {
3550
3748
 
3551
3749
  switch (sub) {
3552
3750
  case "list": {
3553
- const table = new Table({
3554
- head: ["Workspace", "Active", "Focus", "Services", "Type"],
3751
+ const only = orgFlag(args.slice(1));
3752
+ const { resolved, warnings } = orgListRows();
3753
+ const orgOfWs = new Map(resolved.map((w) => [w.workspace, w.org]));
3754
+ const table = newTable({
3755
+ head: ["Workspace", "Org", "Active", "Focus", "Services", "Type"],
3555
3756
  style: { head: ["cyan"] },
3556
3757
  });
3557
- for (const [name, cfg] of Object.entries(file.workspaces)) {
3758
+ const shown = Object.entries(file.workspaces).filter(
3759
+ ([name]) => !only || sameOrg(orgOfWs.get(name) ?? null, only),
3760
+ );
3761
+ for (const [name, cfg] of shown) {
3558
3762
  table.push([
3559
3763
  chalk.bold(name),
3764
+ orgOfWs.get(name) ?? chalk.gray("none"),
3560
3765
  activeSet.has(name) ? chalk.green("✓") : "",
3561
3766
  name === focus ? chalk.cyan("●") : "",
3562
3767
  Object.keys(cfg.services).join(", ") || chalk.gray("none"),
@@ -3564,6 +3769,14 @@ class DenCLI {
3564
3769
  ]);
3565
3770
  }
3566
3771
  console.log(table.toString());
3772
+ if (only && shown.length === 0)
3773
+ console.log(chalk.yellow(` No workspace in org ${only}.`));
3774
+ const names = new Set(shown.map(([n]) => n));
3775
+ printOrgWarnings(
3776
+ warnings.filter((w) =>
3777
+ [...names].some((n) => w.startsWith(`workspace ${n}:`)),
3778
+ ),
3779
+ );
3567
3780
  break;
3568
3781
  }
3569
3782
  case "activate": {
@@ -3669,7 +3882,7 @@ class DenCLI {
3669
3882
  break;
3670
3883
  }
3671
3884
  case "seed": {
3672
- await this.seedDefaultWorkspaceFlow("init");
3885
+ await this.seedDefaultWorkspaceFlow("init", args.includes("--reset"));
3673
3886
  break;
3674
3887
  }
3675
3888
  case "create": {
@@ -3862,7 +4075,15 @@ class DenCLI {
3862
4075
  // ── den validate ────────────────────────────────────────────────────────
3863
4076
 
3864
4077
  private async cmdValidate(args: string[]): Promise<void> {
3865
- const target = args[0] ?? getInvokeCwd();
4078
+ const positional = args.filter((a) => !a.startsWith("-"));
4079
+ const held = args.includes("--held");
4080
+ // Bare `validate` in the distribution root (which has no manifest of its
4081
+ // own), or `--held`: every manifest the distribution uses.
4082
+ if (held || (positional.length === 0 && this.invokedInDistRoot())) {
4083
+ this.validateEverything(held);
4084
+ return;
4085
+ }
4086
+ const target = positional[0] ?? getInvokeCwd();
3866
4087
  const fs = await import("fs");
3867
4088
  const nodePath = await import("path");
3868
4089
 
@@ -3928,6 +4149,78 @@ class DenCLI {
3928
4149
  if (!result.valid) process.exit(1);
3929
4150
  }
3930
4151
 
4152
+ /** Whether the CLI was run from the distribution root (real paths). */
4153
+ private invokedInDistRoot(): boolean {
4154
+ const real = (p: string) => {
4155
+ try {
4156
+ return fs.realpathSync(nodePath.resolve(p));
4157
+ } catch {
4158
+ return nodePath.resolve(p);
4159
+ }
4160
+ };
4161
+ return (
4162
+ real(getInvokeCwd()) === real(getDistRoot()) &&
4163
+ !fs.existsSync(nodePath.join(getDistRoot(), product().manifestYml))
4164
+ );
4165
+ }
4166
+
4167
+ /** `validate` (bare, in the root) / `validate --held`: see validate-all.ts. */
4168
+ private validateEverything(held: boolean): void {
4169
+ const results = validateAll({ held, validate: validateManifestLayers });
4170
+ console.log();
4171
+ if (results.length === 0) {
4172
+ console.log(
4173
+ chalk.yellow(
4174
+ held
4175
+ ? ` No manifests in ${heldManifestsDir()}.`
4176
+ : " No repos in any workspace and no held manifests.",
4177
+ ),
4178
+ );
4179
+ console.log();
4180
+ return;
4181
+ }
4182
+ const width = Math.max(...results.map((r) => r.repo.length));
4183
+ for (const r of results) {
4184
+ const where = r.checkout ?? "manifests/";
4185
+ const ws = r.workspaces.length ? ` [${r.workspaces.join(", ")}]` : "";
4186
+ const note = r.note ? ` (${r.note})` : "";
4187
+ const head = `${r.repo.padEnd(width)} ${r.source.padEnd(15)} ${where}${ws}${note}`;
4188
+ if (r.missing) {
4189
+ console.log(chalk.yellow(` ⚠ ${head}`));
4190
+ continue;
4191
+ }
4192
+ const warnings = r.result.layers.reduce(
4193
+ (n, l) => n + l.result.warnings.length,
4194
+ 0,
4195
+ );
4196
+ if (r.result.valid && warnings === 0) {
4197
+ console.log(chalk.green(` ✓ ${head}`));
4198
+ continue;
4199
+ }
4200
+ console.log(
4201
+ r.result.valid
4202
+ ? chalk.yellow(` ⚠ ${head}`)
4203
+ : chalk.red(` ✗ ${head}`),
4204
+ );
4205
+ for (const l of r.result.layers)
4206
+ if (!l.result.valid || l.result.warnings.length)
4207
+ printValidation(
4208
+ ` ${l.file ?? "merged"}`,
4209
+ l.result,
4210
+ l.layer === "merged" ? "merged" : l.layer,
4211
+ );
4212
+ }
4213
+ const failed = results.filter((r) => !r.missing && !r.result.valid).length;
4214
+ const missing = results.filter((r) => r.missing).length;
4215
+ console.log();
4216
+ console.log(
4217
+ ` ${plural(results.length, held ? "manifest" : "repo")}: ${failed ? chalk.red(`${failed} failed`) : chalk.green("all valid")}` +
4218
+ (missing ? chalk.yellow(`, ${missing} with no manifest to check`) : ""),
4219
+ );
4220
+ console.log();
4221
+ if (validateAllFailed(results)) process.exitCode = 1;
4222
+ }
4223
+
3931
4224
  // ── den local-dev ───────────────────────────────────────────────────────
3932
4225
 
3933
4226
  private async cmdLocalDev(arg?: string): Promise<void> {
@@ -4008,7 +4301,9 @@ class DenCLI {
4008
4301
  " register [path] Register a repo in the service catalog",
4009
4302
  );
4010
4303
  console.log();
4011
- console.log(" setup [service...] Run make setup for services");
4304
+ console.log(
4305
+ " setup [service...] Run make setup for services (--plan: orgs + clone URLs, no changes)",
4306
+ );
4012
4307
  console.log(" sync [service...] Check for updates and offer to pull");
4013
4308
  console.log(
4014
4309
  " create <name> [dir] Scaffold a new distribution of your own (--title, --cli, --env-prefix)",
@@ -4039,6 +4334,9 @@ class DenCLI {
4039
4334
  console.log(
4040
4335
  " workspace code [n] Open workspace in VS Code (generates .code-workspace)",
4041
4336
  );
4337
+ console.log(
4338
+ " org list [--org <id>] List orgs: label, workspaces, default, warnings",
4339
+ );
4042
4340
  console.log("\nTest commands:");
4043
4341
  console.log(
4044
4342
  " menu Open the interactive menu (pillar/module/env picker)",