@monster-orange/den 0.2.7 → 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.
@@ -5,12 +5,22 @@
5
5
  *
6
6
  * On a fresh machine (no workspace set up yet, a blueprint in the config)
7
7
  * the plan is what `<cli> workspace seed` and then setup would do, for every
8
- * blueprint workspace. Otherwise it is the focused workspace's repos, the
9
- * ones setup works on.
8
+ * blueprint workspace (`-w` narrows it). Otherwise it is the focused
9
+ * workspace's repos, the ones setup works on, or the `-w` / `--all-active`
10
+ * ones; repos only other workspaces hold are named in a hint.
10
11
  */
11
12
  import * as fs from "fs";
12
13
  import * as path from "path";
13
- import { readCatalog } from "../../shared/environment-manager/catalog.js";
14
+ import {
15
+ readCatalog,
16
+ resolveRepoCloneSource,
17
+ } from "../../shared/environment-manager/catalog.js";
18
+ import {
19
+ isGitRemote,
20
+ redactRemote,
21
+ } from "../../shared/environment-manager/git-remote.js";
22
+ import { readDenConfig } from "../../shared/den-config.js";
23
+ import type { WorkOrgsConfig } from "../../shared/org-sources.js";
14
24
  import {
15
25
  blueprintWorkspaces,
16
26
  catalogFill,
@@ -32,6 +42,7 @@ import {
32
42
  } from "../../shared/environment-manager/workspace-org.js";
33
43
  import { orgOf } from "../../shared/manifest/service-links.js";
34
44
  import type { ServiceCatalog } from "../../shared/types/index.js";
45
+ import { plural } from "../../shared/plural.js";
35
46
 
36
47
  export type ClonePlanState =
37
48
  | "cloned"
@@ -64,6 +75,39 @@ export interface ClonePlan {
64
75
  /** True when planned from the blueprint (nothing seeded yet). */
65
76
  fromBlueprint: boolean;
66
77
  workspaces: ClonePlanWorkspace[];
78
+ /** Repos held only by workspaces outside the plan, per workspace. */
79
+ outside?: { workspace: string; repos: string[] }[];
80
+ }
81
+
82
+ /** Which workspaces `setup --plan` plans (default: the focused one). */
83
+ export interface PlanSelection {
84
+ /** `-w <ws>`, repeatable. */
85
+ workspaces?: readonly string[];
86
+ /** `--all-active`: every active workspace (plus any `-w` one). */
87
+ allActive?: boolean;
88
+ }
89
+
90
+ /**
91
+ * The branch a checkout has out, read from its HEAD file (no git process):
92
+ * the branch name, `detached <sha7>`, or null when it can't be read.
93
+ */
94
+ export function checkoutBranch(dir: string): string | null {
95
+ try {
96
+ let gitDir = path.join(dir, ".git");
97
+ if (fs.statSync(gitDir).isFile()) {
98
+ // A worktree or submodule: .git names the real git dir.
99
+ const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(gitDir, "utf-8"));
100
+ if (!m) return null;
101
+ gitDir = path.resolve(dir, m[1].trim());
102
+ }
103
+ const head = fs.readFileSync(path.join(gitDir, "HEAD"), "utf-8").trim();
104
+ const ref = /^ref:\s*refs\/heads\/(.+)$/.exec(head);
105
+ if (ref) return ref[1];
106
+ if (/^[0-9a-f]{40,64}$/.test(head)) return `detached ${head.slice(0, 7)}`;
107
+ return null;
108
+ } catch {
109
+ return null;
110
+ }
67
111
  }
68
112
 
69
113
  const ownerOf = (slug: string): string => slug.slice(0, slug.indexOf("/"));
@@ -72,22 +116,33 @@ function cloneDir(repo: string, branch: string): string {
72
116
  return path.join(DEN_SERVICES_DIR, repo, branch);
73
117
  }
74
118
 
75
- /** The slug setup and up would clone `repo` from, or null without an org. */
76
- function slugFor(
119
+ /**
120
+ * What setup and up would clone `repo` from (a GitHub slug, or an ORG-1d
121
+ * `remote:`), or null without an org. `owner` gives the slug's owner for a
122
+ * repo with neither `gh` nor `remote`.
123
+ */
124
+ function sourceFor(
77
125
  repo: string,
78
- workspace: string,
79
126
  catalog: ServiceCatalog,
80
127
  ctx: OrgContext,
128
+ owner: () => string,
81
129
  ): string | null {
82
- const gh = catalog[repo]?.gh?.trim();
83
- if (gh) return gh;
84
130
  try {
85
- return `${githubOrgFor(workspace, ctx)}/${repo}`;
131
+ return resolveRepoCloneSource(repo, catalog, owner, ctx.config.orgs);
86
132
  } catch {
87
133
  return null;
88
134
  }
89
135
  }
90
136
 
137
+ function slugFor(
138
+ repo: string,
139
+ workspace: string,
140
+ catalog: ServiceCatalog,
141
+ ctx: OrgContext,
142
+ ): string | null {
143
+ return sourceFor(repo, catalog, ctx, () => githubOrgFor(workspace, ctx));
144
+ }
145
+
91
146
  function managedRow(
92
147
  repo: string,
93
148
  branch: string,
@@ -96,12 +151,13 @@ function managedRow(
96
151
  ): ClonePlanRow {
97
152
  const dir = cloneDir(repo, branch);
98
153
  const cloned = fs.existsSync(dir);
154
+ const remote = slug !== null && isGitRemote(slug);
99
155
  return {
100
156
  repo,
101
157
  mode: "managed",
102
158
  branch,
103
- owner: slug ? ownerOf(slug) : null,
104
- url: slug ? ghCloneUrl(slug) : null,
159
+ owner: !slug ? null : remote ? null : ownerOf(slug),
160
+ url: !slug ? null : remote ? redactRemote(slug) : ghCloneUrl(slug),
105
161
  state: cloned
106
162
  ? "cloned"
107
163
  : !slug
@@ -135,7 +191,9 @@ export function workspaceClonePlan(
135
191
  rows.push({
136
192
  repo: repoName,
137
193
  mode: "dev-managed",
138
- branch,
194
+ // Den doesn't set a checkout's branch: say what it has out.
195
+ branch:
196
+ (entry.path && checkoutBranch(entry.path)) || "(branch unknown)",
139
197
  owner: owner || null,
140
198
  url: null,
141
199
  state: "checkout",
@@ -175,28 +233,88 @@ function blueprintClonePlan(ctx: OrgContext): ClonePlanWorkspace[] {
175
233
  managedRow(
176
234
  repo,
177
235
  bp.branch,
178
- ctx.catalog[repo]?.gh?.trim() || fill.get(repo) || null,
236
+ sourceFor(repo, ctx.catalog, ctx, () => {
237
+ const slug = fill.get(repo);
238
+ if (!slug) throw new Error(`no org for ${repo}`);
239
+ return ownerOf(slug);
240
+ }),
179
241
  true, // seeding writes every blueprint repo into the catalog
180
242
  ),
181
243
  ),
182
244
  }));
183
245
  }
184
246
 
185
- /** `<cli> setup --plan`: the plan for `names` (every repo when empty). */
186
- export function buildSetupPlan(names: readonly string[] = []): ClonePlan {
247
+ /**
248
+ * `<cli> setup --plan`: the plan for `names` (every repo when empty) in the
249
+ * selected workspaces. Throws on a workspace that does not exist.
250
+ */
251
+ export function buildSetupPlan(
252
+ names: readonly string[] = [],
253
+ sel: PlanSelection = {},
254
+ ): ClonePlan {
187
255
  const ctx = readOrgContext();
188
- if (isFreshWorkspaceSetup(ctx.file) && blueprintWorkspaces().length > 0)
189
- return { fromBlueprint: true, workspaces: blueprintClonePlan(ctx) };
190
- const ws = getActiveWorkspace();
191
- if (!ws) return { fromBlueprint: false, workspaces: [] };
256
+ const want = [...new Set(sel.workspaces ?? [])];
257
+ if (isFreshWorkspaceSetup(ctx.file) && blueprintWorkspaces().length > 0) {
258
+ const all = blueprintClonePlan(ctx);
259
+ const unknown = want.filter((n) => !all.some((w) => w.name === n));
260
+ if (unknown.length)
261
+ throw new Error(
262
+ `no blueprint workspace named ${unknown.join(", ")} (the blueprint has ${all.map((w) => w.name).join(", ")})`,
263
+ );
264
+ return {
265
+ fromBlueprint: true,
266
+ workspaces: want.length ? all.filter((w) => want.includes(w.name)) : all,
267
+ };
268
+ }
269
+ const focused = getActiveWorkspace()?.name;
270
+ const planned = [
271
+ ...new Set(
272
+ sel.allActive
273
+ ? [...ctx.file.active, ...want]
274
+ : want.length
275
+ ? want
276
+ : focused
277
+ ? [focused]
278
+ : [],
279
+ ),
280
+ ];
281
+ const unknown = planned.filter((n) => !ctx.file.workspaces[n]);
282
+ if (unknown.length)
283
+ throw new Error(
284
+ `no workspace named ${unknown.join(", ")} (workspaces: ${Object.keys(ctx.file.workspaces).join(", ") || "none"})`,
285
+ );
286
+ const workspaces = planned.map((n) =>
287
+ workspaceClonePlan(n, names.length ? names : undefined, ctx),
288
+ );
192
289
  return {
193
290
  fromBlueprint: false,
194
- workspaces: [
195
- workspaceClonePlan(ws.name, names.length ? names : undefined, ctx),
196
- ],
291
+ workspaces,
292
+ outside: outsideRepos(ctx, planned, workspaces),
197
293
  };
198
294
  }
199
295
 
296
+ /** Repos that only workspaces outside `planned` hold, per workspace. */
297
+ function outsideRepos(
298
+ ctx: OrgContext,
299
+ planned: readonly string[],
300
+ workspaces: readonly ClonePlanWorkspace[],
301
+ ): { workspace: string; repos: string[] }[] {
302
+ const inPlan = new Set(workspaces.flatMap((w) => w.rows.map((r) => r.repo)));
303
+ const out: { workspace: string; repos: string[] }[] = [];
304
+ for (const [name, ws] of Object.entries(ctx.file.workspaces)) {
305
+ if (planned.includes(name)) continue;
306
+ const repos = [
307
+ ...new Set(
308
+ Object.keys(ws?.services ?? {}).map(
309
+ (a) => parseAddress(a, ctx.catalog).repoName,
310
+ ),
311
+ ),
312
+ ].filter((r) => !inPlan.has(r));
313
+ if (repos.length) out.push({ workspace: name, repos });
314
+ }
315
+ return out;
316
+ }
317
+
200
318
  const STATE_TEXT: Record<ClonePlanState, string> = {
201
319
  cloned: "cloned",
202
320
  "would-clone": "would clone",
@@ -210,6 +328,7 @@ export function renderClonePlan(
210
328
  plan: ClonePlan,
211
329
  cli: string,
212
330
  catalog: ServiceCatalog = readCatalog(),
331
+ orgs: WorkOrgsConfig | undefined = readDenConfig().orgs,
213
332
  ): string[] {
214
333
  const out: string[] = [];
215
334
  if (plan.fromBlueprint)
@@ -230,7 +349,7 @@ export function renderClonePlan(
230
349
  ? ` ${r.path}`
231
350
  : ""
232
351
  : "";
233
- const owner = r.owner ?? orgOf(r.repo, catalog) ?? "";
352
+ const owner = r.owner ?? orgOf(r.repo, catalog, orgs) ?? "";
234
353
  out.push(
235
354
  ` ${r.repo} ${r.mode} ${r.branch} org: ${owner || "(unknown)"}` +
236
355
  `${r.url ? ` ${r.url}` : ""} ${STATE_TEXT[r.state]}${where}`,
@@ -238,6 +357,18 @@ export function renderClonePlan(
238
357
  }
239
358
  for (const w of ws.warnings) out.push(` ⚠ ${w}`);
240
359
  }
360
+ const outside = plan.outside ?? [];
361
+ if (outside.length) {
362
+ const n = new Set(outside.flatMap((o) => o.repos)).size;
363
+ out.push(
364
+ "",
365
+ `Not in this plan: ${plural(n, "repo")} in other workspaces: ` +
366
+ outside
367
+ .map((o) => `${o.workspace} (${o.repos.join(", ")})`)
368
+ .join(", ") +
369
+ `. Plan them with -w <workspace> or --all-active.`,
370
+ );
371
+ }
241
372
  out.push("", "Plan only: nothing was cloned, seeded or written.");
242
373
  return out;
243
374
  }
@@ -61,11 +61,25 @@ const WORKSPACE_READ_COMMANDS = new Set([
61
61
  "stack",
62
62
  ]);
63
63
 
64
+ /**
65
+ * Commands that edit one workspace's entries without changing what runs:
66
+ * `-w` picks the workspace to write, active or not, and leaves focus alone.
67
+ */
68
+ const WORKSPACE_EDIT_COMMANDS = new Set(["register"]);
69
+
64
70
  const WORKSPACE_COMMANDS = new Set([
65
71
  ...WORKSPACE_MUTATION_COMMANDS,
66
72
  ...WORKSPACE_READ_COMMANDS,
73
+ ...WORKSPACE_EDIT_COMMANDS,
67
74
  ]);
68
75
 
76
+ /**
77
+ * Commands that read `-w`/`--workspace` themselves, so it is left in their
78
+ * args: `setup --plan -w a -w b` takes several, and `mcp --workspace <ws>`
79
+ * scopes the MCP server (it never sets the CLI's workspace override).
80
+ */
81
+ const OWN_WORKSPACE_FLAG_COMMANDS = new Set(["setup", "mcp"]);
82
+
69
83
  /** Whether `-w <name>` on this command requires `<name>` to be active. */
70
84
  export function workspaceFlagRequiresActive(command: string): boolean {
71
85
  return WORKSPACE_MUTATION_COMMANDS.has(command);
@@ -180,7 +194,7 @@ const COMMAND_FLAGS: Record<string, readonly string[]> = {
180
194
  ],
181
195
  restart: ["-A", "--app", "-n", "--dry-run"],
182
196
  logs: [],
183
- setup: ["-f", "--force", "--plan"],
197
+ setup: ["-f", "--force", "--plan", "-w", "--workspace", "--all-active"],
184
198
  sync: [],
185
199
  };
186
200
 
@@ -200,8 +214,9 @@ export function unknownCommandFlags(command: string, args: string[]): string[] {
200
214
  (a) =>
201
215
  a.startsWith("-") &&
202
216
  !allowed.includes(a) &&
203
- // `--app=<name>` is the one flag that also takes the = form.
204
- !(allowed.includes("--app") && a.startsWith("--app=")),
217
+ // `--app=<name>` and `-w=`/`--workspace=` also take the = form.
218
+ !(allowed.includes("--app") && a.startsWith("--app=")) &&
219
+ !(allowed.includes("--workspace") && /^(-w|--workspace)=/.test(a)),
205
220
  );
206
221
  }
207
222
 
@@ -228,6 +243,16 @@ export function parseCliArgs(raw: string[]): ParsedCliArgs {
228
243
  continue;
229
244
  }
230
245
 
246
+ if (
247
+ parsed.command &&
248
+ OWN_WORKSPACE_FLAG_COMMANDS.has(parsed.command) &&
249
+ /^(-w|--workspace)(=|$)/.test(token)
250
+ ) {
251
+ parsed.args.push(token);
252
+ i += 1;
253
+ continue;
254
+ }
255
+
231
256
  const nextIndex = applyFlag(raw, i, parsed.flags);
232
257
  if (nextIndex !== null) {
233
258
  i = nextIndex;
@@ -245,3 +270,32 @@ export function parseCliArgs(raw: string[]): ParsedCliArgs {
245
270
  validateFlagScopes(parsed);
246
271
  return parsed;
247
272
  }
273
+
274
+ /**
275
+ * Every `-w <ws>` / `--workspace <ws>` (and `=` form) in `args`, in order and
276
+ * repeatable, and the args without them. For a command that reads `-w`
277
+ * itself (OWN_WORKSPACE_FLAG_COMMANDS).
278
+ */
279
+ export function takeWorkspaceFlags(args: readonly string[]): {
280
+ workspaces: string[];
281
+ rest: string[];
282
+ } {
283
+ const workspaces: string[] = [];
284
+ const rest: string[] = [];
285
+ for (let i = 0; i < args.length; i++) {
286
+ const a = args[i];
287
+ const m = /^(-w|--workspace)(?:=(.*))?$/.exec(a);
288
+ if (!m) {
289
+ rest.push(a);
290
+ continue;
291
+ }
292
+ if (m[2] !== undefined) {
293
+ if (!m[2]) throw new Error(`${m[1]} requires a value`);
294
+ workspaces.push(m[2]);
295
+ continue;
296
+ }
297
+ workspaces.push(consumeFlagValue([...args], i, m[1]));
298
+ i++;
299
+ }
300
+ return { workspaces, rest };
301
+ }
@@ -32,10 +32,12 @@ import { moduleScanner } from "../../shared/test-discovery/module-scanner.js";
32
32
  import { resolvePerformanceModules } from "./module-selection.js";
33
33
  import {
34
34
  readCatalog,
35
- resolveRepoGhSlug,
35
+ needsGhRepair,
36
+ resolveRepoCloneSource,
36
37
  upsertCatalogEntry,
37
38
  } from "../../shared/environment-manager/catalog.js";
38
39
  import { seedSecretsFromSibling } from "../../shared/environment-manager/secrets-check.js";
40
+ import { upNeedsTarget } from "./up-args.js";
39
41
  import {
40
42
  readWorkspacesFile,
41
43
  writeWorkspacesFile,
@@ -109,6 +111,7 @@ import {
109
111
  } from "../../shared/environment-manager/service-runner.js";
110
112
  import { getGitHubCoords } from "../../shared/smoke/git-remote.js";
111
113
  import {
114
+ assertDenConfigValid,
112
115
  getReposRoot,
113
116
  mergeUserConfig,
114
117
  readDenConfig,
@@ -191,6 +194,7 @@ import { cmdGraph } from "./graph-command.js";
191
194
  import { resolveLogsTarget, tailServiceLogs } from "./logs-command.js";
192
195
  import {
193
196
  parseCliArgs,
197
+ takeWorkspaceFlags,
194
198
  unknownCommandFlags,
195
199
  workspaceFlagRequiresActive,
196
200
  type ParsedCliArgs,
@@ -315,15 +319,17 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
315
319
  lines: ["List all registered services in the catalog."],
316
320
  },
317
321
  register: {
318
- usage: "register [path] [--org <id>]",
322
+ usage: "register [path] [--org <id>] [-w <workspace>]",
319
323
  lines: [
320
324
  "With a path: add that checkout (dev-managed) under its manifest name.",
321
325
  "Without one: pick the repo interactively. A managed clone is picked",
322
326
  "from the focused workspace's org; --org <id> picks from another.",
327
+ "-w writes that workspace (active or not) and leaves focus alone.",
323
328
  ],
324
329
  },
325
330
  setup: {
326
- usage: "setup [service...] [--force|-f] [--plan]",
331
+ usage:
332
+ "setup [service...] [--force|-f] [--plan [-w <workspace>]... [--all-active]]",
327
333
  lines: [
328
334
  "Run 'make setup' once per system (clones missing managed repos first).",
329
335
  "Default targets MANAGED clones only — setup's codegen/installs",
@@ -331,6 +337,8 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
331
337
  "--force (-f) includes dev-managed repos; naming a service always works.",
332
338
  "--plan prints each repo's org and clone URL and stops: nothing is",
333
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.",
334
342
  ],
335
343
  },
336
344
  org: {
@@ -487,6 +495,27 @@ const TELEMETRY_COMMANDS = new Set<string>([
487
495
  "service",
488
496
  ]);
489
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
+
490
519
  /** Map a raw command token to a PII-safe, registered telemetry command name. */
491
520
  function telemetryCommandName(command?: string): string {
492
521
  if (!command) return "none";
@@ -709,6 +738,12 @@ class DenCLI {
709
738
  }
710
739
 
711
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
+
712
747
  switch (command) {
713
748
  case "run":
714
749
  await this.runTestsCommand(args.slice(1));
@@ -1142,10 +1177,11 @@ class DenCLI {
1142
1177
  try {
1143
1178
  // An entry with `gh: ""` clones from the workspace's org, as `up`
1144
1179
  // does (and `setup --plan` says), instead of failing on an empty slug.
1145
- const ghSlug = resolveRepoGhSlug(repoName, catalog, () =>
1180
+ // ORG-1d: a repo with no source and a `remote:` clones from it.
1181
+ const ghSlug = resolveRepoCloneSource(repoName, catalog, () =>
1146
1182
  githubOrgFor(ws.name),
1147
1183
  );
1148
- if (!catalogEntry.gh?.trim())
1184
+ if (needsGhRepair(catalogEntry, ghSlug))
1149
1185
  upsertCatalogEntry(repoName, { ...catalogEntry, gh: ghSlug });
1150
1186
  ensureManagedServiceClone(repoName, ghSlug, branch);
1151
1187
  } catch (err) {
@@ -1248,10 +1284,12 @@ class DenCLI {
1248
1284
  // Catalog entries written by the dev-managed add flow carry `gh: ""`, so
1249
1285
  // an entry can exist yet be unclonable. Derive the conventional slug and
1250
1286
  // persist the repair rather than cloning from an empty URL.
1251
- const ghSlug = resolveRepoGhSlug(repoName, catalog, () =>
1287
+ // ORG-1d: a repo with no source and a `remote:` clones from it.
1288
+ const ghSlug = resolveRepoCloneSource(repoName, catalog, () =>
1252
1289
  githubOrgFor(ws.name),
1253
1290
  );
1254
- if (!catalog[repoName]?.gh) upsertCatalogEntry(repoName, { gh: ghSlug });
1291
+ if (needsGhRepair(catalog[repoName], ghSlug))
1292
+ upsertCatalogEntry(repoName, { ...catalog[repoName], gh: ghSlug });
1255
1293
  try {
1256
1294
  // runSetup=false: clone only — setup is run below, hidden + non-interactive.
1257
1295
  ensureManagedServiceClone(repoName, ghSlug, branch, false);
@@ -1488,6 +1526,8 @@ class DenCLI {
1488
1526
  * in order, and run nothing. Read-only on every path.
1489
1527
  */
1490
1528
  dryRun?: boolean;
1529
+ /** `--plan`: a preview that would run nothing exits 1. */
1530
+ plan?: boolean;
1491
1531
  },
1492
1532
  ): Promise<void> {
1493
1533
  // How long to show startup output before handing control back to the menu
@@ -1599,6 +1639,7 @@ class DenCLI {
1599
1639
  );
1600
1640
  }
1601
1641
  if (previews.some((p) => p.kind === "error")) process.exitCode = 1;
1642
+ if (opts?.plan && runs.length === 0) process.exitCode = 1;
1602
1643
  console.log(chalk.gray(` (--dry-run: not ${doing} anything)\n`));
1603
1644
  return;
1604
1645
  }
@@ -2042,6 +2083,7 @@ class DenCLI {
2042
2083
  `\n ${chalk.bold(seed)} has no derived dependencies to start.\n`,
2043
2084
  ),
2044
2085
  );
2086
+ if (plan) process.exitCode = 1;
2045
2087
  return;
2046
2088
  }
2047
2089
  // Systems the user toggled to auto-start OFF in this workspace are not
@@ -2283,6 +2325,7 @@ class DenCLI {
2283
2325
  "\n No services in the active workspace — nothing to start.\n",
2284
2326
  ),
2285
2327
  );
2328
+ if (plan) process.exitCode = 1;
2286
2329
  return;
2287
2330
  }
2288
2331
  const { rawOrder, graph } = target;
@@ -2714,10 +2757,10 @@ class DenCLI {
2714
2757
  }
2715
2758
 
2716
2759
  private async cmdServiceUp(args: string[]): Promise<void> {
2717
- await this.ensureWorkspaceSeeded();
2718
2760
  // Tokens after a `--` terminator belong to a child command — they must
2719
2761
  // never be read as up's own flags (`den up -- -a` is NOT `up -a`).
2720
2762
  const terminator = args.indexOf("--");
2763
+ await this.ensureWorkspaceSeeded();
2721
2764
  const { app, rest } = takeAppFlag(
2722
2765
  terminator === -1 ? args : args.slice(0, terminator),
2723
2766
  );
@@ -2781,6 +2824,7 @@ class DenCLI {
2781
2824
  await this.cmdServiceUpManifest(
2782
2825
  ownArgs.filter((a) => !a.startsWith("-")),
2783
2826
  dryRun || plan,
2827
+ plan,
2784
2828
  );
2785
2829
  }
2786
2830
 
@@ -2788,6 +2832,7 @@ class DenCLI {
2788
2832
  private async cmdServiceUpManifest(
2789
2833
  addresses: string[],
2790
2834
  dryRun = false,
2835
+ plan = false,
2791
2836
  ): Promise<void> {
2792
2837
  // Resolve targets BEFORE the secrets preflight — otherwise running
2793
2838
  // `den up` from a non-service dir (e.g. the den repo root) triggers
@@ -2806,6 +2851,16 @@ class DenCLI {
2806
2851
  ` or run \`${product().cli} graph\` to see the workspace.\n`,
2807
2852
  ),
2808
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;
2809
2864
  } else {
2810
2865
  console.log(
2811
2866
  chalk.yellow(
@@ -2813,12 +2868,15 @@ class DenCLI {
2813
2868
  ),
2814
2869
  );
2815
2870
  }
2871
+ // A plan that resolves nothing is a failed plan (scripts check it).
2872
+ if (plan) process.exitCode = 1;
2816
2873
  return;
2817
2874
  }
2818
2875
  if (!dryRun) preflightSecrets();
2819
2876
  await this.runServicesCommand(targets, "up", undefined, {
2820
2877
  named: addresses,
2821
2878
  dryRun,
2879
+ plan,
2822
2880
  });
2823
2881
  }
2824
2882
 
@@ -3173,15 +3231,29 @@ class DenCLI {
3173
3231
  private async cmdRegister(args: string[]): Promise<void> {
3174
3232
  if (args.includes("-h") || args.includes("--help")) {
3175
3233
  console.log(
3176
- `\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` +
3177
3235
  " With a path: add that checkout to the catalog and the focused\n" +
3178
3236
  " workspace as your own (dev-managed) copy, without prompts. The name\n" +
3179
3237
  " defaults to the manifest's name, then the folder name; the GitHub\n" +
3180
- " 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" +
3181
3240
  " Without a path: choose a local checkout or a managed clone interactively.\n",
3182
3241
  );
3183
3242
  return;
3184
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
+ }
3185
3257
  const ws = getActiveWorkspace();
3186
3258
  if (!ws) {
3187
3259
  console.log(
@@ -3192,10 +3264,6 @@ class DenCLI {
3192
3264
  return;
3193
3265
  }
3194
3266
 
3195
- const repoPathArg = args.find(
3196
- (a, i) =>
3197
- !a.startsWith("-") && !/^--(name|gh|org)$/.test(args[i - 1] ?? ""),
3198
- );
3199
3267
  if (repoPathArg) {
3200
3268
  const flag = (n: string) => {
3201
3269
  const i = args.indexOf(n);
@@ -3306,7 +3374,8 @@ class DenCLI {
3306
3374
  console.log(
3307
3375
  chalk.red(
3308
3376
  `\n ❌ No GitHub org to list: set github_org in ${product().configFile}, ` +
3309
- `org: on workspace ${ws.name}, or pass --org <id>\n`,
3377
+ `org: on workspace ${ws.name}, or pass --org <id> ` +
3378
+ `(an org with no GitHub source in orgs.<id>.sources has none)\n`,
3310
3379
  ),
3311
3380
  );
3312
3381
  return;
@@ -3559,18 +3628,38 @@ class DenCLI {
3559
3628
  console.log(chalk.gray(` revert: ${revert}\n`));
3560
3629
  }
3561
3630
 
3562
- 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");
3563
3635
  const force = args.includes("--force") || args.includes("-f");
3564
3636
  const names = args.filter((a) => !a.startsWith("-"));
3565
3637
 
3566
3638
  // `--plan`: each repo's org and clone URL, then stop. Read-only.
3567
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
+ }
3568
3648
  console.log(chalk.bold("\n Setup plan (no changes will be made):\n"));
3569
- for (const line of renderClonePlan(buildSetupPlan(names), product().cli))
3649
+ for (const line of renderClonePlan(plan, product().cli))
3570
3650
  console.log(` ${line}`.trimEnd());
3571
3651
  console.log();
3572
3652
  return;
3573
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
+ }
3574
3663
 
3575
3664
  // Clone any managed repos that aren't on disk yet before reading manifests.
3576
3665
  this.cloneMissingManagedRepos();