@monster-orange/den 0.2.0 → 0.2.2

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,162 @@ 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.2] - 2026-11-04
8
+
9
+ ### Added
10
+
11
+ - **`<cli> service install | status | print | uninstall`**: the web UI's
12
+ launchd service, over `service/install.sh`, so a distribution no longer
13
+ wraps the script in its Makefile. The label and log directory are
14
+ `web.service: { label, logs }` in the org config; the defaults are the
15
+ script's own (`local.<distribution dir name>.web`,
16
+ `~/Library/Logs/<distribution dir name>`), so an existing install keeps its
17
+ label. A bad label, a non-path `logs` or an unknown key under `web.service`
18
+ is an error. `install` (which also restarts) says `running` only once
19
+ `/api/server-info` answers with JSON, and after 30s without an answer says
20
+ `installed but not answering yet: check <logs dir>` and exits 1. `print`
21
+ writes the plist only, on any OS. See "Running the web UI as a service" in
22
+ `docs/distributions.md`.
23
+ - **`<cli> mcp --call <tool> ['<json args>']` and `<cli> mcp --list`.** One
24
+ tool call from a shell, through the same tool layer as the MCP server
25
+ (registered tools only, so actions only as named in `mcp.actions`; every
26
+ guard; redaction; audit headers), against the running web server. Prints
27
+ the tool's text to stdout and exits 1 when the tool reports an error.
28
+ `--list` prints the tool names.
29
+ - **Service aliases.** `up`, `down`, `restart` and `logs` (and every caller
30
+ of the service resolver, so `/api/service/exec` too) accept a service's
31
+ manifest key or a compose stack's name, when it names exactly one service
32
+ in the workspace: `<cli> restart indexer` restarts the repo whose service
33
+ or stack is `indexer`. Repo names and listed addresses win over an alias.
34
+ An ambiguous alias is an error naming the candidates (`"worker" is
35
+ ambiguous in workspace "default": one/worker, two/worker`). See "Naming a
36
+ service" in `docs/distributions.md`.
37
+ - **`validate` flags a redundant held manifest.** When a repo carries its own
38
+ manifest and the distribution still holds `manifests/<repo>.<name>.yml`,
39
+ `validate` warns (never an error) that the held file is not used and can be
40
+ deleted, or turned into `manifests/<repo>.override.yml`.
41
+
42
+ ### Fixed
43
+
44
+ - **`-n`/`--dry-run` and `--plan` run nothing on every path.** Given a
45
+ `repo/service` address or an alias, `up` and `down` ignored them and
46
+ really ran the service (and its dependencies); they now print the order
47
+ they would run and stop. `restart` accepts `-n`/`--dry-run` too. Tested
48
+ for each way to name a target: the cwd repo, a repo, an address, an alias,
49
+ a compose stack name and an app.
50
+ - **A dry run says what the real run does**, per target: the same command
51
+ lookup (own command, restart as down + up, a repo-started service's
52
+ repo up, nothing to stop, not found), so a dry run fails where the real
53
+ run would. Every dry run names its targets (`Would down: …`).
54
+ - **`down` and `restart` on named services act on those services only.**
55
+ They took the services' dependencies down (or restarted them) too, even
56
+ ones other running services still use. `up` still starts dependencies;
57
+ `down <repo>` still stops the repo's closure, naming what else depends on
58
+ it.
59
+ - **`down <repo>` stops that repo only.** It stopped the repo's whole
60
+ derived closure, so `down api` took down stacks it sits on that other
61
+ running systems use. `--with-deps` keeps the old behaviour (naming what
62
+ else uses them); `down -a` still stops everything.
63
+ - **Plans show remote services as skipped, with the reason**
64
+ (`core (remote, skipped): core runs in dev; …`), and a named remote target
65
+ fails with the real run's reason.
66
+ - **A compose sub-service restarts on its own**: `restart <repo>/<svc>` runs
67
+ `docker compose restart <svc>` in its stack (`<cli> stack restart <stack>
68
+ --service <svc>`): the same container and env, nothing recreated. Up and
69
+ down stay the stack's.
70
+ - **A service its repo starts**: `down` on it says it is stopped by the
71
+ repo's down and names the command (exit 0, so a workspace Down that sends
72
+ every service stays clean); `restart` fails the same way, exit 1.
73
+ - **`down` of a remote repo no longer warns about its dependents**: it stops
74
+ nothing, so nothing is at risk. Only targets that really stop here count.
75
+ - **A failed or unknown target exits 1.** `up`, `down` and `restart` exited 0
76
+ when a named service failed or did not exist.
77
+ - **A service with no commands of its own** (its repo's up starts it): `up`
78
+ runs the repo's up and says so, instead of `command "up" not found`; other
79
+ commands point at the repo, since they would reach its other services.
80
+ - **`service status`** no longer prints launchctl's errors when the service
81
+ is not loaded.
82
+
83
+ ### Migrations
84
+
85
+ - None required.
86
+ - `down <repo/service>` and `restart <repo/service>` no longer touch the
87
+ service's dependencies; stop or restart those by name.
88
+ - `down <repo>` (and a bare `down` in a repo) stops only that repo; add
89
+ `--with-deps` for its closure.
90
+ - A Makefile target that runs `service/install.sh` with `DEN_SERVICE_LABEL`
91
+ and `DEN_SERVICE_LOGS` can become `<cli> service install`; put a non-default
92
+ label or log directory in `web.service` first, since the CLI reads the
93
+ config, not those env vars.
94
+ - Delete held manifests `validate` now reports as unused.
95
+
96
+ ## [0.2.1] - 2026-11-04
97
+
98
+ ### Fixed
99
+
100
+ - **MCP `service_restart` on a service with only `up` and `down`.**
101
+ `/api/service/exec` now runs `up`, `down` and `restart` (env local) through
102
+ the service runner, as `<cli> up|down|restart` do, instead of looking up
103
+ and executing the manifest's command itself. So, for the web UI's panel and
104
+ for MCP alike: `restart` with no declared `restart` is `down` then `up`;
105
+ `up` on a service that is already healthy starts nothing and says
106
+ `already up (healthy)`; `up` attaches to a running shared instance; `down`
107
+ honours workspace ownership. The panel offers Restart whenever a service has
108
+ `up` and `down`.
109
+ - **A detached start says when it is ready.** `/api/service/exec` sends a
110
+ `ready` event once a detached `up` or `restart` is up: its health check
111
+ passes (`checked: true`; for a restart, only after the old instance went
112
+ down or 5s passed), or, with no health check, its launcher is still running
113
+ after 5s (`checked: false`). MCP `service_up` and `service_restart` return
114
+ at `ready` (`"ready": true` in the result) instead of waiting for a
115
+ launcher that runs in the foreground (a dev server) to exit, which ran
116
+ into the 180s timeout and reported a false failure. A restart of a running
117
+ shared instance now says `restarting the running instance`, not
118
+ `already running`, and the log path is printed once. An MCP action's
119
+ `ended` reads `ready` or `handed-off` for a detached start, never
120
+ `stopped`.
121
+ - **MCP actions report failure.** `service_up|down|restart`, `run_tests` and
122
+ `workspace_sync` return `isError` with `"failed": true` in the result when
123
+ the command exits non-zero, a detached service's launcher exits non-zero,
124
+ or the call times out. They used to come back ok with `exitCode: 1` inside.
125
+ - **`<cli> up|down|restart <remote service>` exits 1.** Naming a remote
126
+ service (or its repo) directly is a refused command. A remote service
127
+ reached through a workspace, `-a` or `-A <app>` run is still said and
128
+ skipped, exit 0.
129
+ - **Refusals name the command.** `runs in dev (AWS); nothing to restart here`
130
+ (and `stop`, `start`, `run "<cmd>" on`), not `start` for everything.
131
+
132
+ ### Added
133
+
134
+ - **`<cli> mcp connect`: register the MCP server with your agents.**
135
+ `mcp clients` lists the agents installed here (Claude Code, Codex, Cursor,
136
+ VS Code, Claude Desktop, Windsurf, Gemini CLI) and whether each is
137
+ connected. `mcp connect <client>...|--all` registers the distribution's
138
+ server at user scope through each client's own CLI or config file, and
139
+ `--project [dir]` writes a project's config instead. `--dry-run` shows the
140
+ change, `mcp disconnect` removes it, and `mcp config` prints JSON for any
141
+ other client. The registration pins `PATH` to the running node so GUI
142
+ agents can launch it. See "Connecting your agents" in
143
+ `docs/distributions.md`.
144
+ - **Refused attempts are in the activity trail.** `/api/activity` entries may
145
+ carry `outcome: "refused"` and a `reason`: a refused or unresolvable
146
+ `/api/service/exec`, and any MCP action a guard refused before it reached
147
+ the web server. Agents report the latter to `POST /api/activity` (agent
148
+ audit header required; it appends one sanitised entry and nothing else).
149
+ The panel's Activity tab shows them as refused. An attempt that named only
150
+ the repo shows for the repo and for each of its services.
151
+ - **`target.expect` in placement.** `/api/services` and `/api/health` rows'
152
+ `target` now carry `expect` (as declared) when the manifest's target has its
153
+ own `url`.
154
+
155
+ ### Migrations
156
+
157
+ - None required. Two additive API changes a contract probe may see:
158
+ `target.expect` on `/api/services` and `/api/health` rows, and
159
+ `outcome`/`reason` on `/api/activity` entries.
160
+ - Scripts that relied on `<cli> restart|down <remote service>` exiting 0
161
+ should expect 1.
162
+
7
163
  ## [0.2.0] - 2026-11-04
8
164
 
9
165
  ### Added
package/README.md CHANGED
@@ -133,7 +133,7 @@ provider in `src/shared/auth/token-providers/`.
133
133
 
134
134
  ```bash
135
135
  den up [repo] # start the repo's derived dependency closure (-x deps only, -n dry run)
136
- den down [repo] # stop it cleanly
136
+ den down [repo] # stop it cleanly (--with-deps: and what it sits on)
137
137
  den status # service health for the active workspace
138
138
  den apps # Org > Application > Repo > Services
139
139
  den graph [repo] # the derived dependency graph
package/bin/den CHANGED
@@ -41,8 +41,9 @@ if [ "${1:-}" = "create" ]; then
41
41
  built="dist/src/ui/cli/create-main.js"
42
42
  fi
43
43
  # `mcp` speaks the MCP protocol on stdout: its own entry point, so nothing
44
- # the full CLI prints can reach the protocol stream.
45
- if [ "${1:-}" = "mcp" ]; then
44
+ # the full CLI prints can reach the protocol stream. Its setup subcommands
45
+ # (connect, disconnect, clients, config) are ordinary CLI commands.
46
+ if [ "${1:-}" = "mcp" ] && ! case "${2:-}" in connect|disconnect|clients|config) true ;; *) false ;; esac; then
46
47
  shift
47
48
  entry="src/ui/cli/mcp-main.ts"
48
49
  built="dist/src/ui/cli/mcp-main.js"
@@ -109,6 +109,23 @@ The web UI's Services cards show the layers each manifest was built from
109
109
  `manifestLayers`, e.g. `["held", "override"]`. `DEN_MANIFESTS_DIR` points the
110
110
  held and override lookup at another directory (tests use it).
111
111
 
112
+ Once a repo carries its own manifest, a held `manifests/<repo>.acme.yml` is
113
+ never read. `validate` says so, as a warning (not an error), naming the held
114
+ file: delete it, or keep only what this distribution changes as
115
+ `manifests/<repo>.override.yml`.
116
+
117
+ ## Naming a service
118
+
119
+ `up`, `down`, `restart` and `logs` take a repo name (`shop`) or a
120
+ `repo/service` address (`shop/api`). A service's manifest key (`api`) or a
121
+ compose stack's name (`compose.name`, else the manifest's `name`) works too,
122
+ when it names exactly one service in the workspace: a stack stands for its
123
+ repo, so `<cli> restart search` restarts the repo whose stack is `search`.
124
+ Repo names and listed addresses always win over an alias, so a catalog repo
125
+ is never mistaken for another repo's service. An alias that names more than
126
+ one service is an error that lists them (`"worker" is ambiguous in workspace
127
+ "default": one/worker, two/worker`); name one of them.
128
+
112
129
  ## Placement
113
130
 
114
131
  Each service says where it runs, per environment: `local` (on this machine,
@@ -329,7 +346,40 @@ are the same directory.
329
346
  ## Running the web UI as a service (macOS)
330
347
 
331
348
  Den ships a launchd installer that runs the web UI at login and restarts it if
332
- it exits. From the distribution root:
349
+ it exits:
350
+
351
+ ```bash
352
+ ./bin/acme service install # install (or refresh) and start it
353
+ ./bin/acme service status # launchd's view: state, pid, last exit code
354
+ ./bin/acme service print # the LaunchAgent plist it would install; installs nothing
355
+ ./bin/acme service uninstall # stop it and remove the LaunchAgent
356
+ ```
357
+
358
+ The label and log directory come from the org config:
359
+
360
+ ```yaml
361
+ web:
362
+ port: 7777
363
+ service:
364
+ label: local.acme.web # default: local.<distribution dir name>.web
365
+ logs: ~/Library/Logs/acme # default: ~/Library/Logs/<distribution dir name>
366
+ ```
367
+
368
+ The defaults are the installer's own, so an install made by the script keeps
369
+ its label. `logs` may start with `~/`; a relative path is under the
370
+ distribution root. A label other than letters, digits, `.`, `-` and `_`, a
371
+ `logs` that is not a path, or any other key under `web.service` is an error.
372
+ `print` works on any OS; the rest are macOS only.
373
+
374
+ `install` also restarts a service that is already installed. It says the
375
+ service is running only once the web server answers
376
+ `http://localhost:<web.port>/api/server-info` with JSON (a fresh server
377
+ answers empty for a few seconds while it starts). If it has not answered
378
+ within 30s, it says `installed but not answering yet: check <logs dir>` and
379
+ exits 1.
380
+
381
+ `<cli> service` runs the installer script below, which you can also run
382
+ yourself, from the distribution root:
333
383
 
334
384
  ```bash
335
385
  DEN_ROOT="$PWD" DEN_SERVICE_LABEL=local.acme.web \
@@ -367,17 +417,59 @@ answers 404, the MCP server reads the web server's version from
367
417
  `web server is Den 0.1.0; stacks needs >= 0.2.0: run acme web restart`. Any
368
418
  other 404 is reported as it is.
369
419
 
370
- Register it with a client as a stdio server, for example:
420
+ ### Connecting your agents
421
+
422
+ `<cli> mcp connect` registers the server with the agents you use, so nobody
423
+ edits an MCP config by hand:
424
+
425
+ ```sh
426
+ ./bin/acme mcp clients # which agents are installed, which are connected
427
+ ./bin/acme mcp connect --all # every installed agent, user scope
428
+ ./bin/acme mcp connect claude-code # one (or several) by id
429
+ ./bin/acme mcp connect cursor --project ../shop # a project's config instead
430
+ ./bin/acme mcp connect --all --dry-run # show what it would write or run
431
+ ./bin/acme mcp disconnect cursor
432
+ ./bin/acme mcp config # JSON for any other MCP client
433
+ ```
371
434
 
372
- ```json
373
- {
374
- "mcpServers": {
375
- "acme": { "command": "/path/to/acme/bin/acme", "args": ["mcp"] }
376
- }
377
- }
435
+ | Client | User scope | Project scope (`--project [dir]`) |
436
+ | ---------------- | ------------------------------------- | --------------------------------- |
437
+ | `claude-code` | `claude mcp add --scope user` | `<dir>/.mcp.json` |
438
+ | `codex` | `codex mcp add` | – |
439
+ | `cursor` | `~/.cursor/mcp.json` | `<dir>/.cursor/mcp.json` |
440
+ | `vscode` | `code --add-mcp` | `<dir>/.vscode/mcp.json` |
441
+ | `claude-desktop` | its `claude_desktop_config.json` | – |
442
+ | `windsurf` | `~/.codeium/windsurf/mcp_config.json` | – |
443
+ | `gemini` | `~/.gemini/settings.json` | `<dir>/.gemini/settings.json` |
444
+
445
+ The server is registered under the CLI's name (`acme`) as the distribution's
446
+ `bin/<cli>` by absolute path, with `PATH` set to the directory of the `node`
447
+ that ran `connect` plus the system dirs. GUI agents start without your login
448
+ shell's PATH (so without nvm's node) and would otherwise fail to launch it.
449
+ After upgrading or switching node, run `connect` again. For a JSON config,
450
+ `connect` changes only its own entry, keeps the previous file as `.bak`, and
451
+ refuses to rewrite a file it cannot parse. Connecting again replaces the entry.
452
+ Restart the agent, or start a new session, to load it.
453
+
454
+ Project-scope files hold absolute paths on your machine: commit them only if
455
+ everyone's checkout is in the same place.
456
+
457
+ `--port <n>` (on `mcp` or `mcp connect`) points it at a web server on another
458
+ port.
459
+
460
+ ### One call from a shell
461
+
462
+ ```sh
463
+ ./bin/acme mcp --list # the tool names this org registers
464
+ ./bin/acme mcp --call status # one tool; prints its text result
465
+ ./bin/acme mcp --call logs_tail '{"service":"shop/api","lines":50}'
378
466
  ```
379
467
 
380
- `--port <n>` points it at a web server on another port.
468
+ `--call <tool> ['<json args>']` runs one tool through the same tool layer as
469
+ the server: only registered tools (so actions only as named in
470
+ `mcp.actions`), every guard, the same redaction and audit headers. It prints
471
+ the tool's text to stdout and exits 1 when the tool reports an error (a
472
+ refusal, a failed action, the web server down).
381
473
 
382
474
  ### Config
383
475
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monster-orange/den",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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,9 @@
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_READY_CHECK=external install does not print "running": the
19
+ # caller (`<cli> service install`) checks the server
20
+ # answers first.
18
21
  set -euo pipefail
19
22
 
20
23
  HERE="$(cd "$(dirname "$0")" && pwd)"
@@ -95,7 +98,11 @@ case "${1:-install}" in
95
98
  done
96
99
  [ "$loaded" = 1 ] || { echo "✗ launchctl bootstrap failed for $LABEL (plist: $PLIST)" >&2; exit 1; }
97
100
  launchctl kickstart -k "$DOMAIN/$LABEL"
98
- echo "✓ $LABEL running (logs: $LOGS/web.log)"
101
+ if [ "${DEN_SERVICE_READY_CHECK:-}" = external ]; then
102
+ echo "… $LABEL started; waiting for it to answer"
103
+ else
104
+ echo "✓ $LABEL running (logs: $LOGS/web.log)"
105
+ fi
99
106
  ;;
100
107
  uninstall)
101
108
  launchctl bootout "$DOMAIN/$LABEL" 2>/dev/null || true
@@ -106,7 +113,12 @@ case "${1:-install}" in
106
113
  write_plist -
107
114
  ;;
108
115
  status)
109
- launchctl print "$DOMAIN/$LABEL" | grep -E '^\s*(state|pid|last exit code|runs)' || echo "$LABEL is not loaded"
116
+ # launchctl's own "Could not find service" is noise when it isn't loaded.
117
+ if out="$(launchctl print "$DOMAIN/$LABEL" 2>/dev/null)"; then
118
+ printf '%s\n' "$out" | grep -E '^\s*(state|pid|last exit code|runs)'
119
+ else
120
+ echo "$LABEL is not loaded"
121
+ fi
110
122
  ;;
111
123
  *) echo "usage: $0 [install|uninstall|status|print]" >&2; exit 2 ;;
112
124
  esac
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import * as fs from "fs";
12
+ import * as os from "os";
12
13
  import * as path from "path";
13
14
  import * as yaml from "yaml";
14
15
  import type { Environment, ReleaseSourceConfig } from "./types/index.js";
@@ -238,6 +239,11 @@ export interface DenConfig {
238
239
  * only; `actions` opts in to named actions. See readMcpConfig.
239
240
  */
240
241
  mcp?: McpConfig;
242
+ /**
243
+ * The web UI: `port` (read by edge/config.ts readWebPort) and `service`,
244
+ * the launchd service `<cli> service` installs. See readWebServiceConfig.
245
+ */
246
+ web?: WebConfig;
241
247
  /**
242
248
  * Shared test accounts (email + secrets-backend item per role) the test
243
249
  * pillars log in as — see src/shared/auth/test-accounts.ts.
@@ -426,6 +432,7 @@ export function readDenConfig(): DenConfig {
426
432
  env_health_urls: parsed?.env_health_urls,
427
433
  security: parsed?.security,
428
434
  mcp: parsed?.mcp,
435
+ web: parsed?.web,
429
436
  test_accounts: parsed?.test_accounts,
430
437
  defaults: parsed?.defaults,
431
438
  secrets: parsed?.secrets,
@@ -602,6 +609,68 @@ export function readMcpConfig(
602
609
  };
603
610
  }
604
611
 
612
+ // ── Web UI service (`<cli> service`) ──────────────────────────────────────────
613
+
614
+ /** `web:` in the org config. */
615
+ export interface WebConfig {
616
+ port?: number;
617
+ /** The launchd service that runs the web UI (`<cli> service install`). */
618
+ service?: { label?: string; logs?: string };
619
+ }
620
+
621
+ /** `web.service` with its defaults applied. */
622
+ export interface ResolvedWebService {
623
+ /** launchd label; also the plist's name in ~/Library/LaunchAgents. */
624
+ label: string;
625
+ /** Directory the service's web.log goes to. */
626
+ logs: string;
627
+ }
628
+
629
+ const WEB_SERVICE_KEYS = ["label", "logs"];
630
+
631
+ /**
632
+ * The web UI's launchd service. The defaults are service/install.sh's own
633
+ * (`local.<distribution dir name>.web`, `~/Library/Logs/<dir name>`), so an
634
+ * install made with the script keeps its label. `logs` may start with `~/`;
635
+ * a relative path is under the distribution root. A bad value throws.
636
+ */
637
+ export function readWebServiceConfig(
638
+ config: DenConfig = readDenConfig(),
639
+ distRoot: string = ROOT,
640
+ home: string = os.homedir(),
641
+ ): ResolvedWebService {
642
+ const name = path.basename(distRoot);
643
+ const raw: unknown = config.web?.service;
644
+ if (
645
+ raw !== undefined &&
646
+ (raw === null || typeof raw !== "object" || Array.isArray(raw))
647
+ )
648
+ throw new Error("web.service must be a map: { label, logs }");
649
+ const svc = (raw ?? {}) as Record<string, unknown>;
650
+ for (const key of Object.keys(svc))
651
+ if (!WEB_SERVICE_KEYS.includes(key))
652
+ throw new Error(
653
+ `web.service: unknown key "${key}" (known: ${WEB_SERVICE_KEYS.join(", ")})`,
654
+ );
655
+ const label = svc.label ?? `local.${name}.web`;
656
+ if (typeof label !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(label))
657
+ throw new Error(
658
+ `web.service.label must be letters, digits, dots, dashes and underscores, got "${String(label)}"`,
659
+ );
660
+ const logs = svc.logs ?? path.join(home, "Library", "Logs", name);
661
+ if (typeof logs !== "string" || !logs.trim())
662
+ throw new Error(
663
+ `web.service.logs must be a directory path, got "${String(logs)}"`,
664
+ );
665
+ const expanded = logs.startsWith("~/")
666
+ ? path.join(home, logs.slice(2))
667
+ : logs;
668
+ return {
669
+ label,
670
+ logs: path.isAbsolute(expanded) ? expanded : path.join(distRoot, expanded),
671
+ };
672
+ }
673
+
605
674
  // ── Release config (Release tab) ──────────────────────────────────────────────
606
675
 
607
676
  /**