@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 +97 -0
- package/docs/distributions.md +64 -16
- package/package.json +1 -1
- 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 +182 -87
- package/src/shared/environment-manager/health.ts +193 -29
- package/src/shared/stacks/compose-health.ts +150 -0
- package/src/shared/stacks/runtime.ts +71 -24
- package/src/shared/stacks/status.ts +3 -2
- package/src/ui/cli/health-display.ts +1 -0
- package/src/ui/cli/index.ts +63 -22
- package/src/ui/cli/stack-command.ts +40 -12
- package/src/ui/cli/web-command.ts +25 -7
- package/src/ui/server/index.ts +89 -27
- 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/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
|
package/docs/distributions.md
CHANGED
|
@@ -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
package/src/shared/constants.ts
CHANGED
|
@@ -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
|
|
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");
|