@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 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" "$@"
@@ -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 `autostart_off`, the same fields as
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
- The service runs the engine's server with the `node` on your `PATH` at install
585
- time, from the distribution root; re-run `install` after changing node versions
586
- or upgrading Den. Keep the label stable: it is how launchd knows the service.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monster-orange/den",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "Den: a local multi-repo dev orchestrator, HTTPS edge and E2E harness",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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="$(command -v node)" || { echo "✗ node is not on PATH" >&2; exit 1; }
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
- case "${1:-install}" in
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)'
@@ -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 `<github_org>/<repo>` —
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 `${requireGithubOrg()}/${repoName}`;
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 `<github_org>/<repo>`. Switch any repo to a local
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 o = org();
78
- for (const repo of repos) {
79
- if (!catalog[repo]?.gh) catalog[repo] = { gh: `${o}/${repo}` };
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) fillCatalog(missing, catalog);
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
- res.resume(); // discard the body
280
- done({
281
- ok: status < 300 || (status >= 400 && status < 500),
282
- status,
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
- const ok = res.status < 300 || (res.status >= 400 && res.status < 500);
338
- // Discard the body: only the status matters, and unconsumed bodies pin
339
- // sockets in the long-lived web server (one leak per poll adds up).
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;