@monster-orange/den 0.2.0 → 0.2.1

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,73 @@ 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.1] - 2026-11-04
8
+
9
+ ### Fixed
10
+
11
+ - **MCP `service_restart` on a service with only `up` and `down`.**
12
+ `/api/service/exec` now runs `up`, `down` and `restart` (env local) through
13
+ the service runner, as `<cli> up|down|restart` do, instead of looking up
14
+ and executing the manifest's command itself. So, for the web UI's panel and
15
+ for MCP alike: `restart` with no declared `restart` is `down` then `up`;
16
+ `up` on a service that is already healthy starts nothing and says
17
+ `already up (healthy)`; `up` attaches to a running shared instance; `down`
18
+ honours workspace ownership. The panel offers Restart whenever a service has
19
+ `up` and `down`.
20
+ - **A detached start says when it is ready.** `/api/service/exec` sends a
21
+ `ready` event once a detached `up` or `restart` is up: its health check
22
+ passes (`checked: true`; for a restart, only after the old instance went
23
+ down or 5s passed), or, with no health check, its launcher is still running
24
+ after 5s (`checked: false`). MCP `service_up` and `service_restart` return
25
+ at `ready` (`"ready": true` in the result) instead of waiting for a
26
+ launcher that runs in the foreground (a dev server) to exit, which ran
27
+ into the 180s timeout and reported a false failure. A restart of a running
28
+ shared instance now says `restarting the running instance`, not
29
+ `already running`, and the log path is printed once. An MCP action's
30
+ `ended` reads `ready` or `handed-off` for a detached start, never
31
+ `stopped`.
32
+ - **MCP actions report failure.** `service_up|down|restart`, `run_tests` and
33
+ `workspace_sync` return `isError` with `"failed": true` in the result when
34
+ the command exits non-zero, a detached service's launcher exits non-zero,
35
+ or the call times out. They used to come back ok with `exitCode: 1` inside.
36
+ - **`<cli> up|down|restart <remote service>` exits 1.** Naming a remote
37
+ service (or its repo) directly is a refused command. A remote service
38
+ reached through a workspace, `-a` or `-A <app>` run is still said and
39
+ skipped, exit 0.
40
+ - **Refusals name the command.** `runs in dev (AWS); nothing to restart here`
41
+ (and `stop`, `start`, `run "<cmd>" on`), not `start` for everything.
42
+
43
+ ### Added
44
+
45
+ - **`<cli> mcp connect`: register the MCP server with your agents.**
46
+ `mcp clients` lists the agents installed here (Claude Code, Codex, Cursor,
47
+ VS Code, Claude Desktop, Windsurf, Gemini CLI) and whether each is
48
+ connected. `mcp connect <client>...|--all` registers the distribution's
49
+ server at user scope through each client's own CLI or config file, and
50
+ `--project [dir]` writes a project's config instead. `--dry-run` shows the
51
+ change, `mcp disconnect` removes it, and `mcp config` prints JSON for any
52
+ other client. The registration pins `PATH` to the running node so GUI
53
+ agents can launch it. See "Connecting your agents" in
54
+ `docs/distributions.md`.
55
+ - **Refused attempts are in the activity trail.** `/api/activity` entries may
56
+ carry `outcome: "refused"` and a `reason`: a refused or unresolvable
57
+ `/api/service/exec`, and any MCP action a guard refused before it reached
58
+ the web server. Agents report the latter to `POST /api/activity` (agent
59
+ audit header required; it appends one sanitised entry and nothing else).
60
+ The panel's Activity tab shows them as refused. An attempt that named only
61
+ the repo shows for the repo and for each of its services.
62
+ - **`target.expect` in placement.** `/api/services` and `/api/health` rows'
63
+ `target` now carry `expect` (as declared) when the manifest's target has its
64
+ own `url`.
65
+
66
+ ### Migrations
67
+
68
+ - None required. Two additive API changes a contract probe may see:
69
+ `target.expect` on `/api/services` and `/api/health` rows, and
70
+ `outcome`/`reason` on `/api/activity` entries.
71
+ - Scripts that relied on `<cli> restart|down <remote service>` exiting 0
72
+ should expect 1.
73
+
7
74
  ## [0.2.0] - 2026-11-04
8
75
 
9
76
  ### Added
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"
@@ -367,17 +367,45 @@ answers 404, the MCP server reads the web server's version from
367
367
  `web server is Den 0.1.0; stacks needs >= 0.2.0: run acme web restart`. Any
368
368
  other 404 is reported as it is.
369
369
 
370
- Register it with a client as a stdio server, for example:
371
-
372
- ```json
373
- {
374
- "mcpServers": {
375
- "acme": { "command": "/path/to/acme/bin/acme", "args": ["mcp"] }
376
- }
377
- }
370
+ ### Connecting your agents
371
+
372
+ `<cli> mcp connect` registers the server with the agents you use, so nobody
373
+ edits an MCP config by hand:
374
+
375
+ ```sh
376
+ ./bin/acme mcp clients # which agents are installed, which are connected
377
+ ./bin/acme mcp connect --all # every installed agent, user scope
378
+ ./bin/acme mcp connect claude-code # one (or several) by id
379
+ ./bin/acme mcp connect cursor --project ../shop # a project's config instead
380
+ ./bin/acme mcp connect --all --dry-run # show what it would write or run
381
+ ./bin/acme mcp disconnect cursor
382
+ ./bin/acme mcp config # JSON for any other MCP client
378
383
  ```
379
384
 
380
- `--port <n>` points it at a web server on another port.
385
+ | Client | User scope | Project scope (`--project [dir]`) |
386
+ | ---------------- | ------------------------------------- | --------------------------------- |
387
+ | `claude-code` | `claude mcp add --scope user` | `<dir>/.mcp.json` |
388
+ | `codex` | `codex mcp add` | – |
389
+ | `cursor` | `~/.cursor/mcp.json` | `<dir>/.cursor/mcp.json` |
390
+ | `vscode` | `code --add-mcp` | `<dir>/.vscode/mcp.json` |
391
+ | `claude-desktop` | its `claude_desktop_config.json` | – |
392
+ | `windsurf` | `~/.codeium/windsurf/mcp_config.json` | – |
393
+ | `gemini` | `~/.gemini/settings.json` | `<dir>/.gemini/settings.json` |
394
+
395
+ The server is registered under the CLI's name (`acme`) as the distribution's
396
+ `bin/<cli>` by absolute path, with `PATH` set to the directory of the `node`
397
+ that ran `connect` plus the system dirs. GUI agents start without your login
398
+ shell's PATH (so without nvm's node) and would otherwise fail to launch it.
399
+ After upgrading or switching node, run `connect` again. For a JSON config,
400
+ `connect` changes only its own entry, keeps the previous file as `.bak`, and
401
+ refuses to rewrite a file it cannot parse. Connecting again replaces the entry.
402
+ Restart the agent, or start a new session, to load it.
403
+
404
+ Project-scope files hold absolute paths on your machine: commit them only if
405
+ everyone's checkout is in the same place.
406
+
407
+ `--port <n>` (on `mcp` or `mcp connect`) points it at a web server on another
408
+ port.
381
409
 
382
410
  ### Config
383
411
 
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.1",
4
4
  "description": "Den: a local multi-repo dev orchestrator, HTTPS edge and E2E harness",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1063,7 +1063,7 @@ export function runServiceCommand(
1063
1063
  resolved.address,
1064
1064
  servicePlacement(readManifest(repoPath), subsystem, "local"),
1065
1065
  (e) => isProductionEnv(e) === true,
1066
- command === "down" ? "stop" : "start",
1066
+ command,
1067
1067
  );
1068
1068
  if (refusal) {
1069
1069
  onLine(refusal);
@@ -1094,7 +1094,9 @@ export function runServiceCommand(
1094
1094
  workspace,
1095
1095
  ];
1096
1096
  onLine(
1097
- `already running (shared) — owners: [${owners.join(", ")}] at ${resolved.repoPath}`,
1097
+ command === "restart"
1098
+ ? `restarting the running instance (shared) — owners: [${owners.join(", ")}] at ${resolved.repoPath}`
1099
+ : `already running (shared) — owners: [${owners.join(", ")}] at ${resolved.repoPath}`,
1098
1100
  );
1099
1101
  // restart still needs to bounce; only short-circuit pure `up`.
1100
1102
  if (command === "up") {
@@ -34,6 +34,8 @@ export interface PlacementTarget {
34
34
  env: string;
35
35
  account?: string;
36
36
  url?: string;
37
+ /** The status codes that count as up for `url`, as declared. */
38
+ expect?: number | number[];
37
39
  }
38
40
 
39
41
  export interface Placement {
@@ -84,6 +86,7 @@ function targetFor(svc: ServiceManagementConfig, env: string): PlacementTarget {
84
86
  env: tEnv,
85
87
  ...(own?.account ? { account: own.account } : {}),
86
88
  ...(url ? { url } : {}),
89
+ ...(own?.url && own.expect !== undefined ? { expect: own.expect } : {}),
87
90
  };
88
91
  }
89
92
 
@@ -186,16 +189,25 @@ export function isProtectedRemote(p: Placement, isProduction: IsProduction) {
186
189
  return p.runs === "remote" && !!p.target && isProduction(p.target.env);
187
190
  }
188
191
 
192
+ /** How a refusal names the command: up → "start", exec → `run "exec" on`. */
193
+ function refusedVerb(command: string): string {
194
+ if (command === "up" || command === "start") return "start";
195
+ if (command === "down" || command === "stop") return "stop";
196
+ if (command === "restart") return "restart";
197
+ return `run "${command}" on`;
198
+ }
199
+
189
200
  /**
190
- * Why Den must not run a command on `address`, or null. Every command on a
191
- * remote service is refused ("runs in dev (AWS); nothing to start here").
201
+ * Why Den must not run `command` on `address`, or null. Every command on a
202
+ * remote service is refused ("runs in dev (AWS); nothing to restart here").
192
203
  */
193
204
  export function placementRefusal(
194
205
  address: string,
195
206
  p: Placement,
196
207
  isProduction: IsProduction,
197
- verb: "start" | "stop" = "start",
208
+ command = "up",
198
209
  ): string | null {
210
+ const verb = refusedVerb(command);
199
211
  if (p.runs !== "remote") return null;
200
212
  const where = p.target
201
213
  ? `${p.target.env}${p.target.account ? ` (${p.target.account})` : ""}`
@@ -5,6 +5,7 @@
5
5
  * Main entry point for the interactive CLI
6
6
  */
7
7
 
8
+ import { isNamedTarget } from "./named-target.js";
8
9
  import chalk from "chalk";
9
10
  import oraSpinner from "ora";
10
11
 
@@ -695,6 +696,17 @@ class DenCLI {
695
696
  break;
696
697
 
697
698
  case "mcp": {
699
+ // Setup subcommands: register the server with agents.
700
+ const { MCP_CONNECT_SUBCOMMANDS, runMcpConnect } =
701
+ await import("./mcp-connect.js");
702
+ if (
703
+ (MCP_CONNECT_SUBCOMMANDS as readonly string[]).includes(
704
+ args[1] ?? "",
705
+ )
706
+ ) {
707
+ process.exitCode = await runMcpConnect(args.slice(1));
708
+ break;
709
+ }
698
710
  // bin/den routes `mcp` to its own entry (stdout is the protocol);
699
711
  // this covers the CLI run directly from a checkout.
700
712
  await import("./mcp-stdout-guard.js");
@@ -1298,7 +1310,15 @@ class DenCLI {
1298
1310
  /** Skip ANY already-healthy node (derived `up` starts only what's down). */
1299
1311
  skipHealthyAll?: boolean;
1300
1312
  },
1301
- opts?: { force?: boolean },
1313
+ opts?: {
1314
+ force?: boolean;
1315
+ /**
1316
+ * What the user named on the command line (repo or address). A remote
1317
+ * service among them is refused with a non-zero exit; one reached only
1318
+ * as part of a workspace or app run is said and skipped.
1319
+ */
1320
+ named?: readonly string[];
1321
+ },
1302
1322
  ): Promise<void> {
1303
1323
  // How long to show startup output before handing control back to the menu
1304
1324
  // for detachable (long-running) commands like `up`.
@@ -1388,11 +1408,16 @@ class DenCLI {
1388
1408
  "local",
1389
1409
  ),
1390
1410
  (e) => isProductionEnv(e),
1391
- command === "down" ? "stop" : "start",
1411
+ command,
1392
1412
  )
1393
1413
  : null;
1394
1414
  if (remote) {
1395
- console.log(chalk.gray(` • ${remote}`));
1415
+ if (isNamedTarget(address, opts?.named ?? [])) {
1416
+ console.log(chalk.red(` ✗ ${remote}`));
1417
+ process.exitCode = 1;
1418
+ } else {
1419
+ console.log(chalk.gray(` • ${remote}`));
1420
+ }
1396
1421
  continue;
1397
1422
  }
1398
1423
  if (command === "up" && !seedAddresses.has(address)) {
@@ -1732,6 +1757,7 @@ class DenCLI {
1732
1757
  plan = false,
1733
1758
  pull = false,
1734
1759
  ): Promise<void> {
1760
+ const named = [seed];
1735
1761
  const full = deriveGraph({ scope: "all" });
1736
1762
  // Same positional closure the web "up with deps" uses: everything the seed
1737
1763
  // sits ON TOP OF, never anything above it. Keeps the two entry points from
@@ -1813,12 +1839,17 @@ class DenCLI {
1813
1839
  );
1814
1840
  return;
1815
1841
  }
1816
- await this.runServicesCommand(order, "up", {
1842
+ await this.runServicesCommand(
1817
1843
  order,
1818
- waitDepsFor: (address) => hardDepsOf(graph, address),
1819
- failFast: true,
1820
- skipHealthyAll: true,
1821
- });
1844
+ "up",
1845
+ {
1846
+ order,
1847
+ waitDepsFor: (address) => hardDepsOf(graph, address),
1848
+ failFast: true,
1849
+ skipHealthyAll: true,
1850
+ },
1851
+ { named },
1852
+ );
1822
1853
  }
1823
1854
 
1824
1855
  /**
@@ -1874,7 +1905,12 @@ class DenCLI {
1874
1905
  console.log(chalk.gray(" (--dry-run: not stopping anything)\n"));
1875
1906
  return;
1876
1907
  }
1877
- await this.runServicesCommand(order, "down", { order }, { force });
1908
+ await this.runServicesCommand(
1909
+ order,
1910
+ "down",
1911
+ { order },
1912
+ { force, named: [seed] },
1913
+ );
1878
1914
  }
1879
1915
 
1880
1916
  /** Workspace repos with no runtime (modules, libraries) → their kind. */
@@ -2392,7 +2428,9 @@ class DenCLI {
2392
2428
  return;
2393
2429
  }
2394
2430
  preflightSecrets();
2395
- await this.runServicesCommand(targets, "up");
2431
+ await this.runServicesCommand(targets, "up", undefined, {
2432
+ named: addresses,
2433
+ });
2396
2434
  }
2397
2435
 
2398
2436
  private async cmdServiceDown(args: string[]): Promise<void> {
@@ -2452,7 +2490,10 @@ class DenCLI {
2452
2490
  addresses.length > 0
2453
2491
  ? addresses
2454
2492
  : this.resolveNoArgServiceTargets("down");
2455
- await this.runServicesCommand(targets, "down", undefined, { force });
2493
+ await this.runServicesCommand(targets, "down", undefined, {
2494
+ force,
2495
+ named: addresses,
2496
+ });
2456
2497
  }
2457
2498
 
2458
2499
  private async cmdServiceRestart(argv: string[]): Promise<void> {
@@ -2471,7 +2512,9 @@ class DenCLI {
2471
2512
  filtered.length > 0
2472
2513
  ? filtered
2473
2514
  : this.resolveNoArgServiceTargets("restart");
2474
- await this.runServicesCommand(addresses, "restart");
2515
+ await this.runServicesCommand(addresses, "restart", undefined, {
2516
+ named: filtered,
2517
+ });
2475
2518
  }
2476
2519
 
2477
2520
  /** Service address with a ⏸ marker when the repo is auto-start off. */