@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
|
@@ -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") */
|
|
@@ -686,6 +696,13 @@ export interface WorkspaceConfig {
|
|
|
686
696
|
* newly JOIN the blueprint are added.
|
|
687
697
|
*/
|
|
688
698
|
seeded_repos?: string[];
|
|
699
|
+
/**
|
|
700
|
+
* The org this workspace belongs to (ORG-1a), set per machine. Wins over
|
|
701
|
+
* the blueprint's `org:`; when neither is set the org is inferred from the
|
|
702
|
+
* repos' catalog owners, then `github_org`. Never written by Den: the
|
|
703
|
+
* resolved org is computed on read (see workspace-org.ts).
|
|
704
|
+
*/
|
|
705
|
+
org?: string;
|
|
689
706
|
}
|
|
690
707
|
|
|
691
708
|
export interface WorkspacesFile {
|