@monster-orange/den 0.2.3 → 0.2.5
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 +208 -0
- package/docs/distributions.md +64 -16
- package/package.json +1 -1
- package/service/install.sh +4 -2
- package/src/shared/constants.ts +7 -0
- package/src/shared/den-config.ts +130 -12
- package/src/shared/edge/config.ts +16 -0
- package/src/shared/engine.ts +18 -0
- package/src/shared/environment-manager/default-workspace.ts +182 -87
- package/src/shared/environment-manager/health.ts +229 -30
- package/src/shared/environment-manager/opctl.ts +83 -1
- package/src/shared/environment-manager/service-runner.ts +49 -4
- package/src/shared/git/origin-url.ts +77 -0
- package/src/shared/manifest/read-manifest.ts +39 -15
- package/src/shared/paths.ts +42 -0
- package/src/shared/product.ts +7 -1
- package/src/shared/profile.ts +8 -0
- package/src/shared/read-cache.ts +77 -0
- package/src/shared/stacks/compose-health.ts +150 -0
- package/src/shared/stacks/runtime.ts +71 -24
- package/src/shared/stacks/status.ts +112 -3
- package/src/ui/cli/health-display.ts +1 -0
- package/src/ui/cli/index.ts +181 -31
- package/src/ui/cli/mcp-connect.ts +77 -15
- package/src/ui/cli/stack-command.ts +40 -12
- package/src/ui/cli/web-command.ts +25 -7
- package/src/ui/cli/workspace-notes.ts +43 -0
- package/src/ui/server/graph-order.ts +22 -0
- package/src/ui/server/index.ts +174 -197
- package/src/ui/server/public/changelog-view.js +16 -0
- package/src/ui/server/public/header.js +121 -12
- package/src/ui/server/services-list.ts +210 -0
- package/src/ui/server/single-flight.ts +53 -0
- package/src/ui/server/ui-assets.ts +1 -0
- package/src/ui/server/workspace-routes.ts +247 -181
|
@@ -14,63 +14,127 @@ 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
|
+
/**
|
|
121
|
+
* Seed (or reset) the `default` workspace from the blueprint — every repo as a
|
|
122
|
+
* bare, first-class managed entry on `branch` — and ensure the catalog has
|
|
123
|
+
* clone URLs (`<org>/<repo>`, only filled where missing). Returns the seeded
|
|
124
|
+
* repos. With the multi blueprint (`defaults.workspaces`), seeds every named
|
|
125
|
+
* workspace instead (see seedBlueprintWorkspaces).
|
|
126
|
+
*/
|
|
127
|
+
export function seedDefaultWorkspace(): string[] {
|
|
128
|
+
if (isMultiBlueprint())
|
|
129
|
+
return seedBlueprintWorkspaces().flatMap((w) => w.repos);
|
|
130
|
+
const bp = singleBlueprint();
|
|
131
|
+
if (!bp) return [];
|
|
132
|
+
const repos = bp.repos;
|
|
69
133
|
|
|
70
134
|
const file = readWorkspacesFile();
|
|
71
135
|
file.workspaces.default = {
|
|
72
136
|
...(file.workspaces.default ?? {}),
|
|
73
|
-
services,
|
|
137
|
+
services: blueprintServices(bp),
|
|
74
138
|
seeded_repos: [...repos],
|
|
75
139
|
};
|
|
76
140
|
if (!file.active?.length) {
|
|
@@ -78,27 +142,44 @@ export function seedDefaultWorkspace(): string[] {
|
|
|
78
142
|
file.focus = "default";
|
|
79
143
|
}
|
|
80
144
|
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
|
-
|
|
145
|
+
fillCatalog(repos);
|
|
89
146
|
return repos;
|
|
90
147
|
}
|
|
91
148
|
|
|
92
149
|
/**
|
|
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.
|
|
150
|
+
* Create (or reset) each workspace `defaults.workspaces` names with its repos,
|
|
151
|
+
* activate them all, and focus the one marked `focus: true` (else the first).
|
|
152
|
+
* Workspaces the blueprint does not name are left as they are. Returns what
|
|
153
|
+
* was seeded, in blueprint order.
|
|
101
154
|
*/
|
|
155
|
+
export function seedBlueprintWorkspaces(): { name: string; repos: string[] }[] {
|
|
156
|
+
const bps = blueprintWorkspaces();
|
|
157
|
+
if (bps.length === 0) return [];
|
|
158
|
+
const file = readWorkspacesFile();
|
|
159
|
+
for (const bp of bps) {
|
|
160
|
+
const prev: WorkspaceConfig | undefined = file.workspaces[bp.name];
|
|
161
|
+
file.workspaces[bp.name] = {
|
|
162
|
+
...(prev ?? {}),
|
|
163
|
+
services: blueprintServices(bp),
|
|
164
|
+
seeded_repos: [...bp.repos],
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
const names = bps.map((b) => b.name);
|
|
168
|
+
// The untouched placeholder `default` of a fresh file is not the user's:
|
|
169
|
+
// only the blueprint's workspaces are active.
|
|
170
|
+
const kept = (file.active ?? []).filter(
|
|
171
|
+
(n) =>
|
|
172
|
+
names.includes(n) ||
|
|
173
|
+
n !== "default" ||
|
|
174
|
+
Object.keys(file.workspaces[n]?.services ?? {}).length > 0,
|
|
175
|
+
);
|
|
176
|
+
file.active = [...names, ...kept.filter((n) => !names.includes(n))];
|
|
177
|
+
file.focus = (bps.find((b) => b.focus) ?? bps[0]).name;
|
|
178
|
+
writeWorkspacesFile(file);
|
|
179
|
+
fillCatalog([...new Set(bps.flatMap((b) => b.repos))]);
|
|
180
|
+
return bps.map((b) => ({ name: b.name, repos: [...b.repos] }));
|
|
181
|
+
}
|
|
182
|
+
|
|
102
183
|
/**
|
|
103
184
|
* Converge an EXISTING `default` workspace on the blueprint. Seeding is
|
|
104
185
|
* one-shot, so a workspace seeded before a repo joined
|
|
@@ -106,74 +187,88 @@ export function seedDefaultWorkspace(): string[] {
|
|
|
106
187
|
*
|
|
107
188
|
* Converges on blueprint DELTAS, not set difference: only repos that JOINED
|
|
108
189
|
* 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.
|
|
190
|
+
* a standard repo the user removed from the workspace stays removed. A
|
|
191
|
+
* candidate is skipped when any workspace already holds it, checked at REPO
|
|
192
|
+
* level (workspace keys may be `repo/subsystem` addresses). Added repos get
|
|
193
|
+
* exactly what seeding writes: bare managed entry on `branch`, team
|
|
194
|
+
* autostart default, catalog clone URL where absent. Returns the added names.
|
|
115
195
|
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
196
|
+
* Never runs once the user has other workspaces: `default` is then one
|
|
197
|
+
* workspace among theirs, and repos they moved out must stay out. Also a
|
|
198
|
+
* no-op (no file writes once converged) for the multi blueprint, an empty
|
|
199
|
+
* blueprint, or an empty/missing `default` (that state belongs to the
|
|
200
|
+
* seeding flow). Workspaces from before `seeded_repos` existed treat the
|
|
201
|
+
* whole blueprint as new once; afterwards removals stick.
|
|
121
202
|
*/
|
|
122
203
|
export function topUpDefaultWorkspace(): string[] {
|
|
123
|
-
const
|
|
124
|
-
if (repos.length === 0) return [];
|
|
204
|
+
const bp = singleBlueprint();
|
|
205
|
+
if (!bp || bp.repos.length === 0) return [];
|
|
206
|
+
const repos = bp.repos;
|
|
125
207
|
|
|
126
208
|
const file = readWorkspacesFile();
|
|
209
|
+
if (Object.keys(file.workspaces).some((n) => n !== "default")) return [];
|
|
127
210
|
const def = file.workspaces["default"];
|
|
128
211
|
if (!def || Object.keys(def.services ?? {}).length === 0) return [];
|
|
129
212
|
|
|
130
213
|
const applied = new Set(def.seeded_repos ?? []);
|
|
131
214
|
const catalog = readCatalog();
|
|
132
215
|
const present = new Set(
|
|
133
|
-
Object.
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
216
|
+
Object.values(file.workspaces).flatMap((ws) =>
|
|
217
|
+
Object.keys(ws.services ?? {}).map(
|
|
218
|
+
(k) => parseAddress(k, catalog).repoName,
|
|
219
|
+
),
|
|
220
|
+
),
|
|
138
221
|
);
|
|
139
222
|
|
|
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
|
-
}
|
|
223
|
+
const missing = repos.filter((r) => !applied.has(r) && !present.has(r));
|
|
224
|
+
Object.assign(def.services, blueprintServices(bp, missing));
|
|
150
225
|
|
|
151
226
|
const blueprintChanged =
|
|
152
227
|
def.seeded_repos?.length !== repos.length ||
|
|
153
228
|
repos.some((r, i) => def.seeded_repos?.[i] !== r);
|
|
154
|
-
if (
|
|
229
|
+
if (missing.length === 0 && !blueprintChanged) return [];
|
|
155
230
|
|
|
156
231
|
def.seeded_repos = [...repos];
|
|
157
232
|
writeWorkspacesFile(file);
|
|
233
|
+
if (missing.length > 0) fillCatalog(missing, catalog);
|
|
234
|
+
return missing;
|
|
235
|
+
}
|
|
158
236
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
237
|
+
/**
|
|
238
|
+
* Record that the user removed `repoName` from workspace `wsName`, so top-up
|
|
239
|
+
* never re-adds it. Only the `default` workspace is topped up, and only a
|
|
240
|
+
* blueprint repo could come back; `seeded_repos` already covers repos the
|
|
241
|
+
* blueprint applied. This closes the gap for a `default` from before
|
|
242
|
+
* `seeded_repos` existed, which top-up would otherwise migrate by re-adding
|
|
243
|
+
* the whole blueprint. Mutates `file` in place; the caller writes it.
|
|
244
|
+
*/
|
|
245
|
+
export function recordRemovedFromWorkspace(
|
|
246
|
+
file: WorkspacesFile,
|
|
247
|
+
wsName: string,
|
|
248
|
+
repoName: string,
|
|
249
|
+
): void {
|
|
250
|
+
if (wsName !== "default") return;
|
|
251
|
+
const def = file.workspaces["default"];
|
|
252
|
+
if (!def) return;
|
|
253
|
+
let blueprint: string[] = [];
|
|
254
|
+
try {
|
|
255
|
+
blueprint = defaultWorkspaceRepos();
|
|
256
|
+
} catch {
|
|
257
|
+
return; // a malformed blueprint is reported where seeding reads it
|
|
165
258
|
}
|
|
166
|
-
|
|
167
|
-
|
|
259
|
+
if (!blueprint.includes(repoName)) return;
|
|
260
|
+
const seeded = def.seeded_repos ?? [];
|
|
261
|
+
if (!seeded.includes(repoName)) def.seeded_repos = [...seeded, repoName];
|
|
168
262
|
}
|
|
169
263
|
|
|
170
264
|
/**
|
|
171
|
-
* Persist an empty `default` workspace
|
|
172
|
-
* asked again after the user declines it.
|
|
265
|
+
* Persist an empty `default` workspace, with an empty `seeded_repos`, so the
|
|
266
|
+
* one-time auto-seed prompt isn't asked again after the user declines it.
|
|
173
267
|
*/
|
|
174
268
|
export function markDefaultWorkspaceDeclined(): void {
|
|
175
269
|
const file = readWorkspacesFile();
|
|
176
270
|
file.workspaces.default = file.workspaces.default ?? { services: {} };
|
|
271
|
+
file.workspaces.default.seeded_repos ??= [];
|
|
177
272
|
if (!file.active?.length) {
|
|
178
273
|
file.active = ["default"];
|
|
179
274
|
file.focus = "default";
|