@monster-orange/den 0.2.6 → 0.2.7

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.
@@ -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";
@@ -139,7 +140,10 @@ import {
139
140
  } from "../../shared/environment-manager/up-plan.js";
140
141
  import { execSync } from "child_process";
141
142
  import * as fs from "fs";
142
- import { readManifest } from "../../shared/manifest/read-manifest.js";
143
+ import {
144
+ heldManifestsDir,
145
+ readManifest,
146
+ } from "../../shared/manifest/read-manifest.js";
143
147
  import * as nodePath from "path";
144
148
  import { runInit } from "./init.js";
145
149
  import {
@@ -168,6 +172,20 @@ import { cmdConfig } from "./config-command.js";
168
172
  import { cmdEdge } from "./edge-command.js";
169
173
  import { cmdService } from "./service-command.js";
170
174
  import { cmdStack } from "./stack-command.js";
175
+ import {
176
+ cmdOrg,
177
+ orgFlag,
178
+ orgListRows,
179
+ printOrgWarnings,
180
+ registerOrg,
181
+ sameOrg,
182
+ } from "./org-command.js";
183
+ import {
184
+ buildSetupPlan,
185
+ renderClonePlan,
186
+ workspaceClonePlan,
187
+ } from "./clone-plan.js";
188
+ import { githubOrgFor } from "../../shared/environment-manager/workspace-org.js";
171
189
  import { cmdWeb, displayWebHelp } from "./web-command.js";
172
190
  import { cmdGraph } from "./graph-command.js";
173
191
  import { resolveLogsTarget, tailServiceLogs } from "./logs-command.js";
@@ -205,8 +223,11 @@ const WORKSPACE_SUBCOMMANDS: {
205
223
  }[] = [
206
224
  {
207
225
  name: "list",
208
- usage: "list",
209
- lines: ["every workspace, the active set and the focus"],
226
+ usage: "list [--org <id>]",
227
+ lines: [
228
+ "every workspace, the active set, the focus and its org;",
229
+ "--org <id> lists one org's workspaces",
230
+ ],
210
231
  },
211
232
  {
212
233
  name: "activate",
@@ -294,19 +315,31 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
294
315
  lines: ["List all registered services in the catalog."],
295
316
  },
296
317
  register: {
297
- usage: "register [path]",
318
+ usage: "register [path] [--org <id>]",
298
319
  lines: [
299
320
  "With a path: add that checkout (dev-managed) under its manifest name.",
300
- "Without one: pick the repo interactively.",
321
+ "Without one: pick the repo interactively. A managed clone is picked",
322
+ "from the focused workspace's org; --org <id> picks from another.",
301
323
  ],
302
324
  },
303
325
  setup: {
304
- usage: "setup [service...] [--force|-f]",
326
+ usage: "setup [service...] [--force|-f] [--plan]",
305
327
  lines: [
306
328
  "Run 'make setup' once per system (clones missing managed repos first).",
307
329
  "Default targets MANAGED clones only — setup's codegen/installs",
308
330
  "shouldn't touch your own dev-managed checkouts uninvited.",
309
331
  "--force (-f) includes dev-managed repos; naming a service always works.",
332
+ "--plan prints each repo's org and clone URL and stops: nothing is",
333
+ "cloned, seeded or written (on a fresh machine: the blueprint's plan).",
334
+ ],
335
+ },
336
+ org: {
337
+ usage: "org list [--org <id>]",
338
+ lines: [
339
+ "Every org: its label, its workspaces, whether it is the default",
340
+ "(github_org), and any org warnings. A workspace's org is its own org:,",
341
+ "then the blueprint's org:, then its repos' one catalog owner, then",
342
+ "github_org.",
310
343
  ],
311
344
  },
312
345
  sync: {
@@ -439,6 +472,7 @@ const TELEMETRY_COMMANDS = new Set<string>([
439
472
  `use-managed`,
440
473
  "setup",
441
474
  "workspace",
475
+ "org",
442
476
  "sync",
443
477
  "init",
444
478
  "validate",
@@ -745,6 +779,10 @@ class DenCLI {
745
779
  await this.cmdWorkspace(args.slice(1));
746
780
  break;
747
781
 
782
+ case "org":
783
+ cmdOrg(args.slice(1));
784
+ break;
785
+
748
786
  case "sync":
749
787
  await this.cmdSync(args.slice(1));
750
788
  break;
@@ -752,9 +790,23 @@ class DenCLI {
752
790
  case "init": {
753
791
  // In the den root, `init` seeds/resets the default workspace; pointed
754
792
  // 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");
793
+ const initTarget =
794
+ args.slice(1).find((a) => !a.startsWith("-")) ?? getInvokeCwd();
795
+ // Compare real paths: a symlinked tmp or home (/var → /private/var)
796
+ // must not turn a seed into a manifest scaffold.
797
+ const real = (p: string) => {
798
+ try {
799
+ return fs.realpathSync(nodePath.resolve(p));
800
+ } catch {
801
+ return nodePath.resolve(p);
802
+ }
803
+ };
804
+ if (real(initTarget) === real(getDistRoot())) {
805
+ await this.seedDefaultWorkspaceFlow(
806
+ "init",
807
+ args.slice(1).includes("--reset"),
808
+ "init",
809
+ );
758
810
  } else {
759
811
  await runInit(initTarget);
760
812
  }
@@ -1088,7 +1140,14 @@ class DenCLI {
1088
1140
  if (!catalogEntry) continue;
1089
1141
  const branch = wsEntry.branch ?? "main";
1090
1142
  try {
1091
- ensureManagedServiceClone(repoName, catalogEntry.gh, branch);
1143
+ // An entry with `gh: ""` clones from the workspace's org, as `up`
1144
+ // does (and `setup --plan` says), instead of failing on an empty slug.
1145
+ const ghSlug = resolveRepoGhSlug(repoName, catalog, () =>
1146
+ githubOrgFor(ws.name),
1147
+ );
1148
+ if (!catalogEntry.gh?.trim())
1149
+ upsertCatalogEntry(repoName, { ...catalogEntry, gh: ghSlug });
1150
+ ensureManagedServiceClone(repoName, ghSlug, branch);
1092
1151
  } catch (err) {
1093
1152
  console.log(
1094
1153
  chalk.red(
@@ -1189,7 +1248,9 @@ class DenCLI {
1189
1248
  // Catalog entries written by the dev-managed add flow carry `gh: ""`, so
1190
1249
  // an entry can exist yet be unclonable. Derive the conventional slug and
1191
1250
  // persist the repair rather than cloning from an empty URL.
1192
- const ghSlug = resolveRepoGhSlug(repoName, catalog);
1251
+ const ghSlug = resolveRepoGhSlug(repoName, catalog, () =>
1252
+ githubOrgFor(ws.name),
1253
+ );
1193
1254
  if (!catalog[repoName]?.gh) upsertCatalogEntry(repoName, { gh: ghSlug });
1194
1255
  try {
1195
1256
  // runSetup=false: clone only — setup is run below, hidden + non-interactive.
@@ -1892,6 +1953,25 @@ class DenCLI {
1892
1953
  return seed && knownRepos.has(seed) ? seed : undefined;
1893
1954
  }
1894
1955
 
1956
+ /**
1957
+ * `up --plan`'s clone half (ORG-1a): each repo's org and the URL a missing
1958
+ * managed clone would come from. Read-only, like the rest of the plan.
1959
+ */
1960
+ private renderUpClonePlan(repos: readonly string[]): void {
1961
+ const ws = getActiveWorkspace();
1962
+ if (!ws) return;
1963
+ const lines = renderClonePlan(
1964
+ {
1965
+ fromBlueprint: false,
1966
+ workspaces: [workspaceClonePlan(ws.name, repos)],
1967
+ },
1968
+ product().cli,
1969
+ );
1970
+ console.log(chalk.bold(" Repos and clone URLs:\n"));
1971
+ for (const line of lines) console.log(chalk.gray(` ${line}`.trimEnd()));
1972
+ console.log();
1973
+ }
1974
+
1895
1975
  /** Render the read-only `den up --plan` view: per-node command + flags. */
1896
1976
  private renderUpPlan(nodes: NodePlan[]): void {
1897
1977
  const tag = (n: NodePlan): string => {
@@ -2005,6 +2085,7 @@ class DenCLI {
2005
2085
  this.renderUpPlan(
2006
2086
  buildUpPlan(graph, rawOrder, { noAutostart: noAutostartSet }),
2007
2087
  );
2088
+ this.renderUpClonePlan(rawOrder);
2008
2089
  return;
2009
2090
  }
2010
2091
  if (dryRun) {
@@ -2216,6 +2297,7 @@ class DenCLI {
2216
2297
  this.renderUpPlan(
2217
2298
  buildUpPlan(graph, rawOrder, { noAutostart: noAutostartSet }),
2218
2299
  );
2300
+ this.renderUpClonePlan(rawOrder);
2219
2301
  return;
2220
2302
  }
2221
2303
  if (dryRun) {
@@ -2494,6 +2576,8 @@ class DenCLI {
2494
2576
  */
2495
2577
  private async seedDefaultWorkspaceFlow(
2496
2578
  reason: "auto" | "init",
2579
+ reset = false,
2580
+ via: "init" | "workspace seed" = "workspace seed",
2497
2581
  ): Promise<boolean> {
2498
2582
  const bps = blueprintWorkspaces();
2499
2583
  if (bps.length === 0) {
@@ -2520,6 +2604,23 @@ class DenCLI {
2520
2604
  }
2521
2605
  const willReset = defaultWorkspaceHasServices();
2522
2606
  const interactive = !!(process.stdin.isTTY && process.stdout.isTTY);
2607
+ // A reset replaces every entry, dev-managed checkouts included. On a TTY
2608
+ // the user is asked (default No); without one there is nobody to ask, so
2609
+ // only an explicit --reset may do it (SEED-0).
2610
+ if (willReset && !interactive && !reset) {
2611
+ const what = multi
2612
+ ? "the blueprint's workspaces"
2613
+ : "the default workspace";
2614
+ console.error(
2615
+ chalk.red(
2616
+ `\n ❌ Not resetting ${what}: it has entries, and a reset replaces them all\n` +
2617
+ ` (your own dev-managed checkouts included) with managed clones.\n` +
2618
+ ` Nothing changed. To reset anyway: ${product().cli} ${via} --reset\n`,
2619
+ ),
2620
+ );
2621
+ process.exitCode = 1;
2622
+ return false;
2623
+ }
2523
2624
 
2524
2625
  for (const bp of bps) {
2525
2626
  const label = multi ? `Workspace ${bp.name}` : "Default workspace";
@@ -2865,7 +2966,7 @@ class DenCLI {
2865
2966
  chalk.cyan(`\n Workspace: ${chalk.bold(ws?.name ?? "none")}\n`),
2866
2967
  );
2867
2968
 
2868
- const table = new Table({
2969
+ const table = newTable({
2869
2970
  head: ["Service", "Mode", "Branch", "Health", "Reason"],
2870
2971
  style: { head: ["cyan"] },
2871
2972
  });
@@ -3027,7 +3128,7 @@ class DenCLI {
3027
3128
  return;
3028
3129
  }
3029
3130
 
3030
- const table = new Table({
3131
+ const table = newTable({
3031
3132
  head: ["Service", "Mode", "Health", "Commands"],
3032
3133
  style: { head: ["cyan"] },
3033
3134
  });
@@ -3092,7 +3193,8 @@ class DenCLI {
3092
3193
  }
3093
3194
 
3094
3195
  const repoPathArg = args.find(
3095
- (a, i) => !a.startsWith("-") && !/^--(name|gh)$/.test(args[i - 1] ?? ""),
3196
+ (a, i) =>
3197
+ !a.startsWith("-") && !/^--(name|gh|org)$/.test(args[i - 1] ?? ""),
3096
3198
  );
3097
3199
  if (repoPathArg) {
3098
3200
  const flag = (n: string) => {
@@ -3108,7 +3210,7 @@ class DenCLI {
3108
3210
  if (source === "local") {
3109
3211
  await this.registerLinked(ws);
3110
3212
  } else {
3111
- await this.registerManaged(ws);
3213
+ await this.registerManaged(ws, orgFlag(args));
3112
3214
  }
3113
3215
  }
3114
3216
 
@@ -3187,26 +3289,24 @@ class DenCLI {
3187
3289
  );
3188
3290
  }
3189
3291
 
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)) {
3292
+ /**
3293
+ * List an org's repos via `gh`, let the user pick one + branch → managed
3294
+ * service. The org is `--org <id>`, else the focused workspace's resolved
3295
+ * org (ORG-1a). Never Den's own checkout or remote (SEC-0).
3296
+ */
3297
+ private async registerManaged(
3298
+ ws: {
3299
+ name: string;
3300
+ config: import("../../shared/types/index.js").WorkspaceConfig;
3301
+ },
3302
+ orgArg?: string,
3303
+ ): Promise<void> {
3304
+ const org = registerOrg(ws.name, orgArg);
3305
+ if (!org) {
3207
3306
  console.log(
3208
3307
  chalk.red(
3209
- `\n ❌ No GitHub org to list: set github_org in ${product().configFile}\n`,
3308
+ `\n ❌ No GitHub org to list: set github_org in ${product().configFile}, ` +
3309
+ `org: on workspace ${ws.name}, or pass --org <id>\n`,
3210
3310
  ),
3211
3311
  );
3212
3312
  return;
@@ -3463,6 +3563,15 @@ class DenCLI {
3463
3563
  const force = args.includes("--force") || args.includes("-f");
3464
3564
  const names = args.filter((a) => !a.startsWith("-"));
3465
3565
 
3566
+ // `--plan`: each repo's org and clone URL, then stop. Read-only.
3567
+ if (args.includes("--plan")) {
3568
+ console.log(chalk.bold("\n Setup plan (no changes will be made):\n"));
3569
+ for (const line of renderClonePlan(buildSetupPlan(names), product().cli))
3570
+ console.log(` ${line}`.trimEnd());
3571
+ console.log();
3572
+ return;
3573
+ }
3574
+
3466
3575
  // Clone any managed repos that aren't on disk yet before reading manifests.
3467
3576
  this.cloneMissingManagedRepos();
3468
3577
 
@@ -3550,13 +3659,20 @@ class DenCLI {
3550
3659
 
3551
3660
  switch (sub) {
3552
3661
  case "list": {
3553
- const table = new Table({
3554
- head: ["Workspace", "Active", "Focus", "Services", "Type"],
3662
+ const only = orgFlag(args.slice(1));
3663
+ const { resolved, warnings } = orgListRows();
3664
+ const orgOfWs = new Map(resolved.map((w) => [w.workspace, w.org]));
3665
+ const table = newTable({
3666
+ head: ["Workspace", "Org", "Active", "Focus", "Services", "Type"],
3555
3667
  style: { head: ["cyan"] },
3556
3668
  });
3557
- for (const [name, cfg] of Object.entries(file.workspaces)) {
3669
+ const shown = Object.entries(file.workspaces).filter(
3670
+ ([name]) => !only || sameOrg(orgOfWs.get(name) ?? null, only),
3671
+ );
3672
+ for (const [name, cfg] of shown) {
3558
3673
  table.push([
3559
3674
  chalk.bold(name),
3675
+ orgOfWs.get(name) ?? chalk.gray("none"),
3560
3676
  activeSet.has(name) ? chalk.green("✓") : "",
3561
3677
  name === focus ? chalk.cyan("●") : "",
3562
3678
  Object.keys(cfg.services).join(", ") || chalk.gray("none"),
@@ -3564,6 +3680,14 @@ class DenCLI {
3564
3680
  ]);
3565
3681
  }
3566
3682
  console.log(table.toString());
3683
+ if (only && shown.length === 0)
3684
+ console.log(chalk.yellow(` No workspace in org ${only}.`));
3685
+ const names = new Set(shown.map(([n]) => n));
3686
+ printOrgWarnings(
3687
+ warnings.filter((w) =>
3688
+ [...names].some((n) => w.startsWith(`workspace ${n}:`)),
3689
+ ),
3690
+ );
3567
3691
  break;
3568
3692
  }
3569
3693
  case "activate": {
@@ -3669,7 +3793,7 @@ class DenCLI {
3669
3793
  break;
3670
3794
  }
3671
3795
  case "seed": {
3672
- await this.seedDefaultWorkspaceFlow("init");
3796
+ await this.seedDefaultWorkspaceFlow("init", args.includes("--reset"));
3673
3797
  break;
3674
3798
  }
3675
3799
  case "create": {
@@ -3862,7 +3986,15 @@ class DenCLI {
3862
3986
  // ── den validate ────────────────────────────────────────────────────────
3863
3987
 
3864
3988
  private async cmdValidate(args: string[]): Promise<void> {
3865
- const target = args[0] ?? getInvokeCwd();
3989
+ const positional = args.filter((a) => !a.startsWith("-"));
3990
+ const held = args.includes("--held");
3991
+ // Bare `validate` in the distribution root (which has no manifest of its
3992
+ // own), or `--held`: every manifest the distribution uses.
3993
+ if (held || (positional.length === 0 && this.invokedInDistRoot())) {
3994
+ this.validateEverything(held);
3995
+ return;
3996
+ }
3997
+ const target = positional[0] ?? getInvokeCwd();
3866
3998
  const fs = await import("fs");
3867
3999
  const nodePath = await import("path");
3868
4000
 
@@ -3928,6 +4060,78 @@ class DenCLI {
3928
4060
  if (!result.valid) process.exit(1);
3929
4061
  }
3930
4062
 
4063
+ /** Whether the CLI was run from the distribution root (real paths). */
4064
+ private invokedInDistRoot(): boolean {
4065
+ const real = (p: string) => {
4066
+ try {
4067
+ return fs.realpathSync(nodePath.resolve(p));
4068
+ } catch {
4069
+ return nodePath.resolve(p);
4070
+ }
4071
+ };
4072
+ return (
4073
+ real(getInvokeCwd()) === real(getDistRoot()) &&
4074
+ !fs.existsSync(nodePath.join(getDistRoot(), product().manifestYml))
4075
+ );
4076
+ }
4077
+
4078
+ /** `validate` (bare, in the root) / `validate --held`: see validate-all.ts. */
4079
+ private validateEverything(held: boolean): void {
4080
+ const results = validateAll({ held, validate: validateManifestLayers });
4081
+ console.log();
4082
+ if (results.length === 0) {
4083
+ console.log(
4084
+ chalk.yellow(
4085
+ held
4086
+ ? ` No manifests in ${heldManifestsDir()}.`
4087
+ : " No repos in any workspace and no held manifests.",
4088
+ ),
4089
+ );
4090
+ console.log();
4091
+ return;
4092
+ }
4093
+ const width = Math.max(...results.map((r) => r.repo.length));
4094
+ for (const r of results) {
4095
+ const where = r.checkout ?? "manifests/";
4096
+ const ws = r.workspaces.length ? ` [${r.workspaces.join(", ")}]` : "";
4097
+ const note = r.note ? ` (${r.note})` : "";
4098
+ const head = `${r.repo.padEnd(width)} ${r.source.padEnd(15)} ${where}${ws}${note}`;
4099
+ if (r.missing) {
4100
+ console.log(chalk.yellow(` ⚠ ${head}`));
4101
+ continue;
4102
+ }
4103
+ const warnings = r.result.layers.reduce(
4104
+ (n, l) => n + l.result.warnings.length,
4105
+ 0,
4106
+ );
4107
+ if (r.result.valid && warnings === 0) {
4108
+ console.log(chalk.green(` ✓ ${head}`));
4109
+ continue;
4110
+ }
4111
+ console.log(
4112
+ r.result.valid
4113
+ ? chalk.yellow(` ⚠ ${head}`)
4114
+ : chalk.red(` ✗ ${head}`),
4115
+ );
4116
+ for (const l of r.result.layers)
4117
+ if (!l.result.valid || l.result.warnings.length)
4118
+ printValidation(
4119
+ ` ${l.file ?? "merged"}`,
4120
+ l.result,
4121
+ l.layer === "merged" ? "merged" : l.layer,
4122
+ );
4123
+ }
4124
+ const failed = results.filter((r) => !r.missing && !r.result.valid).length;
4125
+ const missing = results.filter((r) => r.missing).length;
4126
+ console.log();
4127
+ console.log(
4128
+ ` ${plural(results.length, held ? "manifest" : "repo")}: ${failed ? chalk.red(`${failed} failed`) : chalk.green("all valid")}` +
4129
+ (missing ? chalk.yellow(`, ${missing} with no manifest to check`) : ""),
4130
+ );
4131
+ console.log();
4132
+ if (validateAllFailed(results)) process.exitCode = 1;
4133
+ }
4134
+
3931
4135
  // ── den local-dev ───────────────────────────────────────────────────────
3932
4136
 
3933
4137
  private async cmdLocalDev(arg?: string): Promise<void> {
@@ -4008,7 +4212,9 @@ class DenCLI {
4008
4212
  " register [path] Register a repo in the service catalog",
4009
4213
  );
4010
4214
  console.log();
4011
- console.log(" setup [service...] Run make setup for services");
4215
+ console.log(
4216
+ " setup [service...] Run make setup for services (--plan: orgs + clone URLs, no changes)",
4217
+ );
4012
4218
  console.log(" sync [service...] Check for updates and offer to pull");
4013
4219
  console.log(
4014
4220
  " create <name> [dir] Scaffold a new distribution of your own (--title, --cli, --env-prefix)",
@@ -4039,6 +4245,9 @@ class DenCLI {
4039
4245
  console.log(
4040
4246
  " workspace code [n] Open workspace in VS Code (generates .code-workspace)",
4041
4247
  );
4248
+ console.log(
4249
+ " org list [--org <id>] List orgs: label, workspaces, default, warnings",
4250
+ );
4042
4251
  console.log("\nTest commands:");
4043
4252
  console.log(
4044
4253
  " menu Open the interactive menu (pillar/module/env picker)",
@@ -0,0 +1,162 @@
1
+ /**
2
+ * `<cli> org list` (ORG-1a): every org with its label, its workspaces on
3
+ * this machine, whether it is the default (`github_org`), and the org
4
+ * warnings (C1). The orgs are `/api/orgs`'s (catalog owners plus `orgs:`),
5
+ * then the default org and any workspace's org that list does not name.
6
+ *
7
+ * `--org <id>` (on `org list` and `workspace list`) keeps one org's rows.
8
+ */
9
+ import chalk from "chalk";
10
+ import { product } from "../../shared/product.js";
11
+ import { listOrgs } from "../../shared/manifest/service-links.js";
12
+ import {
13
+ defaultOrg,
14
+ githubOrgFor,
15
+ readOrgContext,
16
+ resolveAllWorkspaceOrgs,
17
+ withOrgMembership,
18
+ type OrgContext,
19
+ type WorkspaceOrg,
20
+ } from "../../shared/environment-manager/workspace-org.js";
21
+ import { newTable } from "./table.js";
22
+
23
+ /** `--org <id>`'s value from `args` (also `--org=<id>`), or undefined. */
24
+ export function orgFlag(args: readonly string[]): string | undefined {
25
+ for (let i = 0; i < args.length; i++) {
26
+ const a = args[i];
27
+ if (a === "--org") return args[i + 1];
28
+ if (a.startsWith("--org=")) return a.slice("--org=".length);
29
+ }
30
+ return undefined;
31
+ }
32
+
33
+ /** `args` without `--org <id>` / `--org=<id>`. */
34
+ export function withoutOrgFlag(args: readonly string[]): string[] {
35
+ const out: string[] = [];
36
+ for (let i = 0; i < args.length; i++) {
37
+ if (args[i] === "--org") {
38
+ i++;
39
+ continue;
40
+ }
41
+ if (args[i].startsWith("--org=")) continue;
42
+ out.push(args[i]);
43
+ }
44
+ return out;
45
+ }
46
+
47
+ export const sameOrg = (a: string | null, b: string): boolean =>
48
+ a !== null && a.toLowerCase() === b.toLowerCase();
49
+
50
+ export interface OrgListRow {
51
+ id: string;
52
+ label: string;
53
+ default: boolean;
54
+ workspaces: string[];
55
+ }
56
+
57
+ /** The rows `org list` prints, and every workspace's warnings. */
58
+ export function orgListRows(ctx: OrgContext = readOrgContext()): {
59
+ rows: OrgListRow[];
60
+ warnings: string[];
61
+ resolved: WorkspaceOrg[];
62
+ } {
63
+ const resolved = resolveAllWorkspaceOrgs(ctx);
64
+ const listed = withOrgMembership(
65
+ listOrgs(ctx.catalog, ctx.config.orgs ?? {}),
66
+ resolved,
67
+ ctx.file,
68
+ ctx.config,
69
+ ).map((o) => ({
70
+ id: o.id,
71
+ label: o.label,
72
+ default: o.default,
73
+ workspaces: o.workspaces,
74
+ }));
75
+ const local = new Set(Object.keys(ctx.file.workspaces));
76
+ const extra = new Map<string, OrgListRow>();
77
+ const add = (id: string, isDefault: boolean): void => {
78
+ if (listed.some((r) => sameOrg(r.id, id))) return;
79
+ const key = id.toLowerCase();
80
+ if (!extra.has(key))
81
+ extra.set(key, { id, label: id, default: isDefault, workspaces: [] });
82
+ };
83
+ const d = defaultOrg(ctx.config);
84
+ if (d) add(d, true);
85
+ for (const w of resolved)
86
+ if (w.org && local.has(w.workspace)) {
87
+ add(w.org, sameOrg(d, w.org));
88
+ extra.get(w.org.toLowerCase())?.workspaces.push(w.workspace);
89
+ }
90
+ return {
91
+ rows: [...listed, ...extra.values()],
92
+ warnings: resolved
93
+ .filter((w) => local.has(w.workspace))
94
+ .flatMap((w) => w.warnings),
95
+ resolved,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * The GitHub org `register` lists repos from: `--org <id>`, else the
101
+ * workspace's resolved org. Never Den's own checkout or remote (SEC-0).
102
+ * Undefined when there is none, or it is not a GitHub owner name.
103
+ */
104
+ export function registerOrg(
105
+ workspace: string,
106
+ orgArg?: string,
107
+ ctx?: OrgContext,
108
+ ): string | undefined {
109
+ let org = orgArg?.trim();
110
+ if (!org) {
111
+ try {
112
+ org = githubOrgFor(workspace, ctx);
113
+ } catch {
114
+ return undefined;
115
+ }
116
+ }
117
+ return /^[A-Za-z0-9][A-Za-z0-9-]*$/.test(org) ? org : undefined;
118
+ }
119
+
120
+ /** Print the org warnings under a listing. */
121
+ export function printOrgWarnings(warnings: readonly string[]): void {
122
+ for (const w of warnings) console.log(chalk.yellow(` ⚠ ${w}`));
123
+ if (warnings.length) console.log();
124
+ }
125
+
126
+ export function cmdOrg(args: string[]): void {
127
+ const sub = args[0] ?? "list";
128
+ if (sub !== "list") {
129
+ console.log(chalk.red(`Usage: ${product().cli} org list [--org <id>]`));
130
+ process.exitCode = 1;
131
+ return;
132
+ }
133
+ const only = orgFlag(args.slice(1));
134
+ const { rows, warnings, resolved } = orgListRows();
135
+ const shown = only ? rows.filter((r) => sameOrg(r.id, only)) : rows;
136
+ const table = newTable({
137
+ head: ["Org", "Label", "Default", "Workspaces"],
138
+ style: { head: ["cyan"] },
139
+ });
140
+ for (const r of shown)
141
+ table.push([
142
+ chalk.bold(r.id),
143
+ r.label,
144
+ r.default ? chalk.green("✓") : "",
145
+ r.workspaces.join(", ") || chalk.gray("none"),
146
+ ]);
147
+ console.log(table.toString());
148
+ if (only && shown.length === 0)
149
+ console.log(chalk.yellow(` No org ${only}.`));
150
+ const inScope = only
151
+ ? new Set(
152
+ resolved.filter((w) => sameOrg(w.org, only)).map((w) => w.workspace),
153
+ )
154
+ : null;
155
+ printOrgWarnings(
156
+ inScope
157
+ ? warnings.filter((w) =>
158
+ [...inScope].some((n) => w.startsWith(`workspace ${n}:`)),
159
+ )
160
+ : warnings,
161
+ );
162
+ }