@monster-orange/den 0.2.6 → 0.2.7
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 +117 -0
- package/bin/den +9 -0
- package/docs/distributions.md +92 -6
- package/package.json +1 -1
- package/service/install.sh +41 -2
- package/src/shared/den-config.ts +38 -1
- package/src/shared/environment-manager/catalog.ts +9 -2
- package/src/shared/environment-manager/default-workspace.ts +45 -14
- 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 +2 -1
- package/src/shared/environment-manager/workspace-org.ts +264 -0
- package/src/shared/types/index.ts +7 -0
- package/src/ui/cli/clone-plan.ts +243 -0
- package/src/ui/cli/flag-parser.ts +1 -1
- package/src/ui/cli/index.ts +249 -40
- package/src/ui/cli/org-command.ts +162 -0
- package/src/ui/cli/results-viewer.ts +5 -5
- package/src/ui/cli/table.ts +20 -0
- package/src/ui/cli/validate-all.ts +171 -0
- package/src/ui/mcp/server.ts +24 -1
- package/src/ui/server/daemon-reaper.ts +22 -4
- package/src/ui/server/index.ts +31 -4
- package/src/ui/server/release-service.ts +16 -3
- package/src/ui/server/workspace-routes.ts +4 -1
|
@@ -69,7 +69,7 @@ import type {
|
|
|
69
69
|
WorkspaceConfig,
|
|
70
70
|
} from "../types/index.js";
|
|
71
71
|
import type { ChildProcess } from "child_process";
|
|
72
|
-
import { getStateDir } from "../paths.js";
|
|
72
|
+
import { getDistRoot, getStateDir } from "../paths.js";
|
|
73
73
|
|
|
74
74
|
import { product } from "../product.js";
|
|
75
75
|
// Re-export so existing callers don't need to change their import path.
|
|
@@ -101,6 +101,33 @@ export interface DaemonRecord {
|
|
|
101
101
|
* command. Optional for backward-compat with records written before this.
|
|
102
102
|
*/
|
|
103
103
|
containers?: string[];
|
|
104
|
+
/**
|
|
105
|
+
* The distribution root (realpath) whose server or CLI registered this
|
|
106
|
+
* daemon. Every root of a product shares one daemons file, so startup
|
|
107
|
+
* pruning skips records stamped by another root. Missing on records
|
|
108
|
+
* written before 0.2.7.
|
|
109
|
+
*/
|
|
110
|
+
root?: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** This process's distribution root, as stamped on its daemon records. */
|
|
114
|
+
function daemonRoot(): string | null {
|
|
115
|
+
try {
|
|
116
|
+
return fs.realpathSync(getDistRoot());
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Whose a daemon record is: this root's, another root's (all roots of a
|
|
124
|
+
* product share one daemons file), or unstamped (written before 0.2.7).
|
|
125
|
+
*/
|
|
126
|
+
export function daemonRootOf(
|
|
127
|
+
d: Pick<DaemonRecord, "root">,
|
|
128
|
+
): "here" | "other" | "unstamped" {
|
|
129
|
+
if (!d.root) return "unstamped";
|
|
130
|
+
return d.root === daemonRoot() ? "here" : "other";
|
|
104
131
|
}
|
|
105
132
|
|
|
106
133
|
export type ServiceConflictReason = "identity_mismatch" | "orphan_containers";
|
|
@@ -263,8 +290,10 @@ export function registerDaemon(record: DaemonRecord): void {
|
|
|
263
290
|
? (existing?.workspaces ?? [])
|
|
264
291
|
: [];
|
|
265
292
|
const workspaces = [...new Set([...inherited, ...(record.workspaces ?? [])])];
|
|
293
|
+
const root = daemonRoot() ?? record.root;
|
|
266
294
|
_daemonRegistry.set(record.address, {
|
|
267
295
|
...record,
|
|
296
|
+
...(root ? { root } : {}),
|
|
268
297
|
workspaces,
|
|
269
298
|
branch: normalizeBranch(record.branch),
|
|
270
299
|
});
|
|
@@ -296,9 +325,17 @@ export function updateDaemonContainers(
|
|
|
296
325
|
saveDaemonsFile(getDaemonsFile());
|
|
297
326
|
}
|
|
298
327
|
|
|
328
|
+
/**
|
|
329
|
+
* This root's daemons (and unstamped legacy ones). Every root of a product
|
|
330
|
+
* shares one daemons file; another root's records stay in it untouched but
|
|
331
|
+
* are not listed, so this server never shows, stops, releases or sweeps
|
|
332
|
+
* them. Lookups by address (findDaemonByAddress) still see every record.
|
|
333
|
+
*/
|
|
299
334
|
export function listDaemons(): DaemonRecord[] {
|
|
300
335
|
loadDaemonsFromFile();
|
|
301
|
-
return Array.from(_daemonRegistry.values())
|
|
336
|
+
return Array.from(_daemonRegistry.values()).filter(
|
|
337
|
+
(d) => daemonRootOf(d) !== "other",
|
|
338
|
+
);
|
|
302
339
|
}
|
|
303
340
|
|
|
304
341
|
export function findDaemonByAddress(address: string): DaemonRecord | undefined {
|
|
@@ -646,8 +683,11 @@ export function isDaemonVerifiablyDead(
|
|
|
646
683
|
* clear stale entries from previous sessions. Otherwise they reopen as tabs in
|
|
647
684
|
* the web UI and clutter the shutdown prompt.
|
|
648
685
|
*
|
|
649
|
-
* A daemon
|
|
650
|
-
*
|
|
686
|
+
* A daemon stamped by another root (all roots of a product share the file)
|
|
687
|
+
* is never touched. Otherwise it is dropped when:
|
|
688
|
+
* - it was stamped by this root and its address no longer resolves to a
|
|
689
|
+
* workspace/catalog service (an unstamped legacy record might belong to
|
|
690
|
+
* another root, so this alone never drops one), OR
|
|
651
691
|
* - it is opctl-based (has captured containers or a discoverable dev-op) and
|
|
652
692
|
* NONE of its candidate containers are currently running.
|
|
653
693
|
*
|
|
@@ -687,7 +727,13 @@ function pruneAgainst(
|
|
|
687
727
|
const pruned: string[] = [];
|
|
688
728
|
for (const d of daemons) {
|
|
689
729
|
try {
|
|
690
|
-
|
|
730
|
+
const root = daemonRootOf(d);
|
|
731
|
+
// Another root's daemon: its services resolve in its workspaces, not
|
|
732
|
+
// ours, and its liveness is its own server's call.
|
|
733
|
+
if (root === "other") continue;
|
|
734
|
+
// An unresolvable address is only proof of staleness for a record
|
|
735
|
+
// this root wrote; an unstamped one may belong to another root.
|
|
736
|
+
if (root === "here" && !canResolveDaemonAddress(d)) {
|
|
691
737
|
unregisterDaemon(d.address);
|
|
692
738
|
pruned.push(d.address);
|
|
693
739
|
continue;
|
|
@@ -91,7 +91,8 @@ function gitCapture(cmd: string, cwd?: string): string {
|
|
|
91
91
|
}).trim();
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
|
|
94
|
+
/** The HTTPS URL a managed clone of `ghSlug` is made from. */
|
|
95
|
+
export function ghCloneUrl(ghSlug: string): string {
|
|
95
96
|
return `https://github.com/${ghSlug}.git`;
|
|
96
97
|
}
|
|
97
98
|
|
|
@@ -0,0 +1,264 @@
|
|
|
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. ORG-1a keeps today's model:
|
|
5
|
+
* an org with no `sources` is the GitHub owner of the same name, and
|
|
6
|
+
* `github_org` is the default org. A workspace's org resolves through this
|
|
7
|
+
* chain, first match wins:
|
|
8
|
+
*
|
|
9
|
+
* 1. `org:` on the workspace in workspaces.yml (per machine)
|
|
10
|
+
* 2. `org:` on the same-named blueprint workspace (`defaults.workspaces`)
|
|
11
|
+
* 3. the single non-empty catalog owner of its repos (case-insensitive)
|
|
12
|
+
* 4. `github_org`
|
|
13
|
+
*
|
|
14
|
+
* Computed on read and never written back to workspaces.yml, so an inferred
|
|
15
|
+
* org never looks like an explicit one. Every rule broken here is a warning,
|
|
16
|
+
* never an error (C1): an old config resolves as before, to `github_org`.
|
|
17
|
+
*/
|
|
18
|
+
import {
|
|
19
|
+
readDenConfig,
|
|
20
|
+
readWorkspaceBlueprints,
|
|
21
|
+
requireGithubOrg,
|
|
22
|
+
type DenConfig,
|
|
23
|
+
type ResolvedWorkspaceBlueprint,
|
|
24
|
+
} from "../den-config.js";
|
|
25
|
+
import { readCatalog } from "./catalog.js";
|
|
26
|
+
import { parseAddress, readWorkspacesFile } from "./workspaces.js";
|
|
27
|
+
import { orgOf } from "../manifest/service-links.js";
|
|
28
|
+
import { cachedRead } from "../read-cache.js";
|
|
29
|
+
import type {
|
|
30
|
+
ServiceCatalog,
|
|
31
|
+
WorkspaceConfig,
|
|
32
|
+
WorkspacesFile,
|
|
33
|
+
} from "../types/index.js";
|
|
34
|
+
|
|
35
|
+
/** Which step of the chain gave the org. */
|
|
36
|
+
export type OrgSource = "workspace" | "blueprint" | "catalog" | "default";
|
|
37
|
+
|
|
38
|
+
export interface WorkspaceOrg {
|
|
39
|
+
workspace: string;
|
|
40
|
+
/** The resolved org id (the `orgs:` key's spelling when one matches), or null. */
|
|
41
|
+
org: string | null;
|
|
42
|
+
source: OrgSource | null;
|
|
43
|
+
/** Each broken org rule, as a sentence (C1: warnings, never errors). */
|
|
44
|
+
warnings: string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface OrgContext {
|
|
48
|
+
config: DenConfig;
|
|
49
|
+
catalog: ServiceCatalog;
|
|
50
|
+
file: WorkspacesFile;
|
|
51
|
+
blueprints: ResolvedWorkspaceBlueprint[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const lower = (s: string): string => s.toLowerCase();
|
|
55
|
+
|
|
56
|
+
/** The current config, catalog, workspaces.yml and blueprint. */
|
|
57
|
+
export function readOrgContext(): OrgContext {
|
|
58
|
+
const config = readDenConfig();
|
|
59
|
+
let blueprints: ResolvedWorkspaceBlueprint[] = [];
|
|
60
|
+
try {
|
|
61
|
+
blueprints = readWorkspaceBlueprints(config).workspaces;
|
|
62
|
+
} catch {
|
|
63
|
+
// A malformed blueprint is reported where seeding reads it; it must not
|
|
64
|
+
// turn an org listing into a failure (C1).
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
config,
|
|
68
|
+
catalog: readCatalog(),
|
|
69
|
+
file: readWorkspacesFile(),
|
|
70
|
+
blueprints,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** `id` spelled as its `orgs:` key when one matches case-insensitively. */
|
|
75
|
+
export function canonicalOrg(id: string, config: DenConfig): string {
|
|
76
|
+
const key = Object.keys(config.orgs ?? {}).find(
|
|
77
|
+
(k) => lower(k) === lower(id),
|
|
78
|
+
);
|
|
79
|
+
return key ?? id;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The default org: `github_org`, or null when unset. */
|
|
83
|
+
export function defaultOrg(config: DenConfig): string | null {
|
|
84
|
+
const d = config.github_org?.trim();
|
|
85
|
+
return d ? canonicalOrg(d, config) : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The repos a workspace holds; a workspace not set up yet holds its blueprint's. */
|
|
89
|
+
function workspaceRepos(
|
|
90
|
+
ws: WorkspaceConfig | undefined,
|
|
91
|
+
bp: ResolvedWorkspaceBlueprint | undefined,
|
|
92
|
+
catalog: ServiceCatalog,
|
|
93
|
+
): string[] {
|
|
94
|
+
const held = [
|
|
95
|
+
...Object.keys(ws?.services ?? {}).map(
|
|
96
|
+
(k) => parseAddress(k, catalog).repoName,
|
|
97
|
+
),
|
|
98
|
+
...Object.keys(ws?.repos ?? {}),
|
|
99
|
+
];
|
|
100
|
+
return [...new Set(held.length > 0 ? held : (bp?.repos ?? []))];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Owners Den knows of: `orgs:` keys, catalog owners and `github_org`. */
|
|
104
|
+
function knownOrgs(ctx: OrgContext): Set<string> {
|
|
105
|
+
const ids = new Set(Object.keys(ctx.config.orgs ?? {}).map(lower));
|
|
106
|
+
for (const repo of Object.keys(ctx.catalog)) {
|
|
107
|
+
const o = orgOf(repo, ctx.catalog);
|
|
108
|
+
if (o) ids.add(lower(o));
|
|
109
|
+
}
|
|
110
|
+
const d = ctx.config.github_org?.trim();
|
|
111
|
+
if (d) ids.add(lower(d));
|
|
112
|
+
return ids;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Resolve one workspace's org through the chain. */
|
|
116
|
+
export function resolveWorkspaceOrg(
|
|
117
|
+
name: string,
|
|
118
|
+
ctx: OrgContext,
|
|
119
|
+
): WorkspaceOrg {
|
|
120
|
+
const warnings: string[] = [];
|
|
121
|
+
const ws = ctx.file.workspaces[name];
|
|
122
|
+
const bp = ctx.blueprints.find((b) => b.name === name);
|
|
123
|
+
const rawWsOrg = ws?.org as unknown;
|
|
124
|
+
const wsOrg =
|
|
125
|
+
typeof rawWsOrg === "string" && rawWsOrg.trim()
|
|
126
|
+
? rawWsOrg.trim()
|
|
127
|
+
: undefined;
|
|
128
|
+
if (rawWsOrg !== undefined && rawWsOrg !== null && !wsOrg)
|
|
129
|
+
warnings.push(
|
|
130
|
+
`workspace ${name}: org in workspaces.yml is not an org id; ignored`,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const repos = workspaceRepos(ws, bp, ctx.catalog);
|
|
134
|
+
const owners = new Map<string, string>(); // lowercased → first spelling
|
|
135
|
+
for (const repo of repos) {
|
|
136
|
+
const o = orgOf(repo, ctx.catalog);
|
|
137
|
+
if (o && !owners.has(lower(o))) owners.set(lower(o), o);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let org: string | null = null;
|
|
141
|
+
let source: OrgSource | null = null;
|
|
142
|
+
if (wsOrg) {
|
|
143
|
+
org = wsOrg;
|
|
144
|
+
source = "workspace";
|
|
145
|
+
if (bp?.org && lower(bp.org) !== lower(wsOrg))
|
|
146
|
+
warnings.push(
|
|
147
|
+
`workspace ${name}: org ${wsOrg} in workspaces.yml overrides the blueprint's org ${bp.org}`,
|
|
148
|
+
);
|
|
149
|
+
} else if (bp?.org) {
|
|
150
|
+
org = bp.org;
|
|
151
|
+
source = "blueprint";
|
|
152
|
+
} else if (owners.size === 1) {
|
|
153
|
+
org = [...owners.values()][0];
|
|
154
|
+
source = "catalog";
|
|
155
|
+
} else {
|
|
156
|
+
org = ctx.config.github_org?.trim() || null;
|
|
157
|
+
source = org ? "default" : null;
|
|
158
|
+
if (owners.size > 1)
|
|
159
|
+
warnings.push(
|
|
160
|
+
`workspace ${name}: its repos belong to several owners (${[...owners.values()].join(", ")}); ` +
|
|
161
|
+
`using ${org ?? "no org"}. Set org: on the workspace to choose one`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
if (org) org = canonicalOrg(org, ctx.config);
|
|
165
|
+
|
|
166
|
+
if (
|
|
167
|
+
org &&
|
|
168
|
+
(source === "workspace" || source === "blueprint") &&
|
|
169
|
+
!knownOrgs(ctx).has(lower(org))
|
|
170
|
+
)
|
|
171
|
+
warnings.push(
|
|
172
|
+
`workspace ${name}: org ${org} is not in orgs:, the catalog or github_org`,
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// A repo in another owner than its workspace's org. A mixed workspace with
|
|
176
|
+
// no org of its own already said so above.
|
|
177
|
+
if (
|
|
178
|
+
org &&
|
|
179
|
+
(owners.size <= 1 || source === "workspace" || source === "blueprint")
|
|
180
|
+
)
|
|
181
|
+
for (const repo of repos) {
|
|
182
|
+
const o = orgOf(repo, ctx.catalog);
|
|
183
|
+
if (o && lower(o) !== lower(org))
|
|
184
|
+
warnings.push(
|
|
185
|
+
`workspace ${name}: repo ${repo} belongs to ${o}, not the workspace's org ${org}`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { workspace: name, org, source, warnings };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Every workspace on this machine, then blueprint workspaces not set up yet. */
|
|
193
|
+
function workspaceNames(ctx: OrgContext): string[] {
|
|
194
|
+
return [
|
|
195
|
+
...new Set([
|
|
196
|
+
...Object.keys(ctx.file.workspaces),
|
|
197
|
+
...ctx.blueprints.map((b) => b.name),
|
|
198
|
+
]),
|
|
199
|
+
];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Every workspace's resolved org (cached for the web server's read TTL). */
|
|
203
|
+
export function resolveAllWorkspaceOrgs(ctx?: OrgContext): WorkspaceOrg[] {
|
|
204
|
+
const compute = (c: OrgContext) =>
|
|
205
|
+
workspaceNames(c).map((n) => resolveWorkspaceOrg(n, c));
|
|
206
|
+
if (ctx) return compute(ctx);
|
|
207
|
+
return cachedRead("workspace-orgs", () => compute(readOrgContext()));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* 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.
|
|
214
|
+
*/
|
|
215
|
+
export function githubOrgFor(workspace: string, ctx?: OrgContext): string {
|
|
216
|
+
const hit = ctx
|
|
217
|
+
? resolveWorkspaceOrg(workspace, ctx)
|
|
218
|
+
: (resolveAllWorkspaceOrgs().find((w) => w.workspace === workspace) ??
|
|
219
|
+
resolveWorkspaceOrg(workspace, readOrgContext()));
|
|
220
|
+
if (hit.org) return hit.org;
|
|
221
|
+
return requireGithubOrg(ctx?.config);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export interface OrgEntry {
|
|
225
|
+
id: string;
|
|
226
|
+
label: string;
|
|
227
|
+
color?: string;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The org listing with `default` and `workspaces` added to each entry and
|
|
232
|
+
* nothing else changed (C3): same entries, same order, same fields. Only
|
|
233
|
+
* workspaces on this machine (workspaces.yml) are listed.
|
|
234
|
+
*/
|
|
235
|
+
export function withOrgMembership<T extends OrgEntry>(
|
|
236
|
+
orgs: T[],
|
|
237
|
+
resolved: WorkspaceOrg[],
|
|
238
|
+
file: WorkspacesFile,
|
|
239
|
+
config: DenConfig,
|
|
240
|
+
): Array<T & { default: boolean; workspaces: string[] }> {
|
|
241
|
+
// The listing can hold one owner twice in different case (catalog owners
|
|
242
|
+
// are listed as spelled); an org lands on the entry spelled exactly like
|
|
243
|
+
// it, else the first that matches case-insensitively.
|
|
244
|
+
const entryFor = (id: string): number => {
|
|
245
|
+
const exact = orgs.findIndex((o) => o.id === id);
|
|
246
|
+
return exact !== -1
|
|
247
|
+
? exact
|
|
248
|
+
: orgs.findIndex((o) => lower(o.id) === lower(id));
|
|
249
|
+
};
|
|
250
|
+
const d = defaultOrg(config);
|
|
251
|
+
const defaultAt = d === null ? -1 : entryFor(d);
|
|
252
|
+
const members = orgs.map((): string[] => []);
|
|
253
|
+
const local = new Set(Object.keys(file.workspaces));
|
|
254
|
+
for (const w of resolved) {
|
|
255
|
+
if (!w.org || !local.has(w.workspace)) continue;
|
|
256
|
+
const at = entryFor(w.org);
|
|
257
|
+
if (at !== -1) members[at].push(w.workspace);
|
|
258
|
+
}
|
|
259
|
+
return orgs.map((o, i) => ({
|
|
260
|
+
...o,
|
|
261
|
+
default: i === defaultAt,
|
|
262
|
+
workspaces: members[i],
|
|
263
|
+
}));
|
|
264
|
+
}
|
|
@@ -686,6 +686,13 @@ export interface WorkspaceConfig {
|
|
|
686
686
|
* newly JOIN the blueprint are added.
|
|
687
687
|
*/
|
|
688
688
|
seeded_repos?: string[];
|
|
689
|
+
/**
|
|
690
|
+
* The org this workspace belongs to (ORG-1a), set per machine. Wins over
|
|
691
|
+
* the blueprint's `org:`; when neither is set the org is inferred from the
|
|
692
|
+
* repos' catalog owners, then `github_org`. Never written by Den: the
|
|
693
|
+
* resolved org is computed on read (see workspace-org.ts).
|
|
694
|
+
*/
|
|
695
|
+
org?: string;
|
|
689
696
|
}
|
|
690
697
|
|
|
691
698
|
export interface WorkspacesFile {
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<cli> setup --plan` and the clone half of `<cli> up --plan` (ORG-1a):
|
|
3
|
+
* each repo's resolved org and the URL it would be cloned from. Read-only:
|
|
4
|
+
* nothing is cloned, seeded or written (the catalog included).
|
|
5
|
+
*
|
|
6
|
+
* On a fresh machine (no workspace set up yet, a blueprint in the config)
|
|
7
|
+
* the plan is what `<cli> workspace seed` and then setup would do, for every
|
|
8
|
+
* blueprint workspace. Otherwise it is the focused workspace's repos, the
|
|
9
|
+
* ones setup works on.
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from "fs";
|
|
12
|
+
import * as path from "path";
|
|
13
|
+
import { readCatalog } from "../../shared/environment-manager/catalog.js";
|
|
14
|
+
import {
|
|
15
|
+
blueprintWorkspaces,
|
|
16
|
+
catalogFill,
|
|
17
|
+
isFreshWorkspaceSetup,
|
|
18
|
+
isMultiBlueprint,
|
|
19
|
+
} from "../../shared/environment-manager/default-workspace.js";
|
|
20
|
+
import {
|
|
21
|
+
DEN_SERVICES_DIR,
|
|
22
|
+
getActiveWorkspace,
|
|
23
|
+
parseAddress,
|
|
24
|
+
} from "../../shared/environment-manager/workspaces.js";
|
|
25
|
+
import { ghCloneUrl } from "../../shared/environment-manager/workspace-manager.js";
|
|
26
|
+
import {
|
|
27
|
+
githubOrgFor,
|
|
28
|
+
readOrgContext,
|
|
29
|
+
resolveWorkspaceOrg,
|
|
30
|
+
type OrgContext,
|
|
31
|
+
type OrgSource,
|
|
32
|
+
} from "../../shared/environment-manager/workspace-org.js";
|
|
33
|
+
import { orgOf } from "../../shared/manifest/service-links.js";
|
|
34
|
+
import type { ServiceCatalog } from "../../shared/types/index.js";
|
|
35
|
+
|
|
36
|
+
export type ClonePlanState =
|
|
37
|
+
| "cloned"
|
|
38
|
+
| "would-clone"
|
|
39
|
+
| "not-in-catalog"
|
|
40
|
+
| "checkout"
|
|
41
|
+
| "no-org";
|
|
42
|
+
|
|
43
|
+
export interface ClonePlanRow {
|
|
44
|
+
repo: string;
|
|
45
|
+
mode: "managed" | "dev-managed";
|
|
46
|
+
branch: string;
|
|
47
|
+
/** The GitHub owner the repo clones from (its slug's owner), or null. */
|
|
48
|
+
owner: string | null;
|
|
49
|
+
url: string | null;
|
|
50
|
+
state: ClonePlanState;
|
|
51
|
+
/** A dev-managed checkout's path, or a managed clone's directory. */
|
|
52
|
+
path?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ClonePlanWorkspace {
|
|
56
|
+
name: string;
|
|
57
|
+
org: string | null;
|
|
58
|
+
source: OrgSource | null;
|
|
59
|
+
warnings: string[];
|
|
60
|
+
rows: ClonePlanRow[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ClonePlan {
|
|
64
|
+
/** True when planned from the blueprint (nothing seeded yet). */
|
|
65
|
+
fromBlueprint: boolean;
|
|
66
|
+
workspaces: ClonePlanWorkspace[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const ownerOf = (slug: string): string => slug.slice(0, slug.indexOf("/"));
|
|
70
|
+
|
|
71
|
+
function cloneDir(repo: string, branch: string): string {
|
|
72
|
+
return path.join(DEN_SERVICES_DIR, repo, branch);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The slug setup and up would clone `repo` from, or null without an org. */
|
|
76
|
+
function slugFor(
|
|
77
|
+
repo: string,
|
|
78
|
+
workspace: string,
|
|
79
|
+
catalog: ServiceCatalog,
|
|
80
|
+
ctx: OrgContext,
|
|
81
|
+
): string | null {
|
|
82
|
+
const gh = catalog[repo]?.gh?.trim();
|
|
83
|
+
if (gh) return gh;
|
|
84
|
+
try {
|
|
85
|
+
return `${githubOrgFor(workspace, ctx)}/${repo}`;
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function managedRow(
|
|
92
|
+
repo: string,
|
|
93
|
+
branch: string,
|
|
94
|
+
slug: string | null,
|
|
95
|
+
inCatalog: boolean,
|
|
96
|
+
): ClonePlanRow {
|
|
97
|
+
const dir = cloneDir(repo, branch);
|
|
98
|
+
const cloned = fs.existsSync(dir);
|
|
99
|
+
return {
|
|
100
|
+
repo,
|
|
101
|
+
mode: "managed",
|
|
102
|
+
branch,
|
|
103
|
+
owner: slug ? ownerOf(slug) : null,
|
|
104
|
+
url: slug ? ghCloneUrl(slug) : null,
|
|
105
|
+
state: cloned
|
|
106
|
+
? "cloned"
|
|
107
|
+
: !slug
|
|
108
|
+
? "no-org"
|
|
109
|
+
: inCatalog
|
|
110
|
+
? "would-clone"
|
|
111
|
+
: "not-in-catalog",
|
|
112
|
+
path: dir,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The plan for one workspace on this machine, limited to `repos` when given. */
|
|
117
|
+
export function workspaceClonePlan(
|
|
118
|
+
name: string,
|
|
119
|
+
repos?: readonly string[],
|
|
120
|
+
ctx: OrgContext = readOrgContext(),
|
|
121
|
+
): ClonePlanWorkspace {
|
|
122
|
+
const resolved = resolveWorkspaceOrg(name, ctx);
|
|
123
|
+
const ws = ctx.file.workspaces[name];
|
|
124
|
+
const want = repos ? new Set(repos) : null;
|
|
125
|
+
const rows: ClonePlanRow[] = [];
|
|
126
|
+
const seen = new Set<string>();
|
|
127
|
+
for (const [address, entry] of Object.entries(ws?.services ?? {})) {
|
|
128
|
+
const { repoName } = parseAddress(address, ctx.catalog);
|
|
129
|
+
if (seen.has(repoName)) continue;
|
|
130
|
+
if (want && !want.has(repoName) && !want.has(address)) continue;
|
|
131
|
+
seen.add(repoName);
|
|
132
|
+
const branch = entry.branch ?? "main";
|
|
133
|
+
if (entry.mode === "dev-managed") {
|
|
134
|
+
const owner = orgOf(repoName, ctx.catalog);
|
|
135
|
+
rows.push({
|
|
136
|
+
repo: repoName,
|
|
137
|
+
mode: "dev-managed",
|
|
138
|
+
branch,
|
|
139
|
+
owner: owner || null,
|
|
140
|
+
url: null,
|
|
141
|
+
state: "checkout",
|
|
142
|
+
...(entry.path ? { path: entry.path } : {}),
|
|
143
|
+
});
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
rows.push(
|
|
147
|
+
managedRow(
|
|
148
|
+
repoName,
|
|
149
|
+
branch,
|
|
150
|
+
slugFor(repoName, name, ctx.catalog, ctx),
|
|
151
|
+
Boolean(ctx.catalog[repoName]),
|
|
152
|
+
),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
return { ...resolved, name, rows };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** What `<cli> workspace seed` and then setup would clone, per blueprint workspace. */
|
|
159
|
+
function blueprintClonePlan(ctx: OrgContext): ClonePlanWorkspace[] {
|
|
160
|
+
const multi = isMultiBlueprint();
|
|
161
|
+
const bps = blueprintWorkspaces().map((b) => ({
|
|
162
|
+
...b,
|
|
163
|
+
name: multi ? b.name : "default",
|
|
164
|
+
}));
|
|
165
|
+
let fill = new Map<string, string>();
|
|
166
|
+
try {
|
|
167
|
+
fill = catalogFill(bps, ctx.catalog, ctx);
|
|
168
|
+
} catch {
|
|
169
|
+
// No org for some repo: those rows say so below.
|
|
170
|
+
}
|
|
171
|
+
return bps.map((bp) => ({
|
|
172
|
+
...resolveWorkspaceOrg(bp.name, ctx),
|
|
173
|
+
name: bp.name,
|
|
174
|
+
rows: bp.repos.map((repo) =>
|
|
175
|
+
managedRow(
|
|
176
|
+
repo,
|
|
177
|
+
bp.branch,
|
|
178
|
+
ctx.catalog[repo]?.gh?.trim() || fill.get(repo) || null,
|
|
179
|
+
true, // seeding writes every blueprint repo into the catalog
|
|
180
|
+
),
|
|
181
|
+
),
|
|
182
|
+
}));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** `<cli> setup --plan`: the plan for `names` (every repo when empty). */
|
|
186
|
+
export function buildSetupPlan(names: readonly string[] = []): ClonePlan {
|
|
187
|
+
const ctx = readOrgContext();
|
|
188
|
+
if (isFreshWorkspaceSetup(ctx.file) && blueprintWorkspaces().length > 0)
|
|
189
|
+
return { fromBlueprint: true, workspaces: blueprintClonePlan(ctx) };
|
|
190
|
+
const ws = getActiveWorkspace();
|
|
191
|
+
if (!ws) return { fromBlueprint: false, workspaces: [] };
|
|
192
|
+
return {
|
|
193
|
+
fromBlueprint: false,
|
|
194
|
+
workspaces: [
|
|
195
|
+
workspaceClonePlan(ws.name, names.length ? names : undefined, ctx),
|
|
196
|
+
],
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const STATE_TEXT: Record<ClonePlanState, string> = {
|
|
201
|
+
cloned: "cloned",
|
|
202
|
+
"would-clone": "would clone",
|
|
203
|
+
"not-in-catalog": "not in the catalog: setup skips it, up clones it",
|
|
204
|
+
checkout: "your checkout (setup skips it without --force)",
|
|
205
|
+
"no-org": "no org: set github_org, or org: on the workspace",
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/** The plan as plain lines (the caller colours and prints them). */
|
|
209
|
+
export function renderClonePlan(
|
|
210
|
+
plan: ClonePlan,
|
|
211
|
+
cli: string,
|
|
212
|
+
catalog: ServiceCatalog = readCatalog(),
|
|
213
|
+
): string[] {
|
|
214
|
+
const out: string[] = [];
|
|
215
|
+
if (plan.fromBlueprint)
|
|
216
|
+
out.push(
|
|
217
|
+
`From the blueprint: nothing is set up yet. \`${cli} workspace seed\` creates these workspaces.`,
|
|
218
|
+
"",
|
|
219
|
+
);
|
|
220
|
+
if (plan.workspaces.length === 0) out.push("No workspace to plan.");
|
|
221
|
+
for (const ws of plan.workspaces) {
|
|
222
|
+
out.push(
|
|
223
|
+
`Workspace ${ws.name} org: ${ws.org ?? "(none)"}${ws.source ? ` (${ws.source})` : ""}`,
|
|
224
|
+
);
|
|
225
|
+
if (ws.rows.length === 0) out.push(" (no repos)");
|
|
226
|
+
for (const r of ws.rows) {
|
|
227
|
+
const where =
|
|
228
|
+
r.state === "cloned" || r.state === "checkout"
|
|
229
|
+
? r.path
|
|
230
|
+
? ` ${r.path}`
|
|
231
|
+
: ""
|
|
232
|
+
: "";
|
|
233
|
+
const owner = r.owner ?? orgOf(r.repo, catalog) ?? "";
|
|
234
|
+
out.push(
|
|
235
|
+
` ${r.repo} ${r.mode} ${r.branch} org: ${owner || "(unknown)"}` +
|
|
236
|
+
`${r.url ? ` ${r.url}` : ""} ${STATE_TEXT[r.state]}${where}`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
for (const w of ws.warnings) out.push(` ⚠ ${w}`);
|
|
240
|
+
}
|
|
241
|
+
out.push("", "Plan only: nothing was cloned, seeded or written.");
|
|
242
|
+
return out;
|
|
243
|
+
}
|