@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.
Files changed (35) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/docs/distributions.md +64 -16
  3. package/package.json +1 -1
  4. package/service/install.sh +4 -2
  5. package/src/shared/constants.ts +7 -0
  6. package/src/shared/den-config.ts +130 -12
  7. package/src/shared/edge/config.ts +16 -0
  8. package/src/shared/engine.ts +18 -0
  9. package/src/shared/environment-manager/default-workspace.ts +182 -87
  10. package/src/shared/environment-manager/health.ts +229 -30
  11. package/src/shared/environment-manager/opctl.ts +83 -1
  12. package/src/shared/environment-manager/service-runner.ts +49 -4
  13. package/src/shared/git/origin-url.ts +77 -0
  14. package/src/shared/manifest/read-manifest.ts +39 -15
  15. package/src/shared/paths.ts +42 -0
  16. package/src/shared/product.ts +7 -1
  17. package/src/shared/profile.ts +8 -0
  18. package/src/shared/read-cache.ts +77 -0
  19. package/src/shared/stacks/compose-health.ts +150 -0
  20. package/src/shared/stacks/runtime.ts +71 -24
  21. package/src/shared/stacks/status.ts +112 -3
  22. package/src/ui/cli/health-display.ts +1 -0
  23. package/src/ui/cli/index.ts +181 -31
  24. package/src/ui/cli/mcp-connect.ts +77 -15
  25. package/src/ui/cli/stack-command.ts +40 -12
  26. package/src/ui/cli/web-command.ts +25 -7
  27. package/src/ui/cli/workspace-notes.ts +43 -0
  28. package/src/ui/server/graph-order.ts +22 -0
  29. package/src/ui/server/index.ts +174 -197
  30. package/src/ui/server/public/changelog-view.js +16 -0
  31. package/src/ui/server/public/header.js +121 -12
  32. package/src/ui/server/services-list.ts +210 -0
  33. package/src/ui/server/single-flight.ts +53 -0
  34. package/src/ui/server/ui-assets.ts +1 -0
  35. 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 { readDenConfig, requireGithubOrg } from "../den-config.js";
18
- import type { WorkspaceServiceEntry } from "../types/index.js";
19
-
20
- const DEFAULT_BRANCH = "main";
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
- export function defaultWorkspaceRepos(): string[] {
23
- return readDenConfig().defaults?.workspace?.repos ?? [];
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
- function defaultBranch(): string {
27
- return readDenConfig().defaults?.workspace?.branch ?? DEFAULT_BRANCH;
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
- function org(): string {
31
- return requireGithubOrg();
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
- /** True on a fresh clone: a blueprint exists but the `default` workspace is empty. */
35
- export function defaultWorkspaceNeedsSeeding(): boolean {
36
- if (defaultWorkspaceRepos().length === 0) return false;
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
- /** Whether the `default` workspace already has services (so seeding would reset it). */
42
- export function defaultWorkspaceHasServices(): boolean {
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
- * Seed (or reset) the `default` workspace from the blueprint — every repo as a
49
- * bare, first-class managed entry on `branch` — and ensure the catalog has
50
- * clone URLs (`<org>/<repo>`, only filled where missing). Returns the seeded repos.
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
- * Converge an EXISTING `default` workspace on the blueprint. Seeding is
94
- * one-shot, so a workspace seeded before a repo joined
95
- * `defaults.workspace.repos` (tasks-api, say) would never gain it on its own.
96
- * Adds each missing blueprint repo exactly as seeding would (bare
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. Presence
110
- * is checked at REPO level (workspace keys may be `repo/subsystem`
111
- * addresses), so a repo held as subsystem entries never gains a duplicate
112
- * bare entry. Added repos get exactly what seeding writes: bare
113
- * managed entry on `branch`, team autostart default, catalog clone
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
- * No-op — and no file writes once converged — when the blueprint is empty or
117
- * the `default` workspace is empty/missing (that state belongs to the
118
- * seeding/declined flow). Workspaces from before `seeded_repos` existed
119
- * treat the whole blueprint as new once — the migration cannot distinguish
120
- * "removed" from "seeded before the repo joined"; afterwards removals stick.
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 repos = defaultWorkspaceRepos();
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.keys(def.services).map((k) => parseAddress(k, catalog).repoName),
134
- );
135
- const branch = defaultBranch();
136
- const autostartOff = new Set(
137
- readDenConfig().defaults?.workspace?.autostart_off ?? [],
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 added: string[] = [];
141
- for (const repo of repos) {
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 (added.length === 0 && !blueprintChanged) return [];
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
- if (added.length > 0) {
160
- const o = org();
161
- for (const repo of added) {
162
- if (!catalog[repo]?.gh) catalog[repo] = { gh: `${o}/${repo}` };
163
- }
164
- writeCatalog(catalog);
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
- return added;
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 so the one-time auto-seed prompt isn't
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";