@monster-orange/den 0.2.1 → 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.
@@ -31,13 +31,23 @@ export function composeCommands(
31
31
  repoName: string,
32
32
  workspace: string,
33
33
  ): Record<string, CommandDef> {
34
- if (!manifest.compose || !isPrimary(manifest, subsystem)) return {};
34
+ if (!manifest.compose) return {};
35
35
  // `docker compose up -d` returns once the containers are started, so these
36
36
  // finish on their own: never daemonize them.
37
37
  const stack = (sub: string) => ({
38
38
  cmd: `${denBin()} stack ${sub} ${manifestStackName(manifest, repoName)} -w ${workspace}`,
39
39
  detachable: false,
40
40
  });
41
+ // Any other manifest service names a compose service: it restarts on its
42
+ // own (`docker compose restart`, same container and env). Up and down stay
43
+ // the stack's, through the primary service.
44
+ if (!isPrimary(manifest, subsystem))
45
+ return {
46
+ restart: {
47
+ ...stack("restart"),
48
+ cmd: `${stack("restart").cmd} --service ${JSON.stringify(subsystem)}`,
49
+ },
50
+ };
41
51
  return {
42
52
  up: stack("up"),
43
53
  down: stack("down"),
@@ -266,6 +266,26 @@ export function stackDown(ctx: StackContext, s: StackConfig): void {
266
266
  }
267
267
  }
268
268
 
269
+ /**
270
+ * Restart one compose service of a running stack in place (`docker compose
271
+ * restart`): the same container, so the same env; nothing is recreated.
272
+ */
273
+ export function stackRestartService(
274
+ ctx: StackContext,
275
+ s: StackConfig,
276
+ service: string,
277
+ ): void {
278
+ const override = path.join(stackDir(ctx, s), "override.yml");
279
+ const args = fs.existsSync(override)
280
+ ? overrideArgs(ctx, s)
281
+ : baseArgs(ctx, s);
282
+ must("docker", [...args, "restart", service], {
283
+ cwd: repoDir(s),
284
+ env: composeEnv(ctx, s),
285
+ inherit: true,
286
+ });
287
+ }
288
+
269
289
  export function stackPs(ctx: StackContext, s: StackConfig): string {
270
290
  return run(
271
291
  "docker",
@@ -176,8 +176,9 @@ const COMMAND_FLAGS: Record<string, readonly string[]> = {
176
176
  "--force",
177
177
  "-A",
178
178
  "--app",
179
+ "--with-deps",
179
180
  ],
180
- restart: ["-A", "--app"],
181
+ restart: ["-A", "--app", "-n", "--dry-run"],
181
182
  logs: [],
182
183
  setup: ["-f", "--force"],
183
184
  sync: [],
@@ -96,7 +96,12 @@ 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
+ resolveService,
103
+ type ServiceCommandPreview,
104
+ } from "../../shared/environment-manager/service-runner.js";
100
105
  import { getGitHubCoords } from "../../shared/smoke/git-remote.js";
101
106
  import {
102
107
  getReposRoot,
@@ -157,6 +162,7 @@ import {
157
162
  import { cleanupStaleTabs } from "../../shared/cli-tabs.js";
158
163
  import { cmdConfig } from "./config-command.js";
159
164
  import { cmdEdge } from "./edge-command.js";
165
+ import { cmdService } from "./service-command.js";
160
166
  import { cmdStack } from "./stack-command.js";
161
167
  import { cmdWeb, displayWebHelp } from "./web-command.js";
162
168
  import { cmdGraph } from "./graph-command.js";
@@ -191,14 +197,15 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
191
197
  down: {
192
198
  usage: "down [repo] [flags]",
193
199
  lines: [
194
- "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.",
195
202
  "-a/--all stops EVERY workspace system (reverse derived order, cwd-agnostic;",
196
203
  "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)",
204
+ "Flags: -a/--all · -A/--app <app> · --with-deps · -n/--dry-run (print order, stop nothing) · --manifest (legacy depends_on)",
198
205
  ],
199
206
  },
200
207
  restart: {
201
- usage: "restart [service...] [-A <app>]",
208
+ usage: "restart [service...] [-A <app>] [-n|--dry-run]",
202
209
  lines: [
203
210
  `Restart the given services, or every repo in an application (\`${product().cli} apps\`).`,
204
211
  ],
@@ -301,10 +308,18 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
301
308
  ],
302
309
  },
303
310
  mcp: {
304
- usage: "mcp [--port <web port>]",
311
+ usage: "mcp [--port <web port>] [--list | --call <tool> ['<json args>']]",
305
312
  lines: [
306
313
  "An MCP server over stdio for agents: a client of the running web server",
307
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 }.",
308
323
  ],
309
324
  },
310
325
  "local-dev": {
@@ -363,6 +378,7 @@ const TELEMETRY_COMMANDS = new Set<string>([
363
378
  "apps",
364
379
  "applications",
365
380
  "mcp",
381
+ "service",
366
382
  ]);
367
383
 
368
384
  /** Map a raw command token to a PII-safe, registered telemetry command name. */
@@ -380,6 +396,20 @@ function telemetryReason(error: unknown): string {
380
396
  return name && name !== "Object" ? name : "Error";
381
397
  }
382
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
+
383
413
  /** Print one manifest's validation: a status line, then each issue. */
384
414
  function printValidation(
385
415
  label: string,
@@ -685,6 +715,10 @@ class DenCLI {
685
715
  await cmdEdge(args.slice(1));
686
716
  break;
687
717
 
718
+ case "service":
719
+ process.exitCode = await cmdService(args.slice(1));
720
+ break;
721
+
688
722
  case "stack":
689
723
  await cmdStack(args.slice(1));
690
724
  break;
@@ -1318,6 +1352,11 @@ class DenCLI {
1318
1352
  * as part of a workspace or app run is said and skipped.
1319
1353
  */
1320
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;
1321
1360
  },
1322
1361
  ): Promise<void> {
1323
1362
  // How long to show startup output before handing control back to the menu
@@ -1328,6 +1367,22 @@ class DenCLI {
1328
1367
  getFocusedWorkspaceName() ??
1329
1368
  undefined;
1330
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
+ };
1331
1386
 
1332
1387
  // Resolve dependency order. Derived mode supplies the order directly;
1333
1388
  // otherwise topo-sort manifest depends_on (`down` → reverse).
@@ -1341,8 +1396,17 @@ class DenCLI {
1341
1396
  const startupOrder = ws
1342
1397
  ? resolveStartupOrder(addresses, catalog, ws.config, ws.name)
1343
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
+ };
1344
1406
  if (command === "down") {
1345
- ordered = [...startupOrder].reverse();
1407
+ ordered = namedOnly().reverse();
1408
+ } else if (command !== "up") {
1409
+ ordered = namedOnly();
1346
1410
  } else {
1347
1411
  ordered = startupOrder;
1348
1412
  if (ordered.length > addresses.length) {
@@ -1358,6 +1422,42 @@ class DenCLI {
1358
1422
  }
1359
1423
  }
1360
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
+
1361
1461
  // Build a catalog + workspace reference once per run for readiness lookups.
1362
1462
  const catalog = readCatalog();
1363
1463
  const ws = getActiveWorkspace();
@@ -1398,19 +1498,7 @@ class DenCLI {
1398
1498
  }
1399
1499
  // R13: a remote service is never started or stopped from here. Said,
1400
1500
  // 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;
1501
+ const remote = remoteRefusal(address);
1414
1502
  if (remote) {
1415
1503
  if (isNamedTarget(address, opts?.named ?? [])) {
1416
1504
  console.log(chalk.red(` ✗ ${remote}`));
@@ -1629,6 +1717,9 @@ class DenCLI {
1629
1717
  }
1630
1718
  });
1631
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;
1632
1723
  if (plan?.failFast && !started) {
1633
1724
  console.log(
1634
1725
  chalk.red(
@@ -1817,7 +1908,12 @@ class DenCLI {
1817
1908
  return;
1818
1909
  }
1819
1910
  if (dryRun) {
1820
- 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
+ );
1821
1917
  return;
1822
1918
  }
1823
1919
  // Check the secrets backend first so any first-clone `make setup` (and the up
@@ -1862,10 +1958,13 @@ class DenCLI {
1862
1958
  seed: string,
1863
1959
  dryRun: boolean,
1864
1960
  force = false,
1961
+ withDeps = false,
1865
1962
  ): Promise<void> {
1866
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).
1867
1966
  const upOrder = this.runtimeOnly(
1868
- deriveStartTargets(full, seed).order,
1967
+ withDeps ? deriveStartTargets(full, seed).order : [seed],
1869
1968
  "stopped",
1870
1969
  );
1871
1970
  if (upOrder.length === 0) return;
@@ -1876,6 +1975,9 @@ class DenCLI {
1876
1975
  // still running on them. Name them rather than letting them fall over
1877
1976
  // silently.
1878
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")));
1879
1981
  const atRisk = [
1880
1982
  ...new Set(
1881
1983
  full.edges
@@ -1883,14 +1985,16 @@ class DenCLI {
1883
1985
  (e) =>
1884
1986
  e.kind !== "jwks" &&
1885
1987
  e.ordering !== false &&
1886
- inClosure.has(e.to) &&
1988
+ stopsHere.has(e.to) &&
1887
1989
  !inClosure.has(e.from),
1888
1990
  )
1889
1991
  .map((e) => e.from),
1890
1992
  ),
1891
1993
  ].sort();
1892
1994
  console.log(
1893
- chalk.cyan(`\n Derived down for ${chalk.bold(seed)} (+ dependencies)`),
1995
+ chalk.cyan(
1996
+ `\n Derived down for ${chalk.bold(seed)}${withDeps ? " (+ dependencies)" : ""}`,
1997
+ ),
1894
1998
  );
1895
1999
  console.log(chalk.gray(` Order: ${order.join(" → ")}\n`));
1896
2000
  if (atRisk.length > 0) {
@@ -1902,7 +2006,12 @@ class DenCLI {
1902
2006
  );
1903
2007
  }
1904
2008
  if (dryRun) {
1905
- 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
+ );
1906
2015
  return;
1907
2016
  }
1908
2017
  await this.runServicesCommand(
@@ -1913,6 +2022,24 @@ class DenCLI {
1913
2022
  );
1914
2023
  }
1915
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
+ );
2041
+ }
2042
+
1916
2043
  /** Workspace repos with no runtime (modules, libraries) → their kind. */
1917
2044
  private noRuntimeRepos(): Map<string, RepoKind> {
1918
2045
  const out = new Map<string, RepoKind>();
@@ -2337,9 +2464,10 @@ class DenCLI {
2337
2464
  // Tokens after a `--` terminator belong to a child command — they must
2338
2465
  // never be read as up's own flags (`den up -- -a` is NOT `up -a`).
2339
2466
  const terminator = args.indexOf("--");
2340
- const { app, rest: ownArgs } = takeAppFlag(
2467
+ const { app, rest } = takeAppFlag(
2341
2468
  terminator === -1 ? args : args.slice(0, terminator),
2342
2469
  );
2470
+ const ownArgs = canonicalTargets(rest);
2343
2471
  const dryRun = ownArgs.includes("--dry-run") || ownArgs.includes("-n");
2344
2472
  const plan = ownArgs.includes("--plan");
2345
2473
  const pull = ownArgs.includes("--pull");
@@ -2396,11 +2524,17 @@ class DenCLI {
2396
2524
  await this.runDerivedUp(seed, include, dryRun, plan, pull);
2397
2525
  return;
2398
2526
  }
2399
- await this.cmdServiceUpManifest(ownArgs.filter((a) => !a.startsWith("-")));
2527
+ await this.cmdServiceUpManifest(
2528
+ ownArgs.filter((a) => !a.startsWith("-")),
2529
+ dryRun || plan,
2530
+ );
2400
2531
  }
2401
2532
 
2402
2533
  /** Legacy: start the given services via manifest depends_on. */
2403
- private async cmdServiceUpManifest(addresses: string[]): Promise<void> {
2534
+ private async cmdServiceUpManifest(
2535
+ addresses: string[],
2536
+ dryRun = false,
2537
+ ): Promise<void> {
2404
2538
  // Resolve targets BEFORE the secrets preflight — otherwise running
2405
2539
  // `den up` from a non-service dir (e.g. the den repo root) triggers
2406
2540
  // an interactive sign-in just to print "nothing to do".
@@ -2427,9 +2561,10 @@ class DenCLI {
2427
2561
  }
2428
2562
  return;
2429
2563
  }
2430
- preflightSecrets();
2564
+ if (!dryRun) preflightSecrets();
2431
2565
  await this.runServicesCommand(targets, "up", undefined, {
2432
2566
  named: addresses,
2567
+ dryRun,
2433
2568
  });
2434
2569
  }
2435
2570
 
@@ -2438,9 +2573,10 @@ class DenCLI {
2438
2573
  // never be read as down's own flags (`den down -- -a` must NOT tear
2439
2574
  // down the whole workspace).
2440
2575
  const terminator = args.indexOf("--");
2441
- const { app, rest: ownArgs } = takeAppFlag(
2576
+ const { app, rest } = takeAppFlag(
2442
2577
  terminator === -1 ? args : args.slice(0, terminator),
2443
2578
  );
2579
+ const ownArgs = canonicalTargets(rest);
2444
2580
  const dryRun = ownArgs.includes("--dry-run") || ownArgs.includes("-n");
2445
2581
  const force = ownArgs.includes("--force") || ownArgs.includes("-f");
2446
2582
  if (app) {
@@ -2482,7 +2618,12 @@ class DenCLI {
2482
2618
  const seed = forceManifest ? undefined : this.derivedSeed(seedArg);
2483
2619
 
2484
2620
  if (seed) {
2485
- await this.runDerivedDown(seed, dryRun, force);
2621
+ await this.runDerivedDown(
2622
+ seed,
2623
+ dryRun,
2624
+ force,
2625
+ ownArgs.includes("--with-deps"),
2626
+ );
2486
2627
  return;
2487
2628
  }
2488
2629
  const addresses = ownArgs.filter((a) => !a.startsWith("-"));
@@ -2493,27 +2634,30 @@ class DenCLI {
2493
2634
  await this.runServicesCommand(targets, "down", undefined, {
2494
2635
  force,
2495
2636
  named: addresses,
2637
+ dryRun,
2496
2638
  });
2497
2639
  }
2498
2640
 
2499
2641
  private async cmdServiceRestart(argv: string[]): Promise<void> {
2500
2642
  const { app, rest: args } = takeAppFlag(argv);
2501
- preflightSecrets();
2643
+ const dryRun = args.includes("--dry-run") || args.includes("-n");
2644
+ if (!dryRun) preflightSecrets();
2502
2645
  if (app) {
2503
2646
  const order =
2504
2647
  this.allWorkspaceOrder(focusedAppRepos(app))?.rawOrder ?? [];
2505
- await this.runServicesCommand(order, "restart");
2648
+ await this.runServicesCommand(order, "restart", undefined, { dryRun });
2506
2649
  return;
2507
2650
  }
2508
2651
  // Drop flag tokens so a bare `den restart --foo` doesn't treat the flag
2509
2652
  // as a service address (matches cmdServiceUp/cmdServiceDown).
2510
- const filtered = args.filter((a) => !a.startsWith("-"));
2653
+ const filtered = canonicalTargets(args.filter((a) => !a.startsWith("-")));
2511
2654
  const addresses =
2512
2655
  filtered.length > 0
2513
2656
  ? filtered
2514
2657
  : this.resolveNoArgServiceTargets("restart");
2515
2658
  await this.runServicesCommand(addresses, "restart", undefined, {
2516
2659
  named: filtered,
2660
+ dryRun,
2517
2661
  });
2518
2662
  }
2519
2663
 
@@ -2622,7 +2766,7 @@ class DenCLI {
2622
2766
  private async cmdLogs(args: string[]): Promise<void> {
2623
2767
  await this.ensureWorkspaceSeeded();
2624
2768
  const alias = loadAliasResolver();
2625
- const target = resolveLogsTarget(args, alias);
2769
+ const target = resolveLogsTarget(canonicalTargets(args, 1), alias);
2626
2770
  if (target.kind === "unknown-repo") {
2627
2771
  console.log(chalk.red(`\n Unknown repo: ${target.arg}\n`));
2628
2772
  process.exit(1);
@@ -3688,6 +3832,12 @@ class DenCLI {
3688
3832
  console.log(
3689
3833
  " mcp [--port <n>] MCP server (stdio) for agents, over the running web server",
3690
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
+ );
3691
3841
  console.log();
3692
3842
  console.log(" workspace list List workspaces");
3693
3843
  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
+ }
@@ -14,6 +14,7 @@ import {
14
14
  stackHealth,
15
15
  stackLogs,
16
16
  stackPs,
17
+ stackRestartService,
17
18
  stackUp,
18
19
  } from "../../shared/stacks/runtime.js";
19
20
  import { loadStackContext, stackStatuses } from "../../shared/stacks/status.js";
@@ -71,6 +72,14 @@ export async function cmdStack(argv: string[]): Promise<void> {
71
72
  return;
72
73
  }
73
74
  case "restart":
75
+ if (service) {
76
+ // One compose service, in place: no edge re-render, no recreate.
77
+ for (const n of pick()) {
78
+ console.log(chalk.cyan(`↻ ${n} ${service}`));
79
+ stackRestartService(ctx, stacks[n], service);
80
+ }
81
+ return;
82
+ }
74
83
  await edgeUp(edge);
75
84
  for (const n of pick()) {
76
85
  console.log(chalk.cyan(`↻ ${n}`));