@monster-orange/den 0.2.4 → 0.2.5

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.
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Compose-stack health answered in-process, from one `docker ps` listing
3
+ * shared by every check that runs at the same time.
4
+ *
5
+ * Den gives a compose repo with no declared check a `<cli> stack health`
6
+ * shell check (composeHealthCmd), one per manifest service. Spawned, each
7
+ * was a full CLI start: 17-19 tsx processes per health cycle for three
8
+ * stacks, which on a loaded machine (load 35-55) ran past their budget and
9
+ * read as down. The checks Den generates itself are now judged here, with
10
+ * the verdict `stack health` gives; a distribution's own shell checks still
11
+ * run as shell commands.
12
+ */
13
+ import { execFile } from "child_process";
14
+ import type { RawManifest } from "../types/index.js";
15
+ import { manifestStackName } from "./config.js";
16
+ import { composeHealthCmd } from "./manifest-commands.js";
17
+ import { projectName } from "./render.js";
18
+ import { judgeStackRows, type ComposeRow } from "./runtime.js";
19
+ import { resolveStackWorkspaces } from "./status.js";
20
+
21
+ export { judgeStackRows, type ComposeRow };
22
+
23
+ /** A Den-generated compose health check: what to look for in the listing. */
24
+ export interface ComposeHealthTarget {
25
+ /** The Compose project (`<stack>-<stack workspace>`). */
26
+ project: string;
27
+ /** A compose service to check on its own; unset: the whole stack. */
28
+ service?: string;
29
+ /** The equivalent `<cli> stack health` command (shown as the check). */
30
+ cmd: string;
31
+ }
32
+
33
+ /** A health verdict; `ok: null` when docker could not be asked. */
34
+ export interface ComposeVerdict {
35
+ ok: boolean | null;
36
+ detail: string;
37
+ /** With ok null: docker gave no answer within the budget. */
38
+ pending?: boolean;
39
+ }
40
+
41
+ /** The compose health check Den generates for this service, or null. */
42
+ export function composeHealthTarget(
43
+ manifest: RawManifest,
44
+ subsystem: string,
45
+ repoName: string,
46
+ workspace: string,
47
+ ): ComposeHealthTarget | null {
48
+ const cmd = composeHealthCmd(manifest, subsystem, repoName, workspace);
49
+ if (!cmd) return null;
50
+ const project = projectName(
51
+ manifestStackName(manifest, repoName),
52
+ resolveStackWorkspaces(workspace).stackWorkspace,
53
+ );
54
+ const service = cmd.includes(" --service ") ? subsystem : undefined;
55
+ return { project, ...(service ? { service } : {}), cmd };
56
+ }
57
+
58
+ /** Budget for the shared listing; past it the checks are pending. */
59
+ export const COMPOSE_LISTING_TIMEOUT_MS = 5_000;
60
+
61
+ type Listing = { rows: ComposeRow[] } | { error: string; pending?: boolean };
62
+
63
+ let inFlight: Promise<Listing> | null = null;
64
+
65
+ /**
66
+ * Every compose container, from one `docker ps -a`. Concurrent callers share
67
+ * the call in flight, so a health cycle lists once however many checks it has.
68
+ */
69
+ export function composeListing(): Promise<Listing> {
70
+ return (inFlight ??= listOnce().finally(() => {
71
+ inFlight = null;
72
+ }));
73
+ }
74
+
75
+ function listOnce(): Promise<Listing> {
76
+ return new Promise((done) => {
77
+ // Backstop: execFile reports only once the pipes close, which a killed
78
+ // process's children can hold open; the checks must not wait on that.
79
+ const guard = setTimeout(
80
+ () =>
81
+ resolve({
82
+ error: `docker gave no answer within ${COMPOSE_LISTING_TIMEOUT_MS / 1000}s`,
83
+ pending: true,
84
+ }),
85
+ COMPOSE_LISTING_TIMEOUT_MS + 500,
86
+ );
87
+ let settled = false;
88
+ const resolve = (l: Listing): void => {
89
+ if (settled) return;
90
+ settled = true;
91
+ clearTimeout(guard);
92
+ done(l);
93
+ };
94
+ execFile(
95
+ "docker",
96
+ [
97
+ "ps",
98
+ "-a",
99
+ "--filter",
100
+ "label=com.docker.compose.project",
101
+ "--format",
102
+ '{{.Label "com.docker.compose.project"}}\t{{.Label "com.docker.compose.service"}}\t{{.State}}\t{{.Status}}',
103
+ ],
104
+ {
105
+ encoding: "utf-8",
106
+ timeout: COMPOSE_LISTING_TIMEOUT_MS,
107
+ maxBuffer: 16 * 1024 * 1024,
108
+ },
109
+ (err, stdout, stderr) => {
110
+ if (err) {
111
+ const killed = (err as { killed?: boolean }).killed === true;
112
+ return resolve(
113
+ killed
114
+ ? {
115
+ error: `docker gave no answer within ${COMPOSE_LISTING_TIMEOUT_MS / 1000}s`,
116
+ pending: true,
117
+ }
118
+ : {
119
+ error:
120
+ String(stderr || err.message).trim() || "docker ps failed",
121
+ },
122
+ );
123
+ }
124
+ const rows = stdout
125
+ .split("\n")
126
+ .filter(Boolean)
127
+ .map((line) => {
128
+ const [project = "", service = "", state = "", status = ""] =
129
+ line.split("\t");
130
+ return { project, service, state, status };
131
+ });
132
+ resolve({ rows });
133
+ },
134
+ );
135
+ });
136
+ }
137
+
138
+ /** The health of one Den-generated compose check, from the shared listing. */
139
+ export async function composeHealth(
140
+ target: ComposeHealthTarget,
141
+ ): Promise<ComposeVerdict> {
142
+ const listing = await composeListing();
143
+ if ("error" in listing)
144
+ return {
145
+ ok: null,
146
+ detail: `container state unknown: ${listing.error}`,
147
+ ...(listing.pending ? { pending: true } : {}),
148
+ };
149
+ return judgeStackRows(listing.rows, target.project, target.service);
150
+ }
@@ -364,39 +364,86 @@ export function stackLogs(
364
364
 
365
365
  /**
366
366
  * Health of a running stack: ok when it has containers, all are running, and
367
- * none reports a failing or pending Docker healthcheck.
367
+ * none reports a failing or pending Docker healthcheck. `ok: null` when
368
+ * docker could not be asked: container state unknown is not "not running".
368
369
  */
369
370
  export function stackHealth(
370
371
  ctx: StackContext,
371
372
  s: StackConfig,
372
373
  service?: string,
373
- ): { ok: boolean; detail: string } {
374
+ ): { ok: boolean | null; detail: string } {
374
375
  const project = projectName(s.name, ctx.workspace);
375
- const r = run("docker", [
376
- "ps",
377
- "-a",
378
- "--filter",
379
- `label=com.docker.compose.project=${project}`,
380
- ...(service
381
- ? ["--filter", `label=com.docker.compose.service=${service}`]
382
- : []),
383
- "--format",
384
- '{{.Label "com.docker.compose.service"}}\t{{.State}}\t{{.Status}}',
385
- ]);
386
- if (r.code !== 0) return { ok: false, detail: r.err || "docker ps failed" };
387
- const rows = r.out.split("\n").filter(Boolean);
388
- if (!rows.length)
376
+ let r: ReturnType<typeof run>;
377
+ try {
378
+ r = run("docker", [
379
+ "ps",
380
+ "-a",
381
+ "--filter",
382
+ `label=com.docker.compose.project=${project}`,
383
+ ...(service
384
+ ? ["--filter", `label=com.docker.compose.service=${service}`]
385
+ : []),
386
+ "--format",
387
+ '{{.Label "com.docker.compose.service"}}\t{{.State}}\t{{.Status}}',
388
+ ]);
389
+ } catch (e) {
390
+ return {
391
+ ok: null,
392
+ detail: `container state unknown: ${(e as Error).message}`,
393
+ };
394
+ }
395
+ if (r.code !== 0)
396
+ return {
397
+ ok: null,
398
+ detail: `container state unknown: ${r.err.trim() || "docker ps failed"}`,
399
+ };
400
+ const rows = r.out
401
+ .split("\n")
402
+ .filter(Boolean)
403
+ .map((line) => {
404
+ const [svc = "", state = "", status = ""] = line.split("\t");
405
+ return { project, service: svc, state, status };
406
+ });
407
+ return judgeStackRows(rows, project, service);
408
+ }
409
+
410
+ /** One compose container, as a `docker ps` listing reports it. */
411
+ export interface ComposeRow {
412
+ project: string;
413
+ service: string;
414
+ state: string;
415
+ status: string;
416
+ }
417
+
418
+ /**
419
+ * The verdict `stack health` gives for `project` (and `service`): ok when it
420
+ * has containers, all running, none with a failing or starting Docker
421
+ * healthcheck. Shared by the CLI and the in-process check (compose-health).
422
+ */
423
+ export function judgeStackRows(
424
+ rows: ComposeRow[],
425
+ project: string,
426
+ service?: string,
427
+ ): { ok: boolean; detail: string } {
428
+ const mine = rows.filter(
429
+ (r) => r.project === project && (!service || r.service === service),
430
+ );
431
+ if (!mine.length)
389
432
  return {
390
433
  ok: false,
391
434
  detail: `${project}${service ? `/${service}` : ""}: not running`,
392
435
  };
393
- const bad = rows.filter((row) => {
394
- const [, state, status] = row.split("\t");
395
- return (
396
- state !== "running" || /\((unhealthy|health: starting)\)/.test(status)
397
- );
398
- });
436
+ const bad = mine.filter(
437
+ (r) =>
438
+ r.state !== "running" ||
439
+ /\((unhealthy|health: starting)\)/.test(r.status),
440
+ );
399
441
  return bad.length
400
- ? { ok: false, detail: bad.join("; ") }
401
- : { ok: true, detail: `${rows.length} container(s) running` };
442
+ ? {
443
+ ok: false,
444
+ detail: bad
445
+ .map((r) => `${r.service}\t${r.state}\t${r.status}`)
446
+ .join("; "),
447
+ }
448
+ : { ok: true, detail: `${mine.length} container(s) running` };
402
449
  }
@@ -34,7 +34,8 @@ export interface StackStatus {
34
34
  routes: StackRoute[];
35
35
  uses: string[];
36
36
  /** Container health (only when asked for: it runs `docker ps`). */
37
- health?: { ok: boolean; detail: string };
37
+ /** `ok: null`: docker could not be asked, so the state is unknown. */
38
+ health?: { ok: boolean | null; detail: string };
38
39
  }
39
40
 
40
41
  const WS_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
@@ -96,7 +97,7 @@ export function stackStatuses(
96
97
  try {
97
98
  status.health = healthOf(ctx, s);
98
99
  } catch (e) {
99
- status.health = { ok: false, detail: (e as Error).message };
100
+ status.health = { ok: null, detail: (e as Error).message };
100
101
  }
101
102
  }
102
103
  return status;
@@ -13,6 +13,7 @@ export function healthLabelText(health: HealthState): string {
13
13
  export function formatHealthLabel(health: HealthState): string {
14
14
  if (health === "ok") return chalk.green("● ok");
15
15
  if (health === "error") return chalk.red("● down");
16
+ if (health === "pending") return chalk.gray("● pending");
16
17
  return chalk.gray("● unknown");
17
18
  }
18
19
 
@@ -85,9 +85,11 @@ import {
85
85
  resolveServiceRuntime,
86
86
  } from "../../shared/environment-manager/service-runner.js";
87
87
  import {
88
- defaultWorkspaceRepos,
88
+ blueprintWorkspaces,
89
89
  defaultWorkspaceNeedsSeeding,
90
90
  defaultWorkspaceHasServices,
91
+ isMultiBlueprint,
92
+ seedBlueprintWorkspaces,
91
93
  seedDefaultWorkspace,
92
94
  topUpDefaultWorkspace,
93
95
  markDefaultWorkspaceDeclined,
@@ -252,6 +254,14 @@ const WORKSPACE_SUBCOMMANDS: {
252
254
  usage: "code [n]",
253
255
  lines: ["open it in your editor (VS Code: a .code-workspace)"],
254
256
  },
257
+ {
258
+ name: "seed",
259
+ usage: "seed",
260
+ lines: [
261
+ "fill workspaces from the distribution's blueprint",
262
+ "(defaults.workspace / defaults.workspaces), all managed",
263
+ ],
264
+ },
255
265
  ];
256
266
 
257
267
  const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
@@ -2483,36 +2493,42 @@ class DenCLI {
2483
2493
  }
2484
2494
 
2485
2495
  /**
2486
- * Seed (or reset) the default workspace from the committed blueprint
2487
- * (config/den.yml `defaults.workspace`), all repos managed. Prints
2488
- * clear guidance and, on a TTY, asks first. `reason: "auto"` is the one-time
2489
- * offer on a fresh clone; `reason: "init"` is the explicit `den init`.
2490
- * Returns true if the workspace was seeded.
2496
+ * Seed (or reset) the blueprint's workspaces from the committed config:
2497
+ * `defaults.workspace` fills `default`; `defaults.workspaces` creates each
2498
+ * named workspace, activates them all and focuses the marked one. All
2499
+ * repos managed. Prints clear guidance and, on a TTY, asks first.
2500
+ * `reason: "auto"` is the one-time offer on a genuine fresh setup (TTY
2501
+ * only: see ensureWorkspaceSeeded); `reason: "init"` is the explicit
2502
+ * `<cli> workspace seed` / `<cli> init` in the root. Returns true if seeded.
2491
2503
  */
2492
2504
  private async seedDefaultWorkspaceFlow(
2493
2505
  reason: "auto" | "init",
2494
2506
  ): Promise<boolean> {
2495
- const repos = defaultWorkspaceRepos();
2496
- if (repos.length === 0) {
2507
+ const bps = blueprintWorkspaces();
2508
+ if (bps.length === 0) {
2497
2509
  if (reason === "init") {
2498
2510
  console.log(
2499
2511
  chalk.yellow(
2500
- `\n No defaults.workspace.repos in ${product().configFile} — nothing to seed.\n`,
2512
+ `\n No defaults.workspace.repos or defaults.workspaces in ${product().configFile} — nothing to seed.\n`,
2501
2513
  ),
2502
2514
  );
2503
2515
  }
2504
2516
  return false;
2505
2517
  }
2518
+ const multi = isMultiBlueprint();
2506
2519
  const willReset = defaultWorkspaceHasServices();
2507
2520
  const interactive = !!(process.stdin.isTTY && process.stdout.isTTY);
2508
2521
 
2509
- console.log(
2510
- chalk.cyan(`\n Default workspace — ${repos.length} repos, all managed:`),
2511
- );
2512
- console.log(chalk.gray(` ${repos.join(", ")}`));
2522
+ for (const bp of bps) {
2523
+ const label = multi ? `Workspace ${bp.name}` : "Default workspace";
2524
+ console.log(
2525
+ chalk.cyan(`\n ${label} — ${bp.repos.length} repos, all managed:`),
2526
+ );
2527
+ console.log(chalk.gray(` ${bp.repos.join(", ")}`));
2528
+ }
2513
2529
  console.log(
2514
2530
  chalk.gray(
2515
- ` ${product().title} clones + tracks them on main under ${product().servicesDir}/. Switch any`,
2531
+ ` ${product().title} clones + tracks them on their branch under ${product().servicesDir}/. Switch any`,
2516
2532
  ),
2517
2533
  );
2518
2534
  console.log(
@@ -2522,16 +2538,17 @@ class DenCLI {
2522
2538
  );
2523
2539
 
2524
2540
  if (interactive) {
2541
+ const what = multi ? "these workspaces" : "the default workspace";
2525
2542
  const question = willReset
2526
- ? " Reset the default workspace to this shipped set?"
2527
- : " Seed the default workspace now?";
2543
+ ? ` Reset ${what} to this shipped set?`
2544
+ : ` Seed ${what} now?`;
2528
2545
  const ok = await cliPrompts.confirm(question, !willReset);
2529
2546
  if (!ok) {
2530
2547
  if (reason === "auto") {
2531
2548
  markDefaultWorkspaceDeclined(); // don't ask again
2532
2549
  console.log(
2533
2550
  chalk.gray(
2534
- ` Skipped — run \`${product().cli} init\` to set it up later.\n`,
2551
+ ` Skipped — run \`${product().cli} workspace seed\` to set it up later.\n`,
2535
2552
  ),
2536
2553
  );
2537
2554
  } else {
@@ -2541,6 +2558,17 @@ class DenCLI {
2541
2558
  }
2542
2559
  }
2543
2560
 
2561
+ if (multi) {
2562
+ const seeded = seedBlueprintWorkspaces();
2563
+ const focus = readWorkspacesFile().focus;
2564
+ console.log(
2565
+ chalk.green(
2566
+ ` ✓ ${seeded.length} workspaces ready (${seeded.map((w) => w.name).join(", ")}), all active; focus: ${focus}. ` +
2567
+ `Run \`${product().cli} up <repo>\` to bring up its closure.\n`,
2568
+ ),
2569
+ );
2570
+ return true;
2571
+ }
2544
2572
  const seeded = seedDefaultWorkspace();
2545
2573
  console.log(
2546
2574
  chalk.green(
@@ -2552,13 +2580,22 @@ class DenCLI {
2552
2580
  }
2553
2581
 
2554
2582
  /**
2555
- * One-time offer to seed the default workspace on a fresh clone; afterwards
2556
- * keep it converged on the blueprint (repos added to defaults later — e.g.
2557
- * tasks-api — join the existing workspace managed).
2583
+ * On a genuine fresh setup (nothing but an empty, never-seeded `default`),
2584
+ * offer to seed from the blueprint: on a TTY, ask; otherwise print a
2585
+ * one-line hint and change nothing. Afterwards keep a lone `default`
2586
+ * converged on the blueprint (repos added to defaults later join it
2587
+ * managed); never once the user has other workspaces.
2558
2588
  */
2559
2589
  private async ensureWorkspaceSeeded(): Promise<void> {
2560
2590
  if (defaultWorkspaceNeedsSeeding()) {
2561
- await this.seedDefaultWorkspaceFlow("auto");
2591
+ if (process.stdin.isTTY && process.stdout.isTTY) {
2592
+ await this.seedDefaultWorkspaceFlow("auto");
2593
+ } else {
2594
+ const hint = isMultiBlueprint()
2595
+ ? "no workspaces yet: run `{cli} workspace seed` to create them from the blueprint"
2596
+ : "default workspace is empty: run `{cli} workspace seed` to fill it from the blueprint";
2597
+ console.error(chalk.gray(` ${hint.replace("{cli}", product().cli)}`));
2598
+ }
2562
2599
  return;
2563
2600
  }
2564
2601
  const added = topUpDefaultWorkspace();
@@ -3627,6 +3664,10 @@ class DenCLI {
3627
3664
  );
3628
3665
  break;
3629
3666
  }
3667
+ case "seed": {
3668
+ await this.seedDefaultWorkspaceFlow("init");
3669
+ break;
3670
+ }
3630
3671
  case "create": {
3631
3672
  const name =
3632
3673
  args[1] ??
@@ -3718,7 +3759,7 @@ class DenCLI {
3718
3759
  default:
3719
3760
  console.log(
3720
3761
  chalk.gray(
3721
- `Usage: ${product().cli} workspace <list|activate|deactivate|focus|use|create|delete|code> [name]`,
3762
+ `Usage: ${product().cli} workspace <${WORKSPACE_SUBCOMMANDS.map((c) => c.name).join("|")}> [name]`,
3722
3763
  ),
3723
3764
  );
3724
3765
  }
@@ -22,6 +22,7 @@ import {
22
22
  } from "../../shared/stacks/runtime.js";
23
23
  import { loadStackContext, stackStatuses } from "../../shared/stacks/status.js";
24
24
  import type { StackConfig } from "../../shared/stacks/types.js";
25
+ import { STACK_HEALTH_UNKNOWN_EXIT } from "../../shared/constants.js";
25
26
 
26
27
  function serviceFlag(args: string[]): { service?: string; rest: string[] } {
27
28
  const rest: string[] = [];
@@ -56,9 +57,48 @@ export async function resolveStacksSecretEnv(
56
57
  return out;
57
58
  }
58
59
 
60
+ /**
61
+ * `stack health <name…>`: exit 0 when every stack is healthy, 1 when one is
62
+ * not, and STACK_HEALTH_UNKNOWN_EXIT when none failed but one could not be
63
+ * told (docker did not answer, the config could not be read). The web
64
+ * server's health row reads that as unknown, never as down.
65
+ */
66
+ function stackHealthCommand(args: string[], service?: string): void {
67
+ let failed = false;
68
+ let unknown = false;
69
+ try {
70
+ const ctx = loadStackContext();
71
+ const names = args.filter((a) => !a.startsWith("-"));
72
+ if (!names.length)
73
+ throw new Error(
74
+ `Usage: stack health <name…> (known: ${Object.keys(ctx.stacks).join(", ")})`,
75
+ );
76
+ for (const n of names) {
77
+ const s = ctx.stacks[n];
78
+ const h = s
79
+ ? stackHealth(ctx, s, service)
80
+ : { ok: false, detail: `unknown stack "${n}"` };
81
+ if (h.ok === false) failed = true;
82
+ if (h.ok === null) unknown = true;
83
+ const mark =
84
+ h.ok === true
85
+ ? chalk.green("✓")
86
+ : h.ok === false
87
+ ? chalk.red("✗")
88
+ : chalk.gray("?");
89
+ console.log(`${mark} ${n} ${h.detail}`);
90
+ }
91
+ } catch (e) {
92
+ console.error(chalk.gray(`? ${(e as Error).message}`));
93
+ unknown = true;
94
+ }
95
+ process.exitCode = failed ? 1 : unknown ? STACK_HEALTH_UNKNOWN_EXIT : 0;
96
+ }
97
+
59
98
  export async function cmdStack(argv: string[]): Promise<void> {
60
99
  const { service, rest } = serviceFlag(argv);
61
100
  const [sub = "ls", ...args] = rest;
101
+ if (sub === "health") return stackHealthCommand(args, service);
62
102
  // The global -w/--workspace is parsed (and checked) before we run and
63
103
  // arrives as DEN_WORKSPACE_OVERRIDE; loadStackContext reads it.
64
104
  const ctx = loadStackContext();
@@ -129,18 +169,6 @@ export async function cmdStack(argv: string[]): Promise<void> {
129
169
  }
130
170
  return;
131
171
  }
132
- case "health": {
133
- let ok = true;
134
- for (const n of pick()) {
135
- const h = stackHealth(ctx, stacks[n], service);
136
- ok &&= h.ok;
137
- console.log(
138
- `${h.ok ? chalk.green("✓") : chalk.red("✗")} ${n} ${h.detail}`,
139
- );
140
- }
141
- process.exitCode = ok ? 0 : 1;
142
- return;
143
- }
144
172
  case "down":
145
173
  for (const n of startOrder(pick(), stacks)
146
174
  .reverse()
@@ -3,6 +3,7 @@ import { execFileSync, spawn, spawnSync } from "child_process";
3
3
  import * as fs from "fs";
4
4
  import * as http from "http";
5
5
  import * as path from "path";
6
+ import { pathToFileURL } from "url";
6
7
  import {
7
8
  CLI_SPAWN_TIMEOUT_MS,
8
9
  DEFAULT_PORT,
@@ -10,7 +11,7 @@ import {
10
11
  } from "../../shared/constants.js";
11
12
  import { getDistRoot, getEngineRoot, getStateDir } from "../../shared/paths.js";
12
13
  import { hasDenUiMarker } from "../../shared/ui-marker.js";
13
- import { engineBin } from "../../shared/engine.js";
14
+ import { engineBin, engineModuleFile } from "../../shared/engine.js";
14
15
  import { readWebPort } from "../../shared/edge/config.js";
15
16
 
16
17
  import { product } from "../../shared/product.js";
@@ -294,8 +295,19 @@ async function waitForDenWeb(port: number): Promise<boolean> {
294
295
  return false;
295
296
  }
296
297
 
297
- /** How to run the web server: Den's own files, from the engine root. */
298
- function resolveServerEntrypoint(engine: string = getEngineRoot()): {
298
+ /**
299
+ * How to run the web server: Den's own files, from the engine root.
300
+ *
301
+ * The TypeScript source runs as `node --import <tsx loader> index.ts`, never
302
+ * through the `tsx` CLI. The CLI is a parent process that relays signals to
303
+ * the server and, if the server has not acknowledged a SIGTERM within about
304
+ * 60ms (two 30ms windows), SIGKILLs it and exits 143. Under load the server
305
+ * misses that window, so `<cli> web down` killed it without its shutdown.
306
+ * With the loader there is one process, and the signal reaches the
307
+ * server's own handler. (The launchd service, service/install.sh, already
308
+ * runs this way.)
309
+ */
310
+ export function resolveServerEntrypoint(engine: string = getEngineRoot()): {
299
311
  cmd: string;
300
312
  args: string[];
301
313
  } {
@@ -313,17 +325,23 @@ function resolveServerEntrypoint(engine: string = getEngineRoot()): {
313
325
  // take effect. The server resolves PUBLIC_DIR relative to its own module, so
314
326
  // running from src/ serves src/ui/server/public directly. Fall back to the
315
327
  // compiled build only when devDependencies (tsx) aren't installed.
316
- const tsxBin = engineBin(process.platform === "win32" ? "tsx.cmd" : "tsx");
328
+ const loader = engineModuleFile("tsx/dist/loader.mjs", engine);
317
329
  const srcServer = path.join(engine, "src/ui/server/index.ts");
318
- if (fs.existsSync(tsxBin) && fs.existsSync(srcServer)) {
319
- return { cmd: tsxBin, args: [srcServer] };
330
+ if (loader && fs.existsSync(srcServer)) {
331
+ return {
332
+ cmd: process.execPath,
333
+ args: ["--import", pathToFileURL(loader).href, srcServer],
334
+ };
320
335
  }
321
336
 
322
337
  if (fs.existsSync(builtServer)) {
323
338
  return { cmd: process.execPath, args: [builtServer] };
324
339
  }
325
340
 
326
- return { cmd: tsxBin, args: [srcServer] };
341
+ return {
342
+ cmd: engineBin(process.platform === "win32" ? "tsx.cmd" : "tsx", engine),
343
+ args: [srcServer],
344
+ };
327
345
  }
328
346
 
329
347
  function startDenWebServer(options: WebCommandOptions): StartedServer {