@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +195 -0
  2. package/docs/distributions.md +158 -16
  3. package/package.json +1 -1
  4. package/src/pillars/security/discover.ts +1 -1
  5. package/src/pillars/smoke/setup-global.ts +2 -1
  6. package/src/scripts/configure-aws-profiles.ts +2 -1
  7. package/src/scripts/register-repos.ts +4 -3
  8. package/src/shared/constants.ts +7 -0
  9. package/src/shared/den-config.ts +130 -12
  10. package/src/shared/engine.ts +18 -0
  11. package/src/shared/environment-manager/default-workspace.ts +229 -87
  12. package/src/shared/environment-manager/health.ts +235 -35
  13. package/src/shared/manifest/placement.ts +1 -1
  14. package/src/shared/manifest/schema.ts +47 -0
  15. package/src/shared/manifest/service-links.ts +1 -1
  16. package/src/shared/manifest/validate-manifest.ts +1 -1
  17. package/src/shared/plural.ts +13 -0
  18. package/src/shared/service-url.ts +2 -5
  19. package/src/shared/stacks/compose-health.ts +242 -0
  20. package/src/shared/stacks/runtime.ts +73 -24
  21. package/src/shared/stacks/status.ts +3 -2
  22. package/src/shared/types/index.ts +13 -1
  23. package/src/ui/cli/create.ts +2 -1
  24. package/src/ui/cli/health-display.ts +1 -0
  25. package/src/ui/cli/index.ts +86 -41
  26. package/src/ui/cli/mcp-connect.ts +36 -5
  27. package/src/ui/cli/server-identity.ts +233 -0
  28. package/src/ui/cli/service-command.ts +136 -12
  29. package/src/ui/cli/spinner.ts +48 -0
  30. package/src/ui/cli/stack-command.ts +40 -12
  31. package/src/ui/cli/web-command.ts +101 -32
  32. package/src/ui/mcp/api.ts +94 -1
  33. package/src/ui/mcp/server.ts +6 -1
  34. package/src/ui/mcp/tools.ts +6 -2
  35. package/src/ui/server/health-row.ts +6 -3
  36. package/src/ui/server/index.ts +120 -40
  37. package/src/ui/server/public/changelog-view.js +16 -0
  38. package/src/ui/server/public/header.js +121 -12
  39. package/src/ui/server/ui-assets.ts +1 -0
  40. package/src/ui/server/workspace-routes.ts +7 -0
@@ -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. */
@@ -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 { 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
+ 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
- * 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.
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. 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.
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
- * 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.
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 repos = defaultWorkspaceRepos();
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.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 ?? [],
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 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
- }
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 (added.length === 0 && !blueprintChanged) return [];
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
- 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);
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
- return added;
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 so the one-time auto-seed prompt isn't
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";