@monster-orange/den 0.2.4 → 0.2.6
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 +195 -0
- package/docs/distributions.md +158 -16
- package/package.json +1 -1
- package/src/pillars/security/discover.ts +1 -1
- package/src/pillars/smoke/setup-global.ts +2 -1
- package/src/scripts/configure-aws-profiles.ts +2 -1
- package/src/scripts/register-repos.ts +4 -3
- package/src/shared/constants.ts +7 -0
- package/src/shared/den-config.ts +130 -12
- package/src/shared/engine.ts +18 -0
- package/src/shared/environment-manager/default-workspace.ts +229 -87
- package/src/shared/environment-manager/health.ts +235 -35
- package/src/shared/manifest/placement.ts +1 -1
- package/src/shared/manifest/schema.ts +47 -0
- package/src/shared/manifest/service-links.ts +1 -1
- package/src/shared/manifest/validate-manifest.ts +1 -1
- package/src/shared/plural.ts +13 -0
- package/src/shared/service-url.ts +2 -5
- package/src/shared/stacks/compose-health.ts +242 -0
- package/src/shared/stacks/runtime.ts +73 -24
- package/src/shared/stacks/status.ts +3 -2
- package/src/shared/types/index.ts +13 -1
- package/src/ui/cli/create.ts +2 -1
- package/src/ui/cli/health-display.ts +1 -0
- package/src/ui/cli/index.ts +86 -41
- package/src/ui/cli/mcp-connect.ts +36 -5
- package/src/ui/cli/server-identity.ts +233 -0
- package/src/ui/cli/service-command.ts +136 -12
- package/src/ui/cli/spinner.ts +48 -0
- package/src/ui/cli/stack-command.ts +40 -12
- package/src/ui/cli/web-command.ts +101 -32
- package/src/ui/mcp/api.ts +94 -1
- package/src/ui/mcp/server.ts +6 -1
- package/src/ui/mcp/tools.ts +6 -2
- package/src/ui/server/health-row.ts +6 -3
- package/src/ui/server/index.ts +120 -40
- package/src/ui/server/public/changelog-view.js +16 -0
- package/src/ui/server/public/header.js +121 -12
- package/src/ui/server/ui-assets.ts +1 -0
- package/src/ui/server/workspace-routes.ts +7 -0
package/src/shared/den-config.ts
CHANGED
|
@@ -255,24 +255,35 @@ export interface DenConfig {
|
|
|
255
255
|
* `default-workspace.ts`.
|
|
256
256
|
*/
|
|
257
257
|
defaults?: {
|
|
258
|
-
workspace?: {
|
|
259
|
-
/** Branch managed clones track (default "main"). */
|
|
260
|
-
branch?: string;
|
|
261
|
-
/** Repo-system names to seed, all as managed clones. */
|
|
262
|
-
repos?: string[];
|
|
258
|
+
workspace?: WorkspaceBlueprint & {
|
|
263
259
|
/** The entry repo the seed's dependency walk starts from (listed first). */
|
|
264
260
|
entry?: string;
|
|
265
|
-
/**
|
|
266
|
-
* Seeded repos that get `autostart: false` stamped on their workspace
|
|
267
|
-
* entry — the team default for systems a fresh clone should not start
|
|
268
|
-
* with workspace up (e.g. billing-api's `make up` opens an interactive
|
|
269
|
-
* Stripe tab). Personal thereafter: toggle per system in the web ⋮ menu.
|
|
270
|
-
*/
|
|
271
|
-
autostart_off?: string[];
|
|
272
261
|
};
|
|
262
|
+
/**
|
|
263
|
+
* Multi-workspace blueprint: `{ <name>: { repos, focus?, branch?,
|
|
264
|
+
* autostart_off? } }`. On a genuine fresh setup each workspace is created
|
|
265
|
+
* with its repos (managed), all are activated, and the one marked
|
|
266
|
+
* `focus: true` (else the first) is focused. Exclusive with `workspace`.
|
|
267
|
+
*/
|
|
268
|
+
workspaces?: Record<string, WorkspaceBlueprint & { focus?: boolean }>;
|
|
273
269
|
};
|
|
274
270
|
}
|
|
275
271
|
|
|
272
|
+
/** One workspace's blueprint: the repo entries a fresh setup seeds it with. */
|
|
273
|
+
export interface WorkspaceBlueprint {
|
|
274
|
+
/** Branch managed clones track (default "main"). */
|
|
275
|
+
branch?: string;
|
|
276
|
+
/** Repo-system names to seed, all as managed clones. */
|
|
277
|
+
repos?: string[];
|
|
278
|
+
/**
|
|
279
|
+
* Seeded repos that get `autostart: false` stamped on their workspace
|
|
280
|
+
* entry — the team default for systems a fresh clone should not start
|
|
281
|
+
* with workspace up (e.g. billing-api's `make up` opens an interactive
|
|
282
|
+
* Stripe tab). Personal thereafter: toggle per system in the web ⋮ menu.
|
|
283
|
+
*/
|
|
284
|
+
autostart_off?: string[];
|
|
285
|
+
}
|
|
286
|
+
|
|
276
287
|
/**
|
|
277
288
|
* Per-engineer preferences stored in config/local/user.yml (gitignored).
|
|
278
289
|
* Only affects settings that should differ between engineers on the same team.
|
|
@@ -618,6 +629,113 @@ export interface WebConfig {
|
|
|
618
629
|
service?: { label?: string; logs?: string };
|
|
619
630
|
}
|
|
620
631
|
|
|
632
|
+
/** A validated workspace blueprint with its defaults applied. */
|
|
633
|
+
export interface ResolvedWorkspaceBlueprint {
|
|
634
|
+
name: string;
|
|
635
|
+
repos: string[];
|
|
636
|
+
branch: string;
|
|
637
|
+
autostartOff: string[];
|
|
638
|
+
focus: boolean;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** Which blueprint shape the distribution uses, and its workspaces. */
|
|
642
|
+
export interface WorkspaceBlueprints {
|
|
643
|
+
shape: "none" | "single" | "multi";
|
|
644
|
+
workspaces: ResolvedWorkspaceBlueprint[];
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const BLUEPRINT_KEYS = ["branch", "repos", "autostart_off"];
|
|
648
|
+
|
|
649
|
+
function blueprintStrings(where: string, key: string, v: unknown): string[] {
|
|
650
|
+
if (v === undefined) return [];
|
|
651
|
+
if (!Array.isArray(v) || v.some((r) => typeof r !== "string" || !r.trim()))
|
|
652
|
+
throw new Error(`${where}.${key} must be a list of repo names`);
|
|
653
|
+
return v as string[];
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function resolveBlueprint(
|
|
657
|
+
where: string,
|
|
658
|
+
name: string,
|
|
659
|
+
raw: unknown,
|
|
660
|
+
/** Extra keys allowed; null skips the unknown-key check (legacy shape). */
|
|
661
|
+
extraKeys: string[] | null,
|
|
662
|
+
): ResolvedWorkspaceBlueprint {
|
|
663
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
664
|
+
throw new Error(`${where} must be a map: { repos: [...] }`);
|
|
665
|
+
const bp = raw as Record<string, unknown>;
|
|
666
|
+
const known = [...BLUEPRINT_KEYS, ...(extraKeys ?? [])];
|
|
667
|
+
for (const key of Object.keys(bp))
|
|
668
|
+
if (extraKeys && !known.includes(key))
|
|
669
|
+
throw new Error(
|
|
670
|
+
`${where}: unknown key "${key}" (known: ${known.join(", ")})`,
|
|
671
|
+
);
|
|
672
|
+
const branch = bp.branch ?? "main";
|
|
673
|
+
if (typeof branch !== "string" || !branch.trim())
|
|
674
|
+
throw new Error(`${where}.branch must be a branch name`);
|
|
675
|
+
if (bp.focus !== undefined && typeof bp.focus !== "boolean")
|
|
676
|
+
throw new Error(`${where}.focus must be true or false`);
|
|
677
|
+
return {
|
|
678
|
+
name,
|
|
679
|
+
repos: blueprintStrings(where, "repos", bp.repos),
|
|
680
|
+
branch,
|
|
681
|
+
autostartOff: blueprintStrings(where, "autostart_off", bp.autostart_off),
|
|
682
|
+
focus: bp.focus === true,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* The distribution's workspace blueprint, validated: `defaults.workspace`
|
|
688
|
+
* (one workspace, `default`) or `defaults.workspaces` (named workspaces).
|
|
689
|
+
* Empty when neither is set. Both at once, or a malformed value, throws.
|
|
690
|
+
*/
|
|
691
|
+
export function readWorkspaceBlueprints(
|
|
692
|
+
config: DenConfig = readDenConfig(),
|
|
693
|
+
): WorkspaceBlueprints {
|
|
694
|
+
const none: WorkspaceBlueprints = { shape: "none", workspaces: [] };
|
|
695
|
+
const defaults: unknown = config.defaults;
|
|
696
|
+
if (defaults === undefined || defaults === null) return none;
|
|
697
|
+
if (typeof defaults !== "object" || Array.isArray(defaults))
|
|
698
|
+
throw new Error(`defaults in ${DEN_CONFIG_PATH} must be a map`);
|
|
699
|
+
const { workspace, workspaces } = defaults as Record<string, unknown>;
|
|
700
|
+
if (workspace != null && workspaces != null)
|
|
701
|
+
throw new Error(
|
|
702
|
+
`${DEN_CONFIG_PATH} sets both defaults.workspace and defaults.workspaces: ` +
|
|
703
|
+
"use defaults.workspaces alone (name the default workspace `default` in it), " +
|
|
704
|
+
"or defaults.workspace alone for a single default workspace",
|
|
705
|
+
);
|
|
706
|
+
if (workspaces != null) {
|
|
707
|
+
if (typeof workspaces !== "object" || Array.isArray(workspaces))
|
|
708
|
+
throw new Error(
|
|
709
|
+
"defaults.workspaces must be a map: { <name>: { repos: [...], focus?: true } }",
|
|
710
|
+
);
|
|
711
|
+
const resolved = Object.entries(workspaces as Record<string, unknown>).map(
|
|
712
|
+
([name, raw]) => {
|
|
713
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name))
|
|
714
|
+
throw new Error(
|
|
715
|
+
`defaults.workspaces: "${name}" is not a valid workspace name (letters, digits, dots, dashes, underscores)`,
|
|
716
|
+
);
|
|
717
|
+
return resolveBlueprint(`defaults.workspaces.${name}`, name, raw, [
|
|
718
|
+
"focus",
|
|
719
|
+
]);
|
|
720
|
+
},
|
|
721
|
+
);
|
|
722
|
+
const focused = resolved.filter((b) => b.focus);
|
|
723
|
+
if (focused.length > 1)
|
|
724
|
+
throw new Error(
|
|
725
|
+
`defaults.workspaces: only one workspace may set focus: true (got ${focused.map((b) => b.name).join(", ")})`,
|
|
726
|
+
);
|
|
727
|
+
return { shape: "multi", workspaces: resolved };
|
|
728
|
+
}
|
|
729
|
+
if (workspace != null)
|
|
730
|
+
return {
|
|
731
|
+
shape: "single",
|
|
732
|
+
workspaces: [
|
|
733
|
+
resolveBlueprint("defaults.workspace", "default", workspace, null),
|
|
734
|
+
],
|
|
735
|
+
};
|
|
736
|
+
return none;
|
|
737
|
+
}
|
|
738
|
+
|
|
621
739
|
/** `web.service` with its defaults applied. */
|
|
622
740
|
export interface ResolvedWebService {
|
|
623
741
|
/** launchd label; also the plist's name in ~/Library/LaunchAgents. */
|
package/src/shared/engine.ts
CHANGED
|
@@ -37,6 +37,24 @@ export function engineBin(name: string, engineRoot = getEngineRoot()): string {
|
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* A file inside a package Den depends on (e.g. "tsx/dist/loader.mjs"): the
|
|
42
|
+
* nearest node_modules/<rel> at or above the engine root, or null.
|
|
43
|
+
*/
|
|
44
|
+
export function engineModuleFile(
|
|
45
|
+
rel: string,
|
|
46
|
+
engineRoot = getEngineRoot(),
|
|
47
|
+
): string | null {
|
|
48
|
+
let dir = path.resolve(engineRoot);
|
|
49
|
+
for (;;) {
|
|
50
|
+
const candidate = path.join(dir, "node_modules", rel);
|
|
51
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
52
|
+
const parent = path.dirname(dir);
|
|
53
|
+
if (parent === dir) return null;
|
|
54
|
+
dir = parent;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
40
58
|
/** The tsx binary that runs Den's TypeScript sources. */
|
|
41
59
|
export function tsxBin(): string {
|
|
42
60
|
return engineBin("tsx");
|
|
@@ -14,63 +14,174 @@ import {
|
|
|
14
14
|
writeWorkspacesFile,
|
|
15
15
|
} from "./workspaces.js";
|
|
16
16
|
import { readCatalog, writeCatalog } from "./catalog.js";
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
import {
|
|
18
|
+
readWorkspaceBlueprints,
|
|
19
|
+
requireGithubOrg,
|
|
20
|
+
type ResolvedWorkspaceBlueprint,
|
|
21
|
+
} from "../den-config.js";
|
|
22
|
+
import type {
|
|
23
|
+
ServiceCatalog,
|
|
24
|
+
WorkspaceConfig,
|
|
25
|
+
WorkspaceServiceEntry,
|
|
26
|
+
WorkspacesFile,
|
|
27
|
+
} from "../types/index.js";
|
|
21
28
|
|
|
22
|
-
|
|
23
|
-
|
|
29
|
+
/** The single-workspace blueprint (`defaults.workspace`), or null. */
|
|
30
|
+
function singleBlueprint(): ResolvedWorkspaceBlueprint | null {
|
|
31
|
+
const bps = readWorkspaceBlueprints();
|
|
32
|
+
return bps.shape === "single" ? bps.workspaces[0] : null;
|
|
24
33
|
}
|
|
25
34
|
|
|
26
|
-
|
|
27
|
-
|
|
35
|
+
/** True when the distribution uses `defaults.workspaces` (named workspaces). */
|
|
36
|
+
export function isMultiBlueprint(): boolean {
|
|
37
|
+
return readWorkspaceBlueprints().shape === "multi";
|
|
28
38
|
}
|
|
29
39
|
|
|
30
|
-
|
|
31
|
-
|
|
40
|
+
/** The workspaces a fresh setup would seed, each with at least one repo. */
|
|
41
|
+
export function blueprintWorkspaces(): ResolvedWorkspaceBlueprint[] {
|
|
42
|
+
return readWorkspaceBlueprints().workspaces.filter((b) => b.repos.length > 0);
|
|
32
43
|
}
|
|
33
44
|
|
|
34
|
-
/**
|
|
35
|
-
export function
|
|
36
|
-
|
|
37
|
-
const def = readWorkspacesFile().workspaces["default"];
|
|
38
|
-
return !def || Object.keys(def.services ?? {}).length === 0;
|
|
45
|
+
/** `defaults.workspace.repos` — the single blueprint's repos (empty for the multi shape). */
|
|
46
|
+
export function defaultWorkspaceRepos(): string[] {
|
|
47
|
+
return singleBlueprint()?.repos ?? [];
|
|
39
48
|
}
|
|
40
49
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const def = readWorkspacesFile().workspaces["default"];
|
|
44
|
-
return !!def && Object.keys(def.services ?? {}).length > 0;
|
|
50
|
+
function org(): string {
|
|
51
|
+
return requireGithubOrg();
|
|
45
52
|
}
|
|
46
53
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
*/
|
|
52
|
-
export function seedDefaultWorkspace(): string[] {
|
|
53
|
-
const repos = defaultWorkspaceRepos();
|
|
54
|
-
const branch = defaultBranch();
|
|
54
|
+
function blueprintServices(
|
|
55
|
+
bp: ResolvedWorkspaceBlueprint,
|
|
56
|
+
repos: string[] = bp.repos,
|
|
57
|
+
): Record<string, WorkspaceServiceEntry> {
|
|
55
58
|
// Team default for systems a fresh workspace should NOT auto-start (e.g.
|
|
56
59
|
// billing-api's `make up` opens an interactive Stripe tab). Personal after
|
|
57
60
|
// seeding — toggled per system in the web ⋮ menu.
|
|
58
|
-
const autostartOff = new Set(
|
|
59
|
-
readDenConfig().defaults?.workspace?.autostart_off ?? [],
|
|
60
|
-
);
|
|
61
|
+
const autostartOff = new Set(bp.autostartOff);
|
|
61
62
|
const services: Record<string, WorkspaceServiceEntry> = {};
|
|
62
63
|
for (const repo of repos) {
|
|
63
64
|
services[repo] = {
|
|
64
65
|
mode: "managed",
|
|
65
|
-
branch,
|
|
66
|
+
branch: bp.branch,
|
|
66
67
|
...(autostartOff.has(repo) ? { autostart: false } : {}),
|
|
67
68
|
};
|
|
68
69
|
}
|
|
70
|
+
return services;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function fillCatalog(
|
|
74
|
+
repos: string[],
|
|
75
|
+
catalog: ServiceCatalog = readCatalog(),
|
|
76
|
+
): void {
|
|
77
|
+
const o = org();
|
|
78
|
+
for (const repo of repos) {
|
|
79
|
+
if (!catalog[repo]?.gh) catalog[repo] = { gh: `${o}/${repo}` };
|
|
80
|
+
}
|
|
81
|
+
writeCatalog(catalog);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A genuine fresh setup: no workspace but `default` exists, `default` holds no
|
|
86
|
+
* services, and no blueprint was ever applied to it (`seeded_repos` unset —
|
|
87
|
+
* seeding, top-up and a declined offer all record it). An emptied `default`,
|
|
88
|
+
* or one next to other workspaces, is the user's deliberate state.
|
|
89
|
+
*/
|
|
90
|
+
export function isFreshWorkspaceSetup(
|
|
91
|
+
file: WorkspacesFile = readWorkspacesFile(),
|
|
92
|
+
): boolean {
|
|
93
|
+
const names = Object.keys(file.workspaces);
|
|
94
|
+
if (names.some((n) => n !== "default")) return false;
|
|
95
|
+
const def = file.workspaces["default"];
|
|
96
|
+
if (!def) return true;
|
|
97
|
+
return (
|
|
98
|
+
Object.keys(def.services ?? {}).length === 0 &&
|
|
99
|
+
def.seeded_repos === undefined
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** True on a genuine fresh setup with a non-empty blueprint (single or multi). */
|
|
104
|
+
export function defaultWorkspaceNeedsSeeding(): boolean {
|
|
105
|
+
if (blueprintWorkspaces().length === 0) return false;
|
|
106
|
+
return isFreshWorkspaceSetup();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Whether a workspace the blueprint seeds already has services (so seeding would reset it). */
|
|
110
|
+
export function defaultWorkspaceHasServices(): boolean {
|
|
111
|
+
const file = readWorkspacesFile();
|
|
112
|
+
const names = isMultiBlueprint()
|
|
113
|
+
? blueprintWorkspaces().map((b) => b.name)
|
|
114
|
+
: ["default"];
|
|
115
|
+
return names.some(
|
|
116
|
+
(n) => Object.keys(file.workspaces[n]?.services ?? {}).length > 0,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function sameEntry(a: unknown, b: unknown): boolean {
|
|
121
|
+
if (a === b) return true;
|
|
122
|
+
if (typeof a !== "object" || typeof b !== "object" || !a || !b) return false;
|
|
123
|
+
const ka = Object.keys(a).sort();
|
|
124
|
+
const kb = Object.keys(b).sort();
|
|
125
|
+
return (
|
|
126
|
+
ka.length === kb.length &&
|
|
127
|
+
ka.every(
|
|
128
|
+
(k, i) =>
|
|
129
|
+
k === kb[i] &&
|
|
130
|
+
sameEntry(
|
|
131
|
+
(a as Record<string, unknown>)[k],
|
|
132
|
+
(b as Record<string, unknown>)[k],
|
|
133
|
+
),
|
|
134
|
+
)
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Whether seeding now would change nothing: every workspace the blueprint
|
|
140
|
+
* seeds holds exactly what seeding writes (same repos, all managed on the
|
|
141
|
+
* blueprint's branch, same autostart defaults), records the blueprint as
|
|
142
|
+
* applied, and (multi blueprint) is active. A workspace the user changed
|
|
143
|
+
* since (a repo switched to a local checkout, one added or removed) is not
|
|
144
|
+
* "already seeded": seeding would reset it.
|
|
145
|
+
*/
|
|
146
|
+
export function blueprintAlreadySeeded(
|
|
147
|
+
file: WorkspacesFile = readWorkspacesFile(),
|
|
148
|
+
): boolean {
|
|
149
|
+
const bps = blueprintWorkspaces();
|
|
150
|
+
if (bps.length === 0) return false;
|
|
151
|
+
const multi = isMultiBlueprint();
|
|
152
|
+
return bps.every((bp) => {
|
|
153
|
+
const name = multi ? bp.name : "default";
|
|
154
|
+
const ws = file.workspaces[name];
|
|
155
|
+
if (!ws) return false;
|
|
156
|
+
if (multi && !(file.active ?? []).includes(name)) return false;
|
|
157
|
+
const applied = ws.seeded_repos ?? [];
|
|
158
|
+
if (
|
|
159
|
+
applied.length !== bp.repos.length ||
|
|
160
|
+
bp.repos.some((r, i) => applied[i] !== r)
|
|
161
|
+
)
|
|
162
|
+
return false;
|
|
163
|
+
return sameEntry(ws.services ?? {}, blueprintServices(bp));
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Seed (or reset) the `default` workspace from the blueprint — every repo as a
|
|
169
|
+
* bare, first-class managed entry on `branch` — and ensure the catalog has
|
|
170
|
+
* clone URLs (`<org>/<repo>`, only filled where missing). Returns the seeded
|
|
171
|
+
* repos. With the multi blueprint (`defaults.workspaces`), seeds every named
|
|
172
|
+
* workspace instead (see seedBlueprintWorkspaces).
|
|
173
|
+
*/
|
|
174
|
+
export function seedDefaultWorkspace(): string[] {
|
|
175
|
+
if (isMultiBlueprint())
|
|
176
|
+
return seedBlueprintWorkspaces().flatMap((w) => w.repos);
|
|
177
|
+
const bp = singleBlueprint();
|
|
178
|
+
if (!bp) return [];
|
|
179
|
+
const repos = bp.repos;
|
|
69
180
|
|
|
70
181
|
const file = readWorkspacesFile();
|
|
71
182
|
file.workspaces.default = {
|
|
72
183
|
...(file.workspaces.default ?? {}),
|
|
73
|
-
services,
|
|
184
|
+
services: blueprintServices(bp),
|
|
74
185
|
seeded_repos: [...repos],
|
|
75
186
|
};
|
|
76
187
|
if (!file.active?.length) {
|
|
@@ -78,27 +189,44 @@ export function seedDefaultWorkspace(): string[] {
|
|
|
78
189
|
file.focus = "default";
|
|
79
190
|
}
|
|
80
191
|
writeWorkspacesFile(file);
|
|
81
|
-
|
|
82
|
-
const catalog = readCatalog();
|
|
83
|
-
const o = org();
|
|
84
|
-
for (const repo of repos) {
|
|
85
|
-
if (!catalog[repo]?.gh) catalog[repo] = { gh: `${o}/${repo}` };
|
|
86
|
-
}
|
|
87
|
-
writeCatalog(catalog);
|
|
88
|
-
|
|
192
|
+
fillCatalog(repos);
|
|
89
193
|
return repos;
|
|
90
194
|
}
|
|
91
195
|
|
|
92
196
|
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* managed entry on `branch`, team autostart default, catalog clone URL
|
|
98
|
-
* where absent) and returns the added names. No-op — and no file writes —
|
|
99
|
-
* when already converged, or when the `default` workspace is empty/missing:
|
|
100
|
-
* that state belongs to the seeding/declined flow, not top-up.
|
|
197
|
+
* Create (or reset) each workspace `defaults.workspaces` names with its repos,
|
|
198
|
+
* activate them all, and focus the one marked `focus: true` (else the first).
|
|
199
|
+
* Workspaces the blueprint does not name are left as they are. Returns what
|
|
200
|
+
* was seeded, in blueprint order.
|
|
101
201
|
*/
|
|
202
|
+
export function seedBlueprintWorkspaces(): { name: string; repos: string[] }[] {
|
|
203
|
+
const bps = blueprintWorkspaces();
|
|
204
|
+
if (bps.length === 0) return [];
|
|
205
|
+
const file = readWorkspacesFile();
|
|
206
|
+
for (const bp of bps) {
|
|
207
|
+
const prev: WorkspaceConfig | undefined = file.workspaces[bp.name];
|
|
208
|
+
file.workspaces[bp.name] = {
|
|
209
|
+
...(prev ?? {}),
|
|
210
|
+
services: blueprintServices(bp),
|
|
211
|
+
seeded_repos: [...bp.repos],
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
const names = bps.map((b) => b.name);
|
|
215
|
+
// The untouched placeholder `default` of a fresh file is not the user's:
|
|
216
|
+
// only the blueprint's workspaces are active.
|
|
217
|
+
const kept = (file.active ?? []).filter(
|
|
218
|
+
(n) =>
|
|
219
|
+
names.includes(n) ||
|
|
220
|
+
n !== "default" ||
|
|
221
|
+
Object.keys(file.workspaces[n]?.services ?? {}).length > 0,
|
|
222
|
+
);
|
|
223
|
+
file.active = [...names, ...kept.filter((n) => !names.includes(n))];
|
|
224
|
+
file.focus = (bps.find((b) => b.focus) ?? bps[0]).name;
|
|
225
|
+
writeWorkspacesFile(file);
|
|
226
|
+
fillCatalog([...new Set(bps.flatMap((b) => b.repos))]);
|
|
227
|
+
return bps.map((b) => ({ name: b.name, repos: [...b.repos] }));
|
|
228
|
+
}
|
|
229
|
+
|
|
102
230
|
/**
|
|
103
231
|
* Converge an EXISTING `default` workspace on the blueprint. Seeding is
|
|
104
232
|
* one-shot, so a workspace seeded before a repo joined
|
|
@@ -106,74 +234,88 @@ export function seedDefaultWorkspace(): string[] {
|
|
|
106
234
|
*
|
|
107
235
|
* Converges on blueprint DELTAS, not set difference: only repos that JOINED
|
|
108
236
|
* the blueprint since it was last applied (`seeded_repos`) are candidates, so
|
|
109
|
-
* a standard repo the user removed from the workspace stays removed.
|
|
110
|
-
* is
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* URL where absent. Returns the added names.
|
|
237
|
+
* a standard repo the user removed from the workspace stays removed. A
|
|
238
|
+
* candidate is skipped when any workspace already holds it, checked at REPO
|
|
239
|
+
* level (workspace keys may be `repo/subsystem` addresses). Added repos get
|
|
240
|
+
* exactly what seeding writes: bare managed entry on `branch`, team
|
|
241
|
+
* autostart default, catalog clone URL where absent. Returns the added names.
|
|
115
242
|
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
243
|
+
* Never runs once the user has other workspaces: `default` is then one
|
|
244
|
+
* workspace among theirs, and repos they moved out must stay out. Also a
|
|
245
|
+
* no-op (no file writes once converged) for the multi blueprint, an empty
|
|
246
|
+
* blueprint, or an empty/missing `default` (that state belongs to the
|
|
247
|
+
* seeding flow). Workspaces from before `seeded_repos` existed treat the
|
|
248
|
+
* whole blueprint as new once; afterwards removals stick.
|
|
121
249
|
*/
|
|
122
250
|
export function topUpDefaultWorkspace(): string[] {
|
|
123
|
-
const
|
|
124
|
-
if (repos.length === 0) return [];
|
|
251
|
+
const bp = singleBlueprint();
|
|
252
|
+
if (!bp || bp.repos.length === 0) return [];
|
|
253
|
+
const repos = bp.repos;
|
|
125
254
|
|
|
126
255
|
const file = readWorkspacesFile();
|
|
256
|
+
if (Object.keys(file.workspaces).some((n) => n !== "default")) return [];
|
|
127
257
|
const def = file.workspaces["default"];
|
|
128
258
|
if (!def || Object.keys(def.services ?? {}).length === 0) return [];
|
|
129
259
|
|
|
130
260
|
const applied = new Set(def.seeded_repos ?? []);
|
|
131
261
|
const catalog = readCatalog();
|
|
132
262
|
const present = new Set(
|
|
133
|
-
Object.
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
263
|
+
Object.values(file.workspaces).flatMap((ws) =>
|
|
264
|
+
Object.keys(ws.services ?? {}).map(
|
|
265
|
+
(k) => parseAddress(k, catalog).repoName,
|
|
266
|
+
),
|
|
267
|
+
),
|
|
138
268
|
);
|
|
139
269
|
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
if (applied.has(repo) || present.has(repo)) continue;
|
|
143
|
-
def.services[repo] = {
|
|
144
|
-
mode: "managed",
|
|
145
|
-
branch,
|
|
146
|
-
...(autostartOff.has(repo) ? { autostart: false } : {}),
|
|
147
|
-
};
|
|
148
|
-
added.push(repo);
|
|
149
|
-
}
|
|
270
|
+
const missing = repos.filter((r) => !applied.has(r) && !present.has(r));
|
|
271
|
+
Object.assign(def.services, blueprintServices(bp, missing));
|
|
150
272
|
|
|
151
273
|
const blueprintChanged =
|
|
152
274
|
def.seeded_repos?.length !== repos.length ||
|
|
153
275
|
repos.some((r, i) => def.seeded_repos?.[i] !== r);
|
|
154
|
-
if (
|
|
276
|
+
if (missing.length === 0 && !blueprintChanged) return [];
|
|
155
277
|
|
|
156
278
|
def.seeded_repos = [...repos];
|
|
157
279
|
writeWorkspacesFile(file);
|
|
280
|
+
if (missing.length > 0) fillCatalog(missing, catalog);
|
|
281
|
+
return missing;
|
|
282
|
+
}
|
|
158
283
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
284
|
+
/**
|
|
285
|
+
* Record that the user removed `repoName` from workspace `wsName`, so top-up
|
|
286
|
+
* never re-adds it. Only the `default` workspace is topped up, and only a
|
|
287
|
+
* blueprint repo could come back; `seeded_repos` already covers repos the
|
|
288
|
+
* blueprint applied. This closes the gap for a `default` from before
|
|
289
|
+
* `seeded_repos` existed, which top-up would otherwise migrate by re-adding
|
|
290
|
+
* the whole blueprint. Mutates `file` in place; the caller writes it.
|
|
291
|
+
*/
|
|
292
|
+
export function recordRemovedFromWorkspace(
|
|
293
|
+
file: WorkspacesFile,
|
|
294
|
+
wsName: string,
|
|
295
|
+
repoName: string,
|
|
296
|
+
): void {
|
|
297
|
+
if (wsName !== "default") return;
|
|
298
|
+
const def = file.workspaces["default"];
|
|
299
|
+
if (!def) return;
|
|
300
|
+
let blueprint: string[] = [];
|
|
301
|
+
try {
|
|
302
|
+
blueprint = defaultWorkspaceRepos();
|
|
303
|
+
} catch {
|
|
304
|
+
return; // a malformed blueprint is reported where seeding reads it
|
|
165
305
|
}
|
|
166
|
-
|
|
167
|
-
|
|
306
|
+
if (!blueprint.includes(repoName)) return;
|
|
307
|
+
const seeded = def.seeded_repos ?? [];
|
|
308
|
+
if (!seeded.includes(repoName)) def.seeded_repos = [...seeded, repoName];
|
|
168
309
|
}
|
|
169
310
|
|
|
170
311
|
/**
|
|
171
|
-
* Persist an empty `default` workspace
|
|
172
|
-
* asked again after the user declines it.
|
|
312
|
+
* Persist an empty `default` workspace, with an empty `seeded_repos`, so the
|
|
313
|
+
* one-time auto-seed prompt isn't asked again after the user declines it.
|
|
173
314
|
*/
|
|
174
315
|
export function markDefaultWorkspaceDeclined(): void {
|
|
175
316
|
const file = readWorkspacesFile();
|
|
176
317
|
file.workspaces.default = file.workspaces.default ?? { services: {} };
|
|
318
|
+
file.workspaces.default.seeded_repos ??= [];
|
|
177
319
|
if (!file.active?.length) {
|
|
178
320
|
file.active = ["default"];
|
|
179
321
|
file.focus = "default";
|