@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.
- package/CHANGELOG.md +108 -0
- package/docs/distributions.md +115 -2
- package/package.json +1 -1
- package/src/shared/agent-actor.ts +38 -3
- package/src/shared/config-error.ts +33 -0
- package/src/shared/den-config.ts +46 -4
- package/src/shared/environment-manager/catalog.ts +52 -1
- package/src/shared/environment-manager/default-workspace.ts +10 -2
- package/src/shared/environment-manager/git-remote.ts +49 -0
- package/src/shared/environment-manager/workspace-manager.ts +67 -25
- package/src/shared/environment-manager/workspace-org.ts +26 -11
- package/src/shared/manifest/service-links.ts +20 -8
- package/src/shared/org-sources.ts +309 -0
- package/src/shared/types/index.ts +12 -2
- package/src/ui/cli/clone-plan.ts +154 -23
- package/src/ui/cli/flag-parser.ts +57 -3
- package/src/ui/cli/index.ts +106 -17
- package/src/ui/cli/org-command.ts +12 -2
- package/src/ui/cli/up-args.ts +15 -0
- package/src/ui/mcp/scope.ts +277 -0
- package/src/ui/mcp/server.ts +70 -8
- package/src/ui/mcp/tools.ts +101 -17
- package/src/ui/server/audit.ts +90 -3
- package/src/ui/server/github-service.ts +6 -1
- package/src/ui/server/index.ts +65 -1
- package/src/ui/server/public/panel.js +1 -1
- package/src/ui/server/readonly-routes.ts +12 -3
- package/src/ui/server/release-service.ts +25 -3
- package/src/ui/server/workspace-routes.ts +10 -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
|
-
|
|
80
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
5
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
213
|
-
*
|
|
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
|
|
221
|
-
|
|
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
|
-
/**
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
/**
|
|
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:
|
|
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
|
-
/**
|
|
626
|
-
|
|
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") */
|