@tridha643/hestia 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -7254,7 +7254,7 @@ function parsePidfile(source, path) {
7254
7254
  if (pidfile.schemaVersion !== undefined && pidfile.schemaVersion !== STATE_SCHEMA_VERSION) {
7255
7255
  throw new HestiaError("state-corrupt", `invalid pidfile ${path}: unsupported schemaVersion`, { path });
7256
7256
  }
7257
- if (typeof pidfile.name !== "string" || !Number.isInteger(pidfile.pid) || typeof pidfile.startTime !== "string" || typeof pidfile.logPath !== "string") {
7257
+ if (typeof pidfile.name !== "string" || !Number.isSafeInteger(pidfile.pid) || pidfile.pid <= 0 || typeof pidfile.startTime !== "string" || typeof pidfile.logPath !== "string") {
7258
7258
  throw new HestiaError("state-corrupt", `invalid pidfile ${path}: missing process identity`, { path });
7259
7259
  }
7260
7260
  if (pidfile.schemaVersion === STATE_SCHEMA_VERSION && typeof pidfile.specFingerprint !== "string") {
@@ -7272,12 +7272,25 @@ function removePidfile(worktreeRoot, name) {
7272
7272
  rmSync2(pidfilePath(worktreeRoot, name), { force: true });
7273
7273
  }
7274
7274
  function listPidfiles(dir) {
7275
+ const scan = scanPidfiles(dir);
7276
+ if (scan.errors[0] !== undefined)
7277
+ throw scan.errors[0];
7278
+ return scan.pidfiles;
7279
+ }
7280
+ function scanPidfiles(dir) {
7275
7281
  if (!existsSync2(dir))
7276
- return [];
7277
- return readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) => {
7278
- const path = join2(dir, f);
7279
- return parsePidfile(readFileSync2(path, "utf8"), path);
7280
- });
7282
+ return { pidfiles: [], errors: [] };
7283
+ const pidfiles = [];
7284
+ const errors2 = [];
7285
+ for (const file of readdirSync(dir).filter((candidate) => candidate.endsWith(".json"))) {
7286
+ const path = join2(dir, file);
7287
+ try {
7288
+ pidfiles.push(parsePidfile(readFileSync2(path, "utf8"), path));
7289
+ } catch (error) {
7290
+ errors2.push(error);
7291
+ }
7292
+ }
7293
+ return { pidfiles, errors: errors2 };
7281
7294
  }
7282
7295
  function procSpecFingerprint(spec) {
7283
7296
  const env = Object.entries(spec.env ?? {}).sort(([left], [right]) => left.localeCompare(right));
@@ -7290,23 +7303,28 @@ function procSpecFingerprint(spec) {
7290
7303
  backend: spec.backend ?? "proc",
7291
7304
  resolver: spec.varlock ? "varlock" : "direct",
7292
7305
  inspectorPort: spec.inspectorPort ?? null,
7306
+ healthPath: spec.healthPath ?? null,
7293
7307
  configPath: spec.configPath ?? null,
7294
7308
  originService: spec.originService ?? null,
7295
7309
  originEndpoint: spec.originEndpoint ?? null
7296
7310
  });
7297
7311
  return createHash3("sha256").update(canonical).digest("hex");
7298
7312
  }
7299
- function readProcessStartTime(pid, env) {
7313
+ function probeProcessStartTime(pid, env) {
7300
7314
  try {
7301
7315
  const out = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
7302
7316
  encoding: "utf8",
7303
7317
  env
7304
7318
  }).trim();
7305
- return out === "" ? null : out;
7319
+ return { status: "ok", value: out === "" ? null : out };
7306
7320
  } catch {
7307
- return null;
7321
+ return { status: "error" };
7308
7322
  }
7309
7323
  }
7324
+ function readProcessStartTime(pid, env) {
7325
+ const result = probeProcessStartTime(pid, env);
7326
+ return result.status === "ok" ? result.value : null;
7327
+ }
7310
7328
  function startTimeOf(pid) {
7311
7329
  return readProcessStartTime(pid, { ...process.env, LC_ALL: "C", LANG: "C" });
7312
7330
  }
@@ -7357,30 +7375,40 @@ function plausibleProcessLocales(canonicalStartTime, recordedStartTime) {
7357
7375
  });
7358
7376
  return [...new Set(plausible)];
7359
7377
  }
7360
- function legacyLocalizedStartTimeMatches(pid, canonicalStartTime, recordedStartTime) {
7378
+ function probeLegacyLocalizedStartTime(pid, canonicalStartTime, recordedStartTime) {
7361
7379
  const cacheKey = `${pid}\x00${canonicalStartTime}\x00${recordedStartTime}`;
7362
7380
  const cached = legacyStartTimeMatches.get(cacheKey);
7363
7381
  if (cached !== undefined)
7364
- return cached;
7382
+ return cached ? "live" : "dead";
7365
7383
  let matched = false;
7384
+ let readFailed = false;
7366
7385
  for (const locale of plausibleProcessLocales(canonicalStartTime, recordedStartTime)) {
7367
- const localized = readProcessStartTime(pid, {
7386
+ const read = probeProcessStartTime(pid, {
7368
7387
  ...process.env,
7369
7388
  LC_ALL: locale,
7370
7389
  LANG: locale
7371
7390
  });
7372
- if (localized !== null && processStartTimeMatches(localized, recordedStartTime)) {
7391
+ if (read.status === "error") {
7392
+ readFailed = true;
7393
+ continue;
7394
+ }
7395
+ if (read.value !== null && processStartTimeMatches(read.value, recordedStartTime)) {
7373
7396
  matched = true;
7374
7397
  break;
7375
7398
  }
7376
7399
  }
7400
+ if (!matched && readFailed)
7401
+ return "unknown";
7377
7402
  if (legacyStartTimeMatches.size >= 512) {
7378
7403
  const oldest = legacyStartTimeMatches.keys().next().value;
7379
7404
  if (oldest !== undefined)
7380
7405
  legacyStartTimeMatches.delete(oldest);
7381
7406
  }
7382
7407
  legacyStartTimeMatches.set(cacheKey, matched);
7383
- return matched;
7408
+ return matched ? "live" : "dead";
7409
+ }
7410
+ function legacyLocalizedStartTimeMatches(pid, canonicalStartTime, recordedStartTime) {
7411
+ return probeLegacyLocalizedStartTime(pid, canonicalStartTime, recordedStartTime) === "live";
7384
7412
  }
7385
7413
  function processStartTimeMatches(current, recorded) {
7386
7414
  if (current === recorded)
@@ -7402,6 +7430,27 @@ function isLive(pf) {
7402
7430
  return true;
7403
7431
  return current !== null && legacyLocalizedStartTimeMatches(pf.pid, current, pf.startTime);
7404
7432
  }
7433
+ function probeProcessIdentity(pf) {
7434
+ try {
7435
+ process.kill(pf.pid, 0);
7436
+ } catch (error) {
7437
+ return error.code === "ESRCH" ? "dead" : "unknown";
7438
+ }
7439
+ let current;
7440
+ try {
7441
+ current = execFileSync("ps", ["-o", "lstart=", "-p", String(pf.pid)], {
7442
+ encoding: "utf8",
7443
+ env: { ...process.env, LC_ALL: "C", LANG: "C" }
7444
+ }).trim();
7445
+ } catch {
7446
+ return "unknown";
7447
+ }
7448
+ if (current === "")
7449
+ return "dead";
7450
+ if (processStartTimeMatches(current, pf.startTime))
7451
+ return "live";
7452
+ return probeLegacyLocalizedStartTime(pf.pid, current, pf.startTime);
7453
+ }
7405
7454
  var installedProcessLocales, legacyStartTimeMatches, legacyMacosProcessLocales;
7406
7455
  var init_pidfile = __esm(() => {
7407
7456
  init_src();
@@ -7529,6 +7578,13 @@ function readMirrorState(project) {
7529
7578
  chmodSync2(p, 384);
7530
7579
  return record;
7531
7580
  }
7581
+ function readMirrorStateSafe(project) {
7582
+ try {
7583
+ return { status: "ok", record: readMirrorState(project) };
7584
+ } catch (error) {
7585
+ return { status: "error", error };
7586
+ }
7587
+ }
7532
7588
  function clearState(worktreeRoot, project) {
7533
7589
  const p = statePath(worktreeRoot);
7534
7590
  if (existsSync3(p))
@@ -7738,6 +7794,26 @@ var init_override = __esm(() => {
7738
7794
  // packages/engine/src/repository-config.ts
7739
7795
  import { existsSync as existsSync4, readFileSync as readFileSync5 } from "fs";
7740
7796
  import { join as join5 } from "path";
7797
+ function parseEnvironment(value, path) {
7798
+ if (value === undefined)
7799
+ return {};
7800
+ const environment = {};
7801
+ for (const [name, rawValue] of Object.entries(table(value, path))) {
7802
+ if (!ENVIRONMENT_KEY.test(name))
7803
+ throw new Error(`${path} has invalid environment key ${JSON.stringify(name)}`);
7804
+ if (typeof rawValue === "string") {
7805
+ environment[name] = rawValue;
7806
+ continue;
7807
+ }
7808
+ const fileValue = table(rawValue, `${path}.${name}`);
7809
+ knownKeys(fileValue, ["file"], `${path}.${name}`);
7810
+ if (typeof fileValue.file !== "string" || !/^\.hestia\//.test(fileValue.file) || fileValue.file.includes("..")) {
7811
+ throw new Error(`${path}.${name}.file must be a path below .hestia/`);
7812
+ }
7813
+ environment[name] = { file: fileValue.file };
7814
+ }
7815
+ return environment;
7816
+ }
7741
7817
  function emptyConfig() {
7742
7818
  return { version: 1, workloads: {} };
7743
7819
  }
@@ -7797,7 +7873,19 @@ function parseRepositoryWorkloadConfig(source, path) {
7797
7873
  if (!WORKLOAD_NAME.test(name))
7798
7874
  throw new Error(`invalid workload name ${JSON.stringify(name)}`);
7799
7875
  const workload = table(rawWorkload, `workloads.${name}`);
7800
- knownKeys(workload, ["source", "compose_service", "dockerfile", "command", "wrangler_config", "port", "endpoints"], `workloads.${name}`);
7876
+ knownKeys(workload, [
7877
+ "source",
7878
+ "compose_service",
7879
+ "dockerfile",
7880
+ "command",
7881
+ "wrangler_config",
7882
+ "port",
7883
+ "cwd",
7884
+ "varlock",
7885
+ "health_path",
7886
+ "env",
7887
+ "endpoints"
7888
+ ], `workloads.${name}`);
7801
7889
  if (!["compose", "dockerfile", "proc", "wrangler"].includes(workload.source)) {
7802
7890
  throw new Error(`workloads.${name}.source must be compose, dockerfile, proc, or wrangler`);
7803
7891
  }
@@ -7813,6 +7901,18 @@ function parseRepositoryWorkloadConfig(source, path) {
7813
7901
  throw new Error(`workloads.${name}.${key} must be a string`);
7814
7902
  }
7815
7903
  }
7904
+ if (workload.cwd !== undefined && typeof workload.cwd !== "string") {
7905
+ throw new Error(`workloads.${name}.cwd must be a string`);
7906
+ }
7907
+ if (workload.varlock !== undefined && typeof workload.varlock !== "boolean") {
7908
+ throw new Error(`workloads.${name}.varlock must be a boolean`);
7909
+ }
7910
+ if (workload.health_path !== undefined && (typeof workload.health_path !== "string" || !workload.health_path.startsWith("/"))) {
7911
+ throw new Error(`workloads.${name}.health_path must start with /`);
7912
+ }
7913
+ if (workload.health_path !== undefined && workload.port === "none") {
7914
+ throw new Error(`workloads.${name}.health_path requires a port`);
7915
+ }
7816
7916
  if (sourceKind === "compose" && typeof workload.compose_service !== "string") {
7817
7917
  throw new Error(`workloads.${name}.compose_service is required for compose`);
7818
7918
  }
@@ -7826,6 +7926,10 @@ function parseRepositoryWorkloadConfig(source, path) {
7826
7926
  command: workload.command,
7827
7927
  wranglerConfig: workload.wrangler_config,
7828
7928
  port: workload.port,
7929
+ cwd: workload.cwd,
7930
+ varlock: workload.varlock,
7931
+ healthPath: workload.health_path,
7932
+ env: parseEnvironment(workload.env, `workloads.${name}.env`),
7829
7933
  endpoints: parseEndpoints(workload.endpoints, `workloads.${name}.endpoints`)
7830
7934
  };
7831
7935
  }
@@ -7870,6 +7974,17 @@ function renderRepositoryWorkloadConfig(config) {
7870
7974
  lines.push(`wrangler_config = ${quote(workload.wranglerConfig)}`);
7871
7975
  if (workload.port !== undefined)
7872
7976
  lines.push(`port = ${quote(workload.port)}`);
7977
+ if (workload.cwd !== undefined)
7978
+ lines.push(`cwd = ${quote(workload.cwd)}`);
7979
+ if (workload.varlock !== undefined)
7980
+ lines.push(`varlock = ${workload.varlock}`);
7981
+ if (workload.healthPath !== undefined)
7982
+ lines.push(`health_path = ${quote(workload.healthPath)}`);
7983
+ for (const key of Object.keys(workload.env).sort()) {
7984
+ const value = workload.env[key];
7985
+ const rendered = typeof value === "string" ? quote(value) : `{ file = ${quote(value.file)} }`;
7986
+ lines.push(`env.${quote(key)} = ${rendered}`);
7987
+ }
7873
7988
  lines.push("");
7874
7989
  for (const alias of Object.keys(workload.endpoints).sort()) {
7875
7990
  const endpoint = workload.endpoints[alias];
@@ -7883,12 +7998,13 @@ function renderRepositoryWorkloadConfig(config) {
7883
7998
  `).trimEnd()}
7884
7999
  `;
7885
8000
  }
7886
- var WORKLOAD_NAME, BINDING;
8001
+ var WORKLOAD_NAME, BINDING, ENVIRONMENT_KEY;
7887
8002
  var init_repository_config = __esm(() => {
7888
8003
  init_src();
7889
8004
  init_state();
7890
8005
  WORKLOAD_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
7891
8006
  BINDING = /^(?:main|[1-9][0-9]{0,4})\/(?:tcp|udp)$/;
8007
+ ENVIRONMENT_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
7892
8008
  });
7893
8009
 
7894
8010
  // packages/engine/src/discovery.ts
@@ -7926,7 +8042,12 @@ function sameEndpoint(left, right) {
7926
8042
  return left.binding === right.binding && left.kind === right.kind && left.local === right.local;
7927
8043
  }
7928
8044
  function cloneWorkload(workload) {
7929
- return { ...workload, command: workload.command?.slice(), endpoints: { ...workload.endpoints } };
8045
+ return {
8046
+ ...workload,
8047
+ command: workload.command?.slice(),
8048
+ env: { ...workload.env },
8049
+ endpoints: { ...workload.endpoints }
8050
+ };
7930
8051
  }
7931
8052
  function mergeConfigLayers(repository, machine) {
7932
8053
  const workloads = {};
@@ -7952,6 +8073,10 @@ function mergeConfigLayers(repository, machine) {
7952
8073
  conflicts.push(`machine workload ${name} cannot replace committed source ${committed.source} with ${overlay.source}`);
7953
8074
  continue;
7954
8075
  }
8076
+ committed.cwd = overlay.cwd ?? committed.cwd;
8077
+ committed.varlock = overlay.varlock ?? committed.varlock;
8078
+ committed.healthPath = overlay.healthPath ?? committed.healthPath;
8079
+ committed.env = { ...committed.env, ...overlay.env };
7955
8080
  for (const [alias, endpoint] of Object.entries(overlay.endpoints)) {
7956
8081
  const existing = committed.endpoints[alias];
7957
8082
  if (existing !== undefined && !sameEndpoint(existing, endpoint)) {
@@ -8244,7 +8369,7 @@ async function composeUp(ctx, services) {
8244
8369
  async function composeDown(ctx, destroy) {
8245
8370
  const rest = ["down", "--remove-orphans"];
8246
8371
  if (destroy)
8247
- rest.push("-v");
8372
+ rest.push("-v", "--rmi", "local");
8248
8373
  await docker(ctx, rest);
8249
8374
  }
8250
8375
  async function composePs(ctx) {
@@ -8373,7 +8498,7 @@ function allocatePort() {
8373
8498
  });
8374
8499
  });
8375
8500
  }
8376
- function psSnapshot() {
8501
+ function tryPsSnapshot() {
8377
8502
  try {
8378
8503
  const out = execFileSync2("ps", ["-A", "-o", "pid=,pgid=,ppid="], {
8379
8504
  encoding: "utf8",
@@ -8388,11 +8513,10 @@ function psSnapshot() {
8388
8513
  }
8389
8514
  return rows;
8390
8515
  } catch {
8391
- return [];
8516
+ return null;
8392
8517
  }
8393
8518
  }
8394
- function processTree(rootPid) {
8395
- const rows = psSnapshot();
8519
+ function processTreeFromSnapshot(rootPid, rows) {
8396
8520
  const byParent = new Map;
8397
8521
  for (const r of rows) {
8398
8522
  const list = byParent.get(r.ppid) ?? [];
@@ -8415,6 +8539,9 @@ function processTree(rootPid) {
8415
8539
  }
8416
8540
  return tree;
8417
8541
  }
8542
+ function processTree(rootPid) {
8543
+ return processTreeFromSnapshot(rootPid, tryPsSnapshot() ?? []);
8544
+ }
8418
8545
  function parseLsof(out) {
8419
8546
  const listeners = [];
8420
8547
  let pid = 0;
@@ -8450,12 +8577,12 @@ async function allListeners() {
8450
8577
  return parseLsof(stdout);
8451
8578
  } catch (err) {
8452
8579
  const e = err;
8453
- if (typeof e.stdout === "string" && e.code !== "ENOENT") {
8580
+ if (typeof e.stdout === "string" && e.code === 1 && (e.stderr ?? "").trim() === "") {
8454
8581
  tool = "lsof";
8455
8582
  return parseLsof(e.stdout);
8456
8583
  }
8457
- if (tool === "lsof")
8458
- return [];
8584
+ if (e.code !== "ENOENT")
8585
+ throw err;
8459
8586
  }
8460
8587
  }
8461
8588
  try {
@@ -8464,16 +8591,24 @@ async function allListeners() {
8464
8591
  });
8465
8592
  tool = "ss";
8466
8593
  return parseSs(stdout);
8467
- } catch {
8594
+ } catch (error) {
8595
+ if (error.code !== "ENOENT")
8596
+ throw error;
8468
8597
  throw new HestiaError("ownership-tool-missing", "neither lsof nor ss is available \u2014 cannot verify port ownership " + "(required: tools like next dev silently bind a different port when " + "theirs is taken, so a bare listen-check would report the wrong port healthy)");
8469
8598
  }
8470
8599
  }
8471
8600
  async function inspectPort(rootPid, port) {
8472
- const members = new Set(processTree(rootPid).map((r) => r.pid));
8601
+ const firstSnapshot = tryPsSnapshot();
8602
+ if (firstSnapshot === null)
8603
+ throw new Error("Port ownership process-tree inspection failed: ps unavailable");
8604
+ const members = new Set(processTreeFromSnapshot(rootPid, firstSnapshot).map((r) => r.pid));
8473
8605
  const listeners = await allListeners();
8474
8606
  const owner = listeners.find((l) => l.port === port);
8475
8607
  if (owner !== undefined && !members.has(owner.pid)) {
8476
- for (const member of processTree(rootPid))
8608
+ const secondSnapshot = tryPsSnapshot();
8609
+ if (secondSnapshot === null)
8610
+ throw new Error("Port ownership process-tree recheck failed: ps unavailable");
8611
+ for (const member of processTreeFromSnapshot(rootPid, secondSnapshot))
8477
8612
  members.add(member.pid);
8478
8613
  }
8479
8614
  return {
@@ -8543,19 +8678,32 @@ var init_shutdown = __esm(() => {
8543
8678
 
8544
8679
  // packages/engine/src/proc/resolver.ts
8545
8680
  import { existsSync as existsSync6 } from "fs";
8546
- import { join as join8 } from "path";
8547
- function detectVarlock(worktreeRoot) {
8548
- const schema = join8(worktreeRoot, ".env.schema");
8549
- const bin = join8(worktreeRoot, "node_modules", ".bin", "varlock");
8550
- return existsSync6(schema) && existsSync6(bin) ? bin : null;
8681
+ import { dirname as dirname3, join as join8, resolve as resolve2 } from "path";
8682
+ function detectVarlock(worktreeRoot, workingDirectory = worktreeRoot) {
8683
+ const root = resolve2(worktreeRoot);
8684
+ const cwd = resolve2(workingDirectory);
8685
+ if (!existsSync6(join8(cwd, ".env.schema")))
8686
+ return null;
8687
+ let directory = cwd;
8688
+ for (;; ) {
8689
+ const bin = join8(directory, "node_modules", ".bin", "varlock");
8690
+ if (existsSync6(bin))
8691
+ return bin;
8692
+ if (directory === root)
8693
+ return null;
8694
+ const parent = dirname3(directory);
8695
+ if (parent === directory || !parent.startsWith(root))
8696
+ return null;
8697
+ directory = parent;
8698
+ }
8551
8699
  }
8552
8700
  function wrapWithVarlock(bin, argv) {
8553
8701
  return [bin, "run", "--no-redact-stdout", "--", ...argv];
8554
8702
  }
8555
- function requireVarlock(worktreeRoot) {
8556
- const bin = detectVarlock(worktreeRoot);
8703
+ function requireVarlock(worktreeRoot, workingDirectory = worktreeRoot) {
8704
+ const bin = detectVarlock(worktreeRoot, workingDirectory);
8557
8705
  if (bin === null) {
8558
- throw new HestiaError("varlock-missing", `--varlock requires both ${join8(worktreeRoot, ".env.schema")} and a local ` + `node_modules/.bin/varlock in the worktree`);
8706
+ throw new HestiaError("varlock-missing", `--varlock requires ${join8(workingDirectory, ".env.schema")} and a local ` + `node_modules/.bin/varlock between that directory and the worktree root`);
8559
8707
  }
8560
8708
  return bin;
8561
8709
  }
@@ -8582,7 +8730,7 @@ function spawnOnce(argv, cwd, env, logPath, attempt) {
8582
8730
  closeSync2(openProcAttemptLog(logPath, attempt));
8583
8731
  const relaySpec = Buffer.from(JSON.stringify({ argv, cwd, env, logPath })).toString("base64");
8584
8732
  const relayFd = openSync2(logPath, "a", 384);
8585
- return new Promise((resolve2, reject) => {
8733
+ return new Promise((resolve3, reject) => {
8586
8734
  const child = spawn(process.execPath, [PROC_RELAY_ENTRYPOINT], {
8587
8735
  cwd,
8588
8736
  env: { ...process.env, [RELAY_SPEC_ENV]: relaySpec },
@@ -8596,7 +8744,7 @@ function spawnOnce(argv, cwd, env, logPath, attempt) {
8596
8744
  child.once("spawn", () => {
8597
8745
  closeSync2(relayFd);
8598
8746
  child.unref();
8599
- resolve2(child.pid);
8747
+ resolve3(child.pid);
8600
8748
  });
8601
8749
  });
8602
8750
  }
@@ -8613,18 +8761,22 @@ async function startProc(worktreeRoot, spec, stackEnv, mirrorPidfile2) {
8613
8761
  const cwd = spec.cwd ? join9(worktreeRoot, spec.cwd) : worktreeRoot;
8614
8762
  const wantPort = spec.port !== "none";
8615
8763
  const readyTimeoutMs = spec.readyTimeoutMs ?? READY_TIMEOUT_MS;
8616
- const varlockBin = spec.varlock ? requireVarlock(worktreeRoot) : null;
8764
+ const varlockBin = spec.varlock ? requireVarlock(worktreeRoot, cwd) : null;
8617
8765
  let lastFailure = "";
8618
8766
  for (let attempt = 1;attempt <= MAX_ATTEMPTS; attempt++) {
8619
8767
  const port = wantPort ? await allocatePort() : undefined;
8620
8768
  let argv = port === undefined ? spec.argv : substitutePort(spec.argv, port);
8621
8769
  if (varlockBin !== null)
8622
8770
  argv = wrapWithVarlock(varlockBin, argv);
8771
+ const resolvedSpecEnvironment = port === undefined ? spec.env : Object.fromEntries(Object.entries(spec.env ?? {}).map(([name, value]) => [
8772
+ name,
8773
+ substitutePort([value], port)[0]
8774
+ ]));
8623
8775
  const env = {
8624
8776
  ...process.env,
8625
8777
  ...stackEnv,
8626
8778
  ...port !== undefined ? { PORT: String(port), [`HESTIA_${envKey(spec.name)}_PORT`]: String(port) } : {},
8627
- ...spec.env
8779
+ ...resolvedSpecEnvironment
8628
8780
  };
8629
8781
  const pid = await spawnOnce(argv, cwd, env, logPath, attempt);
8630
8782
  const startTime = startTimeOf(pid) ?? "";
@@ -8644,7 +8796,7 @@ async function startProc(worktreeRoot, spec, stackEnv, mirrorPidfile2) {
8644
8796
  };
8645
8797
  writePidfile(worktreeRoot, pf);
8646
8798
  mirrorPidfile2?.(pf);
8647
- const outcome = await waitUntilReady(pf, port, readyTimeoutMs);
8799
+ const outcome = await waitUntilReady(pf, port, readyTimeoutMs, spec.healthPath);
8648
8800
  const record = {
8649
8801
  name: spec.name,
8650
8802
  backend: pf.backend,
@@ -8680,7 +8832,7 @@ async function startProc(worktreeRoot, spec, stackEnv, mirrorPidfile2) {
8680
8832
  continue;
8681
8833
  case "timeout": {
8682
8834
  record.state = "unhealthy";
8683
- const bound = outcome.memberPorts.length > 0 ? `it is listening on port(s) ${outcome.memberPorts.join(", ")} instead` : `it never opened a listening socket (if it isn't a server, use --no-port)`;
8835
+ const bound = outcome.healthPending ? `it owned port ${port}, but ${spec.healthPath} never returned a 2xx response` : outcome.memberPorts.length > 0 ? `it is listening on port(s) ${outcome.memberPorts.join(", ")} instead` : `it never opened a listening socket (if it isn't a server, use --no-port)`;
8684
8836
  return {
8685
8837
  record,
8686
8838
  pidfile: pf,
@@ -8691,26 +8843,41 @@ async function startProc(worktreeRoot, spec, stackEnv, mirrorPidfile2) {
8691
8843
  }
8692
8844
  throw new HestiaError("port-allocation-failed", `could not hold a port for "${spec.name}" after ${MAX_ATTEMPTS} attempts (${lastFailure})`);
8693
8845
  }
8694
- async function waitUntilReady(pf, port, timeoutMs) {
8846
+ async function waitUntilReady(pf, port, timeoutMs, healthPath) {
8695
8847
  const deadline = Date.now() + timeoutMs;
8696
8848
  if (port === undefined) {
8697
8849
  await new Promise((r) => setTimeout(r, NO_PORT_GRACE_MS));
8698
8850
  return isLive(pf) ? { kind: "ready" } : { kind: "exited" };
8699
8851
  }
8700
8852
  let memberPorts = [];
8853
+ let ownershipProven = false;
8701
8854
  while (Date.now() < deadline) {
8702
8855
  if (!isLive(pf))
8703
8856
  return { kind: "exited" };
8704
- const view = await inspectPort(pf.pgid, port);
8705
- memberPorts = view.memberPorts;
8706
- if (view.ownerIsMember)
8707
- return { kind: "ready" };
8708
- if (view.owner !== undefined) {
8709
- return { kind: "stolen", byPid: view.owner.pid };
8857
+ if (!ownershipProven) {
8858
+ const view = await inspectPort(pf.pgid, port);
8859
+ memberPorts = view.memberPorts;
8860
+ if (view.ownerIsMember)
8861
+ ownershipProven = true;
8862
+ else if (view.owner !== undefined)
8863
+ return { kind: "stolen", byPid: view.owner.pid };
8864
+ }
8865
+ if (ownershipProven) {
8866
+ if (healthPath === undefined)
8867
+ return { kind: "ready" };
8868
+ try {
8869
+ const response = await fetch(`http://127.0.0.1:${port}${healthPath}`);
8870
+ if (response.ok)
8871
+ return { kind: "ready" };
8872
+ } catch {}
8710
8873
  }
8711
8874
  await new Promise((r) => setTimeout(r, POLL_MS2));
8712
8875
  }
8713
- return { kind: "timeout", memberPorts };
8876
+ return {
8877
+ kind: "timeout",
8878
+ memberPorts,
8879
+ healthPending: healthPath !== undefined && ownershipProven
8880
+ };
8714
8881
  }
8715
8882
  var READY_TIMEOUT_MS = 60000, NO_PORT_GRACE_MS = 2000, POLL_MS2 = 300, MAX_ATTEMPTS = 3, PROC_RESTART_SENTINEL = `--- hestia: proc restarted (port stolen) ---
8716
8883
  `, RELAY_SPEC_ENV = "HESTIA_PROC_RELAY_SPEC", PROC_RELAY_ENTRYPOINT, PORT_TOKEN = "{port}", ESCAPED = "{{port}}", SENTINEL = "\x00";
@@ -9106,12 +9273,12 @@ class SlotLedger {
9106
9273
  return { live, reserved, warnings };
9107
9274
  }
9108
9275
  }
9109
- var DEFAULT_MAX_STACKS = 5, RESERVATION_TTL_MS = 60000, DOCKER_PROBE_TIMEOUT_MS = 5000, dockerProbe = (project) => new Promise((resolve2) => {
9276
+ var DEFAULT_MAX_STACKS = 5, RESERVATION_TTL_MS = 60000, DOCKER_PROBE_TIMEOUT_MS = 5000, dockerProbe = (project) => new Promise((resolve3) => {
9110
9277
  execFile5("docker", ["ps", "-q", "--filter", `label=${LABELS.stack}=${project}`], { timeout: DOCKER_PROBE_TIMEOUT_MS }, (err, stdout) => {
9111
9278
  if (err)
9112
- resolve2(null);
9279
+ resolve3(null);
9113
9280
  else
9114
- resolve2(stdout.trim() === "" ? "dead" : "live");
9281
+ resolve3(stdout.trim() === "" ? "dead" : "live");
9115
9282
  });
9116
9283
  });
9117
9284
  var init_slots = __esm(() => {
@@ -9312,7 +9479,7 @@ var init_client = __esm(() => {
9312
9479
  import { existsSync as existsSync11, mkdirSync as mkdirSync6, readFileSync as readFileSync11, rmSync as rmSync6 } from "fs";
9313
9480
  import { execFileSync as execFileSync3 } from "child_process";
9314
9481
  import { homedir as homedir2 } from "os";
9315
- import { dirname as dirname3, join as join13 } from "path";
9482
+ import { dirname as dirname4, join as join13 } from "path";
9316
9483
  import { fileURLToPath } from "url";
9317
9484
  function launchAgentsDir() {
9318
9485
  return process.env.HESTIA_LAUNCHD_DIR ?? join13(homedir2(), "Library", "LaunchAgents");
@@ -9321,7 +9488,7 @@ function plistPath() {
9321
9488
  return join13(launchAgentsDir(), `${LAUNCHD_LABEL}.plist`);
9322
9489
  }
9323
9490
  function daemonMainPath() {
9324
- const bundled = join13(dirname3(fileURLToPath(import.meta.url)), "daemon.js");
9491
+ const bundled = join13(dirname4(fileURLToPath(import.meta.url)), "daemon.js");
9325
9492
  if (existsSync11(bundled))
9326
9493
  return bundled;
9327
9494
  return fileURLToPath(new URL("./main.ts", import.meta.url));
@@ -9424,7 +9591,7 @@ function installLaunchd(opts) {
9424
9591
  return { plist, warnings };
9425
9592
  }
9426
9593
  mkdirSync6(launchAgentsDir(), { recursive: true });
9427
- mkdirSync6(dirname3(input.logPath), { recursive: true });
9594
+ mkdirSync6(dirname4(input.logPath), { recursive: true });
9428
9595
  const target = plistPath();
9429
9596
  const previous = existsSync11(target) ? readFileSync11(target, "utf8") : null;
9430
9597
  const previouslyBootstrapped = isBootstrapped();
@@ -9510,14 +9677,14 @@ class Admission {
9510
9677
  const first = await this.#locked(() => this.#try(identity2, holder));
9511
9678
  if (first.granted || waitMs <= 0)
9512
9679
  return first;
9513
- return new Promise((resolve2) => {
9680
+ return new Promise((resolve3) => {
9514
9681
  const waiter = {
9515
9682
  identity: identity2,
9516
9683
  holder,
9517
- resolve: resolve2,
9684
+ resolve: resolve3,
9518
9685
  timer: setTimeout(() => {
9519
9686
  this.#queue = this.#queue.filter((candidate) => candidate !== waiter);
9520
- resolve2({ granted: false, live: first.live });
9687
+ resolve3({ granted: false, live: first.live });
9521
9688
  }, waitMs)
9522
9689
  };
9523
9690
  this.#queue.push(waiter);
@@ -9579,11 +9746,11 @@ class Admission {
9579
9746
  return { granted: false, live: occupancy.live };
9580
9747
  }
9581
9748
  async#pump() {
9582
- if (this.#queue.length === 0)
9583
- return;
9584
9749
  const { maxStacks } = resolveMaxStacks();
9585
9750
  const occupancy = await this.ledger.occupancy();
9586
9751
  this.#cache(occupancy);
9752
+ if (this.#queue.length === 0)
9753
+ return;
9587
9754
  let used = occupancy.live.length + occupancy.reserved.length;
9588
9755
  const granted = [];
9589
9756
  for (const waiter of this.#queue) {
@@ -9621,7 +9788,7 @@ class Admission {
9621
9788
  };
9622
9789
  }
9623
9790
  }
9624
- var HESTIAD_PROTOCOL_VERSION = 5, MAX_JSON_BODY_BYTES;
9791
+ var HESTIAD_PROTOCOL_VERSION = 6, MAX_JSON_BODY_BYTES;
9625
9792
  var init_routes = __esm(() => {
9626
9793
  init_src();
9627
9794
  init_state();
@@ -9758,13 +9925,13 @@ function portlessPayloadProvenanceIsCurrent(value) {
9758
9925
  const provenance = value;
9759
9926
  return provenance.package === "portless" && provenance.version === HESTIA_PORTLESS_VERSION && provenance.patchSha256 === HESTIA_PORTLESS_PATCH_SHA256;
9760
9927
  }
9761
- var HESTIA_PORTLESS_VERSION = "0.15.1", HESTIA_PORTLESS_PATCH_SHA256 = "9ca94ca771ce502769a2c7cbe3bb4fe8d54dc847ff9f7e3e2b34abf728618690";
9928
+ var HESTIA_PORTLESS_VERSION = "0.15.1", HESTIA_PORTLESS_PATCH_SHA256 = "7d969a7e8bc556d0e866accc836b06e237f567b68d3d7fb7eaed5b0378fbc2d6";
9762
9929
 
9763
9930
  // packages/engine/src/router/portless-adapter.ts
9764
9931
  import { execFile as execFile6, spawnSync } from "child_process";
9765
9932
  import { existsSync as existsSync12, readFileSync as readFileSync12, rmSync as rmSync7, statSync } from "fs";
9766
9933
  import { promisify as promisify5 } from "util";
9767
- import { dirname as dirname4, join as join15 } from "path";
9934
+ import { dirname as dirname5, join as join15 } from "path";
9768
9935
  import { fileURLToPath as fileURLToPath2 } from "url";
9769
9936
  import { createConnection } from "net";
9770
9937
  import { tmpdir } from "os";
@@ -9775,12 +9942,12 @@ function hestiaPortlessAliasesPath() {
9775
9942
  return join15(hestiaHome(), "router", "portless", "aliases.json");
9776
9943
  }
9777
9944
  function portlessCliPath() {
9778
- const moduleDirectory = dirname4(fileURLToPath2(import.meta.url));
9945
+ const moduleDirectory = dirname5(fileURLToPath2(import.meta.url));
9779
9946
  const bundled = join15(moduleDirectory, "assets", "portless", "dist", "cli.js");
9780
- if (existsSync12(bundled) && portlessPayloadRootIsCurrent(dirname4(dirname4(bundled))))
9947
+ if (existsSync12(bundled) && portlessPayloadRootIsCurrent(dirname5(dirname5(bundled))))
9781
9948
  return bundled;
9782
9949
  const workspaceBuild = join15(moduleDirectory, "..", "..", "..", "..", "dist", "assets", "portless", "dist", "cli.js");
9783
- if (existsSync12(workspaceBuild) && portlessPayloadRootIsCurrent(dirname4(dirname4(workspaceBuild)))) {
9950
+ if (existsSync12(workspaceBuild) && portlessPayloadRootIsCurrent(dirname5(dirname5(workspaceBuild)))) {
9784
9951
  return workspaceBuild;
9785
9952
  }
9786
9953
  throw new HestiaError("router-version-unsupported", "current hardened Portless payload is unavailable; run bun run build and retry");
@@ -9904,11 +10071,11 @@ async function runRootCommand(command, args, interactive, optional = false) {
9904
10071
  }
9905
10072
  }
9906
10073
  async function prepareHardenedPortlessPayloadAt(installDir, interactive) {
9907
- const packageRoot = dirname4(dirname4(portlessCliPath()));
10074
+ const packageRoot = dirname5(dirname5(portlessCliPath()));
9908
10075
  const portlessDir = join15(installDir, "portless");
9909
10076
  const bunPath = join15(installDir, "bun");
9910
- const provenanceSource = join15(dirname4(hestiaPortlessAliasesPath()), "payload-provenance.json");
9911
- ensureDir(dirname4(provenanceSource));
10077
+ const provenanceSource = join15(dirname5(hestiaPortlessAliasesPath()), "payload-provenance.json");
10078
+ ensureDir(dirname5(provenanceSource));
9912
10079
  writeAtomicJsonFile(provenanceSource, expectedPortlessPayloadProvenance());
9913
10080
  await runRootCommand("mkdir", ["-p", installDir], interactive);
9914
10081
  await runRootCommand("rm", ["-rf", portlessDir], interactive);
@@ -9942,7 +10109,7 @@ async function rollbackHardenedPortlessInstall(interactive) {
9942
10109
  await removeHestiaPortlessCa(interactive);
9943
10110
  } catch {}
9944
10111
  await runRootCommand("rm", ["-rf", HESTIA_ROUTER_INSTALL_DIR], interactive, true);
9945
- rmSync7(dirname4(hestiaPortlessAliasesPath()), { recursive: true, force: true });
10112
+ rmSync7(dirname5(hestiaPortlessAliasesPath()), { recursive: true, force: true });
9946
10113
  }
9947
10114
  async function trustHestiaPortlessCa(interactive) {
9948
10115
  await runRootCommand("env", [
@@ -10008,11 +10175,11 @@ function requireNonInteractivePrivilege() {
10008
10175
  }
10009
10176
  }
10010
10177
  async function loopbackPortIsBusy(port) {
10011
- return new Promise((resolve2) => {
10178
+ return new Promise((resolve3) => {
10012
10179
  const socket = createConnection({ host: "127.0.0.1", port });
10013
10180
  const finish = (busy) => {
10014
10181
  socket.destroy();
10015
- resolve2(busy);
10182
+ resolve3(busy);
10016
10183
  };
10017
10184
  socket.setTimeout(500, () => finish(false));
10018
10185
  socket.once("connect", () => finish(true));
@@ -10023,7 +10190,7 @@ async function waitForInstalledHestiaRouter(timeoutMs = 1e4) {
10023
10190
  const deadline = Date.now() + timeoutMs;
10024
10191
  let status = await readHestiaRouterStatus();
10025
10192
  while (Date.now() < deadline && !(status.installed && status.running && status.trusted)) {
10026
- await new Promise((resolve2) => setTimeout(resolve2, 200));
10193
+ await new Promise((resolve3) => setTimeout(resolve3, 200));
10027
10194
  status = await readHestiaRouterStatus();
10028
10195
  }
10029
10196
  if (!(status.installed && status.running && status.trusted)) {
@@ -10035,7 +10202,7 @@ async function waitForRunningHestiaRouter(timeoutMs = 1e4) {
10035
10202
  const deadline = Date.now() + timeoutMs;
10036
10203
  let status = await readHestiaRouterStatus();
10037
10204
  while (Date.now() < deadline && !(status.installed && status.running)) {
10038
- await new Promise((resolve2) => setTimeout(resolve2, 200));
10205
+ await new Promise((resolve3) => setTimeout(resolve3, 200));
10039
10206
  status = await readHestiaRouterStatus();
10040
10207
  }
10041
10208
  if (!(status.installed && status.running)) {
@@ -10147,7 +10314,7 @@ async function installHestiaRouter(interactive) {
10147
10314
  } else {
10148
10315
  requireNonInteractivePrivilege();
10149
10316
  }
10150
- ensureDir(dirname4(hestiaPortlessAliasesPath()));
10317
+ ensureDir(dirname5(hestiaPortlessAliasesPath()));
10151
10318
  if (!existsSync12(hestiaPortlessAliasesPath())) {
10152
10319
  writeAtomicJsonFile(hestiaPortlessAliasesPath(), []);
10153
10320
  }
@@ -10182,7 +10349,7 @@ async function uninstallHestiaRouter(interactive) {
10182
10349
  if (ownership === "hestia") {
10183
10350
  await runRootCommand("rm", ["-rf", HESTIA_ROUTER_INSTALL_DIR], interactive);
10184
10351
  }
10185
- rmSync7(dirname4(hestiaPortlessAliasesPath()), { recursive: true, force: true });
10352
+ rmSync7(dirname5(hestiaPortlessAliasesPath()), { recursive: true, force: true });
10186
10353
  } catch (error) {
10187
10354
  const detail = error.stderr ?? error.message;
10188
10355
  throw new HestiaError("router-unreachable", `Router uninstall failed: ${detail.trim()}`);
@@ -10354,11 +10521,93 @@ var init_shared = __esm(() => {
10354
10521
  PATH_SEGMENT_RE = /^[A-Za-z0-9._~!$&'()*+,;=:@%-]+$/;
10355
10522
  });
10356
10523
 
10357
- // packages/engine/src/router/local-http-router.ts
10524
+ // packages/engine/src/router/origin-liveness.ts
10358
10525
  import { execFile as execFile7 } from "child_process";
10526
+ import { promisify as promisify6 } from "util";
10527
+ async function probeDockerOrigin(target, port) {
10528
+ try {
10529
+ const service = target.service;
10530
+ const args = [
10531
+ "ps",
10532
+ "--no-trunc",
10533
+ "--format",
10534
+ "{{.ID}}\t{{.Ports}}",
10535
+ "--filter",
10536
+ `label=dev.hestia.stack=${target.project}`,
10537
+ "--filter",
10538
+ `label=com.docker.compose.service=${service.name}`
10539
+ ];
10540
+ const { stdout } = await pexec6("docker", args, { timeout: 2000 });
10541
+ const matches = stdout.split(`
10542
+ `).some((line) => {
10543
+ const [id, ports = ""] = line.split("\t");
10544
+ const identityMatches = service.containerId === undefined || id?.startsWith(service.containerId);
10545
+ return identityMatches && new RegExp(`(?:127\\.0\\.0\\.1|0\\.0\\.0\\.0):${port}->`).test(ports);
10546
+ });
10547
+ return matches ? "live" : "dead";
10548
+ } catch {
10549
+ return "unknown";
10550
+ }
10551
+ }
10552
+ async function probeProcOrigin(service, port) {
10553
+ if (service.pid === undefined || service.startTime === undefined)
10554
+ return "dead";
10555
+ if (!Number.isSafeInteger(service.pid) || service.pid <= 0 || typeof service.startTime !== "string") {
10556
+ throw new Error("Route origin has an invalid process identity");
10557
+ }
10558
+ const identity2 = probeProcessIdentity({ pid: service.pid, startTime: service.startTime });
10559
+ if (identity2 !== "live")
10560
+ return identity2;
10561
+ try {
10562
+ return (await inspectPort(service.pid, port)).ownerIsMember ? "live" : "dead";
10563
+ } catch {
10564
+ return "unknown";
10565
+ }
10566
+ }
10567
+ async function probeLocalRouterTarget(target) {
10568
+ const service = target.service;
10569
+ const port = service?.publishedPort;
10570
+ if (service === undefined || port === undefined || service.backend === "tunnel")
10571
+ return "dead";
10572
+ return service.backend === "docker" ? await probeDockerOrigin(target, port) : await probeProcOrigin(service, port);
10573
+ }
10574
+ async function verifyLocalRouterTarget(target) {
10575
+ const port = target.service?.publishedPort;
10576
+ try {
10577
+ return port !== undefined && await probeLocalRouterTarget(target) === "live" ? port : null;
10578
+ } catch {
10579
+ return null;
10580
+ }
10581
+ }
10582
+ async function verifyStackServiceOrigin(record, service, publishedPort = service.publishedPort) {
10583
+ return await verifyLocalRouterTarget({
10584
+ project: record.project,
10585
+ service: { ...service, publishedPort }
10586
+ }) !== null;
10587
+ }
10588
+ function resolveSharedContractOrigin(mirror, contractService) {
10589
+ const endpoint = mirror.endpoints.find((candidate) => (candidate.alias ?? candidate.name) === contractService);
10590
+ const service = mirror.services.find((candidate) => candidate.name === (endpoint?.workload ?? endpoint?.name));
10591
+ const binding = service?.bindings?.find((candidate) => `${candidate.target}/${candidate.protocol}` === endpoint?.binding);
10592
+ const publishedPort = binding?.publishedPort ?? service?.publishedPort;
10593
+ return service === undefined || service.backend === "tunnel" || publishedPort === undefined ? undefined : { ...service, publishedPort };
10594
+ }
10595
+ async function probeSharedHolderOrigin(mirror, contractService) {
10596
+ const service = resolveSharedContractOrigin(mirror, contractService);
10597
+ if (service === undefined)
10598
+ return "unknown";
10599
+ return await probeLocalRouterTarget({ project: mirror.project, service });
10600
+ }
10601
+ var pexec6;
10602
+ var init_origin_liveness = __esm(() => {
10603
+ init_pidfile();
10604
+ init_ports();
10605
+ pexec6 = promisify6(execFile7);
10606
+ });
10607
+
10608
+ // packages/engine/src/router/local-http-router.ts
10359
10609
  import { Agent, createServer as createServer2, request as httpRequest } from "http";
10360
10610
  import { createConnection as createConnection2, createServer as createNetServer } from "net";
10361
- import { promisify as promisify6 } from "util";
10362
10611
  import { existsSync as existsSync14, readFileSync as readFileSync14, readdirSync as readdirSync5 } from "fs";
10363
10612
  import { chmodSync as chmodSync5, lstatSync, mkdirSync as mkdirSync7, rmSync as rmSync9 } from "fs";
10364
10613
  import { tmpdir as tmpdir2 } from "os";
@@ -10430,55 +10679,6 @@ function forwardedHeaders(request, originPort) {
10430
10679
  headers["x-forwarded-for"] = remote;
10431
10680
  return headers;
10432
10681
  }
10433
- async function verifyDockerRouteTarget(target, port) {
10434
- try {
10435
- const service = target.service;
10436
- const args = [
10437
- "ps",
10438
- "--no-trunc",
10439
- "--format",
10440
- "{{.ID}}\t{{.Ports}}",
10441
- "--filter",
10442
- `label=dev.hestia.stack=${target.project}`,
10443
- "--filter",
10444
- `label=com.docker.compose.service=${service.name}`
10445
- ];
10446
- const { stdout } = await pexec6("docker", args, { timeout: 2000 });
10447
- return stdout.split(`
10448
- `).some((line) => {
10449
- const [id, ports = ""] = line.split("\t");
10450
- const identityMatches = service.containerId === undefined || id?.startsWith(service.containerId);
10451
- return identityMatches && new RegExp(`(?:127\\.0\\.0\\.1|0\\.0\\.0\\.0):${port}->`).test(ports);
10452
- });
10453
- } catch {
10454
- return false;
10455
- }
10456
- }
10457
- async function verifyLocalRouterTarget(target) {
10458
- const service = target.service;
10459
- const port = service?.publishedPort;
10460
- if (service === undefined || port === undefined || service.backend === "tunnel")
10461
- return null;
10462
- if (service.backend === "docker") {
10463
- return await verifyDockerRouteTarget(target, port) ? port : null;
10464
- }
10465
- if (service.pid === undefined || service.startTime === undefined)
10466
- return null;
10467
- if (!isLive({ pid: service.pid, startTime: service.startTime }))
10468
- return null;
10469
- try {
10470
- return (await inspectPort(service.pid, port)).ownerIsMember ? port : null;
10471
- } catch {
10472
- return null;
10473
- }
10474
- }
10475
- async function verifyStackServiceOrigin(record, service, publishedPort = service.publishedPort) {
10476
- return await verifyLocalRouterTarget({
10477
- hostname: "",
10478
- project: record.project,
10479
- service: { ...service, publishedPort }
10480
- }) !== null;
10481
- }
10482
10682
  function targetIdentity(target) {
10483
10683
  const service = target.service;
10484
10684
  return [
@@ -10566,27 +10766,27 @@ class HestiaLocalHttpRouter {
10566
10766
  #sharedRoutes = new Map;
10567
10767
  #timer;
10568
10768
  async start() {
10569
- await new Promise((resolve2, reject) => {
10769
+ await new Promise((resolve3, reject) => {
10570
10770
  this.#server.once("error", reject);
10571
10771
  this.#server.listen(0, "127.0.0.1", () => {
10572
10772
  this.#server.off("error", reject);
10573
- resolve2();
10773
+ resolve3();
10574
10774
  });
10575
10775
  });
10576
10776
  prepareGatewaySocket(this.socketPath);
10577
- await new Promise((resolve2, reject) => {
10777
+ await new Promise((resolve3, reject) => {
10578
10778
  this.#unixServer.once("error", reject);
10579
10779
  this.#unixServer.listen(this.socketPath, () => {
10580
10780
  this.#unixServer.off("error", reject);
10581
10781
  chmodSync5(this.socketPath, 384);
10582
- resolve2();
10782
+ resolve3();
10583
10783
  });
10584
10784
  });
10585
- await new Promise((resolve2, reject) => {
10785
+ await new Promise((resolve3, reject) => {
10586
10786
  this.#frontServer.once("error", reject);
10587
10787
  this.#frontServer.listen(0, "127.0.0.1", () => {
10588
10788
  this.#frontServer.off("error", reject);
10589
- resolve2();
10789
+ resolve3();
10590
10790
  });
10591
10791
  });
10592
10792
  await this.refreshRoutes();
@@ -10675,14 +10875,12 @@ class HestiaLocalHttpRouter {
10675
10875
  let target;
10676
10876
  if (holder !== undefined) {
10677
10877
  const stack = records.find((record) => record.project === holder.project);
10678
- const endpoint = stack?.endpoints.find((candidate2) => (candidate2.alias ?? candidate2.name) === holder.service);
10679
- const service = stack?.services.find((candidate2) => candidate2.name === (endpoint?.workload ?? endpoint?.name));
10680
- const binding = service?.bindings?.find((candidate2) => `${candidate2.target}/${candidate2.protocol}` === endpoint?.binding);
10681
- const publishedPort = binding?.publishedPort ?? service?.publishedPort;
10878
+ const service = stack === undefined ? undefined : resolveSharedContractOrigin(stack, holder.service);
10682
10879
  const candidate = {
10683
10880
  hostname,
10684
10881
  project: holder.project,
10685
- service: service === undefined || service.backend === "tunnel" || publishedPort === undefined ? undefined : { ...service, publishedPort }
10882
+ sharedHolder: holder.project,
10883
+ service
10686
10884
  };
10687
10885
  const previous = this.#sharedRoutes.get(hostname)?.find((entry) => entry.name === shared.name)?.target;
10688
10886
  target = previous !== undefined && targetIdentity(previous) === targetIdentity(candidate) ? previous : candidate;
@@ -10784,7 +10982,10 @@ Connection: close\r
10784
10982
  };
10785
10983
  }
10786
10984
  if (entry.target?.service?.publishedPort === undefined) {
10787
- return { status: 503, message: "Hestia route origin unavailable" };
10985
+ return {
10986
+ status: 503,
10987
+ message: `Hestia route origin unavailable \u2014 held by ${entry.target?.project ?? "unknown"}`
10988
+ };
10788
10989
  }
10789
10990
  return { target: entry.target };
10790
10991
  }
@@ -10806,7 +11007,8 @@ Connection: close\r
10806
11007
  const target = match.target;
10807
11008
  const port = target.service.publishedPort;
10808
11009
  if (await verifyLocalRouterTarget(target) !== port) {
10809
- return sendRouterResponse(response, 503, "Hestia route origin unavailable");
11010
+ const suffix = target.sharedHolder === undefined ? "" : ` \u2014 held by ${target.sharedHolder}`;
11011
+ return sendRouterResponse(response, 503, `Hestia route origin unavailable${suffix}`);
10810
11012
  }
10811
11013
  const proxy = httpRequest({
10812
11014
  hostname: "127.0.0.1",
@@ -10828,7 +11030,8 @@ Connection: close\r
10828
11030
  proxy.on("error", (error) => {
10829
11031
  if (!response.headersSent) {
10830
11032
  const ownershipFailure = error instanceof OriginOwnershipError;
10831
- sendRouterResponse(response, ownershipFailure ? 503 : 502, ownershipFailure ? "Hestia route origin unavailable" : "Hestia route origin failed");
11033
+ const suffix = target.sharedHolder === undefined ? "" : ` \u2014 held by ${target.sharedHolder}`;
11034
+ sendRouterResponse(response, ownershipFailure ? 503 : 502, ownershipFailure ? `Hestia route origin unavailable${suffix}` : "Hestia route origin failed");
10832
11035
  } else
10833
11036
  response.destroy();
10834
11037
  });
@@ -10905,16 +11108,14 @@ Connection: close\r
10905
11108
  socket.once("error", () => originSocket.destroy());
10906
11109
  }
10907
11110
  }
10908
- var pexec6, ROUTE_REFRESH_MS = 1000, MAX_MIRROR_BYTES, MAX_REQUEST_HEADER_BYTES, HOP_BY_HOP_HEADERS, OriginOwnershipError;
11111
+ var ROUTE_REFRESH_MS = 1000, MAX_MIRROR_BYTES, MAX_REQUEST_HEADER_BYTES, HOP_BY_HOP_HEADERS, OriginOwnershipError;
10909
11112
  var init_local_http_router = __esm(() => {
10910
11113
  init_src();
10911
- init_pidfile();
10912
- init_ports();
10913
11114
  init_state();
10914
11115
  init_router_config();
10915
11116
  init_portless_adapter();
10916
11117
  init_shared();
10917
- pexec6 = promisify6(execFile7);
11118
+ init_origin_liveness();
10918
11119
  MAX_MIRROR_BYTES = 2 * 1024 * 1024;
10919
11120
  MAX_REQUEST_HEADER_BYTES = 64 * 1024;
10920
11121
  HOP_BY_HOP_HEADERS = new Set([
@@ -10931,31 +11132,73 @@ var init_local_http_router = __esm(() => {
10931
11132
  };
10932
11133
  });
10933
11134
 
11135
+ // packages/engine/src/configured-workload-environment.ts
11136
+ import { readFileSync as readFileSync15 } from "fs";
11137
+ import { join as join18 } from "path";
11138
+ function endpointTemplateValue(endpoint, field) {
11139
+ const value = endpoint[field];
11140
+ if (value === undefined) {
11141
+ throw new HestiaError("config-invalid", `endpoint template requests ${endpoint.name}.${field}, but that endpoint has no ${field}`);
11142
+ }
11143
+ return String(value);
11144
+ }
11145
+ function resolveEndpointTemplate(template, record) {
11146
+ const resolved = template.replace(ENDPOINT_TEMPLATE, (_match, endpointName, field) => {
11147
+ const endpoint = record.endpoints.find((candidate) => candidate.name === endpointName);
11148
+ if (endpoint === undefined) {
11149
+ throw new HestiaError("config-invalid", `endpoint template references ${JSON.stringify(endpointName)} before that endpoint is available`);
11150
+ }
11151
+ return endpointTemplateValue(endpoint, field);
11152
+ });
11153
+ if (resolved.includes("${endpoint:")) {
11154
+ throw new HestiaError("config-invalid", `malformed endpoint template ${JSON.stringify(template)}`);
11155
+ }
11156
+ return resolved;
11157
+ }
11158
+ function readIgnoredEnvironmentFile(worktreeRoot, file) {
11159
+ try {
11160
+ return readFileSync15(join18(worktreeRoot, file), "utf8").replace(/\r?\n$/, "");
11161
+ } catch (error) {
11162
+ throw new HestiaError("config-invalid", `cannot read configured environment file ${file}: ${error.message}`, { path: join18(worktreeRoot, file) });
11163
+ }
11164
+ }
11165
+ function resolveConfiguredEnvironment(worktreeRoot, configured, record) {
11166
+ return Object.fromEntries(Object.entries(configured).map(([name, value]) => {
11167
+ const raw = typeof value === "string" ? value : readIgnoredEnvironmentFile(worktreeRoot, value.file);
11168
+ return [name, resolveEndpointTemplate(raw, record)];
11169
+ }));
11170
+ }
11171
+ var ENDPOINT_TEMPLATE;
11172
+ var init_configured_workload_environment = __esm(() => {
11173
+ init_src();
11174
+ ENDPOINT_TEMPLATE = /\$\{endpoint:([^}]+)\.(host|port|url)\}/g;
11175
+ });
11176
+
10934
11177
  // packages/engine/src/wrangler/adapter.ts
10935
- import { existsSync as existsSync15, readFileSync as readFileSync15, statSync as statSync2 } from "fs";
11178
+ import { existsSync as existsSync15, readFileSync as readFileSync16, statSync as statSync2 } from "fs";
10936
11179
  import { homedir as homedir3 } from "os";
10937
- import { dirname as dirname5, join as join18, relative as relative3 } from "path";
11180
+ import { dirname as dirname6, join as join19, relative as relative3 } from "path";
10938
11181
  import { connect } from "net";
10939
11182
  function privateRegistryDir(worktreeRoot) {
10940
- return join18(worktreeRoot, ".hestia", "wrangler-registry");
11183
+ return join19(worktreeRoot, ".hestia", "wrangler-registry");
10941
11184
  }
10942
11185
  function globalRegistryDir() {
10943
- return process.env.WRANGLER_REGISTRY_PATH ?? join18(homedir3(), ".wrangler", "config", "registry");
11186
+ return process.env.WRANGLER_REGISTRY_PATH ?? join19(homedir3(), ".wrangler", "config", "registry");
10944
11187
  }
10945
11188
  function tcpConnectable(address, timeoutMs = 500) {
10946
11189
  const [host, portStr] = address.split(":");
10947
11190
  const port = Number(portStr);
10948
11191
  if (!host || !Number.isFinite(port))
10949
11192
  return Promise.resolve(false);
10950
- return new Promise((resolve2) => {
11193
+ return new Promise((resolve3) => {
10951
11194
  const sock = connect({ host, port, timeout: timeoutMs });
10952
11195
  sock.once("connect", () => {
10953
11196
  sock.destroy();
10954
- resolve2(true);
11197
+ resolve3(true);
10955
11198
  });
10956
11199
  const dead = () => {
10957
11200
  sock.destroy();
10958
- resolve2(false);
11201
+ resolve3(false);
10959
11202
  };
10960
11203
  sock.once("error", dead);
10961
11204
  sock.once("timeout", dead);
@@ -10968,14 +11211,14 @@ async function preflightForeignSessions(workers) {
10968
11211
  for (const w of workers) {
10969
11212
  if (w.name === null)
10970
11213
  continue;
10971
- const entryPath = join18(dir, w.name);
11214
+ const entryPath = join19(dir, w.name);
10972
11215
  if (!existsSync15(entryPath))
10973
11216
  continue;
10974
11217
  const freshMs = Date.now() - statSync2(entryPath).mtimeMs;
10975
11218
  let live = freshMs < HEARTBEAT_FRESH_MS;
10976
11219
  if (!live) {
10977
11220
  try {
10978
- const entry = JSON.parse(readFileSync15(entryPath, "utf8"));
11221
+ const entry = JSON.parse(readFileSync16(entryPath, "utf8"));
10979
11222
  live = entry.debugPortAddress ? await tcpConnectable(entry.debugPortAddress) : false;
10980
11223
  } catch {
10981
11224
  live = false;
@@ -10996,14 +11239,14 @@ async function planWorkers(worktreeRoot, opts) {
10996
11239
  throw new HestiaError("no-workers-found", opts.filter.length > 0 ? `no wrangler configs match --workers=${opts.filter.join(",")}` : `no wrangler.{jsonc,json,toml} found in ${worktreeRoot}`);
10997
11240
  }
10998
11241
  const wranglerBinFor = (configPath) => {
10999
- let dir = dirname5(configPath);
11242
+ let dir = dirname6(configPath);
11000
11243
  for (;; ) {
11001
- const candidate = join18(dir, "node_modules", ".bin", "wrangler");
11244
+ const candidate = join19(dir, "node_modules", ".bin", "wrangler");
11002
11245
  if (existsSync15(candidate))
11003
11246
  return candidate;
11004
11247
  if (dir === worktreeRoot)
11005
11248
  return null;
11006
- const parent = dirname5(dir);
11249
+ const parent = dirname6(dir);
11007
11250
  if (parent === dir)
11008
11251
  return null;
11009
11252
  dir = parent;
@@ -11035,6 +11278,7 @@ async function planWorkers(worktreeRoot, opts) {
11035
11278
  throw new HestiaError("wrangler-missing", `no local wrangler for ${relative3(worktreeRoot, w.configPath)} ` + `(searched node_modules/.bin from the config dir up to the worktree ` + `root) \u2014 hestia never uses a global install`);
11036
11279
  }
11037
11280
  const inspectorPort = await allocatePort();
11281
+ const workerCwd = dirname6(w.configPath);
11038
11282
  specs.push({
11039
11283
  name: w.name,
11040
11284
  argv: [
@@ -11052,9 +11296,10 @@ async function planWorkers(worktreeRoot, opts) {
11052
11296
  WRANGLER_REGISTRY_PATH: registry,
11053
11297
  MINIFLARE_REGISTRY_PATH: registry
11054
11298
  },
11299
+ cwd: relative3(worktreeRoot, workerCwd),
11055
11300
  port: "auto",
11056
11301
  signal: "int",
11057
- varlock: opts.varlock,
11302
+ varlock: opts.varlock && detectVarlock(worktreeRoot, workerCwd) !== null,
11058
11303
  backend: "wrangler",
11059
11304
  inspectorPort,
11060
11305
  configPath: w.configPath
@@ -11066,6 +11311,7 @@ var HEARTBEAT_FRESH_MS = 60000;
11066
11311
  var init_adapter = __esm(() => {
11067
11312
  init_src();
11068
11313
  init_ports();
11314
+ init_resolver();
11069
11315
  init_discover();
11070
11316
  });
11071
11317
 
@@ -11105,12 +11351,12 @@ import { execFile as execFile8 } from "child_process";
11105
11351
  import { promisify as promisify7 } from "util";
11106
11352
  import { existsSync as existsSync17 } from "fs";
11107
11353
  import { homedir as homedir4 } from "os";
11108
- import { join as join19 } from "path";
11354
+ import { join as join20 } from "path";
11109
11355
  function cloudflaredHome() {
11110
- return process.env.HESTIA_CLOUDFLARED_HOME ?? join19(homedir4(), ".cloudflared");
11356
+ return process.env.HESTIA_CLOUDFLARED_HOME ?? join20(homedir4(), ".cloudflared");
11111
11357
  }
11112
11358
  function credFilePath(uuid) {
11113
- return process.env.TUNNEL_CRED_FILE ?? join19(cloudflaredHome(), `${uuid}.json`);
11359
+ return process.env.TUNNEL_CRED_FILE ?? join20(cloudflaredHome(), `${uuid}.json`);
11114
11360
  }
11115
11361
  async function run(args, timeoutMs = 30000) {
11116
11362
  try {
@@ -11157,8 +11403,8 @@ var init_cloudflared = __esm(() => {
11157
11403
 
11158
11404
  // packages/engine/src/tunnel/ingress.ts
11159
11405
  import { createHash as createHash6 } from "crypto";
11160
- import { existsSync as existsSync18, readFileSync as readFileSync16 } from "fs";
11161
- import { join as join20 } from "path";
11406
+ import { existsSync as existsSync18, readFileSync as readFileSync17 } from "fs";
11407
+ import { join as join21 } from "path";
11162
11408
  function hostnameFor(tunnelName, branch, service, zone) {
11163
11409
  const label = `${slug(tunnelName)}-${slug(branch)}-${slug(service)}`;
11164
11410
  if (label.length <= LABEL_MAX)
@@ -11176,12 +11422,12 @@ function inferZone(baseRules) {
11176
11422
  return zones.size === 1 ? [...zones][0] : undefined;
11177
11423
  }
11178
11424
  function importBaseRules(uuid, name) {
11179
- const p = join20(cloudflaredHome(), "config.yml");
11425
+ const p = join21(cloudflaredHome(), "config.yml");
11180
11426
  if (!existsSync18(p))
11181
11427
  return [];
11182
11428
  let parsed;
11183
11429
  try {
11184
- parsed = $parse(readFileSync16(p, "utf8"));
11430
+ parsed = $parse(readFileSync17(p, "utf8"));
11185
11431
  } catch {
11186
11432
  return [];
11187
11433
  }
@@ -11251,9 +11497,9 @@ var init_ingress = __esm(() => {
11251
11497
 
11252
11498
  // packages/engine/src/tunnel/orphans.ts
11253
11499
  import { execFileSync as execFileSync4 } from "child_process";
11254
- import { join as join21 } from "path";
11500
+ import { join as join22 } from "path";
11255
11501
  function hestiaTunnelMarker(uuid) {
11256
- return join21(hestiaHome(), "tunnel", uuid);
11502
+ return join22(hestiaHome(), "tunnel", uuid);
11257
11503
  }
11258
11504
  function parseLocalHestiaConnectors(psOutput, marker) {
11259
11505
  const rows = [];
@@ -11376,18 +11622,18 @@ var POLL_MS4 = 300;
11376
11622
  import {
11377
11623
  existsSync as existsSync19,
11378
11624
  mkdirSync as mkdirSync8,
11379
- readFileSync as readFileSync17,
11625
+ readFileSync as readFileSync18,
11380
11626
  readdirSync as readdirSync7
11381
11627
  } from "fs";
11382
- import { join as join22 } from "path";
11628
+ import { join as join23 } from "path";
11383
11629
  function tunnelDir(uuid) {
11384
- return join22(hestiaHome(), "tunnel", uuid);
11630
+ return join23(hestiaHome(), "tunnel", uuid);
11385
11631
  }
11386
11632
  function configPath(uuid) {
11387
- return join22(tunnelDir(uuid), "config.yml");
11633
+ return join23(tunnelDir(uuid), "config.yml");
11388
11634
  }
11389
11635
  function adoptedMarker(uuid) {
11390
- return join22(tunnelDir(uuid), "adopted.json");
11636
+ return join23(tunnelDir(uuid), "adopted.json");
11391
11637
  }
11392
11638
  function isAdopted(uuid) {
11393
11639
  return existsSync19(adoptedMarker(uuid));
@@ -11398,7 +11644,7 @@ function readAdopted(uuid) {
11398
11644
  return null;
11399
11645
  let marker;
11400
11646
  try {
11401
- marker = JSON.parse(readFileSync17(p, "utf8"));
11647
+ marker = JSON.parse(readFileSync18(p, "utf8"));
11402
11648
  } catch {
11403
11649
  marker = {};
11404
11650
  }
@@ -11406,14 +11652,14 @@ function readAdopted(uuid) {
11406
11652
  return { uuid, name: marker.name, credFile: marker.credFile, reconstructed: false };
11407
11653
  }
11408
11654
  let name;
11409
- const stacksDir = join22(hestiaHome(), "stacks");
11655
+ const stacksDir = join23(hestiaHome(), "stacks");
11410
11656
  if (existsSync19(stacksDir)) {
11411
11657
  for (const project of readdirSync7(stacksDir)) {
11412
- const sp = join22(stacksDir, project, "stack.json");
11658
+ const sp = join23(stacksDir, project, "stack.json");
11413
11659
  if (!existsSync19(sp))
11414
11660
  continue;
11415
11661
  try {
11416
- const record = parseStackRecord(readFileSync17(sp, "utf8"), sp);
11662
+ const record = parseStackRecord(readFileSync18(sp, "utf8"), sp);
11417
11663
  if (record.tunnel?.uuid === uuid) {
11418
11664
  name = record.tunnel.name;
11419
11665
  break;
@@ -11429,24 +11675,24 @@ function readAdopted(uuid) {
11429
11675
  };
11430
11676
  }
11431
11677
  function listAdopted() {
11432
- const root = join22(hestiaHome(), "tunnel");
11678
+ const root = join23(hestiaHome(), "tunnel");
11433
11679
  if (!existsSync19(root))
11434
11680
  return [];
11435
11681
  return readdirSync7(root).filter((uuid) => isAdopted(uuid));
11436
11682
  }
11437
11683
  function collectDynamicRules(uuid) {
11438
- const stacksDir = join22(hestiaHome(), "stacks");
11684
+ const stacksDir = join23(hestiaHome(), "stacks");
11439
11685
  const rules = [];
11440
11686
  const dropped = [];
11441
11687
  if (!existsSync19(stacksDir))
11442
11688
  return { rules, dropped };
11443
11689
  for (const project of readdirSync7(stacksDir)) {
11444
- const p = join22(stacksDir, project, "stack.json");
11690
+ const p = join23(stacksDir, project, "stack.json");
11445
11691
  if (!existsSync19(p))
11446
11692
  continue;
11447
11693
  let record;
11448
11694
  try {
11449
- record = parseStackRecord(readFileSync17(p, "utf8"), p);
11695
+ record = parseStackRecord(readFileSync18(p, "utf8"), p);
11450
11696
  } catch {
11451
11697
  continue;
11452
11698
  }
@@ -11490,7 +11736,7 @@ async function reconcileTunnel(ref, opts) {
11490
11736
  if (reapedGroups > 0) {
11491
11737
  warnings.push(`reaped ${reapedGroups} orphan hestia connector process group(s) for this tunnel`);
11492
11738
  }
11493
- const currentConfig = existsSync19(cfgPath) ? readFileSync17(cfgPath, "utf8") : null;
11739
+ const currentConfig = existsSync19(cfgPath) ? readFileSync18(cfgPath, "utf8") : null;
11494
11740
  if (live && currentConfig === nextConfig) {
11495
11741
  return { restarted: false, metricsPort: pf.port, ready: true, warnings };
11496
11742
  }
@@ -11657,7 +11903,7 @@ async function* composeLogLines(project, service, options = {}) {
11657
11903
  args.push("--follow");
11658
11904
  args.push(service);
11659
11905
  const child = spawn3("docker", args, { stdio: ["ignore", "pipe", "pipe"] });
11660
- const completion = new Promise((resolve2) => child.once("close", resolve2));
11906
+ const completion = new Promise((resolve3) => child.once("close", resolve3));
11661
11907
  let stderr = "";
11662
11908
  let spawnErrorMessage;
11663
11909
  child.stderr.setEncoding("utf8");
@@ -11747,12 +11993,12 @@ function readLastLineEventsBeforeOffset(path, count, endOffset) {
11747
11993
  function waitForLogPoll(signal) {
11748
11994
  if (signal?.aborted)
11749
11995
  return Promise.resolve();
11750
- return new Promise((resolve2) => {
11996
+ return new Promise((resolve3) => {
11751
11997
  const timer = setTimeout(done, LOG_POLL_MS);
11752
11998
  function done() {
11753
11999
  clearTimeout(timer);
11754
12000
  signal?.removeEventListener("abort", done);
11755
- resolve2();
12001
+ resolve3();
11756
12002
  }
11757
12003
  signal?.addEventListener("abort", done, { once: true });
11758
12004
  });
@@ -11862,10 +12108,10 @@ class BoundedLogMergeQueue {
11862
12108
  }
11863
12109
  async push(item, signal) {
11864
12110
  while (!this.#closed && !signal?.aborted && this.#items.length >= this.capacity) {
11865
- await new Promise((resolve2) => {
12111
+ await new Promise((resolve3) => {
11866
12112
  const done = () => {
11867
12113
  signal?.removeEventListener("abort", done);
11868
- resolve2();
12114
+ resolve3();
11869
12115
  };
11870
12116
  this.#capacityWaiters.push(done);
11871
12117
  signal?.addEventListener("abort", done, { once: true });
@@ -11888,7 +12134,7 @@ class BoundedLogMergeQueue {
11888
12134
  }
11889
12135
  if (this.#closed)
11890
12136
  return Promise.resolve({ done: true });
11891
- return new Promise((resolve2) => this.#waiters.push(resolve2));
12137
+ return new Promise((resolve3) => this.#waiters.push(resolve3));
11892
12138
  }
11893
12139
  close() {
11894
12140
  if (this.#closed)
@@ -12006,12 +12252,46 @@ var init_stream = __esm(() => {
12006
12252
  });
12007
12253
 
12008
12254
  // packages/engine/src/daemon/shared-arbiter.ts
12255
+ function probeMirrorProcessOccupancy(mirror) {
12256
+ const identities = [];
12257
+ let malformed = false;
12258
+ for (const service of [...mirror.services, ...mirror.auxiliary ?? []].filter((candidate) => candidate.backend === "proc" || candidate.backend === "wrangler")) {
12259
+ if (service.pid === undefined && service.startTime === undefined)
12260
+ continue;
12261
+ if (!Number.isSafeInteger(service.pid) || service.pid <= 0 || typeof service.startTime !== "string") {
12262
+ malformed = true;
12263
+ continue;
12264
+ }
12265
+ identities.push({ pid: service.pid, startTime: service.startTime });
12266
+ }
12267
+ const pidfileScan = scanPidfiles(mirrorProcsDir(mirror.project));
12268
+ malformed ||= pidfileScan.errors.length > 0;
12269
+ identities.push(...pidfileScan.pidfiles.filter((pidfile) => pidfile.backend !== "tunnel").map((pidfile) => ({ pid: pidfile.pid, startTime: pidfile.startTime })));
12270
+ let unknown = false;
12271
+ for (const identityRecord of identities) {
12272
+ const identity2 = probeProcessIdentity(identityRecord);
12273
+ if (identity2 === "live")
12274
+ return "live";
12275
+ if (identity2 === "unknown")
12276
+ unknown = true;
12277
+ }
12278
+ if (unknown)
12279
+ return "unknown";
12280
+ if (malformed)
12281
+ throw new Error("Shared holder mirror has an invalid process identity");
12282
+ return "dead";
12283
+ }
12284
+
12009
12285
  class SharedArbiter {
12010
- onChange;
12011
12286
  #mutex = Promise.resolve();
12012
12287
  #polls = new Map;
12013
- constructor(onChange) {
12014
- this.onChange = onChange;
12288
+ #deadOriginStrikes = new Map;
12289
+ #onChange;
12290
+ #probeHolderOrigin;
12291
+ constructor(onChangeOrOptions) {
12292
+ const options = typeof onChangeOrOptions === "function" ? { onChange: onChangeOrOptions } : onChangeOrOptions;
12293
+ this.#onChange = options?.onChange;
12294
+ this.#probeHolderOrigin = options?.probeHolderOrigin ?? probeSharedHolderOrigin;
12015
12295
  }
12016
12296
  #locked(fn) {
12017
12297
  const next = this.#mutex.then(fn, fn);
@@ -12020,7 +12300,7 @@ class SharedArbiter {
12020
12300
  }
12021
12301
  async#notify() {
12022
12302
  try {
12023
- await this.onChange?.();
12303
+ await this.#onChange?.();
12024
12304
  } catch {}
12025
12305
  }
12026
12306
  #result(record, granted) {
@@ -12043,7 +12323,8 @@ class SharedArbiter {
12043
12323
  delete next.holder;
12044
12324
  return next;
12045
12325
  }
12046
- if (readMirrorState(head.project) === null)
12326
+ const mirror = readMirrorStateSafe(head.project);
12327
+ if (mirror.status === "ok" && mirror.record === null)
12047
12328
  continue;
12048
12329
  return {
12049
12330
  ...record,
@@ -12101,11 +12382,11 @@ class SharedArbiter {
12101
12382
  }
12102
12383
  if (waitMs <= 0)
12103
12384
  return this.#result(outcome.record, false);
12104
- return new Promise((resolve2) => {
12385
+ return new Promise((resolve3) => {
12105
12386
  const key = `${name}\x00${requester.project}`;
12106
12387
  const polls = this.#polls.get(key) ?? [];
12107
12388
  const poll = {
12108
- resolve: resolve2,
12389
+ resolve: resolve3,
12109
12390
  timer: setTimeout(() => {
12110
12391
  const remaining = (this.#polls.get(key) ?? []).filter((candidate) => candidate !== poll);
12111
12392
  if (remaining.length === 0)
@@ -12113,7 +12394,7 @@ class SharedArbiter {
12113
12394
  else
12114
12395
  this.#polls.set(key, remaining);
12115
12396
  const record = readSharedHostname(name);
12116
- resolve2(record === null ? { granted: false, queued: [] } : this.#result(record, record.holder?.project === requester.project));
12397
+ resolve3(record === null ? { granted: false, queued: [] } : this.#result(record, record.holder?.project === requester.project));
12117
12398
  }, waitMs)
12118
12399
  };
12119
12400
  polls.push(poll);
@@ -12195,47 +12476,150 @@ class SharedArbiter {
12195
12476
  }
12196
12477
  async sweep(occupied) {
12197
12478
  let changed = false;
12479
+ const releases = [];
12198
12480
  for (const record of listSharedHostnames()) {
12199
- const holderDead = record.holder !== undefined && !occupied.has(record.holder.project) && readMirrorState(record.holder.project) === null;
12200
- const deadWaiters = (record.queue ?? []).filter((waiter) => readMirrorState(waiter.project) === null);
12201
- if (!holderDead && deadWaiters.length === 0)
12481
+ const holder = record.holder;
12482
+ const holderMirror = holder === undefined ? undefined : readMirrorStateSafe(holder.project);
12483
+ const deadWaiterProjects = new Set((record.queue ?? []).flatMap((waiter) => {
12484
+ const mirror = readMirrorStateSafe(waiter.project);
12485
+ return mirror.status === "ok" && mirror.record === null ? [waiter.project] : [];
12486
+ }));
12487
+ const deadWaiters = (record.queue ?? []).filter((waiter) => deadWaiterProjects.has(waiter.project));
12488
+ let releaseReason;
12489
+ if (holder !== undefined && holderMirror !== undefined) {
12490
+ const holderKey = `${holder.project}\x00${holder.at}`;
12491
+ const holderOccupied = occupied.has(holder.project);
12492
+ if (holderMirror.status === "error") {
12493
+ if (this.#recordDeadStrike(record.name, holderKey))
12494
+ releaseReason = "stack-dead";
12495
+ } else {
12496
+ try {
12497
+ const mirror = holderMirror.record;
12498
+ if (mirror === null) {
12499
+ if (!holderOccupied)
12500
+ releaseReason = "stack-gone";
12501
+ else
12502
+ this.#deadOriginStrikes.delete(record.name);
12503
+ } else {
12504
+ const protectedState = mirror.state === "starting" || mirror.state === "queued";
12505
+ if (!protectedState && mirror.starter !== undefined && (typeof mirror.starter !== "object" || mirror.starter === null || !Number.isSafeInteger(mirror.starter.pid) || mirror.starter.pid <= 0 || typeof mirror.starter.startTime !== "string")) {
12506
+ throw new Error("Shared holder mirror has an invalid starter identity");
12507
+ }
12508
+ const starterLiveness = protectedState || mirror.starter === undefined ? "dead" : probeProcessIdentity(mirror.starter);
12509
+ const protectedStartup = protectedState || starterLiveness === "live";
12510
+ if (protectedStartup) {
12511
+ this.#deadOriginStrikes.delete(record.name);
12512
+ } else if (starterLiveness === "unknown") {
12513
+ this.#retainStrikeForHolder(record.name, holderKey);
12514
+ } else if (!holderOccupied) {
12515
+ const contractOrigin = resolveSharedContractOrigin(mirror, holder.service);
12516
+ if (contractOrigin === undefined) {
12517
+ const processOccupancy = probeMirrorProcessOccupancy(mirror);
12518
+ if (processOccupancy === "live") {
12519
+ this.#deadOriginStrikes.delete(record.name);
12520
+ } else if (processOccupancy === "unknown") {
12521
+ this.#retainStrikeForHolder(record.name, holderKey);
12522
+ } else if (this.#recordDeadStrike(record.name, holderKey)) {
12523
+ releaseReason = "stack-dead";
12524
+ }
12525
+ } else {
12526
+ const origin = await this.#probeHolderOrigin(mirror, holder.service);
12527
+ if (origin === "live") {
12528
+ this.#deadOriginStrikes.delete(record.name);
12529
+ } else if (origin === "dead") {
12530
+ if (this.#recordDeadStrike(record.name, holderKey))
12531
+ releaseReason = "stack-dead";
12532
+ } else {
12533
+ this.#retainStrikeForHolder(record.name, holderKey);
12534
+ }
12535
+ }
12536
+ } else {
12537
+ const origin = await this.#probeHolderOrigin(mirror, holder.service);
12538
+ if (origin === "live") {
12539
+ this.#deadOriginStrikes.delete(record.name);
12540
+ } else if (origin === "dead") {
12541
+ if (this.#recordDeadStrike(record.name, holderKey))
12542
+ releaseReason = "origin-dead";
12543
+ } else {
12544
+ this.#retainStrikeForHolder(record.name, holderKey);
12545
+ }
12546
+ }
12547
+ }
12548
+ } catch {
12549
+ if (this.#recordDeadStrike(record.name, holderKey))
12550
+ releaseReason = "stack-dead";
12551
+ }
12552
+ }
12553
+ } else {
12554
+ this.#deadOriginStrikes.delete(record.name);
12555
+ }
12556
+ if (releaseReason === undefined && deadWaiters.length === 0)
12202
12557
  continue;
12558
+ let released = false;
12203
12559
  const updated = await this.#locked(() => updateSharedHostname(record.name, (candidate) => {
12204
12560
  const pruned = {
12205
12561
  ...candidate,
12206
- queue: (candidate.queue ?? []).filter((waiter) => readMirrorState(waiter.project) !== null)
12562
+ queue: (candidate.queue ?? []).filter((waiter) => {
12563
+ if (!deadWaiterProjects.has(waiter.project))
12564
+ return true;
12565
+ const freshMirror = readMirrorStateSafe(waiter.project);
12566
+ return freshMirror.status === "error" || freshMirror.record !== null;
12567
+ })
12207
12568
  };
12208
- const currentHolderDead = pruned.holder !== undefined && !occupied.has(pruned.holder.project) && readMirrorState(pruned.holder.project) === null;
12209
- return currentHolderDead ? this.#grantNext(pruned) : pruned;
12569
+ const holderMatches = releaseReason !== undefined && pruned.holder?.project === holder?.project && pruned.holder?.at === holder?.at;
12570
+ if (!holderMatches)
12571
+ return pruned;
12572
+ released = true;
12573
+ return this.#grantNext(pruned);
12210
12574
  }));
12211
12575
  if (updated === null)
12212
12576
  continue;
12213
- changed = true;
12577
+ changed ||= released || deadWaiters.length > 0;
12578
+ if (released && holder !== undefined && releaseReason !== undefined) {
12579
+ releases.push({ name: record.name, project: holder.project, reason: releaseReason });
12580
+ this.#deadOriginStrikes.delete(record.name);
12581
+ }
12214
12582
  if (updated.holder !== undefined && updated.holder.project !== record.holder?.project) {
12215
12583
  this.#wake(updated.name, updated.holder.project, this.#result(updated, true));
12216
12584
  }
12217
12585
  }
12218
12586
  if (changed)
12219
12587
  await this.#notify();
12588
+ return releases;
12589
+ }
12590
+ #recordDeadStrike(name, holderKey) {
12591
+ const previous = this.#deadOriginStrikes.get(name);
12592
+ const strikes = previous?.holderKey === holderKey ? previous.strikes + 1 : 1;
12593
+ this.#deadOriginStrikes.set(name, { holderKey, strikes });
12594
+ return strikes >= ORIGIN_DEAD_RELEASE_STRIKES;
12595
+ }
12596
+ #retainStrikeForHolder(name, holderKey) {
12597
+ const previous = this.#deadOriginStrikes.get(name);
12598
+ if (previous?.holderKey !== holderKey) {
12599
+ this.#deadOriginStrikes.set(name, { holderKey, strikes: 0 });
12600
+ }
12220
12601
  }
12221
12602
  }
12603
+ var ORIGIN_DEAD_RELEASE_STRIKES = 3;
12222
12604
  var init_shared_arbiter = __esm(() => {
12223
12605
  init_src();
12606
+ init_pidfile();
12224
12607
  init_state();
12608
+ init_origin_liveness();
12225
12609
  init_shared();
12226
12610
  });
12227
12611
 
12228
12612
  // packages/engine/src/doctor.ts
12229
- import { existsSync as existsSync20, readFileSync as readFileSync18, readdirSync as readdirSync8 } from "fs";
12613
+ import { existsSync as existsSync20, readFileSync as readFileSync19, readdirSync as readdirSync8 } from "fs";
12230
12614
  import { execFile as execFile9 } from "child_process";
12231
- import { dirname as dirname6, join as join23 } from "path";
12615
+ import { dirname as dirname7, join as join24 } from "path";
12232
12616
  function row(check, level, detail) {
12233
12617
  return { check, level, detail };
12234
12618
  }
12235
12619
  async function bounded(check, fn, timeoutMs = CHECK_TIMEOUT_MS) {
12236
12620
  let timer;
12237
- const timeout = new Promise((resolve2) => {
12238
- timer = setTimeout(() => resolve2([row(check, "unknown", "timed out")]), timeoutMs);
12621
+ const timeout = new Promise((resolve3) => {
12622
+ timer = setTimeout(() => resolve3([row(check, "unknown", "timed out")]), timeoutMs);
12239
12623
  });
12240
12624
  try {
12241
12625
  return await Promise.race([fn(), timeout]);
@@ -12246,9 +12630,9 @@ async function bounded(check, fn, timeoutMs = CHECK_TIMEOUT_MS) {
12246
12630
  }
12247
12631
  }
12248
12632
  function exec(cmd, args, timeoutMs = CHECK_TIMEOUT_MS - 500) {
12249
- return new Promise((resolve2) => {
12633
+ return new Promise((resolve3) => {
12250
12634
  execFile9(cmd, args, { timeout: timeoutMs, encoding: "utf8" }, (err, stdout, stderr) => {
12251
- resolve2({
12635
+ resolve3({
12252
12636
  ok: err === null,
12253
12637
  stdout: stdout ?? "",
12254
12638
  message: err !== null ? (stderr || err.message).trim().split(`
@@ -12269,21 +12653,21 @@ async function envChecks(ctx) {
12269
12653
  }
12270
12654
  if (ctx !== null) {
12271
12655
  const ignored = await exec("git", ["-C", ctx.worktreeRoot, "check-ignore", "-q", ".hestia/"]);
12272
- rows.push(ignored.ok ? row("state-ignore", "ok", ".hestia/ is ignored") : row("state-ignore", "error", `add the exact line ".hestia/" to ${join23(ctx.worktreeRoot, ".gitignore")}`));
12273
- const schema = existsSync20(join23(ctx.worktreeRoot, ".env.schema"));
12656
+ rows.push(ignored.ok ? row("state-ignore", "ok", ".hestia/ is ignored") : row("state-ignore", "error", `add the exact line ".hestia/" to ${join24(ctx.worktreeRoot, ".gitignore")}`));
12657
+ const schema = existsSync20(join24(ctx.worktreeRoot, ".env.schema"));
12274
12658
  if (schema) {
12275
12659
  rows.push(detectVarlock(ctx.worktreeRoot) !== null ? row("varlock", "ok", "local varlock found \u2014 worker env composition active") : row("varlock", "warn", ".env.schema present but no local varlock (bun install?)"));
12276
12660
  }
12277
12661
  const workers = discoverWorkers(ctx.worktreeRoot);
12278
12662
  if (workers.length > 0) {
12279
12663
  const missing = workers.filter((w) => {
12280
- let dir = dirname6(w.configPath);
12664
+ let dir = dirname7(w.configPath);
12281
12665
  for (;; ) {
12282
- if (existsSync20(join23(dir, "node_modules", ".bin", "wrangler")))
12666
+ if (existsSync20(join24(dir, "node_modules", ".bin", "wrangler")))
12283
12667
  return false;
12284
- if (dir === ctx.worktreeRoot || dirname6(dir) === dir)
12668
+ if (dir === ctx.worktreeRoot || dirname7(dir) === dir)
12285
12669
  return true;
12286
- dir = dirname6(dir);
12670
+ dir = dirname7(dir);
12287
12671
  }
12288
12672
  });
12289
12673
  rows.push(missing.length === 0 ? row("wrangler", "ok", `${workers.length} config(s), local binary reachable for all`) : row("wrangler", "error", `${missing.length}/${workers.length} config(s) have no local wrangler binary (install deps)`));
@@ -12338,10 +12722,10 @@ async function stateChecks(ctx) {
12338
12722
  rows.push(row(`exposure:${exp.service}`, "error", `ingress rule points at port ${exp.originPort} but the service owns ` + `${publishedPort} \u2014 a recycled port serves ANOTHER worktree; ` + `run any hestia command to resync, this should not persist`));
12339
12723
  }
12340
12724
  }
12341
- const lockPath2 = join23(hestiaDir(worktreeRoot), "lock");
12725
+ const lockPath2 = join24(hestiaDir(worktreeRoot), "lock");
12342
12726
  if (existsSync20(lockPath2)) {
12343
12727
  try {
12344
- const holder = JSON.parse(readFileSync18(lockPath2, "utf8"));
12728
+ const holder = JSON.parse(readFileSync19(lockPath2, "utf8"));
12345
12729
  if (!isLive(holder)) {
12346
12730
  rows.push(row("lock", "warn", "stale worktree lock (dead holder) \u2014 auto-broken on next command"));
12347
12731
  }
@@ -12349,7 +12733,7 @@ async function stateChecks(ctx) {
12349
12733
  rows.push(row("lock", "warn", "unreadable worktree lock file"));
12350
12734
  }
12351
12735
  }
12352
- const mirror = join23(hestiaHome(), "stacks", record.project, "stack.json");
12736
+ const mirror = join24(hestiaHome(), "stacks", record.project, "stack.json");
12353
12737
  if (!existsSync20(mirror)) {
12354
12738
  rows.push(row("mirror", "warn", "no ~/.hestia mirror \u2014 `down --project` would not work after worktree deletion"));
12355
12739
  }
@@ -12357,16 +12741,16 @@ async function stateChecks(ctx) {
12357
12741
  }
12358
12742
  async function machineChecks() {
12359
12743
  const rows = [];
12360
- const stacksDir = join23(hestiaHome(), "stacks");
12744
+ const stacksDir = join24(hestiaHome(), "stacks");
12361
12745
  const mirrored = new Set;
12362
12746
  if (existsSync20(stacksDir)) {
12363
12747
  for (const project of readdirSync8(stacksDir)) {
12364
- const p = join23(stacksDir, project, "stack.json");
12748
+ const p = join24(stacksDir, project, "stack.json");
12365
12749
  if (!existsSync20(p))
12366
12750
  continue;
12367
12751
  mirrored.add(project);
12368
12752
  try {
12369
- const rec = parseStackRecord(readFileSync18(p, "utf8"), p);
12753
+ const rec = parseStackRecord(readFileSync19(p, "utf8"), p);
12370
12754
  if (!existsSync20(rec.worktree)) {
12371
12755
  rows.push(row(`orphan-mirror:${project}`, "warn", `worktree ${rec.worktree} is gone \u2014 \`hestia down --project ${project}\` to clean up`));
12372
12756
  }
@@ -12467,7 +12851,7 @@ async function daemonChecks() {
12467
12851
  }
12468
12852
  const plist = plistPath();
12469
12853
  if (existsSync20(plist)) {
12470
- const content = readFileSync18(plist, "utf8");
12854
+ const content = readFileSync19(plist, "utf8");
12471
12855
  const args = [...content.matchAll(/<string>([^<]+)<\/string>/g)].map((m) => m[1]);
12472
12856
  const paths = args.filter((a) => a.startsWith("/") && !a.includes(":"));
12473
12857
  const missing = paths.filter((p) => !existsSync20(p));
@@ -12539,8 +12923,8 @@ var init_doctor = __esm(() => {
12539
12923
  // packages/engine/src/daemon/fleet-monitor.ts
12540
12924
  import { execFile as execFile10 } from "child_process";
12541
12925
  import { promisify as promisify8 } from "util";
12542
- import { existsSync as existsSync21, readFileSync as readFileSync19, readdirSync as readdirSync9 } from "fs";
12543
- import { join as join24 } from "path";
12926
+ import { existsSync as existsSync21, readFileSync as readFileSync20, readdirSync as readdirSync9 } from "fs";
12927
+ import { join as join25 } from "path";
12544
12928
 
12545
12929
  class LatestFleetChannel {
12546
12930
  #pending;
@@ -12567,8 +12951,8 @@ class LatestFleetChannel {
12567
12951
  }
12568
12952
  if (this.#closed)
12569
12953
  return Promise.resolve({ value: undefined, done: true });
12570
- return new Promise((resolve2) => {
12571
- this.#waiter = resolve2;
12954
+ return new Promise((resolve3) => {
12955
+ this.#waiter = resolve3;
12572
12956
  });
12573
12957
  }
12574
12958
  close() {
@@ -12583,13 +12967,13 @@ class LatestFleetChannel {
12583
12967
  function readManagedMirrors() {
12584
12968
  const records = [];
12585
12969
  const warnings = [];
12586
- const stacksDirectory = join24(hestiaHome(), "stacks");
12970
+ const stacksDirectory = join25(hestiaHome(), "stacks");
12587
12971
  if (!existsSync21(stacksDirectory))
12588
12972
  return { records, warnings };
12589
12973
  for (const project of readdirSync9(stacksDirectory).sort()) {
12590
- const path = join24(stacksDirectory, project, "stack.json");
12974
+ const path = join25(stacksDirectory, project, "stack.json");
12591
12975
  try {
12592
- const source = readFileSync19(path);
12976
+ const source = readFileSync20(path);
12593
12977
  if (source.byteLength > MAX_MIRROR_BYTES2) {
12594
12978
  warnings.push(`Fleet mirror too large for ${project}`);
12595
12979
  continue;
@@ -12971,7 +13355,7 @@ var init_fleet_monitor = __esm(() => {
12971
13355
  });
12972
13356
 
12973
13357
  // packages/engine/src/init-config.ts
12974
- import { dirname as dirname7, relative as relative4 } from "path";
13358
+ import { dirname as dirname8, relative as relative4 } from "path";
12975
13359
  import { chmodSync as chmodSync6, mkdirSync as mkdirSync9 } from "fs";
12976
13360
  function validateName2(name, what) {
12977
13361
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(name)) {
@@ -12987,6 +13371,7 @@ function inferredWorkload(report, name) {
12987
13371
  composeService: workload.source === "compose" ? workload.name : undefined,
12988
13372
  dockerfile: workload.source === "dockerfile" && workload.definitionPath !== undefined ? relative4(report.repository.worktree, workload.definitionPath) : undefined,
12989
13373
  wranglerConfig: workload.source === "wrangler" && workload.definitionPath !== undefined ? relative4(report.repository.worktree, workload.definitionPath) : undefined,
13374
+ env: {},
12990
13375
  endpoints: {}
12991
13376
  };
12992
13377
  }
@@ -13024,6 +13409,7 @@ function applyRequest(report, workloads, request) {
13024
13409
  command: request.kind === "proc" ? request.command : undefined,
13025
13410
  port: request.kind === "proc" ? request.port ?? "auto" : undefined,
13026
13411
  wranglerConfig: request.kind === "wrangler" ? request.file ?? "wrangler.toml" : undefined,
13412
+ env: existing?.env ?? {},
13027
13413
  endpoints: existing?.endpoints ?? {}
13028
13414
  };
13029
13415
  }
@@ -13038,16 +13424,21 @@ async function initializeRepositoryConfig(cwd, request, scope, write) {
13038
13424
  version: 1,
13039
13425
  workloads: Object.fromEntries(Object.entries(layer.config.workloads).map(([name, workload]) => [
13040
13426
  name,
13041
- { ...workload, command: workload.command?.slice(), endpoints: { ...workload.endpoints } }
13427
+ {
13428
+ ...workload,
13429
+ command: workload.command?.slice(),
13430
+ env: { ...workload.env },
13431
+ endpoints: { ...workload.endpoints }
13432
+ }
13042
13433
  ]))
13043
13434
  };
13044
13435
  applyRequest(before, config.workloads, request);
13045
13436
  const proposed = renderRepositoryWorkloadConfig(config);
13046
13437
  parseRepositoryWorkloadConfig(proposed, path);
13047
13438
  if (write) {
13048
- mkdirSync9(dirname7(path), { recursive: true, mode: 448 });
13439
+ mkdirSync9(dirname8(path), { recursive: true, mode: 448 });
13049
13440
  if (scope === "machine")
13050
- chmodSync6(dirname7(path), 448);
13441
+ chmodSync6(dirname8(path), 448);
13051
13442
  writeAtomicTextFile(path, proposed, 384);
13052
13443
  }
13053
13444
  const discovery = write ? await discoverRepository(repo.worktreeRoot) : before;
@@ -13075,7 +13466,7 @@ var init_init_config = __esm(() => {
13075
13466
  import { existsSync as existsSync22, rmSync as rmSync10 } from "fs";
13076
13467
  import { execFile as execFile11 } from "child_process";
13077
13468
  import { promisify as promisify9 } from "util";
13078
- import { join as join25, resolve as resolve2 } from "path";
13469
+ import { join as join26, resolve as resolve3 } from "path";
13079
13470
  import { createHash as createHash7 } from "crypto";
13080
13471
  import { resolve4, resolve6, resolveCname } from "dns/promises";
13081
13472
  function composeUnsupported(message) {
@@ -13185,7 +13576,7 @@ async function assertHestiaStateIgnored(worktreeRoot) {
13185
13576
  try {
13186
13577
  await pexec9("git", ["-C", worktreeRoot, "check-ignore", "-q", ".hestia/"], { timeout: 5000 });
13187
13578
  } catch {
13188
- throw new HestiaError("state-not-ignored", `.hestia is not ignored in ${worktreeRoot}; add the line ".hestia/" to ${join25(worktreeRoot, ".gitignore")}`, { remedy: `.hestia/`, path: join25(worktreeRoot, ".gitignore") });
13579
+ throw new HestiaError("state-not-ignored", `.hestia is not ignored in ${worktreeRoot}; add the line ".hestia/" to ${join26(worktreeRoot, ".gitignore")}`, { remedy: `.hestia/`, path: join26(worktreeRoot, ".gitignore") });
13189
13580
  }
13190
13581
  }
13191
13582
  async function prepareCompose(worktreeRoot, project, repo, branch, opts, configuredBaseFile) {
@@ -13202,7 +13593,7 @@ async function prepareCompose(worktreeRoot, project, repo, branch, opts, configu
13202
13593
  services
13203
13594
  });
13204
13595
  ensureDir(hestiaDir(worktreeRoot));
13205
- const overridePath = join25(hestiaDir(worktreeRoot), OVERRIDE_FILE);
13596
+ const overridePath = join26(hestiaDir(worktreeRoot), OVERRIDE_FILE);
13206
13597
  writeAtomicTextFile(overridePath, yaml);
13207
13598
  return {
13208
13599
  ctx: {
@@ -13235,7 +13626,7 @@ function writeDockerfileComposeModel(worktreeRoot, workloads, conventionalCompos
13235
13626
  ...conventionalComposeFile === undefined ? {} : { include: [{ path: conventionalComposeFile }] },
13236
13627
  services
13237
13628
  };
13238
- const path = join25(hestiaDir(worktreeRoot), DOCKERFILE_COMPOSE_FILE);
13629
+ const path = join26(hestiaDir(worktreeRoot), DOCKERFILE_COMPOSE_FILE);
13239
13630
  writeAtomicTextFile(path, $stringify(model));
13240
13631
  return path;
13241
13632
  }
@@ -13255,9 +13646,9 @@ function freshRecord(project, repoId, repo, branch, worktree) {
13255
13646
  };
13256
13647
  }
13257
13648
  function assertCurrentStackIdentity(record, current) {
13258
- const path = join25(hestiaDir(current.worktreeRoot), "stack.json");
13649
+ const path = join26(hestiaDir(current.worktreeRoot), "stack.json");
13259
13650
  assertMutableStackRecord(record, path);
13260
- const matches = record.repoId === current.repoId && record.repo === current.repo && record.branch === current.branch && resolve2(record.worktree) === resolve2(current.worktreeRoot) && record.project === projectName(current.repoId, current.repo, current.branch, current.worktreeRoot);
13651
+ const matches = record.repoId === current.repoId && record.repo === current.repo && record.branch === current.branch && resolve3(record.worktree) === resolve3(current.worktreeRoot) && record.project === projectName(current.repoId, current.repo, current.branch, current.worktreeRoot);
13261
13652
  if (!matches) {
13262
13653
  throw new HestiaError("stack-identity-changed", `this checkout no longer matches stack ${record.project}; run hestia down before starting or changing it`, {
13263
13654
  recorded: {
@@ -13434,7 +13825,7 @@ function matchesExpectedStack(record, expected) {
13434
13825
  return record !== null && (record.repoId === undefined || record.repoId === expected.repoId) && record.worktree === expected.worktree && record.createdAt === expected.createdAt;
13435
13826
  }
13436
13827
  function projectMutationRoot(project) {
13437
- return join25(hestiaHome(), "project-locks", project);
13828
+ return join26(hestiaHome(), "project-locks", project);
13438
13829
  }
13439
13830
  async function assertNamedTunnelDns(hostname, tunnelUuid) {
13440
13831
  const expected = `${tunnelUuid}.cfargotunnel.com`;
@@ -13691,12 +14082,25 @@ class ComposeEngine {
13691
14082
  }
13692
14083
  }
13693
14084
  }
14085
+ applyConfiguredEndpoints(record, configuredWorkloads);
14086
+ if (opts?.workers || configuredWorkers.length > 0) {
14087
+ const configuredFilters = configuredWorkers.map(([name, workload]) => workload.wranglerConfig ?? name);
14088
+ const explicitFilters = Array.isArray(opts?.workers) ? opts.workers : [];
14089
+ await this.#upWorkers(worktreeRoot, record, configuredWorkloads, {
14090
+ ...opts,
14091
+ workers: opts?.workers === true ? true : [...new Set([...explicitFilters, ...configuredFilters])]
14092
+ });
14093
+ }
14094
+ applyConfiguredEndpoints(record, configuredWorkloads);
13694
14095
  for (const [name, workload] of configuredProcs) {
13695
- const command = workload.command;
13696
14096
  const configuredSpec = {
13697
14097
  name,
13698
- argv: command,
14098
+ argv: workload.command,
14099
+ cwd: workload.cwd,
14100
+ env: resolveConfiguredEnvironment(worktreeRoot, workload.env, record),
13699
14101
  port: workload.port ?? "auto",
14102
+ varlock: workload.varlock,
14103
+ healthPath: workload.healthPath,
13700
14104
  backend: "proc"
13701
14105
  };
13702
14106
  const clash = record.services.find((service) => service.name === name && service.backend === "docker");
@@ -13717,19 +14121,11 @@ class ComposeEngine {
13717
14121
  }
13718
14122
  const result = await startProc(worktreeRoot, configuredSpec, record.env, (pidfile2) => mirrorPidfile(record.project, pidfile2));
13719
14123
  recordProc(record, result.record);
14124
+ applyConfiguredEndpoints(record, configuredWorkloads);
13720
14125
  writeState(worktreeRoot, record);
13721
14126
  if (result.error !== undefined)
13722
14127
  throw result.error;
13723
14128
  }
13724
- if (opts?.workers || configuredWorkers.length > 0) {
13725
- const configuredFilters = configuredWorkers.map(([name, workload]) => workload.wranglerConfig ?? name);
13726
- const explicitFilters = Array.isArray(opts?.workers) ? opts.workers : [];
13727
- await this.#upWorkers(worktreeRoot, record, {
13728
- ...opts,
13729
- workers: opts?.workers === true ? true : [...new Set([...explicitFilters, ...configuredFilters])]
13730
- });
13731
- }
13732
- applyConfiguredEndpoints(record, configuredWorkloads);
13733
14129
  record.state = "up";
13734
14130
  delete record.starter;
13735
14131
  setTunnelDirty(syncExposures(record));
@@ -13737,17 +14133,27 @@ class ComposeEngine {
13737
14133
  return record;
13738
14134
  }));
13739
14135
  }
13740
- async#upWorkers(worktreeRoot, record, opts) {
14136
+ async#upWorkers(worktreeRoot, record, configuredWorkloads, opts) {
13741
14137
  const plan = await planWorkers(worktreeRoot, {
13742
14138
  filter: Array.isArray(opts.workers) ? opts.workers : [],
13743
14139
  allowRemote: opts.allowRemote ?? false,
13744
14140
  force: opts.force ?? false,
13745
- varlock: !opts.noVarlock && detectVarlock(worktreeRoot) !== null
14141
+ varlock: !opts.noVarlock
13746
14142
  });
13747
14143
  for (const w of plan.warnings)
13748
14144
  process.stderr.write(`warning: ${w}
13749
14145
  `);
13750
14146
  for (const spec of plan.specs) {
14147
+ const configured = Object.entries(configuredWorkloads).find(([name, workload]) => workload.source === "wrangler" && (name === spec.name || workload.wranglerConfig !== undefined && resolve3(worktreeRoot, workload.wranglerConfig) === spec.configPath))?.[1];
14148
+ if (configured !== undefined && configured.source === "wrangler") {
14149
+ spec.cwd = configured.cwd ?? spec.cwd;
14150
+ spec.varlock = configured.varlock ?? spec.varlock;
14151
+ spec.healthPath = configured.healthPath;
14152
+ spec.env = {
14153
+ ...spec.env,
14154
+ ...resolveConfiguredEnvironment(worktreeRoot, configured.env, record)
14155
+ };
14156
+ }
13751
14157
  const clash = record.services.find((s) => s.name === spec.name && s.backend === "docker");
13752
14158
  if (clash !== undefined) {
13753
14159
  throw new HestiaError("name-conflict", `worker "${spec.name}" collides with a compose service name`);
@@ -14041,13 +14447,13 @@ class ComposeEngine {
14041
14447
  const composeFile = record?.composeFile ?? tryLoadConfig(worktreeRoot)?.composeFile;
14042
14448
  if (composeFile !== undefined) {
14043
14449
  const project2 = record?.project ?? projectName(repoId, repo, branch, worktreeRoot);
14044
- const overrideFile = record?.overrideFile ?? join25(hestiaDir(worktreeRoot), OVERRIDE_FILE);
14450
+ const overrideFile = record?.overrideFile ?? join26(hestiaDir(worktreeRoot), OVERRIDE_FILE);
14045
14451
  if (existsSync22(overrideFile)) {
14046
14452
  await composeDown({ project: project2, baseFile: composeFile, overrideFile, cwd: worktreeRoot }, opts?.destroy ?? false);
14047
14453
  } else if (record?.composeFile !== undefined) {
14048
14454
  const rest = ["compose", "-p", project2, "down", "--remove-orphans"];
14049
14455
  if (opts?.destroy)
14050
- rest.push("-v");
14456
+ rest.push("-v", "--rmi", "local");
14051
14457
  await pexec9("docker", rest, { timeout: 180000 });
14052
14458
  }
14053
14459
  }
@@ -14104,7 +14510,7 @@ class ComposeEngine {
14104
14510
  try {
14105
14511
  const rest = ["compose", "-p", project, "down", "--remove-orphans"];
14106
14512
  if (opts?.destroy)
14107
- rest.push("-v");
14513
+ rest.push("-v", "--rmi", "local");
14108
14514
  await pexec9("docker", rest, { timeout: 180000 });
14109
14515
  } catch (err) {
14110
14516
  if (fresh?.composeFile !== undefined) {
@@ -14115,7 +14521,7 @@ class ComposeEngine {
14115
14521
  };
14116
14522
  if (record !== null && existsSync22(record.worktree)) {
14117
14523
  const info = await getRepoInfo(record.worktree);
14118
- const identityMatches = resolve2(info.worktreeRoot) === resolve2(record.worktree) && (record.repoId === undefined ? record.repo === info.repo : record.repoId === info.repoId);
14524
+ const identityMatches = resolve3(info.worktreeRoot) === resolve3(record.worktree) && (record.repoId === undefined ? record.repo === info.repo : record.repoId === info.repoId);
14119
14525
  const localRecord = readState(record.worktree);
14120
14526
  if (identityMatches && localRecord?.project === record.project) {
14121
14527
  await this.down(record.worktree, opts);
@@ -14274,7 +14680,7 @@ class ComposeEngine {
14274
14680
  const alias = selection.endpoint.alias ?? selection.endpoint.name;
14275
14681
  const authority = internalEndpointAuthority(record.project, alias);
14276
14682
  const name = `aux-quick-${createHash7("sha256").update(alias).digest("hex").slice(0, 10)}`;
14277
- const quickCfg = join25(hestiaDir(worktreeRoot), `quick-${name}.yml`);
14683
+ const quickCfg = join26(hestiaDir(worktreeRoot), `quick-${name}.yml`);
14278
14684
  writeAtomicTextFile(quickCfg, `ingress:
14279
14685
  - service: ${JSON.stringify(`unix:${publicGatewaySocketPath()}`)}
14280
14686
  ` + ` originRequest:
@@ -14660,7 +15066,8 @@ var init_src2 = __esm(() => {
14660
15066
  init_shutdown();
14661
15067
  init_ports();
14662
15068
  init_local_http_router();
14663
- init_resolver();
15069
+ init_origin_liveness();
15070
+ init_configured_workload_environment();
14664
15071
  init_adapter();
14665
15072
  init_verify();
14666
15073
  init_cloudflared();
@@ -14718,11 +15125,15 @@ class ReconnectLogDeduper {
14718
15125
  #capacity;
14719
15126
  #history = [];
14720
15127
  #reconnectBuffer = null;
15128
+ #reconnectCandidates = null;
15129
+ #completedOverlap = 0;
14721
15130
  constructor(capacity = 50) {
14722
15131
  this.#capacity = capacity;
14723
15132
  }
14724
15133
  beginReconnect() {
14725
15134
  this.#reconnectBuffer = [];
15135
+ this.#reconnectCandidates = null;
15136
+ this.#completedOverlap = 0;
14726
15137
  }
14727
15138
  push(line) {
14728
15139
  const entry = { signature: logLineSignature(line), line };
@@ -14732,30 +15143,26 @@ class ReconnectLogDeduper {
14732
15143
  }
14733
15144
  this.#reconnectBuffer.push(entry);
14734
15145
  const buffered = this.#reconnectBuffer;
14735
- const activeOverlapLengths = [];
14736
- const completedOverlapLengths = [];
14737
- for (let start = 0;start < this.#history.length; start += 1) {
14738
- const overlapLength = this.#history.length - start;
14739
- const compared = Math.min(buffered.length, overlapLength);
14740
- let matches = true;
14741
- for (let index = 0;index < compared; index += 1) {
14742
- if (buffered[index].signature !== this.#history[start + index].signature) {
14743
- matches = false;
14744
- break;
14745
- }
15146
+ const index = buffered.length - 1;
15147
+ const candidates = this.#reconnectCandidates ?? this.#history.map((_candidate, start) => start).filter((start) => this.#history[start].signature === entry.signature);
15148
+ const active = candidates.filter((start) => {
15149
+ const historyIndex = start + index;
15150
+ if (historyIndex >= this.#history.length)
15151
+ return false;
15152
+ if (this.#history[historyIndex].signature !== entry.signature)
15153
+ return false;
15154
+ if (historyIndex === this.#history.length - 1) {
15155
+ this.#completedOverlap = Math.max(this.#completedOverlap, this.#history.length - start);
14746
15156
  }
14747
- if (!matches)
14748
- continue;
14749
- if (buffered.length <= overlapLength)
14750
- activeOverlapLengths.push(overlapLength);
14751
- if (buffered.length >= overlapLength)
14752
- completedOverlapLengths.push(overlapLength);
14753
- }
14754
- if (activeOverlapLengths.length > 0)
15157
+ return true;
15158
+ });
15159
+ this.#reconnectCandidates = active;
15160
+ if (active.length > 0)
14755
15161
  return [];
14756
- const suppressed = Math.max(0, ...completedOverlapLengths);
14757
- const fresh = buffered.slice(suppressed);
15162
+ const fresh = buffered.slice(this.#completedOverlap);
14758
15163
  this.#reconnectBuffer = null;
15164
+ this.#reconnectCandidates = null;
15165
+ this.#completedOverlap = 0;
14759
15166
  this.#remember(fresh);
14760
15167
  return fresh.map((candidate) => candidate.line);
14761
15168
  }
@@ -14788,12 +15195,12 @@ async function reconnectDelay(attempt, signal) {
14788
15195
  const delay = Math.min(4000, 250 * 2 ** Math.min(attempt, 4));
14789
15196
  if (signal.aborted)
14790
15197
  return;
14791
- await new Promise((resolve3) => {
15198
+ await new Promise((resolve5) => {
14792
15199
  const timer = setTimeout(done, delay);
14793
15200
  function done() {
14794
15201
  clearTimeout(timer);
14795
15202
  signal.removeEventListener("abort", done);
14796
- resolve3();
15203
+ resolve5();
14797
15204
  }
14798
15205
  signal.addEventListener("abort", done, { once: true });
14799
15206
  });
@@ -14833,7 +15240,7 @@ class DaemonFleetSource {
14833
15240
  async* logs(project, service, signal) {
14834
15241
  let attempt = 0;
14835
15242
  let connectedBefore = false;
14836
- const deduper = new ReconnectLogDeduper(50);
15243
+ const deduper = new ReconnectLogDeduper(FLEET_LOG_BACKFILL_LINES);
14837
15244
  while (!signal.aborted && !this.#lifetime.signal.aborted) {
14838
15245
  const connectedAt = Date.now();
14839
15246
  try {
@@ -14842,7 +15249,7 @@ class DaemonFleetSource {
14842
15249
  deduper.beginReconnect();
14843
15250
  }
14844
15251
  connectedBefore = true;
14845
- for await (const line of streamDaemonServiceLogs(daemon.port, project, service, 50, signal)) {
15252
+ for await (const line of streamDaemonServiceLogs(daemon.port, project, service, FLEET_LOG_BACKFILL_LINES, signal)) {
14846
15253
  if (!isLogLine(line))
14847
15254
  throw new Error("log protocol frame is invalid");
14848
15255
  for (const freshLine of deduper.push(line))
@@ -14879,7 +15286,7 @@ class DaemonFleetSource {
14879
15286
  return Promise.reject(new Error(`stack ${stack.project} has no stable incarnation timestamp`));
14880
15287
  }
14881
15288
  return engine.downProject(stack.project, {
14882
- destroy: false,
15289
+ destroy: true,
14883
15290
  expectedStack: {
14884
15291
  repoId: stack.repoId,
14885
15292
  worktree: stack.worktree,
@@ -14891,6 +15298,7 @@ class DaemonFleetSource {
14891
15298
  this.#lifetime.abort();
14892
15299
  }
14893
15300
  }
15301
+ var FLEET_LOG_BACKFILL_LINES = 1000;
14894
15302
  var init_fleet_source = __esm(() => {
14895
15303
  init_src2();
14896
15304
  });
@@ -14911,6 +15319,8 @@ function createFleetUiState() {
14911
15319
  layout: "auto",
14912
15320
  filter: "",
14913
15321
  follow: true,
15322
+ serviceOffset: 0,
15323
+ serviceOffsetManual: false,
14914
15324
  logOffset: 0,
14915
15325
  unseenLines: 0,
14916
15326
  helpOpen: false,
@@ -14955,9 +15365,13 @@ function reduceFleetUiState(state, action) {
14955
15365
  switch (action.type) {
14956
15366
  case "reconcile":
14957
15367
  const visibleSnapshot = state.filter === "" ? action.snapshot : { ...action.snapshot, stacks: visibleFleetStacks(action.snapshot, state.filter) };
15368
+ const selection = reconcileFleetSelection(state.selection, visibleSnapshot, action.preferredProject);
15369
+ const selectionChanged = selection.project !== state.selection.project || selection.service !== state.selection.service || selection.endpoint !== state.selection.endpoint;
14958
15370
  return {
14959
15371
  ...state,
14960
- selection: reconcileFleetSelection(state.selection, visibleSnapshot, action.preferredProject)
15372
+ selection,
15373
+ serviceOffset: selection.project === state.selection.project ? state.serviceOffset : 0,
15374
+ serviceOffsetManual: selectionChanged ? false : state.serviceOffsetManual
14961
15375
  };
14962
15376
  case "move-stack": {
14963
15377
  const stacks = visibleFleetStacks(action.snapshot, state.filter);
@@ -14970,6 +15384,8 @@ function reduceFleetUiState(state, action) {
14970
15384
  service: stack.services[0]?.name,
14971
15385
  endpoint: undefined
14972
15386
  },
15387
+ serviceOffset: 0,
15388
+ serviceOffsetManual: false,
14973
15389
  follow: true,
14974
15390
  logOffset: 0,
14975
15391
  unseenLines: 0
@@ -14988,6 +15404,7 @@ function reduceFleetUiState(state, action) {
14988
15404
  service: service.name,
14989
15405
  endpoint: undefined
14990
15406
  },
15407
+ serviceOffsetManual: false,
14991
15408
  follow: true,
14992
15409
  logOffset: 0,
14993
15410
  unseenLines: 0
@@ -15003,6 +15420,8 @@ function reduceFleetUiState(state, action) {
15003
15420
  service: stack.services[0]?.name,
15004
15421
  endpoint: undefined
15005
15422
  },
15423
+ serviceOffset: 0,
15424
+ serviceOffsetManual: false,
15006
15425
  follow: true,
15007
15426
  logOffset: 0,
15008
15427
  unseenLines: 0
@@ -15020,6 +15439,7 @@ function reduceFleetUiState(state, action) {
15020
15439
  service: action.service,
15021
15440
  endpoint: undefined
15022
15441
  },
15442
+ serviceOffsetManual: false,
15023
15443
  follow: true,
15024
15444
  logOffset: 0,
15025
15445
  unseenLines: 0
@@ -15033,7 +15453,8 @@ function reduceFleetUiState(state, action) {
15033
15453
  return {
15034
15454
  ...state,
15035
15455
  focus: "services",
15036
- selection: { ...state.selection, service: action.service, endpoint: action.endpoint }
15456
+ selection: { ...state.selection, service: action.service, endpoint: action.endpoint },
15457
+ serviceOffsetManual: false
15037
15458
  };
15038
15459
  }
15039
15460
  case "focus":
@@ -15047,10 +15468,14 @@ function reduceFleetUiState(state, action) {
15047
15468
  ...action.snapshot,
15048
15469
  stacks: visibleFleetStacks(action.snapshot, action.filter)
15049
15470
  };
15471
+ const selection2 = reconcileFleetSelection(state.selection, visibleSnapshot2);
15472
+ const selectionChanged2 = selection2.project !== state.selection.project || selection2.service !== state.selection.service || selection2.endpoint !== state.selection.endpoint;
15050
15473
  return {
15051
15474
  ...state,
15052
15475
  filter: action.filter,
15053
- selection: reconcileFleetSelection(state.selection, visibleSnapshot2)
15476
+ selection: selection2,
15477
+ serviceOffset: selection2.project === state.selection.project ? state.serviceOffset : 0,
15478
+ serviceOffsetManual: selectionChanged2 ? false : state.serviceOffsetManual
15054
15479
  };
15055
15480
  }
15056
15481
  case "follow":
@@ -15060,6 +15485,12 @@ function reduceFleetUiState(state, action) {
15060
15485
  logOffset: action.follow ? 0 : state.logOffset,
15061
15486
  unseenLines: action.follow ? 0 : state.unseenLines
15062
15487
  };
15488
+ case "scroll-services":
15489
+ return {
15490
+ ...state,
15491
+ serviceOffset: Math.min(action.maxOffset, Math.max(0, state.serviceOffset + action.delta)),
15492
+ serviceOffsetManual: true
15493
+ };
15063
15494
  case "scroll-logs": {
15064
15495
  const offset = Math.min(action.maxOffset ?? Number.MAX_SAFE_INTEGER, Math.max(0, state.logOffset - action.delta));
15065
15496
  return action.delta > 0 && offset === 0 ? { ...state, follow: true, logOffset: 0, unseenLines: 0 } : { ...state, follow: action.delta > 0 ? state.follow : false, logOffset: offset };
@@ -15280,6 +15711,9 @@ var init_string_width = __esm(() => {
15280
15711
  });
15281
15712
 
15282
15713
  // packages/tui/src/fleet-text.ts
15714
+ function fleetTextWidth(text) {
15715
+ return stringWidth(text);
15716
+ }
15283
15717
  function fitFleetText(text, width) {
15284
15718
  if (width <= 0)
15285
15719
  return "";
@@ -15456,6 +15890,18 @@ function isScrollTopKey(key) {
15456
15890
  function isDoctorKey(key) {
15457
15891
  return isShiftedKey(key, "d");
15458
15892
  }
15893
+ function isPageUpKey(key) {
15894
+ return key.name.toLowerCase() === "pageup";
15895
+ }
15896
+ function isPageDownKey(key) {
15897
+ return key.name.toLowerCase() === "pagedown";
15898
+ }
15899
+ function isHalfPageUpKey(key) {
15900
+ return key.ctrl === true && key.name.toLowerCase() === "u";
15901
+ }
15902
+ function isHalfPageDownKey(key) {
15903
+ return key.ctrl === true && key.name.toLowerCase() === "d";
15904
+ }
15459
15905
 
15460
15906
  // packages/tui/src/fleet-layout.ts
15461
15907
  function resolveFleetLayout(mode, terminalWidth) {
@@ -15520,6 +15966,43 @@ var init_fleet_log_rows = __esm(() => {
15520
15966
  wrapCache = new WeakMap;
15521
15967
  });
15522
15968
 
15969
+ // packages/tui/src/fleet-service-rows.ts
15970
+ function buildFleetServiceRows(stack) {
15971
+ if (stack === undefined)
15972
+ return [];
15973
+ return stack.services.flatMap((service) => {
15974
+ const endpoints = serviceEndpoints(service);
15975
+ return [
15976
+ { kind: "workload", service },
15977
+ ...endpoints.map((endpoint, endpointIndex) => ({
15978
+ kind: "endpoint",
15979
+ service,
15980
+ endpoint,
15981
+ endpointIndex
15982
+ }))
15983
+ ];
15984
+ });
15985
+ }
15986
+ function fleetServiceRowCount(stack) {
15987
+ return buildFleetServiceRows(stack).length;
15988
+ }
15989
+ function selectedFleetServiceRowIndex(rows, serviceName, endpointName) {
15990
+ return rows.findIndex((row2) => row2.service.name === serviceName && (endpointName === undefined ? row2.kind === "workload" : row2.kind === "endpoint" && row2.endpoint.name === endpointName));
15991
+ }
15992
+ function ensureFleetServiceRowVisible(offset, selectedIndex, viewportRows, rowCount) {
15993
+ const maxOffset = Math.max(0, rowCount - viewportRows);
15994
+ const clamped = Math.min(maxOffset, Math.max(0, offset));
15995
+ if (selectedIndex < 0)
15996
+ return clamped;
15997
+ if (selectedIndex < clamped)
15998
+ return selectedIndex;
15999
+ if (selectedIndex >= clamped + viewportRows) {
16000
+ return Math.min(maxOffset, selectedIndex - viewportRows + 1);
16001
+ }
16002
+ return clamped;
16003
+ }
16004
+ var init_fleet_service_rows = () => {};
16005
+
15523
16006
  // packages/tui/src/fleet-format.ts
15524
16007
  function formatUptime(createdAt, now) {
15525
16008
  const born = Date.parse(createdAt);
@@ -15701,6 +16184,15 @@ var init_StackSidebar = __esm(() => {
15701
16184
  });
15702
16185
  });
15703
16186
 
16187
+ // packages/tui/src/fleet-scroll.ts
16188
+ function fleetMouseScrollDelta(event, rows = 3) {
16189
+ if (event.scroll?.direction === "up")
16190
+ return -rows;
16191
+ if (event.scroll?.direction === "down")
16192
+ return rows;
16193
+ return;
16194
+ }
16195
+
15704
16196
  // packages/tui/src/components/ServicePane.tsx
15705
16197
  import { memo as memo2 } from "react";
15706
16198
  import { jsxDEV as jsxDEV2, Fragment } from "@opentui/react/jsx-dev-runtime";
@@ -15711,6 +16203,7 @@ function portLabel(service) {
15711
16203
  }
15712
16204
  var ServicePane;
15713
16205
  var init_ServicePane = __esm(() => {
16206
+ init_fleet_service_rows();
15714
16207
  init_fleet_text();
15715
16208
  init_fleet_theme();
15716
16209
  init_terminal_text();
@@ -15719,10 +16212,13 @@ var init_ServicePane = __esm(() => {
15719
16212
  uptime,
15720
16213
  selectedService,
15721
16214
  selectedEndpoint,
16215
+ offset = 0,
16216
+ viewportRows = 1,
15722
16217
  width = 80,
15723
16218
  focused = false,
15724
16219
  onSelectService,
15725
- onSelectEndpoint
16220
+ onSelectEndpoint,
16221
+ onScroll
15726
16222
  }) {
15727
16223
  const nameWidth = Math.min(24, Math.max(12, Math.floor(width * 0.28)));
15728
16224
  const backendWidth = 10;
@@ -15730,7 +16226,26 @@ var init_ServicePane = __esm(() => {
15730
16226
  const portWidth = 7;
15731
16227
  const title = stack === undefined ? "Workloads" : `Workloads \u2014 ${sanitizeFleetTerminalText(stack.branch)}`;
15732
16228
  const warning = stack?.warning === undefined ? undefined : sanitizeFleetTerminalText(stack.warning);
16229
+ const rows = buildFleetServiceRows(stack);
16230
+ const maxOffset = Math.max(0, rows.length - viewportRows);
16231
+ const clampedOffset = Math.min(maxOffset, Math.max(0, offset));
16232
+ const visibleRows = rows.slice(clampedOffset, clampedOffset + viewportRows);
16233
+ const hiddenAbove = clampedOffset;
16234
+ const hiddenBelow = Math.max(0, rows.length - clampedOffset - visibleRows.length);
16235
+ const overflowIndicator = (index) => {
16236
+ const first = index === 0 && hiddenAbove > 0 ? `\u2191 ${hiddenAbove} more` : undefined;
16237
+ const last = index === visibleRows.length - 1 && hiddenBelow > 0 ? `\u2193 ${hiddenBelow} more` : undefined;
16238
+ return [first, last].filter(Boolean).join(" \xB7 ") || undefined;
16239
+ };
15733
16240
  return /* @__PURE__ */ jsxDEV2("box", {
16241
+ onMouseScroll: (event) => {
16242
+ const delta = fleetMouseScrollDelta(event);
16243
+ if (delta === undefined)
16244
+ return;
16245
+ event.preventDefault();
16246
+ event.stopPropagation();
16247
+ onScroll?.(delta);
16248
+ },
15734
16249
  style: {
15735
16250
  height: "100%",
15736
16251
  width: "100%",
@@ -15790,97 +16305,106 @@ var init_ServicePane = __esm(() => {
15790
16305
  }, undefined, false, undefined, this)
15791
16306
  ]
15792
16307
  }, undefined, true, undefined, this),
15793
- stack.services.flatMap((service) => {
16308
+ visibleRows.map((row2, visibleIndex) => {
16309
+ const service = row2.service;
15794
16310
  const workloadSelected = service.name === selectedService && selectedEndpoint === undefined;
15795
- const endpoints = serviceEndpoints(service);
15796
16311
  const state = sanitizeFleetTerminalText(service.state);
15797
16312
  const workloadBg = workloadSelected ? fleetTheme.selectedBg : fleetTheme.background;
15798
- const workloadRow = /* @__PURE__ */ jsxDEV2("box", {
15799
- onMouseDown: (event) => {
15800
- if (event.button !== 0)
15801
- return;
15802
- event.preventDefault();
15803
- event.stopPropagation();
15804
- onSelectService?.(service.name);
15805
- },
15806
- style: { height: 1, paddingRight: 1, flexDirection: "row", backgroundColor: workloadBg },
15807
- children: [
15808
- /* @__PURE__ */ jsxDEV2("text", {
15809
- fg: fleetTheme.stripe,
15810
- children: workloadSelected ? "\u258E" : " "
15811
- }, undefined, false, undefined, this),
15812
- /* @__PURE__ */ jsxDEV2("text", {
15813
- fg: serviceStateColor(service.state),
15814
- children: [
15815
- " ",
15816
- serviceStateGlyph(service.state),
15817
- " "
15818
- ]
15819
- }, undefined, true, undefined, this),
15820
- /* @__PURE__ */ jsxDEV2("text", {
15821
- fg: workloadSelected ? fleetTheme.bright : fleetTheme.text,
15822
- children: padFleetText(sanitizeFleetTerminalText(service.name), nameWidth)
15823
- }, undefined, false, undefined, this),
15824
- /* @__PURE__ */ jsxDEV2("text", {
15825
- fg: backendColor(service.backend),
15826
- children: padFleetText(service.backend, backendWidth)
15827
- }, undefined, false, undefined, this),
15828
- /* @__PURE__ */ jsxDEV2("text", {
15829
- fg: serviceStateColor(service.state),
15830
- children: padFleetText(state, stateWidth)
15831
- }, undefined, false, undefined, this),
15832
- /* @__PURE__ */ jsxDEV2("text", {
15833
- fg: fleetTheme.muted,
15834
- children: padFleetText(portLabel(service), portWidth)
15835
- }, undefined, false, undefined, this)
15836
- ]
15837
- }, `workload:${service.name}`, true, undefined, this);
15838
- const endpointRows = endpoints.map((endpoint, index) => {
15839
- const endpointSelected = service.name === selectedService && endpoint.name === selectedEndpoint;
15840
- const connector = index === endpoints.length - 1 ? "\u2514\u2500" : "\u251C\u2500";
15841
- const kind = (endpoint.kind ?? "http").toUpperCase();
15842
- const reach = sanitizeFleetTerminalText(endpointReach(endpoint));
15843
- const aliasWidth = Math.min(18, Math.max(10, Math.floor(width * 0.22)));
15844
- const pub = endpoint.publicUrl === undefined ? "" : " pub";
15845
- const endpointBg = endpointSelected ? fleetTheme.selectedBg : fleetTheme.background;
16313
+ const indicator = overflowIndicator(visibleIndex);
16314
+ if (row2.kind === "workload")
15846
16315
  return /* @__PURE__ */ jsxDEV2("box", {
15847
16316
  onMouseDown: (event) => {
15848
16317
  if (event.button !== 0)
15849
16318
  return;
15850
16319
  event.preventDefault();
15851
16320
  event.stopPropagation();
15852
- onSelectEndpoint?.(service.name, endpoint.name);
16321
+ onSelectService?.(service.name);
15853
16322
  },
15854
- style: { height: 1, paddingRight: 1, flexDirection: "row", backgroundColor: endpointBg },
16323
+ style: { height: 1, paddingRight: 1, flexDirection: "row", backgroundColor: workloadBg },
15855
16324
  children: [
15856
16325
  /* @__PURE__ */ jsxDEV2("text", {
15857
16326
  fg: fleetTheme.stripe,
15858
- children: endpointSelected ? "\u258E" : " "
16327
+ children: workloadSelected ? "\u258E" : " "
15859
16328
  }, undefined, false, undefined, this),
15860
16329
  /* @__PURE__ */ jsxDEV2("text", {
15861
- fg: fleetTheme.faint,
15862
- children: ` ${connector} `
15863
- }, undefined, false, undefined, this),
16330
+ fg: serviceStateColor(service.state),
16331
+ children: [
16332
+ " ",
16333
+ serviceStateGlyph(service.state),
16334
+ " "
16335
+ ]
16336
+ }, undefined, true, undefined, this),
15864
16337
  /* @__PURE__ */ jsxDEV2("text", {
15865
- fg: endpointSelected ? fleetTheme.bright : fleetTheme.accent,
15866
- children: padFleetText(sanitizeFleetTerminalText(endpoint.name), aliasWidth)
16338
+ fg: workloadSelected ? fleetTheme.bright : fleetTheme.text,
16339
+ children: padFleetText(sanitizeFleetTerminalText(service.name), nameWidth)
15867
16340
  }, undefined, false, undefined, this),
15868
16341
  /* @__PURE__ */ jsxDEV2("text", {
15869
- fg: fleetTheme.muted,
15870
- children: padFleetText(kind, 6)
16342
+ fg: backendColor(service.backend),
16343
+ children: padFleetText(service.backend, backendWidth)
15871
16344
  }, undefined, false, undefined, this),
15872
16345
  /* @__PURE__ */ jsxDEV2("text", {
15873
- fg: fleetTheme.text,
15874
- children: fitFleetText(reach, Math.max(12, width - aliasWidth - 19 - pub.length))
16346
+ fg: serviceStateColor(service.state),
16347
+ children: padFleetText(state, stateWidth)
15875
16348
  }, undefined, false, undefined, this),
15876
16349
  /* @__PURE__ */ jsxDEV2("text", {
15877
- fg: fleetTheme.publicBadge,
15878
- children: pub
16350
+ fg: fleetTheme.muted,
16351
+ children: padFleetText(portLabel(service), portWidth)
16352
+ }, undefined, false, undefined, this),
16353
+ indicator === undefined ? null : /* @__PURE__ */ jsxDEV2("text", {
16354
+ fg: fleetTheme.faint,
16355
+ children: indicator
15879
16356
  }, undefined, false, undefined, this)
15880
16357
  ]
15881
- }, `endpoint:${service.name}:${endpoint.name}`, true, undefined, this);
15882
- });
15883
- return [workloadRow, ...endpointRows];
16358
+ }, `workload:${service.name}`, true, undefined, this);
16359
+ const endpoint = row2.endpoint;
16360
+ const endpointSelected = service.name === selectedService && endpoint.name === selectedEndpoint;
16361
+ const endpointCount = service.endpoints?.length ?? (service.endpoint === undefined ? 0 : 1);
16362
+ const connector = row2.endpointIndex === endpointCount - 1 ? "\u2514\u2500" : "\u251C\u2500";
16363
+ const kind = (endpoint.kind ?? "http").toUpperCase();
16364
+ const reach = sanitizeFleetTerminalText(endpointReach(endpoint));
16365
+ const aliasWidth = Math.min(18, Math.max(10, Math.floor(width * 0.22)));
16366
+ const pub = endpoint.publicUrl === undefined ? "" : " pub";
16367
+ const endpointBg = endpointSelected ? fleetTheme.selectedBg : fleetTheme.background;
16368
+ return /* @__PURE__ */ jsxDEV2("box", {
16369
+ onMouseDown: (event) => {
16370
+ if (event.button !== 0)
16371
+ return;
16372
+ event.preventDefault();
16373
+ event.stopPropagation();
16374
+ onSelectEndpoint?.(service.name, endpoint.name);
16375
+ },
16376
+ style: { height: 1, paddingRight: 1, flexDirection: "row", backgroundColor: endpointBg },
16377
+ children: [
16378
+ /* @__PURE__ */ jsxDEV2("text", {
16379
+ fg: fleetTheme.stripe,
16380
+ children: endpointSelected ? "\u258E" : " "
16381
+ }, undefined, false, undefined, this),
16382
+ /* @__PURE__ */ jsxDEV2("text", {
16383
+ fg: fleetTheme.faint,
16384
+ children: ` ${connector} `
16385
+ }, undefined, false, undefined, this),
16386
+ /* @__PURE__ */ jsxDEV2("text", {
16387
+ fg: endpointSelected ? fleetTheme.bright : fleetTheme.accent,
16388
+ children: padFleetText(sanitizeFleetTerminalText(endpoint.name), aliasWidth)
16389
+ }, undefined, false, undefined, this),
16390
+ /* @__PURE__ */ jsxDEV2("text", {
16391
+ fg: fleetTheme.muted,
16392
+ children: padFleetText(kind, 6)
16393
+ }, undefined, false, undefined, this),
16394
+ /* @__PURE__ */ jsxDEV2("text", {
16395
+ fg: fleetTheme.text,
16396
+ children: fitFleetText(reach, Math.max(12, width - aliasWidth - 19 - pub.length))
16397
+ }, undefined, false, undefined, this),
16398
+ /* @__PURE__ */ jsxDEV2("text", {
16399
+ fg: fleetTheme.publicBadge,
16400
+ children: pub
16401
+ }, undefined, false, undefined, this),
16402
+ indicator === undefined ? null : /* @__PURE__ */ jsxDEV2("text", {
16403
+ fg: fleetTheme.faint,
16404
+ children: indicator
16405
+ }, undefined, false, undefined, this)
16406
+ ]
16407
+ }, `endpoint:${service.name}:${endpoint.name}`, true, undefined, this);
15884
16408
  })
15885
16409
  ]
15886
16410
  }, undefined, true, undefined, this) : /* @__PURE__ */ jsxDEV2("box", {
@@ -15914,14 +16438,20 @@ function LogPane({
15914
16438
  const end = Math.max(0, rows.length - clamped);
15915
16439
  const start = Math.max(0, end - viewportRows);
15916
16440
  const visible = rows.slice(start, end);
15917
- const title = fitFleetText(`Logs \u2014 ${label}`, Math.max(8, width - 28));
16441
+ const position = visible.length === 0 ? "0/0" : `${start + 1}\u2013${end}/${rows.length}`;
16442
+ const rawStatus = follow ? "following" : `paused \xB7 ${position}${unseen > 0 ? ` \xB7 ${unseen} new` : ""}`;
16443
+ const headerWidth = Math.max(1, width - 4);
16444
+ const status = fitFleetText(rawStatus, Math.max(1, headerWidth - Math.min(8, headerWidth - 1)));
16445
+ const titleWidth = Math.max(0, headerWidth - fleetTextWidth(status));
16446
+ const title = fitFleetText(`Logs \u2014 ${label}`, titleWidth);
15918
16447
  return /* @__PURE__ */ jsxDEV3("box", {
15919
16448
  onMouseScroll: (event) => {
15920
- if (event.button !== 4 && event.button !== 5)
16449
+ const delta = fleetMouseScrollDelta(event);
16450
+ if (delta === undefined)
15921
16451
  return;
15922
16452
  event.preventDefault();
15923
16453
  event.stopPropagation();
15924
- onScroll?.(event.button === 4 ? -3 : 3);
16454
+ onScroll?.(delta);
15925
16455
  },
15926
16456
  style: {
15927
16457
  height: "100%",
@@ -15937,11 +16467,11 @@ function LogPane({
15937
16467
  children: [
15938
16468
  /* @__PURE__ */ jsxDEV3("text", {
15939
16469
  fg: focused ? fleetTheme.accent : fleetTheme.text,
15940
- children: padFleetText(title, Math.max(1, width - 24))
16470
+ children: padFleetText(title, titleWidth)
15941
16471
  }, undefined, false, undefined, this),
15942
16472
  /* @__PURE__ */ jsxDEV3("text", {
15943
16473
  fg: follow ? fleetTheme.healthy : fleetTheme.warning,
15944
- children: follow ? "following" : `paused${unseen > 0 ? ` \xB7 ${unseen} new` : ""}`
16474
+ children: status
15945
16475
  }, undefined, false, undefined, this)
15946
16476
  ]
15947
16477
  }, undefined, true, undefined, this),
@@ -16058,9 +16588,6 @@ function emptySnapshot(repoId) {
16058
16588
  function selectedStack(snapshot, project) {
16059
16589
  return snapshot.stacks.find((stack) => stack.project === project);
16060
16590
  }
16061
- function fleetServiceRowCount(stack) {
16062
- return stack?.services.reduce((count, service) => count + 1 + serviceEndpoints(service).length, 0) ?? 0;
16063
- }
16064
16591
  function selectedFleetEndpoint(stack, serviceName, endpointName) {
16065
16592
  const service = stack?.services.find((candidate) => candidate.name === serviceName);
16066
16593
  const endpoints = service === undefined ? [] : serviceEndpoints(service);
@@ -16222,6 +16749,11 @@ function FleetApp({
16222
16749
  const svcRows = fleetServiceRowCount(stack) + warningRows;
16223
16750
  const stackedSidebarHeight = stackSidebarHeight(stacks.length);
16224
16751
  const svcPaneH = servicePaneHeight(svcRows, effectiveLayout === "split" ? 14 : 12);
16752
+ const serviceViewportRows = Math.max(1, svcPaneH - 4 - warningRows);
16753
+ const serviceRows = useMemo(() => buildFleetServiceRows(stack), [stack]);
16754
+ const maxServiceOffset = Math.max(0, serviceRows.length - serviceViewportRows);
16755
+ const selectedServiceRowIndex = selectedFleetServiceRowIndex(serviceRows, state.selection.service, state.selection.endpoint);
16756
+ const effectiveServiceOffset = ensureFleetServiceRowVisible(state.serviceOffset, state.serviceOffsetManual ? -1 : selectedServiceRowIndex, serviceViewportRows, serviceRows.length);
16225
16757
  const logHeight = Math.max(5, terminal.height - headerRows - contextRows - footerRows - svcPaneH - (effectiveLayout === "stack" ? stackedSidebarHeight : 0));
16226
16758
  const logWidth = effectiveLayout === "split" ? paneWidths.main : terminal.width;
16227
16759
  const logViewportRows = Math.max(1, logHeight - 3);
@@ -16277,7 +16809,7 @@ function FleetApp({
16277
16809
  source.down(confirmed).then(() => {
16278
16810
  dispatch({ type: "down-pending", pending: false });
16279
16811
  dispatch({ type: "confirm-down" });
16280
- showToast(`${project} is down; named volumes retained`);
16812
+ showToast(`${project} is down; volumes and project images removed`);
16281
16813
  }).catch((error) => {
16282
16814
  dispatch({ type: "down-pending", pending: false });
16283
16815
  showToast(`down failed: ${error.message}`);
@@ -16395,6 +16927,21 @@ function FleetApp({
16395
16927
  if (isScrollTopKey(key)) {
16396
16928
  return dispatch({ type: "scroll-logs", delta: -logRows.length, maxOffset: maxLogOffset });
16397
16929
  }
16930
+ if (current.focus === "logs") {
16931
+ if (isPageUpKey(key)) {
16932
+ return dispatch({ type: "scroll-logs", delta: -logViewportRows, maxOffset: maxLogOffset });
16933
+ }
16934
+ if (isPageDownKey(key)) {
16935
+ return dispatch({ type: "scroll-logs", delta: logViewportRows, maxOffset: maxLogOffset });
16936
+ }
16937
+ const halfPageRows = Math.max(1, Math.floor(logViewportRows / 2));
16938
+ if (isHalfPageUpKey(key)) {
16939
+ return dispatch({ type: "scroll-logs", delta: -halfPageRows, maxOffset: maxLogOffset });
16940
+ }
16941
+ if (isHalfPageDownKey(key)) {
16942
+ return dispatch({ type: "scroll-logs", delta: halfPageRows, maxOffset: maxLogOffset });
16943
+ }
16944
+ }
16398
16945
  if (isDoctorKey(key)) {
16399
16946
  if (doctorRunning)
16400
16947
  return;
@@ -16471,10 +17018,17 @@ function FleetApp({
16471
17018
  uptime,
16472
17019
  selectedService: state.selection.service,
16473
17020
  selectedEndpoint: state.selection.endpoint,
17021
+ offset: effectiveServiceOffset,
17022
+ viewportRows: serviceViewportRows,
16474
17023
  width: logWidth,
16475
17024
  focused: state.focus === "services",
16476
17025
  onSelectService,
16477
- onSelectEndpoint
17026
+ onSelectEndpoint,
17027
+ onScroll: (delta) => dispatch({
17028
+ type: "scroll-services",
17029
+ delta: delta + effectiveServiceOffset - state.serviceOffset,
17030
+ maxOffset: maxServiceOffset
17031
+ })
16478
17032
  }, undefined, false, undefined, this);
16479
17033
  const sidebar = (width) => /* @__PURE__ */ jsxDEV5(StackSidebar, {
16480
17034
  stacks,
@@ -16814,7 +17368,7 @@ function FleetApp({
16814
17368
  }, undefined, false, undefined, this),
16815
17369
  /* @__PURE__ */ jsxDEV5("text", {
16816
17370
  fg: fleetTheme.warning,
16817
- children: "Named volumes are retained."
17371
+ children: "Removes named volumes and project-built images (data loss)."
16818
17372
  }, undefined, false, undefined, this),
16819
17373
  /* @__PURE__ */ jsxDEV5("text", {
16820
17374
  fg: fleetTheme.danger,
@@ -16832,6 +17386,7 @@ var init_FleetApp = __esm(() => {
16832
17386
  init_fleet_text();
16833
17387
  init_fleet_theme();
16834
17388
  init_fleet_log_rows();
17389
+ init_fleet_service_rows();
16835
17390
  init_fleet_format();
16836
17391
  init_fleet_status();
16837
17392
  init_StackSidebar();
@@ -16843,12 +17398,13 @@ var init_FleetApp = __esm(() => {
16843
17398
  ["j k \u2191\u2193", "move in focused pane \xB7 Tab cycles panes"],
16844
17399
  [", . [ ]", "previous / next stack \xB7 service"],
16845
17400
  ["/", "filter stacks (Esc clears, then exits)"],
16846
- ["f \xB7 g G", "pause/resume follow \xB7 top / bottom of logs"],
17401
+ ["f \xB7 g G \xB7 PgUp PgDn", "follow \xB7 top / bottom \xB7 page logs"],
17402
+ ["^U ^D", "scroll logs by half a page"],
16847
17403
  ["o", "open selected endpoint in browser"],
16848
17404
  ["y \xB7 Y", "yank endpoint URL \xB7 stack env block"],
16849
17405
  ["c l p", "yank direct / local / public URL"],
16850
17406
  ["s", "shared hostnames (claim, allow, deny, release)"],
16851
- ["D \xB7 d", "doctor report \xB7 down stack (volumes retained)"],
17407
+ ["D \xB7 d", "doctor report \xB7 down stack (removes volumes + images)"],
16852
17408
  ["1 2 0", "split / stacked / auto layout"],
16853
17409
  ["q", "quit \xB7 mouse click and wheel work everywhere"]
16854
17410
  ];
@@ -16925,7 +17481,7 @@ async function runFleetTui(cwd = process.cwd()) {
16925
17481
  openConsoleOnError: false
16926
17482
  });
16927
17483
  const root = createRoot(renderer);
16928
- await new Promise((resolve3, reject) => {
17484
+ await new Promise((resolve5, reject) => {
16929
17485
  let shuttingDown = false;
16930
17486
  let removeInterrupt = () => {};
16931
17487
  let removeSuspend = () => {};
@@ -16947,7 +17503,7 @@ async function runFleetTui(cwd = process.cwd()) {
16947
17503
  if (error !== undefined)
16948
17504
  reject(error);
16949
17505
  else
16950
- resolve3();
17506
+ resolve5();
16951
17507
  };
16952
17508
  for (const signal of signals)
16953
17509
  process.once(signal, shutdown);
@@ -17011,9 +17567,9 @@ init_src2();
17011
17567
  init_src();
17012
17568
  import { execFileSync as execFileSync5 } from "child_process";
17013
17569
  import { existsSync as existsSync24 } from "fs";
17014
- import { dirname as dirname8, join as join26 } from "path";
17570
+ import { dirname as dirname9, join as join27 } from "path";
17015
17571
  import { fileURLToPath as fileURLToPath3 } from "url";
17016
- var CLI_VERSION = "1.2.0";
17572
+ var CLI_VERSION = "1.3.0";
17017
17573
  var PACKAGE_NAME = "@tridha643/hestia";
17018
17574
  function compareVersions(a, b) {
17019
17575
  const parse = (value) => {
@@ -17420,7 +17976,8 @@ usage:
17420
17976
  health. Exit 1 only on error-level rows; never mutates anything
17421
17977
  hestia stop <name> [--json] stop one supervised proc (idempotent)
17422
17978
  hestia down [--destroy] [--project <name>] [--json]
17423
- tear down procs + containers (--destroy also removes volumes);
17979
+ tear down procs + containers (--destroy also removes volumes
17980
+ and project-built images);
17424
17981
  --project works from the mirror after the worktree is deleted
17425
17982
  hestia status [--json] show this worktree's stack
17426
17983
  hestia env [--json] print the injected env (export lines)
@@ -17505,10 +18062,10 @@ async function main() {
17505
18062
  case "skill": {
17506
18063
  if (flags._[1] !== "path")
17507
18064
  fail("usage", "usage: hestia skill path", flags.json);
17508
- const packageRoot = dirname8(dirname8(fileURLToPath3(import.meta.url)));
18065
+ const packageRoot = dirname9(dirname9(fileURLToPath3(import.meta.url)));
17509
18066
  const candidates = [
17510
- join26(packageRoot, "skills", "hestia", "SKILL.md"),
17511
- join26(process.cwd(), "skills", "hestia", "SKILL.md")
18067
+ join27(packageRoot, "skills", "hestia", "SKILL.md"),
18068
+ join27(process.cwd(), "skills", "hestia", "SKILL.md")
17512
18069
  ];
17513
18070
  const path = candidates.find(existsSync24);
17514
18071
  if (path === undefined)
@@ -18181,4 +18738,4 @@ export {
18181
18738
  compareVersions
18182
18739
  };
18183
18740
 
18184
- //# debugId=E655EED0C9FC8A1364756E2164756E21
18741
+ //# debugId=EE213D14626327FC64756E2164756E21