@monster-orange/den 0.2.1 → 0.2.3

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,135 @@ 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.3] - 2026-11-04
8
+
9
+ ### Added
10
+
11
+ - **`secret_env`: secrets resolved by Den into a stack's or a service's env**
12
+ (R16), so a consumer no longer wraps `<provider> run -- <cli> restart
13
+ <repo>` by hand, and a plain restart no longer drops a key. In a manifest,
14
+ `compose.secret_env` (for the docker compose process: the compose file's
15
+ `${VAR}` or pass-through `environment:` entries) and
16
+ `services.<svc>.secret_env` (for that service's own up/restart command)
17
+ map an env var to a fully qualified ref, `<vault>/<item>/<field>`. For
18
+ Doppler that is `<project>/<config>/<key>`; for 1Password vault / item /
19
+ field; for `env` the field is the env var to read. Existing refs (the
20
+ engine's named secrets, test users) are unchanged. Resolved through the
21
+ configured `secrets.provider` at `up` and `restart` (also the in-place
22
+ restart of one compose service), all or nothing, before anything stops or
23
+ starts. A missing key fails that target, exit 1, naming the key, ref and
24
+ provider. Values go only into the spawned process's env: never into the
25
+ compose override, logs, activity records, MCP output or messages. A dry
26
+ run (`-n`) never asks the provider; it lists the key names it would
27
+ inject. A CLI-tabs start of a service with `secret_env` runs as a daemon,
28
+ so the values stay out of the tab's shell. `validate` rejects a ref that
29
+ is not exactly three non-empty parts, and a key that is not an env var
30
+ name. See "Secrets in a target's env" in `docs/distributions.md`.
31
+
32
+ ### Fixed
33
+
34
+ - **`<cli> mcp connect` for Claude Code failed whenever the server has env**
35
+ ("Invalid environment variable format: <name>"): `claude mcp add`'s `-e`
36
+ takes several values, so the server name after the env pairs was read as
37
+ one more. The name now comes right after `add`.
38
+ - **The up/restart secrets check said the backend was "not ready" when it
39
+ was signed in with no default scope.** `secret_env` refs name their own
40
+ scope, so they still resolve; it now says "signed in, no default scope
41
+ set" instead of warning.
42
+
43
+ ### Migrations
44
+
45
+ - None required.
46
+
47
+ ## [0.2.2] - 2026-11-04
48
+
49
+ ### Added
50
+
51
+ - **`<cli> service install | status | print | uninstall`**: the web UI's
52
+ launchd service, over `service/install.sh`, so a distribution no longer
53
+ wraps the script in its Makefile. The label and log directory are
54
+ `web.service: { label, logs }` in the org config; the defaults are the
55
+ script's own (`local.<distribution dir name>.web`,
56
+ `~/Library/Logs/<distribution dir name>`), so an existing install keeps its
57
+ label. A bad label, a non-path `logs` or an unknown key under `web.service`
58
+ is an error. `install` (which also restarts) says `running` only once
59
+ `/api/server-info` answers with JSON, and after 30s without an answer says
60
+ `installed but not answering yet: check <logs dir>` and exits 1. `print`
61
+ writes the plist only, on any OS. See "Running the web UI as a service" in
62
+ `docs/distributions.md`.
63
+ - **`<cli> mcp --call <tool> ['<json args>']` and `<cli> mcp --list`.** One
64
+ tool call from a shell, through the same tool layer as the MCP server
65
+ (registered tools only, so actions only as named in `mcp.actions`; every
66
+ guard; redaction; audit headers), against the running web server. Prints
67
+ the tool's text to stdout and exits 1 when the tool reports an error.
68
+ `--list` prints the tool names.
69
+ - **Service aliases.** `up`, `down`, `restart` and `logs` (and every caller
70
+ of the service resolver, so `/api/service/exec` too) accept a service's
71
+ manifest key or a compose stack's name, when it names exactly one service
72
+ in the workspace: `<cli> restart indexer` restarts the repo whose service
73
+ or stack is `indexer`. Repo names and listed addresses win over an alias.
74
+ An ambiguous alias is an error naming the candidates (`"worker" is
75
+ ambiguous in workspace "default": one/worker, two/worker`). See "Naming a
76
+ service" in `docs/distributions.md`.
77
+ - **`validate` flags a redundant held manifest.** When a repo carries its own
78
+ manifest and the distribution still holds `manifests/<repo>.<name>.yml`,
79
+ `validate` warns (never an error) that the held file is not used and can be
80
+ deleted, or turned into `manifests/<repo>.override.yml`.
81
+
82
+ ### Fixed
83
+
84
+ - **`-n`/`--dry-run` and `--plan` run nothing on every path.** Given a
85
+ `repo/service` address or an alias, `up` and `down` ignored them and
86
+ really ran the service (and its dependencies); they now print the order
87
+ they would run and stop. `restart` accepts `-n`/`--dry-run` too. Tested
88
+ for each way to name a target: the cwd repo, a repo, an address, an alias,
89
+ a compose stack name and an app.
90
+ - **A dry run says what the real run does**, per target: the same command
91
+ lookup (own command, restart as down + up, a repo-started service's
92
+ repo up, nothing to stop, not found), so a dry run fails where the real
93
+ run would. Every dry run names its targets (`Would down: …`).
94
+ - **`down` and `restart` on named services act on those services only.**
95
+ They took the services' dependencies down (or restarted them) too, even
96
+ ones other running services still use. `up` still starts dependencies;
97
+ `down <repo>` still stops the repo's closure, naming what else depends on
98
+ it.
99
+ - **`down <repo>` stops that repo only.** It stopped the repo's whole
100
+ derived closure, so `down api` took down stacks it sits on that other
101
+ running systems use. `--with-deps` keeps the old behaviour (naming what
102
+ else uses them); `down -a` still stops everything.
103
+ - **Plans show remote services as skipped, with the reason**
104
+ (`core (remote, skipped): core runs in dev; …`), and a named remote target
105
+ fails with the real run's reason.
106
+ - **A compose sub-service restarts on its own**: `restart <repo>/<svc>` runs
107
+ `docker compose restart <svc>` in its stack (`<cli> stack restart <stack>
108
+ --service <svc>`): the same container and env, nothing recreated. Up and
109
+ down stay the stack's.
110
+ - **A service its repo starts**: `down` on it says it is stopped by the
111
+ repo's down and names the command (exit 0, so a workspace Down that sends
112
+ every service stays clean); `restart` fails the same way, exit 1.
113
+ - **`down` of a remote repo no longer warns about its dependents**: it stops
114
+ nothing, so nothing is at risk. Only targets that really stop here count.
115
+ - **A failed or unknown target exits 1.** `up`, `down` and `restart` exited 0
116
+ when a named service failed or did not exist.
117
+ - **A service with no commands of its own** (its repo's up starts it): `up`
118
+ runs the repo's up and says so, instead of `command "up" not found`; other
119
+ commands point at the repo, since they would reach its other services.
120
+ - **`service status`** no longer prints launchctl's errors when the service
121
+ is not loaded.
122
+
123
+ ### Migrations
124
+
125
+ - None required.
126
+ - `down <repo/service>` and `restart <repo/service>` no longer touch the
127
+ service's dependencies; stop or restart those by name.
128
+ - `down <repo>` (and a bare `down` in a repo) stops only that repo; add
129
+ `--with-deps` for its closure.
130
+ - A Makefile target that runs `service/install.sh` with `DEN_SERVICE_LABEL`
131
+ and `DEN_SERVICE_LOGS` can become `<cli> service install`; put a non-default
132
+ label or log directory in `web.service` first, since the CLI reads the
133
+ config, not those env vars.
134
+ - Delete held manifests `validate` now reports as unused.
135
+
7
136
  ## [0.2.1] - 2026-11-04
8
137
 
9
138
  ### Fixed
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
@@ -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,
@@ -196,6 +213,57 @@ ones (`⇄ hybrid`).
196
213
  production_envs: [prod] # default: [prod, production]
197
214
  ```
198
215
 
216
+ ## Secrets in a target's env
217
+
218
+ `secret_env` puts secrets from the configured secrets provider
219
+ (`secrets.provider`: `doppler`, `1password` or `env`) into the env of what
220
+ Den starts, instead of wrapping `<provider> run -- <cli> restart <repo>` by
221
+ hand:
222
+
223
+ ```yaml
224
+ # .acme.yml
225
+ compose:
226
+ secret_env: # the docker compose process's env, at stack up / restart
227
+ OPENAI_API_KEY: shop/dev_personal/OPENAI_API_KEY
228
+ services:
229
+ worker:
230
+ secret_env: # the env of this service's own up / restart command
231
+ STRIPE_KEY: Engineering/Stripe Test/secret key
232
+ ```
233
+
234
+ Each value is a fully qualified ref, `<vault>/<item>/<field>`:
235
+
236
+ | Provider | `a/b/c` means |
237
+ | ----------- | -------------------------------------------------------------------------- |
238
+ | `doppler` | `--project a --config b`, key `c` (`secrets.doppler` is not consulted) |
239
+ | `1password` | vault `a`, item `b`, field `c` |
240
+ | `env` | the env var `c` of the process running Den (`secrets.keys` is not applied) |
241
+
242
+ A `compose.secret_env` key reaches a container only through the compose
243
+ file: `${OPENAI_API_KEY}` in it, or a pass-through `environment:
244
+ [OPENAI_API_KEY]`.
245
+
246
+ - **When.** `up` and `restart`, including `restart <repo>/<service>` of one
247
+ compose service (`stack up`, `stack restart`, `stack restart --service`),
248
+ from the CLI, the web UI and MCP. Every key is resolved before anything
249
+ stops or starts.
250
+ - **Missing.** A key the provider cannot resolve (unset, empty, no access)
251
+ fails that target, exit 1, naming the key, the ref and the provider. The
252
+ rest of the set is not used: nothing starts with a partial set, and a
253
+ restart leaves the old instance running.
254
+ - **Where values go.** Only into the spawned process's env (the docker
255
+ compose process, or the service's command). Never into the generated
256
+ compose override, logs, activity records, MCP output, dry runs or
257
+ messages. A CLI-tabs (`cli_tabs`) start of a service with `secret_env`
258
+ runs as a daemon, since a tab would put them in its shell history.
259
+ - **Dry run.** `-n`/`--dry-run` never asks the provider. It lists the key
260
+ names it would inject: `shop: would inject secret_env compose:
261
+ OPENAI_API_KEY (names only; not resolved in a dry run)`.
262
+ - **Validation.** `<cli> validate` rejects a ref that is not exactly three
263
+ non-empty parts and a key that is not an env var name
264
+ (`[A-Za-z_][A-Za-z0-9_]*`). A `stacks:` entry in the org config takes
265
+ `secret_env` too.
266
+
199
267
  ## den.yml: the product profile
200
268
 
201
269
  Every runtime name derives from `product.name`. Set only what you want to
@@ -329,7 +397,40 @@ are the same directory.
329
397
  ## Running the web UI as a service (macOS)
330
398
 
331
399
  Den ships a launchd installer that runs the web UI at login and restarts it if
332
- it exits. From the distribution root:
400
+ it exits:
401
+
402
+ ```bash
403
+ ./bin/acme service install # install (or refresh) and start it
404
+ ./bin/acme service status # launchd's view: state, pid, last exit code
405
+ ./bin/acme service print # the LaunchAgent plist it would install; installs nothing
406
+ ./bin/acme service uninstall # stop it and remove the LaunchAgent
407
+ ```
408
+
409
+ The label and log directory come from the org config:
410
+
411
+ ```yaml
412
+ web:
413
+ port: 7777
414
+ service:
415
+ label: local.acme.web # default: local.<distribution dir name>.web
416
+ logs: ~/Library/Logs/acme # default: ~/Library/Logs/<distribution dir name>
417
+ ```
418
+
419
+ The defaults are the installer's own, so an install made by the script keeps
420
+ its label. `logs` may start with `~/`; a relative path is under the
421
+ distribution root. A label other than letters, digits, `.`, `-` and `_`, a
422
+ `logs` that is not a path, or any other key under `web.service` is an error.
423
+ `print` works on any OS; the rest are macOS only.
424
+
425
+ `install` also restarts a service that is already installed. It says the
426
+ service is running only once the web server answers
427
+ `http://localhost:<web.port>/api/server-info` with JSON (a fresh server
428
+ answers empty for a few seconds while it starts). If it has not answered
429
+ within 30s, it says `installed but not answering yet: check <logs dir>` and
430
+ exits 1.
431
+
432
+ `<cli> service` runs the installer script below, which you can also run
433
+ yourself, from the distribution root:
333
434
 
334
435
  ```bash
335
436
  DEN_ROOT="$PWD" DEN_SERVICE_LABEL=local.acme.web \
@@ -407,6 +508,20 @@ everyone's checkout is in the same place.
407
508
  `--port <n>` (on `mcp` or `mcp connect`) points it at a web server on another
408
509
  port.
409
510
 
511
+ ### One call from a shell
512
+
513
+ ```sh
514
+ ./bin/acme mcp --list # the tool names this org registers
515
+ ./bin/acme mcp --call status # one tool; prints its text result
516
+ ./bin/acme mcp --call logs_tail '{"service":"shop/api","lines":50}'
517
+ ```
518
+
519
+ `--call <tool> ['<json args>']` runs one tool through the same tool layer as
520
+ the server: only registered tools (so actions only as named in
521
+ `mcp.actions`), every guard, the same redaction and audit headers. It prints
522
+ the tool's text to stdout and exits 1 when the tool reports an error (a
523
+ refusal, a failed action, the web server down).
524
+
410
525
  ### Config
411
526
 
412
527
  Without an `mcp:` block in the org config (`config/<name>.yml`) only the read
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monster-orange/den",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Den: a local multi-repo dev orchestrator, HTTPS edge and E2E harness",
5
5
  "type": "module",
6
6
  "bin": {
@@ -72,6 +72,17 @@ async function main(): Promise<number> {
72
72
  const r = await provider.preflight();
73
73
  if (r.ok) return 0;
74
74
  if (gate && !cfg.required) {
75
+ // Signed in but no default scope: secret_env refs name their own
76
+ // (<project>/<config>/<key>), so only the engine's own secrets lack one.
77
+ if (await provider.isReady()) {
78
+ console.error(
79
+ chalk.gray(
80
+ ` ${label}: signed in, no default scope set (secret_env refs still resolve;` +
81
+ ` test logins and the saved sudo password need one: \`make ${product().cli}-setup-secrets\`).`,
82
+ ),
83
+ );
84
+ return 0;
85
+ }
75
86
  console.error(
76
87
  chalk.yellow(
77
88
  `⚠️ ${label} is not ready — continuing.\n` +
@@ -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
  /**