@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.
@@ -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
 
@@ -95,7 +96,12 @@ import {
95
96
  getWaitDeps,
96
97
  } from "../../shared/environment-manager/dag.js";
97
98
  import { waitForReady } from "../../shared/environment-manager/readiness.js";
98
- import { resolveService } from "../../shared/environment-manager/service-runner.js";
99
+ import {
100
+ canonicalServiceAddress,
101
+ previewServiceCommand,
102
+ resolveService,
103
+ type ServiceCommandPreview,
104
+ } from "../../shared/environment-manager/service-runner.js";
99
105
  import { getGitHubCoords } from "../../shared/smoke/git-remote.js";
100
106
  import {
101
107
  getReposRoot,
@@ -156,6 +162,7 @@ import {
156
162
  import { cleanupStaleTabs } from "../../shared/cli-tabs.js";
157
163
  import { cmdConfig } from "./config-command.js";
158
164
  import { cmdEdge } from "./edge-command.js";
165
+ import { cmdService } from "./service-command.js";
159
166
  import { cmdStack } from "./stack-command.js";
160
167
  import { cmdWeb, displayWebHelp } from "./web-command.js";
161
168
  import { cmdGraph } from "./graph-command.js";
@@ -190,14 +197,15 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
190
197
  down: {
191
198
  usage: "down [repo] [flags]",
192
199
  lines: [
193
- "Stop the cwd (or named) repo's derived dependency closure, in reverse order.",
200
+ "Stop the cwd (or named) repo; --with-deps also stops what it sits on (its",
201
+ "derived closure, in reverse order), naming anything else that uses it.",
194
202
  "-a/--all stops EVERY workspace system (reverse derived order, cwd-agnostic;",
195
203
  "every down script runs — no already-stopped skip).",
196
- "Flags: -a/--all · -A/--app <app> · -n/--dry-run (print order, stop nothing) · --manifest (legacy depends_on)",
204
+ "Flags: -a/--all · -A/--app <app> · --with-deps · -n/--dry-run (print order, stop nothing) · --manifest (legacy depends_on)",
197
205
  ],
198
206
  },
199
207
  restart: {
200
- usage: "restart [service...] [-A <app>]",
208
+ usage: "restart [service...] [-A <app>] [-n|--dry-run]",
201
209
  lines: [
202
210
  `Restart the given services, or every repo in an application (\`${product().cli} apps\`).`,
203
211
  ],
@@ -300,10 +308,18 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
300
308
  ],
301
309
  },
302
310
  mcp: {
303
- usage: "mcp [--port <web port>]",
311
+ usage: "mcp [--port <web port>] [--list | --call <tool> ['<json args>']]",
304
312
  lines: [
305
313
  "An MCP server over stdio for agents: a client of the running web server",
306
314
  `(\`${product().cli} web up\`). Read tools always; actions only as named in mcp.actions.`,
315
+ "--list prints the tool names; --call runs one tool and prints its result.",
316
+ ],
317
+ },
318
+ service: {
319
+ usage: "service [install|status|print|uninstall]",
320
+ lines: [
321
+ "The web UI as a macOS launchd service, started at login. Label and logs:",
322
+ "config web.service { label, logs }.",
307
323
  ],
308
324
  },
309
325
  "local-dev": {
@@ -362,6 +378,7 @@ const TELEMETRY_COMMANDS = new Set<string>([
362
378
  "apps",
363
379
  "applications",
364
380
  "mcp",
381
+ "service",
365
382
  ]);
366
383
 
367
384
  /** Map a raw command token to a PII-safe, registered telemetry command name. */
@@ -379,6 +396,20 @@ function telemetryReason(error: unknown): string {
379
396
  return name && name !== "Object" ? name : "Error";
380
397
  }
381
398
 
399
+ /**
400
+ * What was typed, with each service alias (a manifest service key or a
401
+ * compose stack name) mapped to its service's address, so the derived graph,
402
+ * ownership and the daemon registry all see one name. Flags pass through; an
403
+ * ambiguous alias throws, naming the candidates. `limit` maps only the first
404
+ * n positional args.
405
+ */
406
+ function canonicalTargets(args: string[], limit = Infinity): string[] {
407
+ let seen = 0;
408
+ return args.map((a) =>
409
+ a.startsWith("-") || seen++ >= limit ? a : canonicalServiceAddress(a),
410
+ );
411
+ }
412
+
382
413
  /** Print one manifest's validation: a status line, then each issue. */
383
414
  function printValidation(
384
415
  label: string,
@@ -684,6 +715,10 @@ class DenCLI {
684
715
  await cmdEdge(args.slice(1));
685
716
  break;
686
717
 
718
+ case "service":
719
+ process.exitCode = await cmdService(args.slice(1));
720
+ break;
721
+
687
722
  case "stack":
688
723
  await cmdStack(args.slice(1));
689
724
  break;
@@ -695,6 +730,17 @@ class DenCLI {
695
730
  break;
696
731
 
697
732
  case "mcp": {
733
+ // Setup subcommands: register the server with agents.
734
+ const { MCP_CONNECT_SUBCOMMANDS, runMcpConnect } =
735
+ await import("./mcp-connect.js");
736
+ if (
737
+ (MCP_CONNECT_SUBCOMMANDS as readonly string[]).includes(
738
+ args[1] ?? "",
739
+ )
740
+ ) {
741
+ process.exitCode = await runMcpConnect(args.slice(1));
742
+ break;
743
+ }
698
744
  // bin/den routes `mcp` to its own entry (stdout is the protocol);
699
745
  // this covers the CLI run directly from a checkout.
700
746
  await import("./mcp-stdout-guard.js");
@@ -1298,7 +1344,20 @@ class DenCLI {
1298
1344
  /** Skip ANY already-healthy node (derived `up` starts only what's down). */
1299
1345
  skipHealthyAll?: boolean;
1300
1346
  },
1301
- opts?: { force?: boolean },
1347
+ opts?: {
1348
+ force?: boolean;
1349
+ /**
1350
+ * What the user named on the command line (repo or address). A remote
1351
+ * service among them is refused with a non-zero exit; one reached only
1352
+ * as part of a workspace or app run is said and skipped.
1353
+ */
1354
+ named?: readonly string[];
1355
+ /**
1356
+ * `-n`/`--dry-run` (and `--plan` on this path): print what would run,
1357
+ * in order, and run nothing. Read-only on every path.
1358
+ */
1359
+ dryRun?: boolean;
1360
+ },
1302
1361
  ): Promise<void> {
1303
1362
  // How long to show startup output before handing control back to the menu
1304
1363
  // for detachable (long-running) commands like `up`.
@@ -1308,6 +1367,22 @@ class DenCLI {
1308
1367
  getFocusedWorkspaceName() ??
1309
1368
  undefined;
1310
1369
  const force = opts?.force ?? false;
1370
+ // R13: why a remote service is not run here, or null.
1371
+ const remoteRefusal = (address: string): string | null => {
1372
+ const placed = resolveService(address, workspace);
1373
+ return placed
1374
+ ? placementRefusal(
1375
+ address,
1376
+ servicePlacement(
1377
+ readManifest(placed.repoPath),
1378
+ placed.subsystem,
1379
+ "local",
1380
+ ),
1381
+ (e) => isProductionEnv(e),
1382
+ command,
1383
+ )
1384
+ : null;
1385
+ };
1311
1386
 
1312
1387
  // Resolve dependency order. Derived mode supplies the order directly;
1313
1388
  // otherwise topo-sort manifest depends_on (`down` → reverse).
@@ -1321,8 +1396,17 @@ class DenCLI {
1321
1396
  const startupOrder = ws
1322
1397
  ? resolveStartupOrder(addresses, catalog, ws.config, ws.name)
1323
1398
  : addresses;
1399
+ // Only up pulls in dependencies (a service needs them to start).
1400
+ // Down and restart act on what was named: its dependencies may be
1401
+ // serving other running services.
1402
+ const namedOnly = (): string[] => {
1403
+ const kept = startupOrder.filter((a) => addresses.includes(a));
1404
+ return [...kept, ...addresses.filter((a) => !kept.includes(a))];
1405
+ };
1324
1406
  if (command === "down") {
1325
- ordered = [...startupOrder].reverse();
1407
+ ordered = namedOnly().reverse();
1408
+ } else if (command !== "up") {
1409
+ ordered = namedOnly();
1326
1410
  } else {
1327
1411
  ordered = startupOrder;
1328
1412
  if (ordered.length > addresses.length) {
@@ -1338,6 +1422,42 @@ class DenCLI {
1338
1422
  }
1339
1423
  }
1340
1424
 
1425
+ if (opts?.dryRun) {
1426
+ // The same lookup a real run makes, so the dry run says what it does.
1427
+ const previews = ordered.map((a): ServiceCommandPreview => {
1428
+ const remote = remoteRefusal(a);
1429
+ if (!remote) return previewServiceCommand(a, command, workspace);
1430
+ // As the loop below: a named remote target fails, any other is
1431
+ // said and skipped.
1432
+ return isNamedTarget(a, opts?.named ?? [])
1433
+ ? { kind: "error", address: a, note: remote }
1434
+ : {
1435
+ kind: "nothing",
1436
+ address: a,
1437
+ note: `${a} (remote, skipped): ${remote}`,
1438
+ };
1439
+ });
1440
+ const doing =
1441
+ { up: "starting", down: "stopping", restart: "restarting" }[command] ??
1442
+ `running "${command}" on`;
1443
+ const runs = previews
1444
+ .filter((p) => p.kind === "run" || p.kind === "via-repo")
1445
+ .map((p) => p.address);
1446
+ console.log(
1447
+ chalk.cyan(
1448
+ `\n Would ${command}: ${[...new Set(runs)].join(" → ") || "(nothing)"}`,
1449
+ ),
1450
+ );
1451
+ for (const p of previews) {
1452
+ if (p.kind === "via-repo" || p.kind === "nothing")
1453
+ console.log(chalk.gray(` ${p.note}`));
1454
+ if (p.kind === "error") console.log(chalk.red(` ✗ ${p.note}`));
1455
+ }
1456
+ if (previews.some((p) => p.kind === "error")) process.exitCode = 1;
1457
+ console.log(chalk.gray(` (--dry-run: not ${doing} anything)\n`));
1458
+ return;
1459
+ }
1460
+
1341
1461
  // Build a catalog + workspace reference once per run for readiness lookups.
1342
1462
  const catalog = readCatalog();
1343
1463
  const ws = getActiveWorkspace();
@@ -1378,21 +1498,14 @@ class DenCLI {
1378
1498
  }
1379
1499
  // R13: a remote service is never started or stopped from here. Said,
1380
1500
  // not failed, so a workspace or app up/down carries on past it.
1381
- const placed = resolveService(address, workspace);
1382
- const remote = placed
1383
- ? placementRefusal(
1384
- address,
1385
- servicePlacement(
1386
- readManifest(placed.repoPath),
1387
- placed.subsystem,
1388
- "local",
1389
- ),
1390
- (e) => isProductionEnv(e),
1391
- command === "down" ? "stop" : "start",
1392
- )
1393
- : null;
1501
+ const remote = remoteRefusal(address);
1394
1502
  if (remote) {
1395
- console.log(chalk.gray(` • ${remote}`));
1503
+ if (isNamedTarget(address, opts?.named ?? [])) {
1504
+ console.log(chalk.red(` ✗ ${remote}`));
1505
+ process.exitCode = 1;
1506
+ } else {
1507
+ console.log(chalk.gray(` • ${remote}`));
1508
+ }
1396
1509
  continue;
1397
1510
  }
1398
1511
  if (command === "up" && !seedAddresses.has(address)) {
@@ -1604,6 +1717,9 @@ class DenCLI {
1604
1717
  }
1605
1718
  });
1606
1719
 
1720
+ // Any target that failed makes the whole command fail (scripts and
1721
+ // agents read the exit code).
1722
+ if (!started) process.exitCode = 1;
1607
1723
  if (plan?.failFast && !started) {
1608
1724
  console.log(
1609
1725
  chalk.red(
@@ -1732,6 +1848,7 @@ class DenCLI {
1732
1848
  plan = false,
1733
1849
  pull = false,
1734
1850
  ): Promise<void> {
1851
+ const named = [seed];
1735
1852
  const full = deriveGraph({ scope: "all" });
1736
1853
  // Same positional closure the web "up with deps" uses: everything the seed
1737
1854
  // sits ON TOP OF, never anything above it. Keeps the two entry points from
@@ -1791,7 +1908,12 @@ class DenCLI {
1791
1908
  return;
1792
1909
  }
1793
1910
  if (dryRun) {
1794
- console.log(chalk.gray(" (--dry-run: not starting anything)\n"));
1911
+ await this.runServicesCommand(
1912
+ order,
1913
+ "up",
1914
+ { order },
1915
+ { named, dryRun: true },
1916
+ );
1795
1917
  return;
1796
1918
  }
1797
1919
  // Check the secrets backend first so any first-clone `make setup` (and the up
@@ -1813,12 +1935,17 @@ class DenCLI {
1813
1935
  );
1814
1936
  return;
1815
1937
  }
1816
- await this.runServicesCommand(order, "up", {
1938
+ await this.runServicesCommand(
1817
1939
  order,
1818
- waitDepsFor: (address) => hardDepsOf(graph, address),
1819
- failFast: true,
1820
- skipHealthyAll: true,
1821
- });
1940
+ "up",
1941
+ {
1942
+ order,
1943
+ waitDepsFor: (address) => hardDepsOf(graph, address),
1944
+ failFast: true,
1945
+ skipHealthyAll: true,
1946
+ },
1947
+ { named },
1948
+ );
1822
1949
  }
1823
1950
 
1824
1951
  /**
@@ -1831,10 +1958,13 @@ class DenCLI {
1831
1958
  seed: string,
1832
1959
  dryRun: boolean,
1833
1960
  force = false,
1961
+ withDeps = false,
1834
1962
  ): Promise<void> {
1835
1963
  const full = deriveGraph({ scope: "all" });
1964
+ // Only the named repo, unless --with-deps: what it sits on may be
1965
+ // serving other running systems (the same rule as a named service).
1836
1966
  const upOrder = this.runtimeOnly(
1837
- deriveStartTargets(full, seed).order,
1967
+ withDeps ? deriveStartTargets(full, seed).order : [seed],
1838
1968
  "stopped",
1839
1969
  );
1840
1970
  if (upOrder.length === 0) return;
@@ -1845,6 +1975,9 @@ class DenCLI {
1845
1975
  // still running on them. Name them rather than letting them fall over
1846
1976
  // silently.
1847
1977
  const inClosure = new Set(order);
1978
+ // Only what really stops here puts dependents at risk: a remote service
1979
+ // is refused (R13), so it stops nothing.
1980
+ const stopsHere = new Set(order.filter((a) => !this.isRemote(a, "down")));
1848
1981
  const atRisk = [
1849
1982
  ...new Set(
1850
1983
  full.edges
@@ -1852,14 +1985,16 @@ class DenCLI {
1852
1985
  (e) =>
1853
1986
  e.kind !== "jwks" &&
1854
1987
  e.ordering !== false &&
1855
- inClosure.has(e.to) &&
1988
+ stopsHere.has(e.to) &&
1856
1989
  !inClosure.has(e.from),
1857
1990
  )
1858
1991
  .map((e) => e.from),
1859
1992
  ),
1860
1993
  ].sort();
1861
1994
  console.log(
1862
- chalk.cyan(`\n Derived down for ${chalk.bold(seed)} (+ dependencies)`),
1995
+ chalk.cyan(
1996
+ `\n Derived down for ${chalk.bold(seed)}${withDeps ? " (+ dependencies)" : ""}`,
1997
+ ),
1863
1998
  );
1864
1999
  console.log(chalk.gray(` Order: ${order.join(" → ")}\n`));
1865
2000
  if (atRisk.length > 0) {
@@ -1871,10 +2006,38 @@ class DenCLI {
1871
2006
  );
1872
2007
  }
1873
2008
  if (dryRun) {
1874
- console.log(chalk.gray(" (--dry-run: not stopping anything)\n"));
2009
+ await this.runServicesCommand(
2010
+ order,
2011
+ "down",
2012
+ { order },
2013
+ { force, named: [seed], dryRun: true },
2014
+ );
1875
2015
  return;
1876
2016
  }
1877
- await this.runServicesCommand(order, "down", { order }, { force });
2017
+ await this.runServicesCommand(
2018
+ order,
2019
+ "down",
2020
+ { order },
2021
+ { force, named: [seed] },
2022
+ );
2023
+ }
2024
+
2025
+ /** Whether `address` runs remotely, so `command` is refused here (R13). */
2026
+ private isRemote(address: string, command: string): boolean {
2027
+ const placed = resolveService(address);
2028
+ return (
2029
+ !!placed &&
2030
+ placementRefusal(
2031
+ address,
2032
+ servicePlacement(
2033
+ readManifest(placed.repoPath),
2034
+ placed.subsystem,
2035
+ "local",
2036
+ ),
2037
+ (e) => isProductionEnv(e),
2038
+ command,
2039
+ ) !== null
2040
+ );
1878
2041
  }
1879
2042
 
1880
2043
  /** Workspace repos with no runtime (modules, libraries) → their kind. */
@@ -2301,9 +2464,10 @@ class DenCLI {
2301
2464
  // Tokens after a `--` terminator belong to a child command — they must
2302
2465
  // never be read as up's own flags (`den up -- -a` is NOT `up -a`).
2303
2466
  const terminator = args.indexOf("--");
2304
- const { app, rest: ownArgs } = takeAppFlag(
2467
+ const { app, rest } = takeAppFlag(
2305
2468
  terminator === -1 ? args : args.slice(0, terminator),
2306
2469
  );
2470
+ const ownArgs = canonicalTargets(rest);
2307
2471
  const dryRun = ownArgs.includes("--dry-run") || ownArgs.includes("-n");
2308
2472
  const plan = ownArgs.includes("--plan");
2309
2473
  const pull = ownArgs.includes("--pull");
@@ -2360,11 +2524,17 @@ class DenCLI {
2360
2524
  await this.runDerivedUp(seed, include, dryRun, plan, pull);
2361
2525
  return;
2362
2526
  }
2363
- await this.cmdServiceUpManifest(ownArgs.filter((a) => !a.startsWith("-")));
2527
+ await this.cmdServiceUpManifest(
2528
+ ownArgs.filter((a) => !a.startsWith("-")),
2529
+ dryRun || plan,
2530
+ );
2364
2531
  }
2365
2532
 
2366
2533
  /** Legacy: start the given services via manifest depends_on. */
2367
- private async cmdServiceUpManifest(addresses: string[]): Promise<void> {
2534
+ private async cmdServiceUpManifest(
2535
+ addresses: string[],
2536
+ dryRun = false,
2537
+ ): Promise<void> {
2368
2538
  // Resolve targets BEFORE the secrets preflight — otherwise running
2369
2539
  // `den up` from a non-service dir (e.g. the den repo root) triggers
2370
2540
  // an interactive sign-in just to print "nothing to do".
@@ -2391,8 +2561,11 @@ class DenCLI {
2391
2561
  }
2392
2562
  return;
2393
2563
  }
2394
- preflightSecrets();
2395
- await this.runServicesCommand(targets, "up");
2564
+ if (!dryRun) preflightSecrets();
2565
+ await this.runServicesCommand(targets, "up", undefined, {
2566
+ named: addresses,
2567
+ dryRun,
2568
+ });
2396
2569
  }
2397
2570
 
2398
2571
  private async cmdServiceDown(args: string[]): Promise<void> {
@@ -2400,9 +2573,10 @@ class DenCLI {
2400
2573
  // never be read as down's own flags (`den down -- -a` must NOT tear
2401
2574
  // down the whole workspace).
2402
2575
  const terminator = args.indexOf("--");
2403
- const { app, rest: ownArgs } = takeAppFlag(
2576
+ const { app, rest } = takeAppFlag(
2404
2577
  terminator === -1 ? args : args.slice(0, terminator),
2405
2578
  );
2579
+ const ownArgs = canonicalTargets(rest);
2406
2580
  const dryRun = ownArgs.includes("--dry-run") || ownArgs.includes("-n");
2407
2581
  const force = ownArgs.includes("--force") || ownArgs.includes("-f");
2408
2582
  if (app) {
@@ -2444,7 +2618,12 @@ class DenCLI {
2444
2618
  const seed = forceManifest ? undefined : this.derivedSeed(seedArg);
2445
2619
 
2446
2620
  if (seed) {
2447
- await this.runDerivedDown(seed, dryRun, force);
2621
+ await this.runDerivedDown(
2622
+ seed,
2623
+ dryRun,
2624
+ force,
2625
+ ownArgs.includes("--with-deps"),
2626
+ );
2448
2627
  return;
2449
2628
  }
2450
2629
  const addresses = ownArgs.filter((a) => !a.startsWith("-"));
@@ -2452,26 +2631,34 @@ class DenCLI {
2452
2631
  addresses.length > 0
2453
2632
  ? addresses
2454
2633
  : this.resolveNoArgServiceTargets("down");
2455
- await this.runServicesCommand(targets, "down", undefined, { force });
2634
+ await this.runServicesCommand(targets, "down", undefined, {
2635
+ force,
2636
+ named: addresses,
2637
+ dryRun,
2638
+ });
2456
2639
  }
2457
2640
 
2458
2641
  private async cmdServiceRestart(argv: string[]): Promise<void> {
2459
2642
  const { app, rest: args } = takeAppFlag(argv);
2460
- preflightSecrets();
2643
+ const dryRun = args.includes("--dry-run") || args.includes("-n");
2644
+ if (!dryRun) preflightSecrets();
2461
2645
  if (app) {
2462
2646
  const order =
2463
2647
  this.allWorkspaceOrder(focusedAppRepos(app))?.rawOrder ?? [];
2464
- await this.runServicesCommand(order, "restart");
2648
+ await this.runServicesCommand(order, "restart", undefined, { dryRun });
2465
2649
  return;
2466
2650
  }
2467
2651
  // Drop flag tokens so a bare `den restart --foo` doesn't treat the flag
2468
2652
  // as a service address (matches cmdServiceUp/cmdServiceDown).
2469
- const filtered = args.filter((a) => !a.startsWith("-"));
2653
+ const filtered = canonicalTargets(args.filter((a) => !a.startsWith("-")));
2470
2654
  const addresses =
2471
2655
  filtered.length > 0
2472
2656
  ? filtered
2473
2657
  : this.resolveNoArgServiceTargets("restart");
2474
- await this.runServicesCommand(addresses, "restart");
2658
+ await this.runServicesCommand(addresses, "restart", undefined, {
2659
+ named: filtered,
2660
+ dryRun,
2661
+ });
2475
2662
  }
2476
2663
 
2477
2664
  /** Service address with a ⏸ marker when the repo is auto-start off. */
@@ -2579,7 +2766,7 @@ class DenCLI {
2579
2766
  private async cmdLogs(args: string[]): Promise<void> {
2580
2767
  await this.ensureWorkspaceSeeded();
2581
2768
  const alias = loadAliasResolver();
2582
- const target = resolveLogsTarget(args, alias);
2769
+ const target = resolveLogsTarget(canonicalTargets(args, 1), alias);
2583
2770
  if (target.kind === "unknown-repo") {
2584
2771
  console.log(chalk.red(`\n Unknown repo: ${target.arg}\n`));
2585
2772
  process.exit(1);
@@ -3645,6 +3832,12 @@ class DenCLI {
3645
3832
  console.log(
3646
3833
  " mcp [--port <n>] MCP server (stdio) for agents, over the running web server",
3647
3834
  );
3835
+ console.log(
3836
+ " mcp --call <tool> Call one MCP tool and print its result (--list: tool names)",
3837
+ );
3838
+ console.log(
3839
+ " service install Run the web UI as a launchd service (status, print, uninstall)",
3840
+ );
3648
3841
  console.log();
3649
3842
  console.log(" workspace list List workspaces");
3650
3843
  console.log(