@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
@@ -22,6 +22,12 @@ import {
22
22
  } from "../process/spawn-streaming.js";
23
23
  import { buildServiceEnv } from "./service-env.js";
24
24
  import { assertSafeCloneDestination } from "./clone-guard.js";
25
+ import {
26
+ SAFE_SLUG_RE,
27
+ isGitRemote,
28
+ isSafeRemote,
29
+ redactRemote,
30
+ } from "./git-remote.js";
25
31
  import type { CommandDef } from "../types/index.js";
26
32
 
27
33
  // ── Terminal resolution (macOS) ──────────────────────────────────────────────
@@ -76,13 +82,45 @@ function resolveTerminalAppName(key: string): string | null {
76
82
 
77
83
  // ── Git helpers ───────────────────────────────────────────────────────────────
78
84
 
79
- function git(cmd: string, cwd?: string): void {
80
- execSync(`git -c credential.helper="!gh auth git-credential" ${cmd}`, {
85
+ const GH_CREDENTIAL = `-c credential.helper="!gh auth git-credential"`;
86
+
87
+ /**
88
+ * Run git with gh as an extra credential helper (a GitHub clone), or as
89
+ * plain git (`gh` false: a clone from a catalog `remote:`, which gets only
90
+ * the user's own credential helper, never gh's: SEC-0).
91
+ */
92
+ function git(cmd: string, cwd?: string, gh = true): void {
93
+ execSync(`git ${gh ? `${GH_CREDENTIAL} ` : ""}${cmd}`, {
81
94
  cwd,
82
95
  stdio: "inherit",
83
96
  });
84
97
  }
85
98
 
99
+ /** Set in a clone made from a `remote:`; its later fetches stay plain git. */
100
+ const PLAIN_MARK = "den.credential";
101
+
102
+ /** Whether a clone's fetches use gh: every clone but one Den made from a `remote:`. */
103
+ function usesGhCredential(repoPath: string): boolean {
104
+ try {
105
+ return (
106
+ execSync(`git config --local --get ${PLAIN_MARK}`, {
107
+ cwd: repoPath,
108
+ stdio: ["ignore", "pipe", "ignore"],
109
+ encoding: "utf-8",
110
+ }).trim() !== "git"
111
+ );
112
+ } catch {
113
+ return true; // unset: a GitHub clone, as every clone before ORG-1d
114
+ }
115
+ }
116
+
117
+ /** Clone `source` (a GitHub slug or a checked git URL) with its own credentials. */
118
+ function gitClone(args: string, source: string, dest: string): void {
119
+ const plain = isGitRemote(source);
120
+ git(`clone ${args}"${cloneUrlOf(source)}" "${dest}"`, undefined, !plain);
121
+ if (plain) git(`config --local ${PLAIN_MARK} git`, dest, false);
122
+ }
123
+
86
124
  function gitCapture(cmd: string, cwd?: string): string {
87
125
  return execSync(cmd, {
88
126
  cwd,
@@ -91,10 +129,21 @@ function gitCapture(cmd: string, cwd?: string): string {
91
129
  }).trim();
92
130
  }
93
131
 
94
- function ghCloneUrl(ghSlug: string): string {
132
+ /** The HTTPS URL a managed clone of `ghSlug` is made from. */
133
+ export function ghCloneUrl(ghSlug: string): string {
95
134
  return `https://github.com/${ghSlug}.git`;
96
135
  }
97
136
 
137
+ /** The URL a clone of `source` is made from: a GitHub slug's, or the remote itself. */
138
+ export function cloneUrlOf(source: string): string {
139
+ return isGitRemote(source) ? source : ghCloneUrl(source);
140
+ }
141
+
142
+ /** `source` for a log line: a slug as is, a remote with any user info masked. */
143
+ function shown(source: string): string {
144
+ return isGitRemote(source) ? redactRemote(source) : source;
145
+ }
146
+
98
147
  // ── Injection guards ──────────────────────────────────────────────────────────
99
148
  //
100
149
  // git() runs through a shell (execSync), so branch names and GitHub slugs
@@ -105,7 +154,7 @@ function ghCloneUrl(ghSlug: string): string {
105
154
  // the single choke point for every clone path.
106
155
 
107
156
  export const SAFE_BRANCH_RE = /^[a-zA-Z0-9._/-]+$/;
108
- export const SAFE_SLUG_RE = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/;
157
+ export { SAFE_SLUG_RE };
109
158
 
110
159
  function assertSafeBranch(branch: string): void {
111
160
  if (!SAFE_BRANCH_RE.test(branch)) {
@@ -115,10 +164,12 @@ function assertSafeBranch(branch: string): void {
115
164
  }
116
165
  }
117
166
 
167
+ /** A GitHub slug, or (ORG-1d) a git URL from a catalog `remote:`. */
118
168
  function assertSafeSlug(ghSlug: string): void {
169
+ if (isGitRemote(ghSlug) && isSafeRemote(ghSlug)) return;
119
170
  if (!SAFE_SLUG_RE.test(ghSlug)) {
120
171
  throw new Error(
121
- `Invalid GitHub slug "${ghSlug}" — expected "owner/repo" using only letters, digits, ".", "_" and "-"`,
172
+ `Invalid GitHub slug "${redactRemote(ghSlug)}" — expected "owner/repo" using only letters, digits, ".", "_" and "-"`,
122
173
  );
123
174
  }
124
175
  }
@@ -426,17 +477,15 @@ export function ensureManagedServiceClone(
426
477
  if (!fs.existsSync(cloneDir)) {
427
478
  assertSafeCloneDestination(cloneDir, repoName);
428
479
  console.log(
429
- ` ⬇ Cloning ${ghSlug}@${branch} → ${product().servicesDir}/${repoName}/${branch}/`,
480
+ ` ⬇ Cloning ${shown(ghSlug)}@${branch} → ${product().servicesDir}/${repoName}/${branch}/`,
430
481
  );
431
482
  fs.mkdirSync(path.join(DEN_SERVICES_DIR, repoName), {
432
483
  recursive: true,
433
484
  });
434
- git(
435
- `clone --filter=blob:none --branch "${branch}" "${ghCloneUrl(ghSlug)}" "${cloneDir}"`,
436
- );
485
+ gitClone(`--filter=blob:none --branch "${branch}" `, ghSlug, cloneDir);
437
486
  if (runSetup) runSetupIfDefined(cloneDir);
438
487
  } else {
439
- git("pull --ff-only", cloneDir);
488
+ git("pull --ff-only", cloneDir, usesGhCredential(cloneDir));
440
489
  }
441
490
 
442
491
  return cloneDir;
@@ -462,12 +511,10 @@ export function ensureManagedServiceCloneNewBranch(
462
511
  if (!fs.existsSync(cloneDir)) {
463
512
  assertSafeCloneDestination(cloneDir, repoName);
464
513
  console.log(
465
- ` ⬇ Cloning ${ghSlug}@${baseBranch} → ${product().servicesDir}/${repoName}/${newBranch}/ (new branch: ${newBranch})`,
514
+ ` ⬇ Cloning ${shown(ghSlug)}@${baseBranch} → ${product().servicesDir}/${repoName}/${newBranch}/ (new branch: ${newBranch})`,
466
515
  );
467
516
  fs.mkdirSync(path.dirname(cloneDir), { recursive: true });
468
- git(
469
- `clone --filter=blob:none --branch "${baseBranch}" "${ghCloneUrl(ghSlug)}" "${cloneDir}"`,
470
- );
517
+ gitClone(`--filter=blob:none --branch "${baseBranch}" `, ghSlug, cloneDir);
471
518
  git(`checkout -b "${newBranch}"`, cloneDir);
472
519
  runSetupIfDefined(cloneDir);
473
520
  }
@@ -495,15 +542,13 @@ export function ensureDevManagedClone(
495
542
  if (!fs.existsSync(targetDir)) {
496
543
  const leafName = path.basename(targetDir);
497
544
  assertSafeCloneDestination(targetDir, leafName);
498
- console.log(` ⬇ Cloning ${ghSlug}@${branch} → ${targetDir}/`);
545
+ console.log(` ⬇ Cloning ${shown(ghSlug)}@${branch} → ${targetDir}/`);
499
546
  fs.mkdirSync(path.dirname(targetDir), { recursive: true });
500
547
  try {
501
- git(
502
- `clone --filter=blob:none --branch "${branch}" "${ghCloneUrl(ghSlug)}" "${targetDir}"`,
503
- );
548
+ gitClone(`--filter=blob:none --branch "${branch}" `, ghSlug, targetDir);
504
549
  } catch {
505
550
  // Branch may not exist — clone default branch and checkout/create the branch
506
- git(`clone --filter=blob:none "${ghCloneUrl(ghSlug)}" "${targetDir}"`);
551
+ gitClone(`--filter=blob:none `, ghSlug, targetDir);
507
552
  try {
508
553
  git(`checkout "${branch}"`, targetDir);
509
554
  } catch {
@@ -537,17 +582,15 @@ export function ensureWorkspaceClone(
537
582
  if (!fs.existsSync(cloneDir)) {
538
583
  assertSafeCloneDestination(cloneDir, repoName);
539
584
  console.log(
540
- ` ⬇ Cloning ${ghSlug}@${branch} → ${product().workspacesDir}/${workspaceName}/${repoName}/`,
585
+ ` ⬇ Cloning ${shown(ghSlug)}@${branch} → ${product().workspacesDir}/${workspaceName}/${repoName}/`,
541
586
  );
542
587
  fs.mkdirSync(wsDir, { recursive: true });
543
588
 
544
589
  try {
545
- git(
546
- `clone --filter=blob:none --branch "${branch}" "${ghCloneUrl(ghSlug)}" "${cloneDir}"`,
547
- );
590
+ gitClone(`--filter=blob:none --branch "${branch}" `, ghSlug, cloneDir);
548
591
  } catch {
549
592
  // Branch doesn't exist yet — clone main and create the branch
550
- git(`clone --filter=blob:none "${ghCloneUrl(ghSlug)}" "${cloneDir}"`);
593
+ gitClone(`--filter=blob:none `, ghSlug, cloneDir);
551
594
  git(`checkout -b "${branch}"`, cloneDir);
552
595
  console.log(` ✓ Created new branch ${branch} in ${repoName}`);
553
596
  }
@@ -659,7 +702,7 @@ export function pullRepo(repoPath: string): void {
659
702
  } catch {
660
703
  /* best-effort — a genuinely diverged clone still errors on the pull */
661
704
  }
662
- git("pull --ff-only", repoPath);
705
+ git("pull --ff-only", repoPath, usesGhCredential(repoPath));
663
706
  }
664
707
 
665
708
  /**
@@ -681,7 +724,7 @@ export function fetchAndFastForward(repoPath: string): {
681
724
  pulled: boolean;
682
725
  error?: string;
683
726
  } {
684
- const GH_CRED = `-c credential.helper="!gh auth git-credential"`;
727
+ const GH_CRED = usesGhCredential(repoPath) ? GH_CREDENTIAL : "";
685
728
  try {
686
729
  execSync(`git ${GH_CRED} fetch --quiet`, {
687
730
  cwd: repoPath,
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Which work org a workspace belongs to (ORG-1a, ORG-SPEC §2).
3
+ *
4
+ * An org is a key of `orgs:` or a GitHub owner. An org with no `sources`
5
+ * and no `repos` is the GitHub owner of the same name (ORG-1a); ORG-1d's
6
+ * keys make it any name (org-sources.ts), and a repo's org is orgOf's.
7
+ * `github_org` is the default org. A workspace's org resolves through this
8
+ * chain, first match wins:
9
+ *
10
+ * 1. `org:` on the workspace in workspaces.yml (per machine)
11
+ * 2. `org:` on the same-named blueprint workspace (`defaults.workspaces`)
12
+ * 3. the single non-empty catalog owner of its repos (case-insensitive)
13
+ * 4. `github_org`
14
+ *
15
+ * Computed on read and never written back to workspaces.yml, so an inferred
16
+ * org never looks like an explicit one. Every rule broken here is a warning,
17
+ * never an error (C1): an old config resolves as before, to `github_org`.
18
+ */
19
+ import {
20
+ readDenConfig,
21
+ readWorkspaceBlueprints,
22
+ requireGithubOrg,
23
+ type DenConfig,
24
+ type ResolvedWorkspaceBlueprint,
25
+ } from "../den-config.js";
26
+ import { readCatalog } from "./catalog.js";
27
+ import { parseAddress, readWorkspacesFile } from "./workspaces.js";
28
+ import { orgOf } from "../manifest/service-links.js";
29
+ import { githubOwnersOf, orgClaimingOwner } from "../org-sources.js";
30
+ import { cachedRead } from "../read-cache.js";
31
+ import type {
32
+ ServiceCatalog,
33
+ WorkspaceConfig,
34
+ WorkspacesFile,
35
+ } from "../types/index.js";
36
+
37
+ /** Which step of the chain gave the org. */
38
+ export type OrgSource = "workspace" | "blueprint" | "catalog" | "default";
39
+
40
+ export interface WorkspaceOrg {
41
+ workspace: string;
42
+ /** The resolved org id (the `orgs:` key's spelling when one matches), or null. */
43
+ org: string | null;
44
+ source: OrgSource | null;
45
+ /** Each broken org rule, as a sentence (C1: warnings, never errors). */
46
+ warnings: string[];
47
+ }
48
+
49
+ export interface OrgContext {
50
+ config: DenConfig;
51
+ catalog: ServiceCatalog;
52
+ file: WorkspacesFile;
53
+ blueprints: ResolvedWorkspaceBlueprint[];
54
+ }
55
+
56
+ const lower = (s: string): string => s.toLowerCase();
57
+
58
+ /** The current config, catalog, workspaces.yml and blueprint. */
59
+ export function readOrgContext(): OrgContext {
60
+ const config = readDenConfig();
61
+ let blueprints: ResolvedWorkspaceBlueprint[] = [];
62
+ try {
63
+ blueprints = readWorkspaceBlueprints(config).workspaces;
64
+ } catch {
65
+ // A malformed blueprint is reported where seeding reads it; it must not
66
+ // turn an org listing into a failure (C1).
67
+ }
68
+ return {
69
+ config,
70
+ catalog: readCatalog(),
71
+ file: readWorkspacesFile(),
72
+ blueprints,
73
+ };
74
+ }
75
+
76
+ /**
77
+ * `id` spelled as its `orgs:` key when one matches case-insensitively; a
78
+ * GitHub owner an ORG-1d source claims (`github_org: acme` with a source
79
+ * for acme) is that source's org.
80
+ */
81
+ export function canonicalOrg(id: string, config: DenConfig): string {
82
+ const key = Object.keys(config.orgs ?? {}).find(
83
+ (k) => lower(k) === lower(id),
84
+ );
85
+ return key ?? orgClaimingOwner(id, config.orgs) ?? id;
86
+ }
87
+
88
+ /** The default org: `github_org`, or null when unset. */
89
+ export function defaultOrg(config: DenConfig): string | null {
90
+ const d = config.github_org?.trim();
91
+ return d ? canonicalOrg(d, config) : null;
92
+ }
93
+
94
+ /** The repos a workspace holds; a workspace not set up yet holds its blueprint's. */
95
+ function workspaceRepos(
96
+ ws: WorkspaceConfig | undefined,
97
+ bp: ResolvedWorkspaceBlueprint | undefined,
98
+ catalog: ServiceCatalog,
99
+ ): string[] {
100
+ const held = [
101
+ ...Object.keys(ws?.services ?? {}).map(
102
+ (k) => parseAddress(k, catalog).repoName,
103
+ ),
104
+ ...Object.keys(ws?.repos ?? {}),
105
+ ];
106
+ return [...new Set(held.length > 0 ? held : (bp?.repos ?? []))];
107
+ }
108
+
109
+ /** Owners Den knows of: `orgs:` keys, catalog owners and `github_org`. */
110
+ function knownOrgs(ctx: OrgContext): Set<string> {
111
+ const ids = new Set(Object.keys(ctx.config.orgs ?? {}).map(lower));
112
+ for (const repo of Object.keys(ctx.catalog)) {
113
+ const o = orgOf(repo, ctx.catalog, ctx.config.orgs);
114
+ if (o) ids.add(lower(o));
115
+ }
116
+ const d = ctx.config.github_org?.trim();
117
+ if (d) ids.add(lower(d));
118
+ return ids;
119
+ }
120
+
121
+ /** Resolve one workspace's org through the chain. */
122
+ export function resolveWorkspaceOrg(
123
+ name: string,
124
+ ctx: OrgContext,
125
+ ): WorkspaceOrg {
126
+ const warnings: string[] = [];
127
+ const ws = ctx.file.workspaces[name];
128
+ const bp = ctx.blueprints.find((b) => b.name === name);
129
+ const rawWsOrg = ws?.org as unknown;
130
+ const wsOrg =
131
+ typeof rawWsOrg === "string" && rawWsOrg.trim()
132
+ ? rawWsOrg.trim()
133
+ : undefined;
134
+ if (rawWsOrg !== undefined && rawWsOrg !== null && !wsOrg)
135
+ warnings.push(
136
+ `workspace ${name}: org in workspaces.yml is not an org id; ignored`,
137
+ );
138
+
139
+ const repos = workspaceRepos(ws, bp, ctx.catalog);
140
+ const owners = new Map<string, string>(); // lowercased → first spelling
141
+ for (const repo of repos) {
142
+ const o = orgOf(repo, ctx.catalog, ctx.config.orgs);
143
+ if (o && !owners.has(lower(o))) owners.set(lower(o), o);
144
+ }
145
+
146
+ let org: string | null = null;
147
+ let source: OrgSource | null = null;
148
+ if (wsOrg) {
149
+ org = wsOrg;
150
+ source = "workspace";
151
+ if (bp?.org && lower(bp.org) !== lower(wsOrg))
152
+ warnings.push(
153
+ `workspace ${name}: org ${wsOrg} in workspaces.yml overrides the blueprint's org ${bp.org}`,
154
+ );
155
+ } else if (bp?.org) {
156
+ org = bp.org;
157
+ source = "blueprint";
158
+ } else if (owners.size === 1) {
159
+ org = [...owners.values()][0];
160
+ source = "catalog";
161
+ } else {
162
+ org = ctx.config.github_org?.trim() || null;
163
+ source = org ? "default" : null;
164
+ if (owners.size > 1)
165
+ warnings.push(
166
+ `workspace ${name}: its repos belong to several owners (${[...owners.values()].join(", ")}); ` +
167
+ `using ${org ?? "no org"}. Set org: on the workspace to choose one`,
168
+ );
169
+ }
170
+ if (org) org = canonicalOrg(org, ctx.config);
171
+
172
+ if (
173
+ org &&
174
+ (source === "workspace" || source === "blueprint") &&
175
+ !knownOrgs(ctx).has(lower(org))
176
+ )
177
+ warnings.push(
178
+ `workspace ${name}: org ${org} is not in orgs:, the catalog or github_org`,
179
+ );
180
+
181
+ // A repo in another owner than its workspace's org. A mixed workspace with
182
+ // no org of its own already said so above.
183
+ if (
184
+ org &&
185
+ (owners.size <= 1 || source === "workspace" || source === "blueprint")
186
+ )
187
+ for (const repo of repos) {
188
+ const o = orgOf(repo, ctx.catalog, ctx.config.orgs);
189
+ if (o && lower(o) !== lower(org))
190
+ warnings.push(
191
+ `workspace ${name}: repo ${repo} belongs to ${o}, not the workspace's org ${org}`,
192
+ );
193
+ }
194
+
195
+ return { workspace: name, org, source, warnings };
196
+ }
197
+
198
+ /** Every workspace on this machine, then blueprint workspaces not set up yet. */
199
+ function workspaceNames(ctx: OrgContext): string[] {
200
+ return [
201
+ ...new Set([
202
+ ...Object.keys(ctx.file.workspaces),
203
+ ...ctx.blueprints.map((b) => b.name),
204
+ ]),
205
+ ];
206
+ }
207
+
208
+ /** Every workspace's resolved org (cached for the web server's read TTL). */
209
+ export function resolveAllWorkspaceOrgs(ctx?: OrgContext): WorkspaceOrg[] {
210
+ const compute = (c: OrgContext) =>
211
+ workspaceNames(c).map((n) => resolveWorkspaceOrg(n, c));
212
+ if (ctx) return compute(ctx);
213
+ return cachedRead("workspace-orgs", () => compute(readOrgContext()));
214
+ }
215
+
216
+ /**
217
+ * The GitHub owner to clone and register a workspace's repos under: its
218
+ * resolved org, which for a legacy org is that GitHub owner. An org with
219
+ * ORG-1d `sources` gives its first GitHub source's owner, and one with no
220
+ * GitHub source throws (its repos clone from `remote:`; discovery is off).
221
+ * With no org at all (no org anywhere and no `github_org`) this throws
222
+ * exactly as requireGithubOrg does, so an old config fails as before.
223
+ */
224
+ export function githubOrgFor(workspace: string, ctx?: OrgContext): string {
225
+ const hit = ctx
226
+ ? resolveWorkspaceOrg(workspace, ctx)
227
+ : (resolveAllWorkspaceOrgs().find((w) => w.workspace === workspace) ??
228
+ resolveWorkspaceOrg(workspace, readOrgContext()));
229
+ if (!hit.org) return requireGithubOrg(ctx?.config);
230
+ const orgs = (ctx?.config ?? readDenConfig()).orgs;
231
+ const owner = githubOwnersOf(hit.org, orgs)[0];
232
+ if (owner) return owner;
233
+ throw new Error(
234
+ `org ${hit.org} has no GitHub source (orgs.${hit.org}.sources): ` +
235
+ `give workspace ${workspace}'s repos a remote: in the service catalog`,
236
+ );
237
+ }
238
+
239
+ export interface OrgEntry {
240
+ id: string;
241
+ label: string;
242
+ color?: string;
243
+ }
244
+
245
+ /**
246
+ * The org listing with `default` and `workspaces` added to each entry and
247
+ * nothing else changed (C3): same entries, same order, same fields. Only
248
+ * workspaces on this machine (workspaces.yml) are listed.
249
+ */
250
+ export function withOrgMembership<T extends OrgEntry>(
251
+ orgs: T[],
252
+ resolved: WorkspaceOrg[],
253
+ file: WorkspacesFile,
254
+ config: DenConfig,
255
+ ): Array<T & { default: boolean; workspaces: string[] }> {
256
+ // The listing can hold one owner twice in different case (catalog owners
257
+ // are listed as spelled); an org lands on the entry spelled exactly like
258
+ // it, else the first that matches case-insensitively.
259
+ const entryFor = (id: string): number => {
260
+ const exact = orgs.findIndex((o) => o.id === id);
261
+ return exact !== -1
262
+ ? exact
263
+ : orgs.findIndex((o) => lower(o.id) === lower(id));
264
+ };
265
+ const d = defaultOrg(config);
266
+ const defaultAt = d === null ? -1 : entryFor(d);
267
+ const members = orgs.map((): string[] => []);
268
+ const local = new Set(Object.keys(file.workspaces));
269
+ for (const w of resolved) {
270
+ if (!w.org || !local.has(w.workspace)) continue;
271
+ const at = entryFor(w.org);
272
+ if (at !== -1) members[at].push(w.workspace);
273
+ }
274
+ return orgs.map((o, i) => ({
275
+ ...o,
276
+ default: i === defaultAt,
277
+ workspaces: members[i],
278
+ }));
279
+ }
@@ -12,6 +12,7 @@ import type {
12
12
  ServiceCatalog,
13
13
  ServiceManagementConfig,
14
14
  } from "../types/index.js";
15
+ import { orgOfRepo, type WorkOrgsConfig } from "../org-sources.js";
15
16
 
16
17
  export interface OpenLink {
17
18
  label: string;
@@ -63,11 +64,18 @@ export function linksFor(
63
64
  return out;
64
65
  }
65
66
 
66
- /** The GitHub owner a repo is catalogued under ("acme"), or "". */
67
- export function orgOf(repo: string, catalog: ServiceCatalog): string {
68
- const gh = catalog[repo]?.gh ?? "";
69
- const slash = gh.indexOf("/");
70
- return slash > 0 ? gh.slice(0, slash) : "";
67
+ /**
68
+ * The org a repo belongs to (ORG-1d): an org whose `repos` lists it, then an
69
+ * org with a source that claims its owner, then (legacy) its bare GitHub
70
+ * owner from `gh` ("acme"), else "". Without `orgs` (or with no ORG-1d keys
71
+ * in it) this is the repo's GitHub owner, as before.
72
+ */
73
+ export function orgOf(
74
+ repo: string,
75
+ catalog: ServiceCatalog,
76
+ orgs: WorkOrgsConfig = {},
77
+ ): string {
78
+ return orgOfRepo(repo, catalog, orgs);
71
79
  }
72
80
 
73
81
  export interface OrgDisplay {
@@ -75,14 +83,18 @@ export interface OrgDisplay {
75
83
  color?: string;
76
84
  }
77
85
 
78
- /** Every org in the catalog with its display name, configured ones first. */
86
+ /**
87
+ * Every org with its display name, configured ones first, then each
88
+ * catalogued repo's org. An owner a source claims is not an org of its own,
89
+ * so it is not listed.
90
+ */
79
91
  export function listOrgs(
80
92
  catalog: ServiceCatalog,
81
- configured: Record<string, OrgDisplay> = {},
93
+ configured: WorkOrgsConfig = {},
82
94
  ): Array<{ id: string; label: string; color?: string }> {
83
95
  const ids = new Set<string>(Object.keys(configured));
84
96
  for (const repo of Object.keys(catalog)) {
85
- const o = orgOf(repo, catalog);
97
+ const o = orgOf(repo, catalog, configured);
86
98
  if (o) ids.add(o);
87
99
  }
88
100
  return [...ids].map((id) => ({