@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.
- package/CHANGELOG.md +225 -0
- package/bin/den +9 -0
- package/docs/distributions.md +205 -6
- package/package.json +1 -1
- package/service/install.sh +41 -2
- package/src/shared/agent-actor.ts +38 -3
- package/src/shared/config-error.ts +33 -0
- package/src/shared/den-config.ts +84 -5
- package/src/shared/environment-manager/catalog.ts +61 -3
- package/src/shared/environment-manager/default-workspace.ts +53 -14
- package/src/shared/environment-manager/git-remote.ts +49 -0
- package/src/shared/environment-manager/health.ts +51 -8
- package/src/shared/environment-manager/service-runner.ts +51 -5
- package/src/shared/environment-manager/workspace-manager.ts +69 -26
- package/src/shared/environment-manager/workspace-org.ts +279 -0
- package/src/shared/manifest/service-links.ts +20 -8
- package/src/shared/org-sources.ts +309 -0
- package/src/shared/types/index.ts +19 -2
- package/src/ui/cli/clone-plan.ts +374 -0
- package/src/ui/cli/flag-parser.ts +57 -3
- package/src/ui/cli/index.ts +346 -48
- package/src/ui/cli/org-command.ts +172 -0
- package/src/ui/cli/results-viewer.ts +5 -5
- package/src/ui/cli/table.ts +20 -0
- package/src/ui/cli/up-args.ts +15 -0
- package/src/ui/cli/validate-all.ts +171 -0
- package/src/ui/mcp/scope.ts +277 -0
- package/src/ui/mcp/server.ts +94 -9
- package/src/ui/mcp/tools.ts +101 -17
- package/src/ui/server/audit.ts +90 -3
- package/src/ui/server/daemon-reaper.ts +22 -4
- package/src/ui/server/github-service.ts +6 -1
- package/src/ui/server/index.ts +96 -5
- 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 +40 -5
- 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
|
-
|
|
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,
|
|
@@ -91,10 +129,21 @@ function gitCapture(cmd: string, cwd?: string): string {
|
|
|
91
129
|
}).trim();
|
|
92
130
|
}
|
|
93
131
|
|
|
94
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
/**
|
|
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) => ({
|