@monster-orange/den 0.2.4 → 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 CHANGED
@@ -4,6 +4,103 @@ All notable changes to Den are documented here. Each release lists the
4
4
  config and manifest changes a distribution must make to move to it under
5
5
  **Migrations**.
6
6
 
7
+ ## [0.2.5] - 2026-11-04
8
+
9
+ ### Fixed
10
+
11
+ - **`<cli> web down` (any SIGTERM) shuts the web server down cleanly
12
+ under load.** `<cli> web up` ran the server under the `tsx` CLI, a
13
+ parent process that relays a signal to the server and, if the server has
14
+ not acknowledged it within about 60ms, SIGKILLs it and exits 143. On a
15
+ loaded machine the server missed that window, so it was killed without
16
+ its shutdown. The server now runs as `node --import <tsx loader>`, one
17
+ process that gets the signal itself, as the launchd service already did.
18
+ - **Compose-stack health no longer starts a CLI per check.** Den's own
19
+ check for a compose repo was a `<cli> stack health <stack> --service X`
20
+ process per manifest service, each a full CLI start running its own
21
+ `docker ps`: 17-19 processes per health cycle for three stacks. On a
22
+ loaded machine (load 35-55) they ran past their budget, and two such
23
+ cycles in a row read every stack down. These checks are now answered
24
+ in the web server and in `<cli> status` from one `docker ps` per cycle,
25
+ shared by every check in it (17 checks: 17 CLI starts and 17 docker
26
+ calls per cycle before, 0 and 1 now). The verdict is the one `stack
27
+ health` gives. A distribution's own shell checks still run as shell
28
+ commands, at most max(2, cpus/2) at a time; a queued check's budget
29
+ starts when it starts.
30
+ - **The health row no longer shows healthy repos down on a cold poll.** A
31
+ check that got no answer within its budget was sent as a finished
32
+ `ok: false`: a compose stack's check (`<cli> stack health`, a full CLI
33
+ start per service, 15-20 of them at once) past its 5s budget on a loaded
34
+ machine, or an HTTP check past its 3s timeout. The row painted it red
35
+ and the 8s health cache replayed that to the next page load, until the
36
+ next poll corrected it. Now a check that has not answered is **pending**
37
+ (`ok: null`, `pending: true`, the reason in `error`), never down. Only a
38
+ check that completed and failed is `ok: false`. A timeout is down only
39
+ when it repeats on the next cycle (`timeout (twice in a row)`), so a hung
40
+ service still shows. `<cli> status` agrees: on a timeout it probes once
41
+ more, and is down only when both probes time out. Health results have a
42
+ new `pending` state (`health: "pending"`), which readiness waits on
43
+ rather than skipping.
44
+ - **"Docker state unknown" is not "not running".** When docker does not
45
+ answer, the health row and `<cli> status` report a compose stack as
46
+ unknown (`ok: null`), not down, and so does `/api/stacks`. `<cli> stack
47
+ health` exits 3 when it could not tell (docker did not answer, the stack
48
+ config could not be read), 1 only when a stack is not healthy.
49
+ - **A health stream and a poll at once run each check once.** A stream
50
+ started its own full run next to an in-flight `/api/health` (or another
51
+ stream), doubling the shell checks' processes. It now joins the run in
52
+ flight, replaying what has landed and then following it.
53
+ - The web header's dot for a multi-service repo without an entrypoint
54
+ turned red when none of its checks were up yet, even with none down. It
55
+ is red now only when a check completed and failed.
56
+
57
+ - **The default workspace is seeded only on a genuine fresh setup.** Any
58
+ command that reads workspaces (`status`, `up`, `graph`, `logs`, …) seeded
59
+ `default` from `defaults.workspace.repos` whenever `default` was empty,
60
+ so moving every repo into per-project workspaces and deactivating
61
+ `default` got it refilled with managed entries on the next command, ready
62
+ to clone duplicates of repos you work on locally. Seeding now requires
63
+ that no workspace but `default` exists and that `default` was never
64
+ seeded (no `seeded_repos`). Declining the offer records that too, so it
65
+ is no longer asked again.
66
+ - **No silent seeding without a TTY.** On a fresh setup a command without
67
+ a TTY now prints a one-line hint to run `<cli> workspace seed` and
68
+ changes nothing; on a TTY the offer stays. New: `<cli>
69
+ workspace seed` seeds explicitly (as `<cli> init` in the root does).
70
+ - **Top-up no longer re-adds repos you moved or removed.** Top-up (adding
71
+ repos that joined the blueprint after seeding) never runs once you have
72
+ other workspaces, and skips a repo any workspace holds. Removing a
73
+ blueprint repo from `default` in the web UI records it, so a `default`
74
+ from before `seeded_repos` no longer gets it back on its one-time
75
+ migration.
76
+
77
+ ### Added
78
+
79
+ - **The "What's new" pop-up shows the newest release only** (from
80
+ 0.3.0-dev.16). A "Show all versions (N earlier)" button in its footer
81
+ lists every earlier version in the same pop-up, and "Show latest only"
82
+ collapses it again. It always opens collapsed, at the top: the scroll is
83
+ reset once the pop-up is shown (a reset made while it was hidden did not
84
+ stick, so a reopened pop-up kept its old scroll position). It is an
85
+ accessible modal dialog (`role="dialog"`, `aria-modal`, labelled by its
86
+ title). Opening moves focus to its heading, so a screen reader starts at
87
+ the latest notes. Tab stays inside, and closing returns focus to what had
88
+ it, which is the version pill after the first-visit auto-open. Each
89
+ version is a heading (h4) for heading navigation, and the icon buttons
90
+ have spoken names ("Close", "Copy v0.2.5 as Markdown").
91
+ - **The version pill names the running build** (`v0.2.5-dev.2`), not the
92
+ newest changelog heading, so a prerelease under test says what it is.
93
+ - **Multi-workspace blueprint: `defaults.workspaces`.** `{ <name>: {
94
+ repos, branch?, autostart_off?, focus? } }` in the org config. A fresh
95
+ setup creates each workspace with its repos (managed), activates them
96
+ all and focuses the one with `focus: true` (else the first). Setting
97
+ both `defaults.workspace` and `defaults.workspaces` is an error, as is a
98
+ malformed entry. See docs/distributions.md, "Workspace blueprint".
99
+
100
+ ### Migrations
101
+
102
+ - None required.
103
+
7
104
  ## [0.2.4] - 2026-11-04
8
105
 
9
106
  ### Fixed
@@ -327,6 +327,54 @@ brand:
327
327
  Runtime names (env vars, files, directories) always come from `den.yml`,
328
328
  never from the brand.
329
329
 
330
+ ## Workspace blueprint
331
+
332
+ The workspaces a new machine starts with. Read from `defaults:` in the org
333
+ config. Every repo is seeded as a managed clone (`mode: managed`) on the
334
+ blueprint's branch; nothing is cloned until a command needs it. Switch a repo
335
+ to a local checkout afterwards with `acme use-local <repo>`.
336
+
337
+ One workspace, `default`:
338
+
339
+ ```yaml
340
+ defaults:
341
+ workspace:
342
+ branch: main # default: main
343
+ repos: [shop-web, shop, infra-stack]
344
+ autostart_off: [shop] # seeded with autostart: false
345
+ entry: shop-web # where the setup script's dependency walk starts
346
+ ```
347
+
348
+ Several named workspaces:
349
+
350
+ ```yaml
351
+ defaults:
352
+ workspaces:
353
+ storefront:
354
+ repos: [shop-web, shop]
355
+ data:
356
+ repos: [warehouse, infra-stack]
357
+ branch: develop
358
+ focus: true
359
+ ```
360
+
361
+ Each entry takes `repos`, `branch` and `autostart_off`, the same fields as
362
+ `defaults.workspace`, plus `focus`. Seeding creates every named workspace
363
+ with its repos, activates them all, and focuses the one with `focus: true`
364
+ (else the first). Set `defaults.workspace` or `defaults.workspaces`, not both:
365
+ both at once is an error, as is more than one `focus: true`.
366
+
367
+ The blueprint applies only to a genuine fresh setup: no workspace except an
368
+ empty `default` that was never seeded. On a TTY the first command that needs
369
+ workspaces offers to seed. Without a TTY it prints a one-line hint and changes
370
+ nothing. `acme workspace seed` (or `acme init` in the distribution root) seeds
371
+ explicitly, and resets the blueprint's workspaces if they already hold repos.
372
+
373
+ Once seeded, a repo that later joins `defaults.workspace.repos` is added to
374
+ `default`, but only while `default` is your only workspace, and never a repo
375
+ you removed from it or that another workspace holds. With
376
+ `defaults.workspaces` nothing is topped up.
377
+
330
378
  ## The bin shim
331
379
 
332
380
  `bin/<cli>` is three lines of sh: it names the distribution root and runs
@@ -544,22 +592,22 @@ guard off.
544
592
 
545
593
  Read tools (always registered):
546
594
 
547
- | Tool | Web API | What |
548
- | ------------------- | ------------------------------------------ | ---------------------------------------------------------- |
549
- | `status` | `/api/health` | Service health; `url` (HTTP check) or `check` (shell) |
550
- | `services` | `/api/services` | Services, their mode (managed or dev-managed) and commands |
551
- | `applications` | `/api/applications` | Org > Application > Repo > Services |
552
- | `graph` | `/api/graph` | The derived dependency graph and startup order |
553
- | `workspaces` | `/api/workspaces` | Workspaces, active and focused |
554
- | `environments` | `/api/environments` | The enabled environment names (never their values) |
555
- | `logs_search` | `/api/logs/search` | Search services' full log history |
556
- | `logs_tail` | `/api/service/taillog` | The last lines of a service's log (bounded, no follow) |
557
- | `tests_available` | `/api/modules`, `/api/test-sources` | A pillar's modules and the repos they come from |
558
- | `security_latest` | `/api/security/latest` | The newest security scan per mode |
559
- | `release_status` | `/api/release/runs`, `/api/release/status` | Recent CD runs, and an env's deployed-version table |
560
- | `manifest_validate` | `/api/manifest/validate` | Validate a manifest by path or content |
561
- | `stacks` | `/api/stacks` | What `stack ls/ps/health` show: project, routes, health |
562
- | `edge_status` | `/api/edge` | The edge: up or down, its routes and cert names |
595
+ | Tool | Web API | What |
596
+ | ------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
597
+ | `status` | `/api/health` | Service health; `url` (HTTP check) or `check` (shell); `ok` true up, false down, null not known (`pending: true` when a check has not answered) |
598
+ | `services` | `/api/services` | Services, their mode (managed or dev-managed) and commands |
599
+ | `applications` | `/api/applications` | Org > Application > Repo > Services |
600
+ | `graph` | `/api/graph` | The derived dependency graph and startup order |
601
+ | `workspaces` | `/api/workspaces` | Workspaces, active and focused |
602
+ | `environments` | `/api/environments` | The enabled environment names (never their values) |
603
+ | `logs_search` | `/api/logs/search` | Search services' full log history |
604
+ | `logs_tail` | `/api/service/taillog` | The last lines of a service's log (bounded, no follow) |
605
+ | `tests_available` | `/api/modules`, `/api/test-sources` | A pillar's modules and the repos they come from |
606
+ | `security_latest` | `/api/security/latest` | The newest security scan per mode |
607
+ | `release_status` | `/api/release/runs`, `/api/release/status` | Recent CD runs, and an env's deployed-version table |
608
+ | `manifest_validate` | `/api/manifest/validate` | Validate a manifest by path or content |
609
+ | `stacks` | `/api/stacks` | What `stack ls/ps/health` show: project, routes, health |
610
+ | `edge_status` | `/api/edge` | The edge: up or down, its routes and cert names |
563
611
 
564
612
  Action tools (only those named in `mcp.actions`): `service_up`,
565
613
  `service_down`, `service_restart` (through `/api/service/exec`),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monster-orange/den",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "Den: a local multi-repo dev orchestrator, HTTPS edge and E2E harness",
5
5
  "type": "module",
6
6
  "bin": {
@@ -48,6 +48,13 @@ export const HOSTED_HEALTH_CHECK_TIMEOUT_MS = 10_000;
48
48
  /** Timeout for spawning short-lived CLI subprocesses (open, sudo, git). */
49
49
  export const CLI_SPAWN_TIMEOUT_MS = 5_000;
50
50
 
51
+ /**
52
+ * Exit code of `<cli> stack health` when it could not tell (docker did not
53
+ * answer, the stack config could not be read): the check is unknown, not
54
+ * down. Any other non-zero exit means the containers are not healthy.
55
+ */
56
+ export const STACK_HEALTH_UNKNOWN_EXIT = 3;
57
+
51
58
  /** Timeout for `gh api` calls (branch listing, repo search). */
52
59
  export const GH_API_TIMEOUT_MS = 15_000;
53
60
 
@@ -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");