@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.
@@ -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,
@@ -96,6 +134,16 @@ export function ghCloneUrl(ghSlug: string): string {
96
134
  return `https://github.com/${ghSlug}.git`;
97
135
  }
98
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
+
99
147
  // ── Injection guards ──────────────────────────────────────────────────────────
100
148
  //
101
149
  // git() runs through a shell (execSync), so branch names and GitHub slugs
@@ -106,7 +154,7 @@ export function ghCloneUrl(ghSlug: string): string {
106
154
  // the single choke point for every clone path.
107
155
 
108
156
  export const SAFE_BRANCH_RE = /^[a-zA-Z0-9._/-]+$/;
109
- export const SAFE_SLUG_RE = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/;
157
+ export { SAFE_SLUG_RE };
110
158
 
111
159
  function assertSafeBranch(branch: string): void {
112
160
  if (!SAFE_BRANCH_RE.test(branch)) {
@@ -116,10 +164,12 @@ function assertSafeBranch(branch: string): void {
116
164
  }
117
165
  }
118
166
 
167
+ /** A GitHub slug, or (ORG-1d) a git URL from a catalog `remote:`. */
119
168
  function assertSafeSlug(ghSlug: string): void {
169
+ if (isGitRemote(ghSlug) && isSafeRemote(ghSlug)) return;
120
170
  if (!SAFE_SLUG_RE.test(ghSlug)) {
121
171
  throw new Error(
122
- `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 "-"`,
123
173
  );
124
174
  }
125
175
  }
@@ -427,17 +477,15 @@ export function ensureManagedServiceClone(
427
477
  if (!fs.existsSync(cloneDir)) {
428
478
  assertSafeCloneDestination(cloneDir, repoName);
429
479
  console.log(
430
- ` ⬇ Cloning ${ghSlug}@${branch} → ${product().servicesDir}/${repoName}/${branch}/`,
480
+ ` ⬇ Cloning ${shown(ghSlug)}@${branch} → ${product().servicesDir}/${repoName}/${branch}/`,
431
481
  );
432
482
  fs.mkdirSync(path.join(DEN_SERVICES_DIR, repoName), {
433
483
  recursive: true,
434
484
  });
435
- git(
436
- `clone --filter=blob:none --branch "${branch}" "${ghCloneUrl(ghSlug)}" "${cloneDir}"`,
437
- );
485
+ gitClone(`--filter=blob:none --branch "${branch}" `, ghSlug, cloneDir);
438
486
  if (runSetup) runSetupIfDefined(cloneDir);
439
487
  } else {
440
- git("pull --ff-only", cloneDir);
488
+ git("pull --ff-only", cloneDir, usesGhCredential(cloneDir));
441
489
  }
442
490
 
443
491
  return cloneDir;
@@ -463,12 +511,10 @@ export function ensureManagedServiceCloneNewBranch(
463
511
  if (!fs.existsSync(cloneDir)) {
464
512
  assertSafeCloneDestination(cloneDir, repoName);
465
513
  console.log(
466
- ` ⬇ Cloning ${ghSlug}@${baseBranch} → ${product().servicesDir}/${repoName}/${newBranch}/ (new branch: ${newBranch})`,
514
+ ` ⬇ Cloning ${shown(ghSlug)}@${baseBranch} → ${product().servicesDir}/${repoName}/${newBranch}/ (new branch: ${newBranch})`,
467
515
  );
468
516
  fs.mkdirSync(path.dirname(cloneDir), { recursive: true });
469
- git(
470
- `clone --filter=blob:none --branch "${baseBranch}" "${ghCloneUrl(ghSlug)}" "${cloneDir}"`,
471
- );
517
+ gitClone(`--filter=blob:none --branch "${baseBranch}" `, ghSlug, cloneDir);
472
518
  git(`checkout -b "${newBranch}"`, cloneDir);
473
519
  runSetupIfDefined(cloneDir);
474
520
  }
@@ -496,15 +542,13 @@ export function ensureDevManagedClone(
496
542
  if (!fs.existsSync(targetDir)) {
497
543
  const leafName = path.basename(targetDir);
498
544
  assertSafeCloneDestination(targetDir, leafName);
499
- console.log(` ⬇ Cloning ${ghSlug}@${branch} → ${targetDir}/`);
545
+ console.log(` ⬇ Cloning ${shown(ghSlug)}@${branch} → ${targetDir}/`);
500
546
  fs.mkdirSync(path.dirname(targetDir), { recursive: true });
501
547
  try {
502
- git(
503
- `clone --filter=blob:none --branch "${branch}" "${ghCloneUrl(ghSlug)}" "${targetDir}"`,
504
- );
548
+ gitClone(`--filter=blob:none --branch "${branch}" `, ghSlug, targetDir);
505
549
  } catch {
506
550
  // Branch may not exist — clone default branch and checkout/create the branch
507
- git(`clone --filter=blob:none "${ghCloneUrl(ghSlug)}" "${targetDir}"`);
551
+ gitClone(`--filter=blob:none `, ghSlug, targetDir);
508
552
  try {
509
553
  git(`checkout "${branch}"`, targetDir);
510
554
  } catch {
@@ -538,17 +582,15 @@ export function ensureWorkspaceClone(
538
582
  if (!fs.existsSync(cloneDir)) {
539
583
  assertSafeCloneDestination(cloneDir, repoName);
540
584
  console.log(
541
- ` ⬇ Cloning ${ghSlug}@${branch} → ${product().workspacesDir}/${workspaceName}/${repoName}/`,
585
+ ` ⬇ Cloning ${shown(ghSlug)}@${branch} → ${product().workspacesDir}/${workspaceName}/${repoName}/`,
542
586
  );
543
587
  fs.mkdirSync(wsDir, { recursive: true });
544
588
 
545
589
  try {
546
- git(
547
- `clone --filter=blob:none --branch "${branch}" "${ghCloneUrl(ghSlug)}" "${cloneDir}"`,
548
- );
590
+ gitClone(`--filter=blob:none --branch "${branch}" `, ghSlug, cloneDir);
549
591
  } catch {
550
592
  // Branch doesn't exist yet — clone main and create the branch
551
- git(`clone --filter=blob:none "${ghCloneUrl(ghSlug)}" "${cloneDir}"`);
593
+ gitClone(`--filter=blob:none `, ghSlug, cloneDir);
552
594
  git(`checkout -b "${branch}"`, cloneDir);
553
595
  console.log(` ✓ Created new branch ${branch} in ${repoName}`);
554
596
  }
@@ -660,7 +702,7 @@ export function pullRepo(repoPath: string): void {
660
702
  } catch {
661
703
  /* best-effort — a genuinely diverged clone still errors on the pull */
662
704
  }
663
- git("pull --ff-only", repoPath);
705
+ git("pull --ff-only", repoPath, usesGhCredential(repoPath));
664
706
  }
665
707
 
666
708
  /**
@@ -682,7 +724,7 @@ export function fetchAndFastForward(repoPath: string): {
682
724
  pulled: boolean;
683
725
  error?: string;
684
726
  } {
685
- const GH_CRED = `-c credential.helper="!gh auth git-credential"`;
727
+ const GH_CRED = usesGhCredential(repoPath) ? GH_CREDENTIAL : "";
686
728
  try {
687
729
  execSync(`git ${GH_CRED} fetch --quiet`, {
688
730
  cwd: repoPath,
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Which work org a workspace belongs to (ORG-1a, ORG-SPEC §2).
3
3
  *
4
- * An org is a key of `orgs:` or a GitHub owner. ORG-1a keeps today's model:
5
- * an org with no `sources` is the GitHub owner of the same name, and
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.
6
7
  * `github_org` is the default org. A workspace's org resolves through this
7
8
  * chain, first match wins:
8
9
  *
@@ -25,6 +26,7 @@ import {
25
26
  import { readCatalog } from "./catalog.js";
26
27
  import { parseAddress, readWorkspacesFile } from "./workspaces.js";
27
28
  import { orgOf } from "../manifest/service-links.js";
29
+ import { githubOwnersOf, orgClaimingOwner } from "../org-sources.js";
28
30
  import { cachedRead } from "../read-cache.js";
29
31
  import type {
30
32
  ServiceCatalog,
@@ -71,12 +73,16 @@ export function readOrgContext(): OrgContext {
71
73
  };
72
74
  }
73
75
 
74
- /** `id` spelled as its `orgs:` key when one matches case-insensitively. */
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
+ */
75
81
  export function canonicalOrg(id: string, config: DenConfig): string {
76
82
  const key = Object.keys(config.orgs ?? {}).find(
77
83
  (k) => lower(k) === lower(id),
78
84
  );
79
- return key ?? id;
85
+ return key ?? orgClaimingOwner(id, config.orgs) ?? id;
80
86
  }
81
87
 
82
88
  /** The default org: `github_org`, or null when unset. */
@@ -104,7 +110,7 @@ function workspaceRepos(
104
110
  function knownOrgs(ctx: OrgContext): Set<string> {
105
111
  const ids = new Set(Object.keys(ctx.config.orgs ?? {}).map(lower));
106
112
  for (const repo of Object.keys(ctx.catalog)) {
107
- const o = orgOf(repo, ctx.catalog);
113
+ const o = orgOf(repo, ctx.catalog, ctx.config.orgs);
108
114
  if (o) ids.add(lower(o));
109
115
  }
110
116
  const d = ctx.config.github_org?.trim();
@@ -133,7 +139,7 @@ export function resolveWorkspaceOrg(
133
139
  const repos = workspaceRepos(ws, bp, ctx.catalog);
134
140
  const owners = new Map<string, string>(); // lowercased → first spelling
135
141
  for (const repo of repos) {
136
- const o = orgOf(repo, ctx.catalog);
142
+ const o = orgOf(repo, ctx.catalog, ctx.config.orgs);
137
143
  if (o && !owners.has(lower(o))) owners.set(lower(o), o);
138
144
  }
139
145
 
@@ -179,7 +185,7 @@ export function resolveWorkspaceOrg(
179
185
  (owners.size <= 1 || source === "workspace" || source === "blueprint")
180
186
  )
181
187
  for (const repo of repos) {
182
- const o = orgOf(repo, ctx.catalog);
188
+ const o = orgOf(repo, ctx.catalog, ctx.config.orgs);
183
189
  if (o && lower(o) !== lower(org))
184
190
  warnings.push(
185
191
  `workspace ${name}: repo ${repo} belongs to ${o}, not the workspace's org ${org}`,
@@ -209,16 +215,25 @@ export function resolveAllWorkspaceOrgs(ctx?: OrgContext): WorkspaceOrg[] {
209
215
 
210
216
  /**
211
217
  * The GitHub owner to clone and register a workspace's repos under: its
212
- * resolved org. With none at all (no org anywhere and no `github_org`) this
213
- * throws exactly as requireGithubOrg does, so an old config fails as before.
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.
214
223
  */
215
224
  export function githubOrgFor(workspace: string, ctx?: OrgContext): string {
216
225
  const hit = ctx
217
226
  ? resolveWorkspaceOrg(workspace, ctx)
218
227
  : (resolveAllWorkspaceOrgs().find((w) => w.workspace === workspace) ??
219
228
  resolveWorkspaceOrg(workspace, readOrgContext()));
220
- if (hit.org) return hit.org;
221
- return requireGithubOrg(ctx?.config);
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
+ );
222
237
  }
223
238
 
224
239
  export interface OrgEntry {
@@ -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) => ({
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Orgs are Den's own concept; sources are bindings (ORG-1d, ORG-SPEC §1).
3
+ *
4
+ * An org is a key of `orgs:`. It may name the git hosts its repos live on
5
+ * (`sources: [{host: github, owner: acme}, {host: gitlab, group: globex}]`)
6
+ * and list repos outright (`repos: [widget]`). A repo's org resolves:
7
+ *
8
+ * 1. an org whose `repos` lists it
9
+ * 2. an org with a source that claims the repo's owner
10
+ * 3. (legacy) the repo's bare GitHub owner, from `gh: owner/repo`
11
+ *
12
+ * An org that sets neither `sources` nor `repos` is a legacy org: the GitHub
13
+ * owner of its own name (ORG-1a). So a config with no new keys resolves
14
+ * exactly as before, and only the new keys can raise an error (C1).
15
+ *
16
+ * A source claims an owner; it never brings credentials of its own here.
17
+ * GitHub sources clone through the user's gh, a repo with no source clones
18
+ * from its catalog `remote:` through plain git (SEC-0).
19
+ */
20
+ import { ConfigError } from "./config-error.js";
21
+ import type { ServiceCatalog, ServiceCatalogEntry } from "./types/index.js";
22
+
23
+ /** `orgs.<id>.sources[i]`: a git host and the owner (or group) it claims. */
24
+ export interface OrgSourceConfig {
25
+ /** `github` (built in), or a git-host id such as `gitlab` (ORG-1e). */
26
+ host: string;
27
+ owner?: string;
28
+ group?: string;
29
+ }
30
+
31
+ /** One `orgs.<id>` entry. */
32
+ export interface WorkOrgConfig {
33
+ label?: string;
34
+ color?: string;
35
+ /** ORG-1d: the git hosts and owners this org's repos come from. */
36
+ sources?: OrgSourceConfig[];
37
+ /** ORG-1d: repos that belong to this org whatever their owner. */
38
+ repos?: string[];
39
+ }
40
+
41
+ export type WorkOrgsConfig = Record<string, WorkOrgConfig>;
42
+
43
+ /** Where a repo lives: a host id (`github`, else the remote's hostname) and owner path. */
44
+ export interface RepoOwner {
45
+ host: string;
46
+ hostname: string;
47
+ owner: string;
48
+ }
49
+
50
+ const lower = (s: string): string => s.toLowerCase();
51
+ const HOST_RE = /^[a-z][a-z0-9-]*$/;
52
+ const GH_OWNER_RE = /^[A-Za-z0-9][A-Za-z0-9-]*$/;
53
+ const GROUP_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*(\/[A-Za-z0-9][A-Za-z0-9._-]*)*$/;
54
+
55
+ /** True when the entry uses the ORG-1d keys (its sources are then exactly `sources`). */
56
+ export function isSourcedOrg(entry: WorkOrgConfig | undefined): boolean {
57
+ return (
58
+ !!entry &&
59
+ typeof entry === "object" &&
60
+ ("sources" in entry || "repos" in entry)
61
+ );
62
+ }
63
+
64
+ const sourceOwner = (s: OrgSourceConfig): string =>
65
+ (s.owner ?? s.group ?? "").trim();
66
+
67
+ /**
68
+ * Check the ORG-1d keys of `orgs:` and throw on the first problem, naming the
69
+ * key. Entries without them are not looked at (C1). A repo listed by two
70
+ * orgs, or an owner claimed by two orgs' sources (or by a source and a
71
+ * legacy org of that name), names both.
72
+ */
73
+ export function validateOrgSources(orgs: unknown, file: string): void {
74
+ if (!orgs || typeof orgs !== "object" || Array.isArray(orgs)) return;
75
+ const entries = Object.entries(orgs as Record<string, unknown>);
76
+ const repoAt = new Map<string, string>();
77
+ const ownerAt = new Map<string, string>();
78
+ for (const [id, raw] of entries) {
79
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
80
+ const e = raw as Record<string, unknown>;
81
+ const at = `orgs.${id}`;
82
+ if ("sources" in e) {
83
+ if (!Array.isArray(e.sources))
84
+ throw new ConfigError(
85
+ `${file}: ${at}.sources must be a list of { host, owner } or { host, group }`,
86
+ );
87
+ e.sources.forEach((s: unknown, i: number) => {
88
+ const k = `${at}.sources[${i}]`;
89
+ if (!s || typeof s !== "object" || Array.isArray(s))
90
+ throw new ConfigError(
91
+ `${file}: ${k} must be a map: { host: github, owner: <owner> }`,
92
+ );
93
+ const src = s as Record<string, unknown>;
94
+ for (const key of Object.keys(src))
95
+ if (!["host", "owner", "group"].includes(key))
96
+ throw new ConfigError(
97
+ `${file}: ${k}: unknown key "${key}" (known: host, owner, group)`,
98
+ );
99
+ if (typeof src.host !== "string" || !HOST_RE.test(src.host))
100
+ throw new ConfigError(
101
+ `${file}: ${k}.host must be a git host id such as github or gitlab`,
102
+ );
103
+ const hasOwner = src.owner !== undefined;
104
+ const hasGroup = src.group !== undefined;
105
+ if (hasOwner === hasGroup)
106
+ throw new ConfigError(
107
+ `${file}: ${k} needs exactly one of owner or group`,
108
+ );
109
+ const key = hasOwner ? "owner" : "group";
110
+ const v = src[key];
111
+ const ok =
112
+ typeof v === "string" &&
113
+ (src.host === "github" ? GH_OWNER_RE : GROUP_RE).test(v.trim());
114
+ if (!ok)
115
+ throw new ConfigError(
116
+ src.host === "github"
117
+ ? `${file}: ${k}.${key} must be a GitHub owner name (letters, digits and hyphens)`
118
+ : `${file}: ${k}.${key} must be an owner or group path`,
119
+ );
120
+ const claim = `${src.host}:${lower((v as string).trim())}`;
121
+ const prior = ownerAt.get(claim);
122
+ if (prior && !prior.startsWith(`${at}.`))
123
+ throw new ConfigError(
124
+ `${file}: ${src.host} owner ${(v as string).trim()} is claimed by both ${prior} and ${k}`,
125
+ );
126
+ if (!prior) ownerAt.set(claim, k);
127
+ });
128
+ }
129
+ if ("repos" in e) {
130
+ if (
131
+ !Array.isArray(e.repos) ||
132
+ e.repos.some((r) => typeof r !== "string" || !r.trim())
133
+ )
134
+ throw new ConfigError(
135
+ `${file}: ${at}.repos must be a list of repo names`,
136
+ );
137
+ for (const r of e.repos as string[]) {
138
+ const prior = repoAt.get(r.trim());
139
+ if (prior && prior !== `${at}.repos`)
140
+ throw new ConfigError(
141
+ `${file}: repo ${r.trim()} is claimed by both ${prior} and ${at}.repos`,
142
+ );
143
+ repoAt.set(r.trim(), `${at}.repos`);
144
+ }
145
+ }
146
+ }
147
+ // A legacy org is the GitHub owner of its own name: a source elsewhere that
148
+ // claims that owner would split it silently.
149
+ for (const [id, raw] of entries) {
150
+ if (isSourcedOrg(raw as WorkOrgConfig)) continue;
151
+ const prior = ownerAt.get(`github:${lower(id)}`);
152
+ if (prior)
153
+ throw new ConfigError(
154
+ `${file}: github owner ${id} is claimed by both ${prior} and orgs.${id} ` +
155
+ `(an org with no sources: is the GitHub owner of its own name)`,
156
+ );
157
+ }
158
+ }
159
+
160
+ /** `gh: owner/repo`'s owner, as spelled, or "" (the legacy orgOf). */
161
+ export function ghOwnerOf(entry: ServiceCatalogEntry | undefined): string {
162
+ const gh = entry?.gh ?? "";
163
+ const slash = gh.indexOf("/");
164
+ return slash > 0 ? gh.slice(0, slash) : "";
165
+ }
166
+
167
+ /** Host, hostname and owner path of a git URL; null for a local path or an unparsable one. */
168
+ export function parseRemote(remote: string): RepoOwner | null {
169
+ const r = remote.trim();
170
+ let hostname = "";
171
+ let p = "";
172
+ const scp = /^[A-Za-z0-9._-]+@([A-Za-z0-9.-]+):(.+)$/.exec(r);
173
+ if (scp) {
174
+ hostname = scp[1];
175
+ p = scp[2];
176
+ } else {
177
+ try {
178
+ const u = new URL(r);
179
+ if (u.protocol === "file:") return null;
180
+ hostname = u.hostname;
181
+ p = u.pathname;
182
+ } catch {
183
+ return null;
184
+ }
185
+ }
186
+ const parts = p
187
+ .replace(/\.git$/, "")
188
+ .split("/")
189
+ .filter(Boolean);
190
+ if (!hostname || parts.length < 2) return null;
191
+ const host = lower(hostname) === "github.com" ? "github" : lower(hostname);
192
+ return {
193
+ host,
194
+ hostname: lower(hostname),
195
+ owner: parts.slice(0, -1).join("/"),
196
+ };
197
+ }
198
+
199
+ /** Where a catalogued repo lives: `gh` first (GitHub shorthand), else its `remote`. */
200
+ export function repoOwnerOf(
201
+ entry: ServiceCatalogEntry | undefined,
202
+ ): RepoOwner | null {
203
+ const gh = ghOwnerOf(entry);
204
+ if (gh) return { host: "github", hostname: "github.com", owner: gh };
205
+ return entry?.remote ? parseRemote(entry.remote) : null;
206
+ }
207
+
208
+ /**
209
+ * Whether `src` claims `owner`. A GitHub source claims a github.com owner;
210
+ * another host's source claims the owner (or a subgroup of it) on a remote
211
+ * whose hostname is `<host>.com` or starts with `<host>.`.
212
+ */
213
+ export function sourceClaims(src: OrgSourceConfig, owner: RepoOwner): boolean {
214
+ const want = lower(sourceOwner(src));
215
+ if (!want) return false;
216
+ const have = lower(owner.owner);
217
+ if (src.host === "github") return owner.host === "github" && have === want;
218
+ const onHost =
219
+ owner.hostname === `${src.host}.com` ||
220
+ owner.hostname.startsWith(`${src.host}.`);
221
+ return onHost && (have === want || have.startsWith(`${want}/`));
222
+ }
223
+
224
+ /** The org that lists `repo` in its `repos`, and the org whose source claims its owner. */
225
+ function claimsOf(
226
+ repo: string,
227
+ catalog: ServiceCatalog,
228
+ orgs: WorkOrgsConfig,
229
+ ): { listed?: string; claimed?: { org: string; source: OrgSourceConfig } } {
230
+ const out: ReturnType<typeof claimsOf> = {};
231
+ const owner = repoOwnerOf(catalog[repo]);
232
+ for (const [id, e] of Object.entries(orgs)) {
233
+ if (!isSourcedOrg(e)) continue;
234
+ if (!out.listed && Array.isArray(e.repos) && e.repos.includes(repo))
235
+ out.listed = id;
236
+ if (!out.claimed && owner && Array.isArray(e.sources)) {
237
+ const src = e.sources.find((s) => sourceClaims(s, owner));
238
+ if (src) out.claimed = { org: id, source: src };
239
+ }
240
+ }
241
+ return out;
242
+ }
243
+
244
+ /** The org `repo` belongs to (see the module comment), or "" when none. */
245
+ export function orgOfRepo(
246
+ repo: string,
247
+ catalog: ServiceCatalog,
248
+ orgs: WorkOrgsConfig = {},
249
+ ): string {
250
+ const { listed, claimed } = claimsOf(repo, catalog, orgs);
251
+ if (listed) return listed;
252
+ if (claimed) return claimed.org;
253
+ const gh = ghOwnerOf(catalog[repo]);
254
+ if (gh) return gh;
255
+ const remote = repoOwnerOf(catalog[repo]);
256
+ return remote?.host === "github" ? remote.owner : "";
257
+ }
258
+
259
+ /**
260
+ * The source `repo` clones through: one of its own org's sources that claims
261
+ * its owner. Null when it has none: a legacy repo (GitHub by its `gh`), or a
262
+ * repo of an org with no matching source (it clones from `remote:`).
263
+ */
264
+ export function repoSource(
265
+ repo: string,
266
+ catalog: ServiceCatalog,
267
+ orgs: WorkOrgsConfig = {},
268
+ ): OrgSourceConfig | null {
269
+ const { listed, claimed } = claimsOf(repo, catalog, orgs);
270
+ if (listed) {
271
+ const owner = repoOwnerOf(catalog[repo]);
272
+ const srcs = orgs[listed]?.sources ?? [];
273
+ return (owner && srcs.find((s) => sourceClaims(s, owner))) || null;
274
+ }
275
+ return claimed?.source ?? null;
276
+ }
277
+
278
+ /** The org's GitHub owners: its own name for a legacy org, else its GitHub sources'. */
279
+ export function githubOwnersOf(
280
+ org: string,
281
+ orgs: WorkOrgsConfig = {},
282
+ ): string[] {
283
+ const key = Object.keys(orgs).find((k) => lower(k) === lower(org));
284
+ const e = key === undefined ? undefined : orgs[key];
285
+ if (!isSourcedOrg(e)) return [org];
286
+ return (e?.sources ?? [])
287
+ .filter((s) => s.host === "github")
288
+ .map(sourceOwner)
289
+ .filter(Boolean);
290
+ }
291
+
292
+ /** True when the org reaches GitHub (discovery, branches, releases work for it). */
293
+ export function orgHasGithub(org: string, orgs: WorkOrgsConfig = {}): boolean {
294
+ return githubOwnersOf(org, orgs).length > 0;
295
+ }
296
+
297
+ /** The ORG-1d org whose GitHub source claims GitHub owner `owner`, if any. */
298
+ export function orgClaimingOwner(
299
+ owner: string,
300
+ orgs: WorkOrgsConfig = {},
301
+ ): string | undefined {
302
+ const o: RepoOwner = { host: "github", hostname: "github.com", owner };
303
+ return Object.entries(orgs).find(
304
+ ([, e]) =>
305
+ isSourcedOrg(e) &&
306
+ Array.isArray(e.sources) &&
307
+ e.sources.some((s) => sourceClaims(s, o)),
308
+ )?.[0];
309
+ }
@@ -622,8 +622,18 @@ export interface DiscoveredRepo {
622
622
  // ── Service catalog & workspace types ────────────────────────────────────────
623
623
 
624
624
  export interface ServiceCatalogEntry {
625
- /** Full GitHub slug: "Globex-Corp/repo-name" — used when cloning managed repos */
626
- gh: string;
625
+ /**
626
+ * Full GitHub slug: "Globex-Corp/repo-name" — used when cloning managed
627
+ * repos. GitHub shorthand: a repo on another host (or none) sets `remote`.
628
+ */
629
+ gh?: string;
630
+ /**
631
+ * The repo's git URL, for any host (ORG-1d): `https://…`, `ssh://…`,
632
+ * `git@host:owner/repo.git`, `file://…` or an absolute path. A repo whose
633
+ * org has no source that claims it clones and pulls from here through
634
+ * plain git and the user's own credential helper.
635
+ */
636
+ remote?: string;
627
637
  }
628
638
 
629
639
  /** Keyed by GH repo name (e.g. "shop", "tasks-runner") */