@monster-orange/den 0.2.6 → 0.2.7
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 +117 -0
- package/bin/den +9 -0
- package/docs/distributions.md +92 -6
- package/package.json +1 -1
- package/service/install.sh +41 -2
- package/src/shared/den-config.ts +38 -1
- package/src/shared/environment-manager/catalog.ts +9 -2
- package/src/shared/environment-manager/default-workspace.ts +45 -14
- package/src/shared/environment-manager/health.ts +51 -8
- package/src/shared/environment-manager/service-runner.ts +51 -5
- package/src/shared/environment-manager/workspace-manager.ts +2 -1
- package/src/shared/environment-manager/workspace-org.ts +264 -0
- package/src/shared/types/index.ts +7 -0
- package/src/ui/cli/clone-plan.ts +243 -0
- package/src/ui/cli/flag-parser.ts +1 -1
- package/src/ui/cli/index.ts +249 -40
- package/src/ui/cli/org-command.ts +162 -0
- package/src/ui/cli/results-viewer.ts +5 -5
- package/src/ui/cli/table.ts +20 -0
- package/src/ui/cli/validate-all.ts +171 -0
- package/src/ui/mcp/server.ts +24 -1
- package/src/ui/server/daemon-reaper.ts +22 -4
- package/src/ui/server/index.ts +31 -4
- package/src/ui/server/release-service.ts +16 -3
- package/src/ui/server/workspace-routes.ts +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,123 @@ 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.7] - 2026-11-05
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **A workspace belongs to an org** (0.2.7-dev.2, ORG-1a). Its org is
|
|
12
|
+
`org:` on the workspace in workspaces.yml, else `org:` on the blueprint
|
|
13
|
+
workspace (`defaults.workspaces.<n>.org`, the one new blueprint key), else
|
|
14
|
+
the one catalog owner of its repos (case-insensitive; `gh: ""` ignored),
|
|
15
|
+
else `github_org`, the default org. It is computed on read, never written
|
|
16
|
+
to workspaces.yml. A config with no `org:` resolves to `github_org` as
|
|
17
|
+
before, unless all of a workspace's repos have one other owner in the
|
|
18
|
+
catalog.
|
|
19
|
+
- **`<cli> org list`** (0.2.7-dev.2, ORG-1a): each org's label, its
|
|
20
|
+
workspaces on this machine, which is the default, and the org warnings.
|
|
21
|
+
`<cli> workspace list` gains an Org column; `--org <id>` filters it.
|
|
22
|
+
- **Org warnings, never errors** (0.2.7-dev.2, ORG-1a, C1): a workspace
|
|
23
|
+
whose repos span several owners, an `org:` naming an owner Den does not
|
|
24
|
+
know, a repo in another owner than its workspace's org, a workspaces.yml
|
|
25
|
+
`org:` that overrides the blueprint's. Shown by `org list` and
|
|
26
|
+
`workspace list`.
|
|
27
|
+
- **`<cli> setup --plan`** (0.2.7-dev.2, ORG-1a): each repo's org and clone
|
|
28
|
+
URL, and whether it is cloned, then stop. On a fresh machine it plans the
|
|
29
|
+
blueprint's workspaces; once seeded, the focused workspace. `<cli> up
|
|
30
|
+
--plan` prints the same lines for its repos. Nothing is cloned, seeded or
|
|
31
|
+
written.
|
|
32
|
+
- **`GET /api/orgs` entries gain `default` and `workspaces`**, and
|
|
33
|
+
`/api/<cli>/config` gains `default_org` (0.2.7-dev.2, ORG-1a). Every
|
|
34
|
+
existing field, entry and order is unchanged; `?repos=1` still maps each
|
|
35
|
+
repo to its owner string.
|
|
36
|
+
- **`<cli> register --org <id>`** (0.2.7-dev.2, ORG-1a) lists another org's
|
|
37
|
+
repos for a managed clone.
|
|
38
|
+
|
|
39
|
+
- **Bare `<cli> validate` checks every manifest** (0.2.7-dev.1, from a
|
|
40
|
+
consumer). In the distribution root, with no argument: each workspace repo
|
|
41
|
+
once per checkout (own or held, plus any override, then the merge), the
|
|
42
|
+
held manifest when there is no checkout, and held manifests no workspace
|
|
43
|
+
uses. One line per repo, details on failure, exit 1 if any fails, nothing
|
|
44
|
+
cloned. `--held` checks `manifests/` alone, independent of the machine. A
|
|
45
|
+
path argument works as before.
|
|
46
|
+
|
|
47
|
+
### Changed
|
|
48
|
+
|
|
49
|
+
- **A repo is released from its own org** (0.2.7-dev.2, REL-1). With no
|
|
50
|
+
`release.org`, a repo with no `gh:` in `release.repos` released from
|
|
51
|
+
`github_org`, so a client org's repo targeted the wrong owner. Its owner
|
|
52
|
+
is now an explicit `release.org` (unchanged: it still wins for every
|
|
53
|
+
repo), else the repo's non-empty catalog owner, else `github_org`.
|
|
54
|
+
- **`register` lists the focused workspace's org** (0.2.7-dev.2, ORG-1a),
|
|
55
|
+
not `github_org` alone, and no longer falls back to the owner of Den's own
|
|
56
|
+
git remote (SEC-0): with no org it says how to set one.
|
|
57
|
+
|
|
58
|
+
### Fixed
|
|
59
|
+
|
|
60
|
+
- **A second root's server leaves the first root's services alone**
|
|
61
|
+
(0.2.7-dev.3, dev.4). Every root of a product shares one daemons file.
|
|
62
|
+
A server's two startup sweeps (the stale-daemon prune and the health
|
|
63
|
+
reaper) dropped any record whose address did not resolve in its own
|
|
64
|
+
workspaces, so starting a scratch root's server silently forgot another
|
|
65
|
+
root's running dev server; an interactive Ctrl-C stopped every service in
|
|
66
|
+
the file, and deactivating a workspace released any record with the same
|
|
67
|
+
workspace name. Records are now stamped with the root that started them
|
|
68
|
+
and a server lists, stops, releases and sweeps only its own (other roots'
|
|
69
|
+
records stay in the file untouched). An older unstamped record is never
|
|
70
|
+
dropped just for not resolving; it still goes when its containers are
|
|
71
|
+
verifiably gone or two health checks fail. The reaper now logs what it
|
|
72
|
+
removes.
|
|
73
|
+
- **A service the edge has no route for is no longer healthy** (0.2.7-dev.2).
|
|
74
|
+
A health URL served through the edge answered the edge's own
|
|
75
|
+
`404 page not found` when the service had no route, and Den counted any
|
|
76
|
+
404 as up. That exact 404 is now down with "no route (edge 404)", over
|
|
77
|
+
HTTP and HTTPS; a 404 from the service itself is still up.
|
|
78
|
+
- **A client workspace no longer clones from the default org** (0.2.7-dev.2,
|
|
79
|
+
ORG-1a). Seeding, `up` and the web sync gave a repo with no clone URL
|
|
80
|
+
`<github_org>/<repo>`; it is now `<org>/<repo>` with its workspace's org.
|
|
81
|
+
- **`<cli> setup` clones a catalog entry with `gh: ""`** from its
|
|
82
|
+
workspace's org (0.2.7-dev.2), as `up` does, instead of failing on an
|
|
83
|
+
empty slug.
|
|
84
|
+
- **A seed without a terminal no longer resets a workspace that has
|
|
85
|
+
entries** (SEED-0). `<cli> init` and `<cli> workspace seed` from a script
|
|
86
|
+
or `make` replaced every entry of the default (or blueprint) workspaces with
|
|
87
|
+
managed clones, your own dev-managed checkouts included, without asking.
|
|
88
|
+
Without a TTY they now refuse (exit 1, nothing written) and name
|
|
89
|
+
`--reset`; on a TTY they still ask, default No. `init --reset` and
|
|
90
|
+
`workspace seed --reset` reset as before.
|
|
91
|
+
- **A failed seed leaves the workspaces as they were** (0.2.7-dev.1, from a
|
|
92
|
+
consumer). Seeding wrote workspaces.yml, then filled the catalog, which
|
|
93
|
+
fails with no `github_org`: the workspace was reset and nothing could be
|
|
94
|
+
cloned. The catalog comes first now, and the org is needed only for a repo
|
|
95
|
+
with no clone URL in the catalog.
|
|
96
|
+
- **A seed refusal names the command that was run** (`init --reset` or
|
|
97
|
+
`workspace seed --reset`).
|
|
98
|
+
- **A `DEN_ROOT` with no den.yml is a one-line error** (exit 2), not a stack
|
|
99
|
+
trace from deep in module loading.
|
|
100
|
+
- **Piped output carries no ANSI from tables** (`status`, `services`, test
|
|
101
|
+
results). cli-table3 coloured borders and head cells itself, ignoring
|
|
102
|
+
NO_COLOR and a pipe.
|
|
103
|
+
- **`<cli> mcp --call` validates its arguments** against the tool's schema,
|
|
104
|
+
strictly: an unknown or misspelt key, or a bad value, exits 2 with the
|
|
105
|
+
problem and the keys the tool takes, and calls nothing.
|
|
106
|
+
- **The web UI service survives a removed nvm version.** Install prefers a
|
|
107
|
+
stable node of the same major version over a version manager's (or uses
|
|
108
|
+
`DEN_SERVICE_NODE`), and warns when it must keep one. `service status`
|
|
109
|
+
exits 1, naming it, when the installed node no longer exists.
|
|
110
|
+
|
|
111
|
+
- **`<cli> init` in the distribution root seeds even through a symlinked
|
|
112
|
+
path** (`/var` → `/private/var`, a symlinked home). It compared paths
|
|
113
|
+
without resolving links and ran the manifest scaffolder instead.
|
|
114
|
+
|
|
115
|
+
### Migrations
|
|
116
|
+
|
|
117
|
+
- A script that relied on a non-interactive seed resetting a changed
|
|
118
|
+
workspace must pass `--reset`.
|
|
119
|
+
- Optional (ORG-1a): to put a blueprint workspace in an org other than the
|
|
120
|
+
one its catalog owners give, add `org: <id>` to it under
|
|
121
|
+
`defaults.workspaces`. Pin Den 0.2.7 or later first: an older Den rejects
|
|
122
|
+
the key. Nothing else needs changing.
|
|
123
|
+
|
|
7
124
|
## [0.2.6] - 2026-11-04
|
|
8
125
|
|
|
9
126
|
### Fixed
|
package/bin/den
CHANGED
|
@@ -49,6 +49,15 @@ if [ "${1:-}" = "mcp" ] && ! case "${2:-}" in connect|disconnect|clients|config)
|
|
|
49
49
|
built="dist/src/ui/cli/mcp-main.js"
|
|
50
50
|
fi
|
|
51
51
|
|
|
52
|
+
# A DEN_ROOT that names no distribution is a usage error, not a crash: say
|
|
53
|
+
# so here, before any module resolves paths from it at import time.
|
|
54
|
+
# (`create` needs no distribution.)
|
|
55
|
+
if [ -n "${DEN_ROOT:-}" ] && [ "$entry" != "src/ui/cli/create-main.ts" ] && [ ! -f "$DEN_ROOT/den.yml" ]; then
|
|
56
|
+
echo "den: DEN_ROOT is set to \"$DEN_ROOT\" but that directory has no den.yml." >&2
|
|
57
|
+
echo " Point it at your distribution's checkout, or unset it." >&2
|
|
58
|
+
exit 2
|
|
59
|
+
fi
|
|
60
|
+
|
|
52
61
|
# Escape hatch: force the compiled build (e.g. to test the prod artifact).
|
|
53
62
|
if [ "${DEN_USE_DIST:-}" = "1" ] && [ -f "$DEN_ENGINE_ROOT/$built" ]; then
|
|
54
63
|
exec node "$DEN_ENGINE_ROOT/$built" "$@"
|
package/docs/distributions.md
CHANGED
|
@@ -104,6 +104,23 @@ naming the file each error is in, then checks the merged result:
|
|
|
104
104
|
ERROR services.api.commands.local.down unknown key "down": not in the base manifest
|
|
105
105
|
```
|
|
106
106
|
|
|
107
|
+
Run with no argument in the distribution root, `<cli> validate` checks every
|
|
108
|
+
manifest the distribution uses, and never clones:
|
|
109
|
+
|
|
110
|
+
- each repo of every workspace, once per checkout path, with the
|
|
111
|
+
workspaces that use it and where its base comes from (`own`, `held`,
|
|
112
|
+
`+ override`);
|
|
113
|
+
- a repo with no checkout on disk falls back to its held manifest, marked
|
|
114
|
+
`held only, no checkout`. With no held manifest either it is a warning, not
|
|
115
|
+
a failure;
|
|
116
|
+
- each held manifest in `manifests/` that no workspace uses.
|
|
117
|
+
|
|
118
|
+
It prints one line per repo, details only for a layer with errors or
|
|
119
|
+
warnings, and exits 1 if any fails. `<cli> validate --held` checks only
|
|
120
|
+
`manifests/`, as if nothing were checked out, so the result does not depend on
|
|
121
|
+
the machine (use it in a CI or upgrade gate). An override whose base is the
|
|
122
|
+
repo's own manifest is noted there, not checked.
|
|
123
|
+
|
|
107
124
|
The web UI's Services cards show the layers each manifest was built from
|
|
108
125
|
(`manifest: repo + override`), and `/api/services` returns them per service as
|
|
109
126
|
`manifestLayers`, e.g. `["held", "override"]`. `DEN_MANIFESTS_DIR` points the
|
|
@@ -435,8 +452,8 @@ defaults:
|
|
|
435
452
|
focus: true
|
|
436
453
|
```
|
|
437
454
|
|
|
438
|
-
Each entry takes `repos`, `branch` and `
|
|
439
|
-
`defaults.workspace`, plus `focus`. Seeding creates every named workspace
|
|
455
|
+
Each entry takes `repos`, `branch`, `autostart_off` and `org`, the same
|
|
456
|
+
fields as `defaults.workspace`, plus `focus`. Seeding creates every named workspace
|
|
440
457
|
with its repos, activates them all, and focuses the one with `focus: true`
|
|
441
458
|
(else the first). Set `defaults.workspace` or `defaults.workspaces`, not both:
|
|
442
459
|
both at once is an error, as is more than one `focus: true`.
|
|
@@ -452,6 +469,68 @@ Once seeded, a repo that later joins `defaults.workspace.repos` is added to
|
|
|
452
469
|
you removed from it or that another workspace holds. With
|
|
453
470
|
`defaults.workspaces` nothing is topped up.
|
|
454
471
|
|
|
472
|
+
To see what a setup would clone before it clones anything, run
|
|
473
|
+
`acme setup --plan`: each repo with its org and clone URL, and whether it
|
|
474
|
+
is cloned yet. On a fresh machine it plans the blueprint (every workspace
|
|
475
|
+
`acme workspace seed` would create); once seeded, the focused workspace.
|
|
476
|
+
`acme up --plan` lists the same lines for the repos it would start. Both
|
|
477
|
+
are read-only: nothing is cloned, seeded or written.
|
|
478
|
+
|
|
479
|
+
## Orgs
|
|
480
|
+
|
|
481
|
+
A distribution can hold work for more than one org: each workspace belongs
|
|
482
|
+
to one. An org's id is a key of `orgs:` or a GitHub owner, and today an org
|
|
483
|
+
is the GitHub owner of the same name. `github_org` is the default org. A
|
|
484
|
+
distribution that never mentions orgs has one org, the default, and every
|
|
485
|
+
workspace is in it.
|
|
486
|
+
|
|
487
|
+
```yaml
|
|
488
|
+
github_org: acme
|
|
489
|
+
orgs:
|
|
490
|
+
acme: { label: Acme }
|
|
491
|
+
globex: { label: Globex, color: "#0af" }
|
|
492
|
+
defaults:
|
|
493
|
+
workspaces:
|
|
494
|
+
storefront:
|
|
495
|
+
repos: [shop-web, shop]
|
|
496
|
+
client:
|
|
497
|
+
org: globex # optional: the workspace's org
|
|
498
|
+
repos: [globex-web, globex-api]
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
A workspace's org is the first of:
|
|
502
|
+
|
|
503
|
+
1. `org:` on the workspace in `config/local/workspaces.yml` (this machine)
|
|
504
|
+
2. `org:` on the blueprint workspace of the same name (committed)
|
|
505
|
+
3. the one owner of its repos in the catalog (`gh: <owner>/<repo>`; owners
|
|
506
|
+
compare case-insensitively, and a `gh: ""` counts as unknown)
|
|
507
|
+
4. `github_org`
|
|
508
|
+
|
|
509
|
+
It is worked out each time it is read and never written back. A repo with
|
|
510
|
+
no clone URL is cloned from `<org>/<repo>` with its workspace's org: seeding
|
|
511
|
+
the blueprint above gives `globex-web` `gh: globex/globex-web`, not
|
|
512
|
+
`acme/...`. `acme register` picks a managed clone from the focused
|
|
513
|
+
workspace's org, or from `--org <id>`.
|
|
514
|
+
|
|
515
|
+
`acme org list` shows each org's label, its workspaces and which is the
|
|
516
|
+
default. `acme workspace list` has an Org column, and `--org <id>` lists one
|
|
517
|
+
org's workspaces. A workspace whose repos belong to several owners, an
|
|
518
|
+
`org:` naming an owner Den does not know, or a repo in another owner than
|
|
519
|
+
its workspace's org is a warning in both lists, never an error.
|
|
520
|
+
`GET /api/orgs` adds `default` and `workspaces` to each entry, and
|
|
521
|
+
`/api/<cli>/config` adds `default_org`.
|
|
522
|
+
|
|
523
|
+
`org:` in the blueprint needs Den 0.2.7 or later: an older Den rejects the
|
|
524
|
+
unknown key. Pin 0.2.7 before you commit it.
|
|
525
|
+
|
|
526
|
+
## Releases
|
|
527
|
+
|
|
528
|
+
The Release page dispatches each repo's CD workflow on GitHub. A repo's
|
|
529
|
+
owner there is its own `gh: owner/repo` in `release.repos`, else
|
|
530
|
+
`release.org` when it is set, else the repo's owner in the catalog, else
|
|
531
|
+
`github_org`. Leave `release.org` unset when the repos belong to different
|
|
532
|
+
orgs: each then releases from its own.
|
|
533
|
+
|
|
455
534
|
## The bin shim
|
|
456
535
|
|
|
457
536
|
`bin/<cli>` is three lines of sh: it names the distribution root and runs
|
|
@@ -580,10 +659,17 @@ node_modules/@monster-orange/den/service/install.sh print # the plist only
|
|
|
580
659
|
| `DEN_ROOT` | required in a distribution |
|
|
581
660
|
| `DEN_SERVICE_LABEL` | `local.<distribution dir name>.web` |
|
|
582
661
|
| `DEN_SERVICE_LOGS` | `~/Library/Logs/<distribution dir name>` |
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
662
|
+
| `DEN_SERVICE_NODE` | see below |
|
|
663
|
+
|
|
664
|
+
The service runs the engine's server, from the distribution root, with the
|
|
665
|
+
`node` on your `PATH` at install time. A version manager's node (nvm, fnm,
|
|
666
|
+
volta, asdf, mise) lives under a versioned path that disappears when that
|
|
667
|
+
version is removed, so install prefers `/opt/homebrew/bin/node` or
|
|
668
|
+
`/usr/local/bin/node` when one has the same major version, and otherwise
|
|
669
|
+
keeps the version manager's and warns. `DEN_SERVICE_NODE` names the node
|
|
670
|
+
outright. `service status` exits 1 when the installed node no longer exists.
|
|
671
|
+
Re-run `install` after changing node versions or upgrading Den. Keep the
|
|
672
|
+
label stable: it is how launchd knows the service.
|
|
587
673
|
|
|
588
674
|
## MCP server
|
|
589
675
|
|
package/package.json
CHANGED
package/service/install.sh
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
#
|
|
16
16
|
# DEN_SERVICE_LABEL launchd label (default local.<dist dir name>.web)
|
|
17
17
|
# DEN_SERVICE_LOGS log directory (default ~/Library/Logs/<dist dir name>)
|
|
18
|
+
# DEN_SERVICE_NODE the node the agent runs (default: see pick_node)
|
|
18
19
|
# DEN_SERVICE_READY_CHECK=external install does not print "running": the
|
|
19
20
|
# caller (`<cli> service install`) checks the server
|
|
20
21
|
# answers first.
|
|
@@ -47,8 +48,36 @@ find_tsx() {
|
|
|
47
48
|
done
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
# A version manager's node (nvm, fnm, volta, asdf, mise) lives under a
|
|
52
|
+
# versioned path that disappears when that version is removed, and the agent
|
|
53
|
+
# then fails to start with no clear reason. Prefer a stable install of the
|
|
54
|
+
# same major version; otherwise keep it and say so.
|
|
55
|
+
STABLE_NODES="${DEN_SERVICE_STABLE_NODES:-/opt/homebrew/bin/node /usr/local/bin/node}"
|
|
56
|
+
node_major() { "$1" -p "process.versions.node.split('.')[0]" 2>/dev/null; }
|
|
57
|
+
pick_node() {
|
|
58
|
+
if [ -n "${DEN_SERVICE_NODE:-}" ]; then
|
|
59
|
+
[ -x "$DEN_SERVICE_NODE" ] || { echo "✗ DEN_SERVICE_NODE ($DEN_SERVICE_NODE) is not executable" >&2; exit 1; }
|
|
60
|
+
echo "$DEN_SERVICE_NODE"; return
|
|
61
|
+
fi
|
|
62
|
+
local node; node="$(command -v node)" || { echo "✗ node is not on PATH" >&2; exit 1; }
|
|
63
|
+
case "$node" in
|
|
64
|
+
*/.nvm/*|*/fnm/*|*/.fnm/*|*/.volta/*|*/.asdf/*|*/mise/*) ;;
|
|
65
|
+
*) echo "$node"; return ;;
|
|
66
|
+
esac
|
|
67
|
+
local want c; want="$(node_major "$node")"
|
|
68
|
+
for c in $STABLE_NODES; do
|
|
69
|
+
if [ -x "$c" ] && [ "$(node_major "$c")" = "$want" ]; then echo "$c"; return; fi
|
|
70
|
+
done
|
|
71
|
+
# Warn when installing; `print` writes the plist and nothing else.
|
|
72
|
+
if [ "${1:-}" = install ]; then
|
|
73
|
+
echo "⚠ the service will run $node, a version manager's node: it stops starting" >&2
|
|
74
|
+
echo " when that version is removed. Re-run install after switching, or set DEN_SERVICE_NODE." >&2
|
|
75
|
+
fi
|
|
76
|
+
echo "$node"
|
|
77
|
+
}
|
|
78
|
+
|
|
50
79
|
write_plist() { # $1 = output file
|
|
51
|
-
NODE="$(
|
|
80
|
+
NODE="$(pick_node "$MODE")"
|
|
52
81
|
TSX="$(find_tsx)" || { echo "✗ tsx not installed: run npm ci in $DIST" >&2; exit 1; }
|
|
53
82
|
DOCKER_DIR="$(dirname "$(command -v docker || echo /usr/local/bin/docker)")"
|
|
54
83
|
SVC_PATH="$(dirname "$NODE"):$DOCKER_DIR:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
|
@@ -79,7 +108,8 @@ else:
|
|
|
79
108
|
PY
|
|
80
109
|
}
|
|
81
110
|
|
|
82
|
-
|
|
111
|
+
MODE="${1:-install}"
|
|
112
|
+
case "$MODE" in
|
|
83
113
|
install)
|
|
84
114
|
mkdir -p "$LOGS" "$(dirname "$PLIST")"
|
|
85
115
|
write_plist "$PLIST.new"
|
|
@@ -115,6 +145,15 @@ case "${1:-install}" in
|
|
|
115
145
|
write_plist -
|
|
116
146
|
;;
|
|
117
147
|
status)
|
|
148
|
+
# The node the agent was installed with may be gone (a removed nvm
|
|
149
|
+
# version): launchd then restarts it forever with no useful message.
|
|
150
|
+
if [ -f "$PLIST" ] && command -v plutil >/dev/null 2>&1; then
|
|
151
|
+
svc_node="$(plutil -extract ProgramArguments.0 raw -o - "$PLIST" 2>/dev/null || true)"
|
|
152
|
+
if [ -n "$svc_node" ] && [ ! -x "$svc_node" ]; then
|
|
153
|
+
echo "✗ $LABEL runs $svc_node, which no longer exists: re-run service install" >&2
|
|
154
|
+
exit 1
|
|
155
|
+
fi
|
|
156
|
+
fi
|
|
118
157
|
# launchctl's own "Could not find service" is noise when it isn't loaded.
|
|
119
158
|
if out="$(launchctl print "$DOMAIN/$LABEL" 2>/dev/null)"; then
|
|
120
159
|
printf '%s\n' "$out" | grep -E '^\s*(state|pid|last exit code|runs|spawn type)'
|
package/src/shared/den-config.ts
CHANGED
|
@@ -636,6 +636,8 @@ export interface ResolvedWorkspaceBlueprint {
|
|
|
636
636
|
branch: string;
|
|
637
637
|
autostartOff: string[];
|
|
638
638
|
focus: boolean;
|
|
639
|
+
/** `org:` (ORG-1a): the org this workspace belongs to, when the blueprint names one. */
|
|
640
|
+
org?: string;
|
|
639
641
|
}
|
|
640
642
|
|
|
641
643
|
/** Which blueprint shape the distribution uses, and its workspaces. */
|
|
@@ -644,7 +646,7 @@ export interface WorkspaceBlueprints {
|
|
|
644
646
|
workspaces: ResolvedWorkspaceBlueprint[];
|
|
645
647
|
}
|
|
646
648
|
|
|
647
|
-
const BLUEPRINT_KEYS = ["branch", "repos", "autostart_off"];
|
|
649
|
+
const BLUEPRINT_KEYS = ["branch", "repos", "autostart_off", "org"];
|
|
648
650
|
|
|
649
651
|
function blueprintStrings(where: string, key: string, v: unknown): string[] {
|
|
650
652
|
if (v === undefined) return [];
|
|
@@ -674,12 +676,15 @@ function resolveBlueprint(
|
|
|
674
676
|
throw new Error(`${where}.branch must be a branch name`);
|
|
675
677
|
if (bp.focus !== undefined && typeof bp.focus !== "boolean")
|
|
676
678
|
throw new Error(`${where}.focus must be true or false`);
|
|
679
|
+
if (bp.org !== undefined && (typeof bp.org !== "string" || !bp.org.trim()))
|
|
680
|
+
throw new Error(`${where}.org must be an org id (a key of orgs:)`);
|
|
677
681
|
return {
|
|
678
682
|
name,
|
|
679
683
|
repos: blueprintStrings(where, "repos", bp.repos),
|
|
680
684
|
branch,
|
|
681
685
|
autostartOff: blueprintStrings(where, "autostart_off", bp.autostart_off),
|
|
682
686
|
focus: bp.focus === true,
|
|
687
|
+
...(typeof bp.org === "string" ? { org: bp.org.trim() } : {}),
|
|
683
688
|
};
|
|
684
689
|
}
|
|
685
690
|
|
|
@@ -864,6 +869,38 @@ export function readReleaseConfig(): ReleaseConfig {
|
|
|
864
869
|
return cfg;
|
|
865
870
|
}
|
|
866
871
|
|
|
872
|
+
/**
|
|
873
|
+
* `release.org` as configured (den.yml, then user.yml), without the
|
|
874
|
+
* `github_org` fallback readReleaseConfig fills in. An explicit value is the
|
|
875
|
+
* release owner of every repo (REL-1); unset, each repo's own catalog owner is.
|
|
876
|
+
*/
|
|
877
|
+
export function readExplicitReleaseOrg(): string | undefined {
|
|
878
|
+
const den = mergeReleaseLayer(
|
|
879
|
+
DEFAULT_RELEASE_CONFIG,
|
|
880
|
+
readDenConfig().release,
|
|
881
|
+
);
|
|
882
|
+
const org = mergeReleaseLayer(den, readUserConfig().release).org;
|
|
883
|
+
return typeof org === "string" && org.trim() ? org.trim() : undefined;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* The GitHub owner a repo is released from (REL-1): an explicit
|
|
888
|
+
* `release.org` wins, then the repo's non-empty catalog owner, then
|
|
889
|
+
* `github_org`. "" when none is known.
|
|
890
|
+
*/
|
|
891
|
+
export function releaseOwnerFor(opts: {
|
|
892
|
+
explicitOrg?: string;
|
|
893
|
+
catalogOwner?: string;
|
|
894
|
+
githubOrg?: string;
|
|
895
|
+
}): string {
|
|
896
|
+
return (
|
|
897
|
+
opts.explicitOrg?.trim() ||
|
|
898
|
+
opts.catalogOwner?.trim() ||
|
|
899
|
+
opts.githubOrg?.trim() ||
|
|
900
|
+
""
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
|
|
867
904
|
/**
|
|
868
905
|
* Persist a personal release override to config/local/user.yml (gitignored).
|
|
869
906
|
* Used by the Release tab's wave editor — team defaults stay in den.yml.
|
|
@@ -55,7 +55,8 @@ export function upsertCatalogEntry(
|
|
|
55
55
|
/**
|
|
56
56
|
* GitHub slug to clone `repoName` from.
|
|
57
57
|
*
|
|
58
|
-
* The catalog's `gh` when set, else the conventional `<
|
|
58
|
+
* The catalog's `gh` when set, else the conventional `<org>/<repo>`, where
|
|
59
|
+
* `<org>` is the workspace's resolved org (`github_org` when none is given) —
|
|
59
60
|
* the same rule `seedDefaultWorkspace` uses. Entries written by the
|
|
60
61
|
* dev-managed add flow carry `gh: ""`, so a repo can be IN the catalog and
|
|
61
62
|
* still have no slug; deriving it (rather than refusing to clone) is what lets
|
|
@@ -64,10 +65,16 @@ export function upsertCatalogEntry(
|
|
|
64
65
|
export function resolveRepoGhSlug(
|
|
65
66
|
repoName: string,
|
|
66
67
|
catalog: ServiceCatalog = readCatalog(),
|
|
68
|
+
/**
|
|
69
|
+
* The owner for a repo with no `gh`: the workspace's resolved org
|
|
70
|
+
* (`() => githubOrgFor(ws)`, ORG-1a). Called only when needed, so a repo
|
|
71
|
+
* with a slug never needs an org. Defaults to `github_org`.
|
|
72
|
+
*/
|
|
73
|
+
owner: () => string = () => requireGithubOrg(),
|
|
67
74
|
): string {
|
|
68
75
|
const gh = catalog[repoName]?.gh?.trim();
|
|
69
76
|
if (gh) return gh;
|
|
70
|
-
return `${
|
|
77
|
+
return `${owner()}/${repoName}`;
|
|
71
78
|
}
|
|
72
79
|
|
|
73
80
|
export function removeCatalogEntry(repoName: string): void {
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* all managed. On a fresh clone (no local workspace) den offers — once —
|
|
6
6
|
* to seed the `default` workspace from it so `den graph` / `den up` show the
|
|
7
7
|
* full system graph immediately; `den init` (run in the den root) re-seeds.
|
|
8
|
-
* Catalog clone URLs derive as `<
|
|
8
|
+
* Catalog clone URLs derive as `<org>/<repo>`, with the workspace's resolved org
|
|
9
|
+
* (workspace-org.ts; `github_org` unless the blueprint or catalog says otherwise). Switch any repo to a local
|
|
9
10
|
* checkout with `den use-local <repo>`.
|
|
10
11
|
*/
|
|
11
12
|
import {
|
|
@@ -16,9 +17,13 @@ import {
|
|
|
16
17
|
import { readCatalog, writeCatalog } from "./catalog.js";
|
|
17
18
|
import {
|
|
18
19
|
readWorkspaceBlueprints,
|
|
19
|
-
requireGithubOrg,
|
|
20
20
|
type ResolvedWorkspaceBlueprint,
|
|
21
21
|
} from "../den-config.js";
|
|
22
|
+
import {
|
|
23
|
+
githubOrgFor,
|
|
24
|
+
readOrgContext,
|
|
25
|
+
type OrgContext,
|
|
26
|
+
} from "./workspace-org.js";
|
|
22
27
|
import type {
|
|
23
28
|
ServiceCatalog,
|
|
24
29
|
WorkspaceConfig,
|
|
@@ -47,10 +52,6 @@ export function defaultWorkspaceRepos(): string[] {
|
|
|
47
52
|
return singleBlueprint()?.repos ?? [];
|
|
48
53
|
}
|
|
49
54
|
|
|
50
|
-
function org(): string {
|
|
51
|
-
return requireGithubOrg();
|
|
52
|
-
}
|
|
53
|
-
|
|
54
55
|
function blueprintServices(
|
|
55
56
|
bp: ResolvedWorkspaceBlueprint,
|
|
56
57
|
repos: string[] = bp.repos,
|
|
@@ -70,14 +71,41 @@ function blueprintServices(
|
|
|
70
71
|
return services;
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
/**
|
|
75
|
+
* The clone slug seeding would give each repo the catalog has no `gh` for:
|
|
76
|
+
* `<org>/<repo>` with its workspace's resolved org. A repo in several
|
|
77
|
+
* workspaces takes the first one's. Reads only; throws when a repo needs an
|
|
78
|
+
* org and none resolves (no org anywhere and no `github_org`).
|
|
79
|
+
*/
|
|
80
|
+
export function catalogFill(
|
|
81
|
+
workspaces: { name: string; repos: string[] }[],
|
|
82
|
+
catalog: ServiceCatalog = readCatalog(),
|
|
83
|
+
ctx: OrgContext = readOrgContext(),
|
|
84
|
+
): Map<string, string> {
|
|
85
|
+
const fill = new Map<string, string>();
|
|
86
|
+
for (const ws of workspaces)
|
|
87
|
+
for (const repo of ws.repos) {
|
|
88
|
+
if (catalog[repo]?.gh || fill.has(repo)) continue;
|
|
89
|
+
fill.set(repo, `${githubOrgFor(ws.name, ctx)}/${repo}`);
|
|
90
|
+
}
|
|
91
|
+
return fill;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Give each blueprint repo with no clone URL `<org>/<repo>`, where `<org>` is
|
|
96
|
+
* its workspace's resolved org (ORG-1a: the blueprint's `org:`, else the
|
|
97
|
+
* catalog owners, else `github_org`), so a client workspace never clones
|
|
98
|
+
* from the default org on a fresh machine. The org is resolved only for a
|
|
99
|
+
* repo that needs one, and before anything is written: an unresolvable org
|
|
100
|
+
* throws with the catalog untouched.
|
|
101
|
+
*/
|
|
73
102
|
function fillCatalog(
|
|
74
|
-
repos: string[],
|
|
103
|
+
workspaces: { name: string; repos: string[] }[],
|
|
75
104
|
catalog: ServiceCatalog = readCatalog(),
|
|
76
105
|
): void {
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
106
|
+
const fill = catalogFill(workspaces, catalog);
|
|
107
|
+
if (fill.size === 0) return;
|
|
108
|
+
for (const [repo, gh] of fill) catalog[repo] = { gh };
|
|
81
109
|
writeCatalog(catalog);
|
|
82
110
|
}
|
|
83
111
|
|
|
@@ -178,6 +206,9 @@ export function seedDefaultWorkspace(): string[] {
|
|
|
178
206
|
if (!bp) return [];
|
|
179
207
|
const repos = bp.repos;
|
|
180
208
|
|
|
209
|
+
// The catalog first: it is what can fail (no github_org), and a failure
|
|
210
|
+
// must leave the workspaces as they were, not reset and uncloneable.
|
|
211
|
+
fillCatalog([{ name: "default", repos }]);
|
|
181
212
|
const file = readWorkspacesFile();
|
|
182
213
|
file.workspaces.default = {
|
|
183
214
|
...(file.workspaces.default ?? {}),
|
|
@@ -189,7 +220,6 @@ export function seedDefaultWorkspace(): string[] {
|
|
|
189
220
|
file.focus = "default";
|
|
190
221
|
}
|
|
191
222
|
writeWorkspacesFile(file);
|
|
192
|
-
fillCatalog(repos);
|
|
193
223
|
return repos;
|
|
194
224
|
}
|
|
195
225
|
|
|
@@ -202,6 +232,7 @@ export function seedDefaultWorkspace(): string[] {
|
|
|
202
232
|
export function seedBlueprintWorkspaces(): { name: string; repos: string[] }[] {
|
|
203
233
|
const bps = blueprintWorkspaces();
|
|
204
234
|
if (bps.length === 0) return [];
|
|
235
|
+
fillCatalog(bps);
|
|
205
236
|
const file = readWorkspacesFile();
|
|
206
237
|
for (const bp of bps) {
|
|
207
238
|
const prev: WorkspaceConfig | undefined = file.workspaces[bp.name];
|
|
@@ -223,7 +254,6 @@ export function seedBlueprintWorkspaces(): { name: string; repos: string[] }[] {
|
|
|
223
254
|
file.active = [...names, ...kept.filter((n) => !names.includes(n))];
|
|
224
255
|
file.focus = (bps.find((b) => b.focus) ?? bps[0]).name;
|
|
225
256
|
writeWorkspacesFile(file);
|
|
226
|
-
fillCatalog([...new Set(bps.flatMap((b) => b.repos))]);
|
|
227
257
|
return bps.map((b) => ({ name: b.name, repos: [...b.repos] }));
|
|
228
258
|
}
|
|
229
259
|
|
|
@@ -277,7 +307,8 @@ export function topUpDefaultWorkspace(): string[] {
|
|
|
277
307
|
|
|
278
308
|
def.seeded_repos = [...repos];
|
|
279
309
|
writeWorkspacesFile(file);
|
|
280
|
-
if (missing.length > 0)
|
|
310
|
+
if (missing.length > 0)
|
|
311
|
+
fillCatalog([{ name: "default", repos: missing }], catalog);
|
|
281
312
|
return missing;
|
|
282
313
|
}
|
|
283
314
|
|
|
@@ -230,6 +230,28 @@ function trustContext(): { context: tls.SecureContext; agent: https.Agent } {
|
|
|
230
230
|
return _trust;
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
+
/**
|
|
234
|
+
* The body a reverse proxy sends for a host it has no route for: Traefik's
|
|
235
|
+
* default 404 (Go's http.NotFound). Behind a shared edge a stopped service
|
|
236
|
+
* answers with this instead of refusing the connection, so a 4xx-means-up
|
|
237
|
+
* rule would show it healthy. A service's own 404 carries its own body.
|
|
238
|
+
*/
|
|
239
|
+
const EDGE_NO_ROUTE_BODY = "404 page not found";
|
|
240
|
+
|
|
241
|
+
/** Up: 2xx, or a 4xx from the service itself (auth/route proves it answers). */
|
|
242
|
+
function statusOk(status: number, body404?: string): boolean {
|
|
243
|
+
if (status === 404 && body404?.trim() === EDGE_NO_ROUTE_BODY) return false;
|
|
244
|
+
return status < 300 || (status >= 400 && status < 500);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The PingResult for `status`, naming an edge 404 as "no route". */
|
|
248
|
+
function statusResult(status: number, body404?: string): PingResult {
|
|
249
|
+
const ok = statusOk(status, body404);
|
|
250
|
+
return ok || status !== 404
|
|
251
|
+
? { ok, status }
|
|
252
|
+
: { ok, status, error: "no route (edge 404)" };
|
|
253
|
+
}
|
|
254
|
+
|
|
233
255
|
/**
|
|
234
256
|
* HTTPS check. Connects to the c-ares-resolved IP (see above) but keeps the
|
|
235
257
|
* hostname for SNI and certificate verification; rewriting the URL to the IP,
|
|
@@ -276,11 +298,23 @@ async function pingHttps(parsed: URL, timeoutMs: number): Promise<PingResult> {
|
|
|
276
298
|
},
|
|
277
299
|
(res) => {
|
|
278
300
|
const status = res.statusCode ?? 0;
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
ok:
|
|
282
|
-
|
|
301
|
+
if (status !== 404) {
|
|
302
|
+
res.resume(); // discard the body
|
|
303
|
+
done({ ok: statusOk(status), status });
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
// A 404: read a little of the body to tell the edge's own "no
|
|
307
|
+
// route" from the service's 404.
|
|
308
|
+
let body = "";
|
|
309
|
+
res.setEncoding("utf8");
|
|
310
|
+
res.on("data", (c: string) => {
|
|
311
|
+
if (body.length < 64) body += c;
|
|
312
|
+
if (body.length >= 64) res.destroy();
|
|
283
313
|
});
|
|
314
|
+
const finish = () => done(statusResult(status, body));
|
|
315
|
+
res.on("end", finish);
|
|
316
|
+
res.on("close", finish);
|
|
317
|
+
res.on("error", finish);
|
|
284
318
|
},
|
|
285
319
|
);
|
|
286
320
|
const timer = setTimeout(() => {
|
|
@@ -334,11 +368,20 @@ async function pingOnce(
|
|
|
334
368
|
// (e.g. VPN / /etc/hosts) returning 301 on port 80 for services that actually run
|
|
335
369
|
// on a different port — the previous `< 500` check showed those as green.
|
|
336
370
|
// 5xx = unhealthy (service errored).
|
|
337
|
-
|
|
338
|
-
//
|
|
339
|
-
// sockets in the long-lived web server
|
|
371
|
+
// A 404 may be the edge's own "no route" (see EDGE_NO_ROUTE_BODY): read
|
|
372
|
+
// a little of it. Otherwise discard the body: only the status matters,
|
|
373
|
+
// and unconsumed bodies pin sockets in the long-lived web server.
|
|
374
|
+
if (res.status === 404) {
|
|
375
|
+
let body = "";
|
|
376
|
+
try {
|
|
377
|
+
body = (await res.text()).slice(0, 64);
|
|
378
|
+
} catch {
|
|
379
|
+
/* unreadable body: judge by the status alone */
|
|
380
|
+
}
|
|
381
|
+
return statusResult(res.status, body);
|
|
382
|
+
}
|
|
340
383
|
void res.body?.cancel().catch(() => {});
|
|
341
|
-
return { ok, status: res.status };
|
|
384
|
+
return { ok: statusOk(res.status), status: res.status };
|
|
342
385
|
} catch (e: unknown) {
|
|
343
386
|
clearTimeout(timer);
|
|
344
387
|
const err = e as Error;
|