@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.
@@ -96,7 +96,14 @@ import {
96
96
  getWaitDeps,
97
97
  } from "../../shared/environment-manager/dag.js";
98
98
  import { waitForReady } from "../../shared/environment-manager/readiness.js";
99
- import { resolveService } from "../../shared/environment-manager/service-runner.js";
99
+ import {
100
+ canonicalServiceAddress,
101
+ previewServiceCommand,
102
+ previewServiceSecretEnv,
103
+ prepareServiceSecretEnv,
104
+ resolveService,
105
+ type ServiceCommandPreview,
106
+ } from "../../shared/environment-manager/service-runner.js";
100
107
  import { getGitHubCoords } from "../../shared/smoke/git-remote.js";
101
108
  import {
102
109
  getReposRoot,
@@ -157,6 +164,7 @@ import {
157
164
  import { cleanupStaleTabs } from "../../shared/cli-tabs.js";
158
165
  import { cmdConfig } from "./config-command.js";
159
166
  import { cmdEdge } from "./edge-command.js";
167
+ import { cmdService } from "./service-command.js";
160
168
  import { cmdStack } from "./stack-command.js";
161
169
  import { cmdWeb, displayWebHelp } from "./web-command.js";
162
170
  import { cmdGraph } from "./graph-command.js";
@@ -191,14 +199,15 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
191
199
  down: {
192
200
  usage: "down [repo] [flags]",
193
201
  lines: [
194
- "Stop the cwd (or named) repo's derived dependency closure, in reverse order.",
202
+ "Stop the cwd (or named) repo; --with-deps also stops what it sits on (its",
203
+ "derived closure, in reverse order), naming anything else that uses it.",
195
204
  "-a/--all stops EVERY workspace system (reverse derived order, cwd-agnostic;",
196
205
  "every down script runs — no already-stopped skip).",
197
- "Flags: -a/--all · -A/--app <app> · -n/--dry-run (print order, stop nothing) · --manifest (legacy depends_on)",
206
+ "Flags: -a/--all · -A/--app <app> · --with-deps · -n/--dry-run (print order, stop nothing) · --manifest (legacy depends_on)",
198
207
  ],
199
208
  },
200
209
  restart: {
201
- usage: "restart [service...] [-A <app>]",
210
+ usage: "restart [service...] [-A <app>] [-n|--dry-run]",
202
211
  lines: [
203
212
  `Restart the given services, or every repo in an application (\`${product().cli} apps\`).`,
204
213
  ],
@@ -301,10 +310,18 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
301
310
  ],
302
311
  },
303
312
  mcp: {
304
- usage: "mcp [--port <web port>]",
313
+ usage: "mcp [--port <web port>] [--list | --call <tool> ['<json args>']]",
305
314
  lines: [
306
315
  "An MCP server over stdio for agents: a client of the running web server",
307
316
  `(\`${product().cli} web up\`). Read tools always; actions only as named in mcp.actions.`,
317
+ "--list prints the tool names; --call runs one tool and prints its result.",
318
+ ],
319
+ },
320
+ service: {
321
+ usage: "service [install|status|print|uninstall]",
322
+ lines: [
323
+ "The web UI as a macOS launchd service, started at login. Label and logs:",
324
+ "config web.service { label, logs }.",
308
325
  ],
309
326
  },
310
327
  "local-dev": {
@@ -363,6 +380,7 @@ const TELEMETRY_COMMANDS = new Set<string>([
363
380
  "apps",
364
381
  "applications",
365
382
  "mcp",
383
+ "service",
366
384
  ]);
367
385
 
368
386
  /** Map a raw command token to a PII-safe, registered telemetry command name. */
@@ -380,6 +398,20 @@ function telemetryReason(error: unknown): string {
380
398
  return name && name !== "Object" ? name : "Error";
381
399
  }
382
400
 
401
+ /**
402
+ * What was typed, with each service alias (a manifest service key or a
403
+ * compose stack name) mapped to its service's address, so the derived graph,
404
+ * ownership and the daemon registry all see one name. Flags pass through; an
405
+ * ambiguous alias throws, naming the candidates. `limit` maps only the first
406
+ * n positional args.
407
+ */
408
+ function canonicalTargets(args: string[], limit = Infinity): string[] {
409
+ let seen = 0;
410
+ return args.map((a) =>
411
+ a.startsWith("-") || seen++ >= limit ? a : canonicalServiceAddress(a),
412
+ );
413
+ }
414
+
383
415
  /** Print one manifest's validation: a status line, then each issue. */
384
416
  function printValidation(
385
417
  label: string,
@@ -685,6 +717,10 @@ class DenCLI {
685
717
  await cmdEdge(args.slice(1));
686
718
  break;
687
719
 
720
+ case "service":
721
+ process.exitCode = await cmdService(args.slice(1));
722
+ break;
723
+
688
724
  case "stack":
689
725
  await cmdStack(args.slice(1));
690
726
  break;
@@ -1318,6 +1354,11 @@ class DenCLI {
1318
1354
  * as part of a workspace or app run is said and skipped.
1319
1355
  */
1320
1356
  named?: readonly string[];
1357
+ /**
1358
+ * `-n`/`--dry-run` (and `--plan` on this path): print what would run,
1359
+ * in order, and run nothing. Read-only on every path.
1360
+ */
1361
+ dryRun?: boolean;
1321
1362
  },
1322
1363
  ): Promise<void> {
1323
1364
  // How long to show startup output before handing control back to the menu
@@ -1328,6 +1369,22 @@ class DenCLI {
1328
1369
  getFocusedWorkspaceName() ??
1329
1370
  undefined;
1330
1371
  const force = opts?.force ?? false;
1372
+ // R13: why a remote service is not run here, or null.
1373
+ const remoteRefusal = (address: string): string | null => {
1374
+ const placed = resolveService(address, workspace);
1375
+ return placed
1376
+ ? placementRefusal(
1377
+ address,
1378
+ servicePlacement(
1379
+ readManifest(placed.repoPath),
1380
+ placed.subsystem,
1381
+ "local",
1382
+ ),
1383
+ (e) => isProductionEnv(e),
1384
+ command,
1385
+ )
1386
+ : null;
1387
+ };
1331
1388
 
1332
1389
  // Resolve dependency order. Derived mode supplies the order directly;
1333
1390
  // otherwise topo-sort manifest depends_on (`down` → reverse).
@@ -1341,8 +1398,17 @@ class DenCLI {
1341
1398
  const startupOrder = ws
1342
1399
  ? resolveStartupOrder(addresses, catalog, ws.config, ws.name)
1343
1400
  : addresses;
1401
+ // Only up pulls in dependencies (a service needs them to start).
1402
+ // Down and restart act on what was named: its dependencies may be
1403
+ // serving other running services.
1404
+ const namedOnly = (): string[] => {
1405
+ const kept = startupOrder.filter((a) => addresses.includes(a));
1406
+ return [...kept, ...addresses.filter((a) => !kept.includes(a))];
1407
+ };
1344
1408
  if (command === "down") {
1345
- ordered = [...startupOrder].reverse();
1409
+ ordered = namedOnly().reverse();
1410
+ } else if (command !== "up") {
1411
+ ordered = namedOnly();
1346
1412
  } else {
1347
1413
  ordered = startupOrder;
1348
1414
  if (ordered.length > addresses.length) {
@@ -1358,6 +1424,56 @@ class DenCLI {
1358
1424
  }
1359
1425
  }
1360
1426
 
1427
+ if (opts?.dryRun) {
1428
+ // The same lookup a real run makes, so the dry run says what it does.
1429
+ const previews = ordered.map((a): ServiceCommandPreview => {
1430
+ const remote = remoteRefusal(a);
1431
+ if (!remote) return previewServiceCommand(a, command, workspace);
1432
+ // As the loop below: a named remote target fails, any other is
1433
+ // said and skipped.
1434
+ return isNamedTarget(a, opts?.named ?? [])
1435
+ ? { kind: "error", address: a, note: remote }
1436
+ : {
1437
+ kind: "nothing",
1438
+ address: a,
1439
+ note: `${a} (remote, skipped): ${remote}`,
1440
+ };
1441
+ });
1442
+ const doing =
1443
+ { up: "starting", down: "stopping", restart: "restarting" }[command] ??
1444
+ `running "${command}" on`;
1445
+ const runs = previews
1446
+ .filter((p) => p.kind === "run" || p.kind === "via-repo")
1447
+ .map((p) => p.address);
1448
+ console.log(
1449
+ chalk.cyan(
1450
+ `\n Would ${command}: ${[...new Set(runs)].join(" → ") || "(nothing)"}`,
1451
+ ),
1452
+ );
1453
+ for (const p of previews) {
1454
+ if (p.kind === "via-repo" || p.kind === "nothing")
1455
+ console.log(chalk.gray(` ${p.note}`));
1456
+ if (p.kind === "error") console.log(chalk.red(` ✗ ${p.note}`));
1457
+ }
1458
+ // R16: key names only. A dry run never asks the secrets provider.
1459
+ for (const a of new Set(runs)) {
1460
+ const sec = previewServiceSecretEnv(a, command, workspace);
1461
+ if (!sec) continue;
1462
+ const parts = [
1463
+ ...(sec.service.length ? [sec.service.join(", ")] : []),
1464
+ ...(sec.compose.length ? [`compose: ${sec.compose.join(", ")}`] : []),
1465
+ ];
1466
+ console.log(
1467
+ chalk.gray(
1468
+ ` ${sec.address}: would inject secret_env ${parts.join("; ")} (names only; not resolved in a dry run)`,
1469
+ ),
1470
+ );
1471
+ }
1472
+ if (previews.some((p) => p.kind === "error")) process.exitCode = 1;
1473
+ console.log(chalk.gray(` (--dry-run: not ${doing} anything)\n`));
1474
+ return;
1475
+ }
1476
+
1361
1477
  // Build a catalog + workspace reference once per run for readiness lookups.
1362
1478
  const catalog = readCatalog();
1363
1479
  const ws = getActiveWorkspace();
@@ -1398,19 +1514,7 @@ class DenCLI {
1398
1514
  }
1399
1515
  // R13: a remote service is never started or stopped from here. Said,
1400
1516
  // not failed, so a workspace or app up/down carries on past it.
1401
- const placed = resolveService(address, workspace);
1402
- const remote = placed
1403
- ? placementRefusal(
1404
- address,
1405
- servicePlacement(
1406
- readManifest(placed.repoPath),
1407
- placed.subsystem,
1408
- "local",
1409
- ),
1410
- (e) => isProductionEnv(e),
1411
- command,
1412
- )
1413
- : null;
1517
+ const remote = remoteRefusal(address);
1414
1518
  if (remote) {
1415
1519
  if (isNamedTarget(address, opts?.named ?? [])) {
1416
1520
  console.log(chalk.red(` ✗ ${remote}`));
@@ -1502,7 +1606,22 @@ class DenCLI {
1502
1606
  }
1503
1607
  }
1504
1608
 
1609
+ // R16: resolve the service's secret_env before anything spawns. A
1610
+ // missing key fails this target; nothing starts with a partial set.
1611
+ const secrets = await prepareServiceSecretEnv(
1612
+ address,
1613
+ command,
1614
+ workspace,
1615
+ );
1616
+
1505
1617
  const started = await new Promise<boolean>((resolve) => {
1618
+ if (!secrets.ok) {
1619
+ console.log(chalk.red(` ✗ ${command} ${chalk.bold(address)}`));
1620
+ for (const l of secrets.message.split("\n"))
1621
+ console.log(chalk.gray(` ${l}`));
1622
+ resolve(false);
1623
+ return;
1624
+ }
1506
1625
  const spinner = ora(`${command} ${chalk.bold(address)}...`).start();
1507
1626
  let detachTimer: ReturnType<typeof setTimeout> | null = null;
1508
1627
  let settled = false;
@@ -1584,6 +1703,7 @@ class DenCLI {
1584
1703
  runtime,
1585
1704
  workspace,
1586
1705
  force: command === "down" ? force : false,
1706
+ secretEnv: secrets.secretEnv,
1587
1707
  onLine: (line) => {
1588
1708
  spinner.text = `${command} ${chalk.bold(address)}: ${chalk.gray(line.slice(0, 80))}`;
1589
1709
  const trimmed = line.trim();
@@ -1629,6 +1749,9 @@ class DenCLI {
1629
1749
  }
1630
1750
  });
1631
1751
 
1752
+ // Any target that failed makes the whole command fail (scripts and
1753
+ // agents read the exit code).
1754
+ if (!started) process.exitCode = 1;
1632
1755
  if (plan?.failFast && !started) {
1633
1756
  console.log(
1634
1757
  chalk.red(
@@ -1817,7 +1940,12 @@ class DenCLI {
1817
1940
  return;
1818
1941
  }
1819
1942
  if (dryRun) {
1820
- console.log(chalk.gray(" (--dry-run: not starting anything)\n"));
1943
+ await this.runServicesCommand(
1944
+ order,
1945
+ "up",
1946
+ { order },
1947
+ { named, dryRun: true },
1948
+ );
1821
1949
  return;
1822
1950
  }
1823
1951
  // Check the secrets backend first so any first-clone `make setup` (and the up
@@ -1862,10 +1990,13 @@ class DenCLI {
1862
1990
  seed: string,
1863
1991
  dryRun: boolean,
1864
1992
  force = false,
1993
+ withDeps = false,
1865
1994
  ): Promise<void> {
1866
1995
  const full = deriveGraph({ scope: "all" });
1996
+ // Only the named repo, unless --with-deps: what it sits on may be
1997
+ // serving other running systems (the same rule as a named service).
1867
1998
  const upOrder = this.runtimeOnly(
1868
- deriveStartTargets(full, seed).order,
1999
+ withDeps ? deriveStartTargets(full, seed).order : [seed],
1869
2000
  "stopped",
1870
2001
  );
1871
2002
  if (upOrder.length === 0) return;
@@ -1876,6 +2007,9 @@ class DenCLI {
1876
2007
  // still running on them. Name them rather than letting them fall over
1877
2008
  // silently.
1878
2009
  const inClosure = new Set(order);
2010
+ // Only what really stops here puts dependents at risk: a remote service
2011
+ // is refused (R13), so it stops nothing.
2012
+ const stopsHere = new Set(order.filter((a) => !this.isRemote(a, "down")));
1879
2013
  const atRisk = [
1880
2014
  ...new Set(
1881
2015
  full.edges
@@ -1883,14 +2017,16 @@ class DenCLI {
1883
2017
  (e) =>
1884
2018
  e.kind !== "jwks" &&
1885
2019
  e.ordering !== false &&
1886
- inClosure.has(e.to) &&
2020
+ stopsHere.has(e.to) &&
1887
2021
  !inClosure.has(e.from),
1888
2022
  )
1889
2023
  .map((e) => e.from),
1890
2024
  ),
1891
2025
  ].sort();
1892
2026
  console.log(
1893
- chalk.cyan(`\n Derived down for ${chalk.bold(seed)} (+ dependencies)`),
2027
+ chalk.cyan(
2028
+ `\n Derived down for ${chalk.bold(seed)}${withDeps ? " (+ dependencies)" : ""}`,
2029
+ ),
1894
2030
  );
1895
2031
  console.log(chalk.gray(` Order: ${order.join(" → ")}\n`));
1896
2032
  if (atRisk.length > 0) {
@@ -1902,7 +2038,12 @@ class DenCLI {
1902
2038
  );
1903
2039
  }
1904
2040
  if (dryRun) {
1905
- console.log(chalk.gray(" (--dry-run: not stopping anything)\n"));
2041
+ await this.runServicesCommand(
2042
+ order,
2043
+ "down",
2044
+ { order },
2045
+ { force, named: [seed], dryRun: true },
2046
+ );
1906
2047
  return;
1907
2048
  }
1908
2049
  await this.runServicesCommand(
@@ -1913,6 +2054,24 @@ class DenCLI {
1913
2054
  );
1914
2055
  }
1915
2056
 
2057
+ /** Whether `address` runs remotely, so `command` is refused here (R13). */
2058
+ private isRemote(address: string, command: string): boolean {
2059
+ const placed = resolveService(address);
2060
+ return (
2061
+ !!placed &&
2062
+ placementRefusal(
2063
+ address,
2064
+ servicePlacement(
2065
+ readManifest(placed.repoPath),
2066
+ placed.subsystem,
2067
+ "local",
2068
+ ),
2069
+ (e) => isProductionEnv(e),
2070
+ command,
2071
+ ) !== null
2072
+ );
2073
+ }
2074
+
1916
2075
  /** Workspace repos with no runtime (modules, libraries) → their kind. */
1917
2076
  private noRuntimeRepos(): Map<string, RepoKind> {
1918
2077
  const out = new Map<string, RepoKind>();
@@ -2337,9 +2496,10 @@ class DenCLI {
2337
2496
  // Tokens after a `--` terminator belong to a child command — they must
2338
2497
  // never be read as up's own flags (`den up -- -a` is NOT `up -a`).
2339
2498
  const terminator = args.indexOf("--");
2340
- const { app, rest: ownArgs } = takeAppFlag(
2499
+ const { app, rest } = takeAppFlag(
2341
2500
  terminator === -1 ? args : args.slice(0, terminator),
2342
2501
  );
2502
+ const ownArgs = canonicalTargets(rest);
2343
2503
  const dryRun = ownArgs.includes("--dry-run") || ownArgs.includes("-n");
2344
2504
  const plan = ownArgs.includes("--plan");
2345
2505
  const pull = ownArgs.includes("--pull");
@@ -2396,11 +2556,17 @@ class DenCLI {
2396
2556
  await this.runDerivedUp(seed, include, dryRun, plan, pull);
2397
2557
  return;
2398
2558
  }
2399
- await this.cmdServiceUpManifest(ownArgs.filter((a) => !a.startsWith("-")));
2559
+ await this.cmdServiceUpManifest(
2560
+ ownArgs.filter((a) => !a.startsWith("-")),
2561
+ dryRun || plan,
2562
+ );
2400
2563
  }
2401
2564
 
2402
2565
  /** Legacy: start the given services via manifest depends_on. */
2403
- private async cmdServiceUpManifest(addresses: string[]): Promise<void> {
2566
+ private async cmdServiceUpManifest(
2567
+ addresses: string[],
2568
+ dryRun = false,
2569
+ ): Promise<void> {
2404
2570
  // Resolve targets BEFORE the secrets preflight — otherwise running
2405
2571
  // `den up` from a non-service dir (e.g. the den repo root) triggers
2406
2572
  // an interactive sign-in just to print "nothing to do".
@@ -2427,9 +2593,10 @@ class DenCLI {
2427
2593
  }
2428
2594
  return;
2429
2595
  }
2430
- preflightSecrets();
2596
+ if (!dryRun) preflightSecrets();
2431
2597
  await this.runServicesCommand(targets, "up", undefined, {
2432
2598
  named: addresses,
2599
+ dryRun,
2433
2600
  });
2434
2601
  }
2435
2602
 
@@ -2438,9 +2605,10 @@ class DenCLI {
2438
2605
  // never be read as down's own flags (`den down -- -a` must NOT tear
2439
2606
  // down the whole workspace).
2440
2607
  const terminator = args.indexOf("--");
2441
- const { app, rest: ownArgs } = takeAppFlag(
2608
+ const { app, rest } = takeAppFlag(
2442
2609
  terminator === -1 ? args : args.slice(0, terminator),
2443
2610
  );
2611
+ const ownArgs = canonicalTargets(rest);
2444
2612
  const dryRun = ownArgs.includes("--dry-run") || ownArgs.includes("-n");
2445
2613
  const force = ownArgs.includes("--force") || ownArgs.includes("-f");
2446
2614
  if (app) {
@@ -2482,7 +2650,12 @@ class DenCLI {
2482
2650
  const seed = forceManifest ? undefined : this.derivedSeed(seedArg);
2483
2651
 
2484
2652
  if (seed) {
2485
- await this.runDerivedDown(seed, dryRun, force);
2653
+ await this.runDerivedDown(
2654
+ seed,
2655
+ dryRun,
2656
+ force,
2657
+ ownArgs.includes("--with-deps"),
2658
+ );
2486
2659
  return;
2487
2660
  }
2488
2661
  const addresses = ownArgs.filter((a) => !a.startsWith("-"));
@@ -2493,27 +2666,30 @@ class DenCLI {
2493
2666
  await this.runServicesCommand(targets, "down", undefined, {
2494
2667
  force,
2495
2668
  named: addresses,
2669
+ dryRun,
2496
2670
  });
2497
2671
  }
2498
2672
 
2499
2673
  private async cmdServiceRestart(argv: string[]): Promise<void> {
2500
2674
  const { app, rest: args } = takeAppFlag(argv);
2501
- preflightSecrets();
2675
+ const dryRun = args.includes("--dry-run") || args.includes("-n");
2676
+ if (!dryRun) preflightSecrets();
2502
2677
  if (app) {
2503
2678
  const order =
2504
2679
  this.allWorkspaceOrder(focusedAppRepos(app))?.rawOrder ?? [];
2505
- await this.runServicesCommand(order, "restart");
2680
+ await this.runServicesCommand(order, "restart", undefined, { dryRun });
2506
2681
  return;
2507
2682
  }
2508
2683
  // Drop flag tokens so a bare `den restart --foo` doesn't treat the flag
2509
2684
  // as a service address (matches cmdServiceUp/cmdServiceDown).
2510
- const filtered = args.filter((a) => !a.startsWith("-"));
2685
+ const filtered = canonicalTargets(args.filter((a) => !a.startsWith("-")));
2511
2686
  const addresses =
2512
2687
  filtered.length > 0
2513
2688
  ? filtered
2514
2689
  : this.resolveNoArgServiceTargets("restart");
2515
2690
  await this.runServicesCommand(addresses, "restart", undefined, {
2516
2691
  named: filtered,
2692
+ dryRun,
2517
2693
  });
2518
2694
  }
2519
2695
 
@@ -2622,7 +2798,7 @@ class DenCLI {
2622
2798
  private async cmdLogs(args: string[]): Promise<void> {
2623
2799
  await this.ensureWorkspaceSeeded();
2624
2800
  const alias = loadAliasResolver();
2625
- const target = resolveLogsTarget(args, alias);
2801
+ const target = resolveLogsTarget(canonicalTargets(args, 1), alias);
2626
2802
  if (target.kind === "unknown-repo") {
2627
2803
  console.log(chalk.red(`\n Unknown repo: ${target.arg}\n`));
2628
2804
  process.exit(1);
@@ -3688,6 +3864,12 @@ class DenCLI {
3688
3864
  console.log(
3689
3865
  " mcp [--port <n>] MCP server (stdio) for agents, over the running web server",
3690
3866
  );
3867
+ console.log(
3868
+ " mcp --call <tool> Call one MCP tool and print its result (--list: tool names)",
3869
+ );
3870
+ console.log(
3871
+ " service install Run the web UI as a launchd service (status, print, uninstall)",
3872
+ );
3691
3873
  console.log();
3692
3874
  console.log(" workspace list List workspaces");
3693
3875
  console.log(
@@ -0,0 +1,131 @@
1
+ /**
2
+ * `service install | status | print | uninstall`
3
+ *
4
+ * The web UI as a macOS launchd service, over Den's service/install.sh. The
5
+ * label and log directory come from the org config's `web.service`
6
+ * (readWebServiceConfig), with the script's own defaults, so a distribution
7
+ * no longer wraps the script in its Makefile.
8
+ *
9
+ * `install` (which also restarts a running service) says the service is
10
+ * running only once the web server answers /api/server-info with JSON: a
11
+ * fresh server takes a few seconds to start, and answers empty until then.
12
+ */
13
+ import { spawnSync } from "child_process";
14
+ import * as os from "os";
15
+ import * as path from "path";
16
+ import {
17
+ readDenConfig,
18
+ readWebServiceConfig,
19
+ type DenConfig,
20
+ } from "../../shared/den-config.js";
21
+ import { readWebPort } from "../../shared/edge/config.js";
22
+ import { getDistRoot, getEngineRoot } from "../../shared/paths.js";
23
+
24
+ /** Runs a command with extra env vars; returns its exit code. */
25
+ export type ServiceRunner = (
26
+ cmd: string,
27
+ args: string[],
28
+ env: Record<string, string>,
29
+ ) => number;
30
+
31
+ const SUBCOMMANDS = ["install", "status", "print", "uninstall"] as const;
32
+
33
+ const defaultRunner: ServiceRunner = (cmd, args, env) =>
34
+ spawnSync(cmd, args, { stdio: "inherit", env: { ...process.env, ...env } })
35
+ .status ?? 1;
36
+
37
+ /** How long `install` waits for the web server to answer. */
38
+ export const READY_TIMEOUT_MS = 30_000;
39
+
40
+ /**
41
+ * Poll `url` until it answers 2xx with a JSON object, or `timeoutMs` passes.
42
+ * Each attempt is bounded too, so a hung connection cannot outlast it.
43
+ */
44
+ export async function waitForServerInfo(
45
+ url: string,
46
+ timeoutMs: number = READY_TIMEOUT_MS,
47
+ intervalMs = 500,
48
+ ): Promise<boolean> {
49
+ const deadline = Date.now() + timeoutMs;
50
+ for (;;) {
51
+ const left = deadline - Date.now();
52
+ if (left <= 0) return false;
53
+ try {
54
+ const res = await fetch(url, {
55
+ signal: AbortSignal.timeout(Math.min(2_000, left)),
56
+ });
57
+ if (res.ok) {
58
+ const body: unknown = JSON.parse(await res.text());
59
+ if (body !== null && typeof body === "object") return true;
60
+ }
61
+ } catch {
62
+ /* not up yet: refused, empty or partial body */
63
+ }
64
+ await new Promise((r) => setTimeout(r, intervalMs));
65
+ }
66
+ }
67
+
68
+ export interface ServiceDeps {
69
+ run?: ServiceRunner;
70
+ config?: DenConfig;
71
+ distRoot?: string;
72
+ engineRoot?: string;
73
+ home?: string;
74
+ platform?: NodeJS.Platform;
75
+ /** The web UI port (default: config web.port). */
76
+ webPort?: number;
77
+ /** Readiness poller (default: waitForServerInfo). */
78
+ waitReady?: (url: string, timeoutMs: number) => Promise<boolean>;
79
+ log?: (line: string) => void;
80
+ }
81
+
82
+ /** Run `service <sub>`; returns the exit code. */
83
+ export async function cmdService(
84
+ args: string[],
85
+ deps: ServiceDeps = {},
86
+ ): Promise<number> {
87
+ const sub = args[0];
88
+ if (!(SUBCOMMANDS as readonly string[]).includes(sub ?? ""))
89
+ throw new Error(
90
+ `${sub ? `Unknown service command "${sub}". ` : ""}Use: ${SUBCOMMANDS.join(" | ")}`,
91
+ );
92
+ // print only writes the plist; the rest talk to launchd.
93
+ if (sub !== "print" && (deps.platform ?? process.platform) !== "darwin")
94
+ throw new Error(
95
+ "the web UI service is a launchd LaunchAgent: macOS only (print works anywhere)",
96
+ );
97
+ const distRoot = deps.distRoot ?? getDistRoot();
98
+ const { label, logs } = readWebServiceConfig(
99
+ deps.config ?? readDenConfig(),
100
+ distRoot,
101
+ deps.home ?? os.homedir(),
102
+ );
103
+ const script = path.join(
104
+ deps.engineRoot ?? getEngineRoot(),
105
+ "service",
106
+ "install.sh",
107
+ );
108
+ const code = (deps.run ?? defaultRunner)("bash", [script, sub!], {
109
+ DEN_ROOT: distRoot,
110
+ DEN_SERVICE_LABEL: label,
111
+ DEN_SERVICE_LOGS: logs,
112
+ // The script then leaves saying "running" to us (after the poll below).
113
+ ...(sub === "install" ? { DEN_SERVICE_READY_CHECK: "external" } : {}),
114
+ });
115
+ if (sub !== "install" || code !== 0) return code;
116
+
117
+ const log = deps.log ?? ((l: string) => console.log(l));
118
+ const port = deps.webPort ?? readWebPort(distRoot);
119
+ const url = `http://localhost:${port}/api/server-info`;
120
+ if (await (deps.waitReady ?? waitForServerInfo)(url, READY_TIMEOUT_MS)) {
121
+ log(
122
+ `✓ ${label} running at http://localhost:${port} (logs: ${path.join(logs, "web.log")})`,
123
+ );
124
+ return 0;
125
+ }
126
+ log(
127
+ `✗ ${label} installed but not answering yet: check ${logs} ` +
128
+ `(${url} gave no JSON within ${READY_TIMEOUT_MS / 1000}s)`,
129
+ );
130
+ return 1;
131
+ }