@tridha643/hestia 1.0.1 → 1.2.0

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
@@ -7158,6 +7158,19 @@ import {
7158
7158
  } from "fs";
7159
7159
  import { dirname as dirname2 } from "path";
7160
7160
  import { randomUUID } from "crypto";
7161
+ function fsyncDir(path) {
7162
+ let fd;
7163
+ try {
7164
+ fd = openSync(dirname2(path), "r");
7165
+ } catch {
7166
+ return;
7167
+ }
7168
+ try {
7169
+ fsyncSync(fd);
7170
+ } catch {} finally {
7171
+ closeSync(fd);
7172
+ }
7173
+ }
7161
7174
  function writeAtomicJsonFile(path, value, options = {}) {
7162
7175
  mkdirSync(dirname2(path), { recursive: true });
7163
7176
  const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
@@ -7179,6 +7192,7 @@ function writeAtomicJsonFile(path, value, options = {}) {
7179
7192
  rmSync(temporaryPath, { force: true });
7180
7193
  throw error;
7181
7194
  }
7195
+ fsyncDir(path);
7182
7196
  }
7183
7197
  function writeAtomicTextFile(path, source, mode = 384) {
7184
7198
  mkdirSync(dirname2(path), { recursive: true });
@@ -7200,6 +7214,7 @@ function writeAtomicTextFile(path, source, mode = 384) {
7200
7214
  rmSync(temporaryPath, { force: true });
7201
7215
  throw error;
7202
7216
  }
7217
+ fsyncDir(path);
7203
7218
  }
7204
7219
  var init_atomic_json_file = () => {};
7205
7220
 
@@ -9252,6 +9267,39 @@ async function releaseSlot(port, project) {
9252
9267
  });
9253
9268
  } catch {}
9254
9269
  }
9270
+ async function sharedVerb(port, verb, body) {
9271
+ const timeoutMs = (body.waitMs ?? 0) + 5000;
9272
+ let response;
9273
+ try {
9274
+ response = await fetch(`http://127.0.0.1:${port}/hestia/shared/${verb}`, {
9275
+ method: "POST",
9276
+ headers: { "content-type": "application/json", ...daemonAuthHeaders(port) },
9277
+ body: JSON.stringify(body),
9278
+ signal: AbortSignal.timeout(timeoutMs)
9279
+ });
9280
+ } catch (error) {
9281
+ throw new HestiaError("daemon-unreachable", `could not reach hestiad on 127.0.0.1:${port}: ${error.message}`);
9282
+ }
9283
+ const payload = await response.json().catch(() => ({}));
9284
+ if (!response.ok) {
9285
+ throw new HestiaError(payload.code ?? "daemon-unreachable", payload.error ?? `daemon rejected shared ${verb}: HTTP ${response.status}`);
9286
+ }
9287
+ return {
9288
+ granted: payload.granted === true,
9289
+ holder: payload.holder,
9290
+ queued: payload.queued ?? []
9291
+ };
9292
+ }
9293
+ async function releaseSharedForProject(port, project, service) {
9294
+ try {
9295
+ await fetch(`http://127.0.0.1:${port}/hestia/shared/release-project`, {
9296
+ method: "POST",
9297
+ headers: { "content-type": "application/json", ...daemonAuthHeaders(port) },
9298
+ body: JSON.stringify({ project, service }),
9299
+ signal: AbortSignal.timeout(2000)
9300
+ });
9301
+ } catch {}
9302
+ }
9255
9303
  var DAEMON_PIDFILE_NAME = "hestiad", MAX_NDJSON_FRAME_BYTES;
9256
9304
  var init_client = __esm(() => {
9257
9305
  init_src();
@@ -9575,6 +9623,7 @@ class Admission {
9575
9623
  }
9576
9624
  var HESTIAD_PROTOCOL_VERSION = 5, MAX_JSON_BODY_BYTES;
9577
9625
  var init_routes = __esm(() => {
9626
+ init_src();
9578
9627
  init_state();
9579
9628
  init_slots();
9580
9629
  MAX_JSON_BODY_BYTES = 16 * 1024;
@@ -10155,25 +10204,182 @@ var init_portless_adapter = __esm(() => {
10155
10204
  HESTIA_ROUTER_RUNTIME_STATE_DIR = join15(HESTIA_ROUTER_INSTALL_DIR, "state");
10156
10205
  });
10157
10206
 
10207
+ // packages/engine/src/tunnel/shared.ts
10208
+ import { existsSync as existsSync13, readFileSync as readFileSync13, readdirSync as readdirSync4, rmSync as rmSync8 } from "fs";
10209
+ import { join as join16 } from "path";
10210
+ function sharedRoot() {
10211
+ return join16(hestiaHome(), "shared");
10212
+ }
10213
+ function sharedPath(name) {
10214
+ return join16(sharedRoot(), `${name}.json`);
10215
+ }
10216
+ function assertSharedName(name) {
10217
+ if (!NAME_RE.test(name)) {
10218
+ throw new HestiaError("usage", `shared hostname handle ${JSON.stringify(name)} must be a DNS label: ` + `lowercase alphanumerics and hyphens, at most 63 characters`);
10219
+ }
10220
+ }
10221
+ function assertSharedHostnameFqdn(hostname) {
10222
+ const labels = hostname.split(".");
10223
+ const valid = hostname.length <= 253 && hostname === hostname.toLowerCase() && labels.length >= 2 && labels.every((label) => LABEL_RE.test(label));
10224
+ if (!valid) {
10225
+ throw new HestiaError("usage", `shared hostname ${JSON.stringify(hostname)} must be a fully-qualified ` + `lowercase domain (e.g. slack.acme.com)`);
10226
+ }
10227
+ }
10228
+ function normalizeSharedPath(input) {
10229
+ if (input === undefined)
10230
+ return;
10231
+ const trimmed = input.trim();
10232
+ const segments = trimmed.split("/").filter((segment) => segment !== "");
10233
+ for (const segment of segments) {
10234
+ if (segment === "." || segment === ".." || !PATH_SEGMENT_RE.test(segment)) {
10235
+ throw new HestiaError("usage", `shared hostname path ${JSON.stringify(input)} must be a URL path prefix ` + `like /webhooks/slack (no query, fragment, or ".." segments)`);
10236
+ }
10237
+ }
10238
+ if (segments.length === 0)
10239
+ return;
10240
+ return `/${segments.join("/")}`;
10241
+ }
10242
+ function sharedPathMatches(prefix, requestPath) {
10243
+ if (prefix === undefined)
10244
+ return true;
10245
+ return requestPath === prefix || requestPath.startsWith(`${prefix}/`);
10246
+ }
10247
+ function withSharedLock(fn) {
10248
+ return withLock(sharedRoot(), fn);
10249
+ }
10250
+ function parseShared(source) {
10251
+ let value;
10252
+ try {
10253
+ value = JSON.parse(source);
10254
+ } catch {
10255
+ return null;
10256
+ }
10257
+ if (value.schemaVersion !== STATE_SCHEMA_VERSION || typeof value.name !== "string" || !NAME_RE.test(value.name) || typeof value.hostname !== "string" || value.hostname.length === 0 || typeof value.tunnelUuid !== "string" || value.tunnelUuid.length === 0 || typeof value.zone !== "string" || value.zone.length === 0 || typeof value.service !== "string" || value.service.length === 0 || typeof value.createdAt !== "string")
10258
+ return null;
10259
+ if (value.path !== undefined && (typeof value.path !== "string" || !value.path.startsWith("/"))) {
10260
+ return null;
10261
+ }
10262
+ if (value.holder !== undefined) {
10263
+ const holder = value.holder;
10264
+ if (typeof holder.project !== "string" || typeof holder.worktree !== "string" || typeof holder.service !== "string" || typeof holder.at !== "string")
10265
+ return null;
10266
+ }
10267
+ if (value.queue !== undefined) {
10268
+ if (!Array.isArray(value.queue))
10269
+ return null;
10270
+ for (const entry of value.queue) {
10271
+ if (typeof entry !== "object" || entry === null || typeof entry.project !== "string" || typeof entry.worktree !== "string" || typeof entry.at !== "string")
10272
+ return null;
10273
+ }
10274
+ }
10275
+ return value;
10276
+ }
10277
+ function readSharedHostname(name) {
10278
+ const p = sharedPath(name);
10279
+ if (!existsSync13(p))
10280
+ return null;
10281
+ try {
10282
+ return parseShared(readFileSync13(p, "utf8"));
10283
+ } catch {
10284
+ return null;
10285
+ }
10286
+ }
10287
+ function listSharedHostnames() {
10288
+ const root = sharedRoot();
10289
+ if (!existsSync13(root))
10290
+ return [];
10291
+ const records = [];
10292
+ for (const entry of readdirSync4(root).sort()) {
10293
+ if (!entry.endsWith(".json"))
10294
+ continue;
10295
+ const record = readSharedHostname(entry.slice(0, -".json".length));
10296
+ if (record !== null && `${record.name}.json` === entry)
10297
+ records.push(record);
10298
+ }
10299
+ return records;
10300
+ }
10301
+ async function declareSharedHostname(record) {
10302
+ assertSharedName(record.name);
10303
+ assertSharedHostnameFqdn(record.hostname);
10304
+ const path = normalizeSharedPath(record.path);
10305
+ return withSharedLock(async () => {
10306
+ const collision = listSharedHostnames().find((candidate) => candidate.name !== record.name && candidate.hostname === record.hostname && (candidate.path ?? undefined) === (path ?? undefined));
10307
+ if (collision !== undefined) {
10308
+ throw new HestiaError("shared-conflict", `${record.hostname}${path ?? ""} is already declared as shared hostname ` + `"${collision.name}" \u2014 pick a distinct path or handle`);
10309
+ }
10310
+ const existing = readSharedHostname(record.name);
10311
+ if (existing !== null) {
10312
+ if (existing.hostname !== record.hostname || existing.tunnelUuid !== record.tunnelUuid || (existing.path ?? undefined) !== (path ?? undefined)) {
10313
+ throw new HestiaError("shared-conflict", `shared hostname "${record.name}" already declared as ${existing.hostname}` + `${existing.path ?? ""} on tunnel ${existing.tunnelUuid} \u2014 release and remove it before re-pointing`);
10314
+ }
10315
+ const merged = { ...existing, service: record.service, zone: record.zone };
10316
+ writeAtomicJsonFile(sharedPath(record.name), merged);
10317
+ return merged;
10318
+ }
10319
+ const fresh = {
10320
+ schemaVersion: STATE_SCHEMA_VERSION,
10321
+ ...record,
10322
+ createdAt: new Date().toISOString()
10323
+ };
10324
+ if (path === undefined)
10325
+ delete fresh.path;
10326
+ else
10327
+ fresh.path = path;
10328
+ writeAtomicJsonFile(sharedPath(record.name), fresh);
10329
+ return fresh;
10330
+ });
10331
+ }
10332
+ async function updateSharedHostname(name, mutate) {
10333
+ return withSharedLock(async () => {
10334
+ const existing = readSharedHostname(name);
10335
+ if (existing === null)
10336
+ return null;
10337
+ const next = mutate(structuredClone(existing));
10338
+ if (next.queue !== undefined && next.queue.length === 0)
10339
+ delete next.queue;
10340
+ if (next.holder === undefined)
10341
+ delete next.holder;
10342
+ writeAtomicJsonFile(sharedPath(name), next);
10343
+ return next;
10344
+ });
10345
+ }
10346
+ var NAME_RE, LABEL_RE, PATH_SEGMENT_RE;
10347
+ var init_shared = __esm(() => {
10348
+ init_src();
10349
+ init_state();
10350
+ init_lock();
10351
+ init_atomic_json_file();
10352
+ NAME_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
10353
+ LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
10354
+ PATH_SEGMENT_RE = /^[A-Za-z0-9._~!$&'()*+,;=:@%-]+$/;
10355
+ });
10356
+
10158
10357
  // packages/engine/src/router/local-http-router.ts
10159
10358
  import { execFile as execFile7 } from "child_process";
10160
10359
  import { Agent, createServer as createServer2, request as httpRequest } from "http";
10161
10360
  import { createConnection as createConnection2, createServer as createNetServer } from "net";
10162
10361
  import { promisify as promisify6 } from "util";
10163
- import { existsSync as existsSync13, readFileSync as readFileSync13, readdirSync as readdirSync4 } from "fs";
10164
- import { chmodSync as chmodSync5, lstatSync, mkdirSync as mkdirSync7, rmSync as rmSync8 } from "fs";
10362
+ import { existsSync as existsSync14, readFileSync as readFileSync14, readdirSync as readdirSync5 } from "fs";
10363
+ import { chmodSync as chmodSync5, lstatSync, mkdirSync as mkdirSync7, rmSync as rmSync9 } from "fs";
10165
10364
  import { tmpdir as tmpdir2 } from "os";
10166
- import { join as join16 } from "path";
10365
+ import { join as join17 } from "path";
10167
10366
  import { createHash as createHash5 } from "crypto";
10367
+ function pathnameOf(target) {
10368
+ if (target === undefined || target.length === 0)
10369
+ return "/";
10370
+ const cut = target.search(/[?#]/);
10371
+ const path = cut < 0 ? target : target.slice(0, cut);
10372
+ return path.startsWith("/") ? path : "/";
10373
+ }
10168
10374
  function readRouterStackRecords() {
10169
- const stacksDir = join16(hestiaHome(), "stacks");
10170
- if (!existsSync13(stacksDir))
10375
+ const stacksDir = join17(hestiaHome(), "stacks");
10376
+ if (!existsSync14(stacksDir))
10171
10377
  return [];
10172
10378
  const records = [];
10173
- for (const project of readdirSync4(stacksDir).sort()) {
10174
- const path = join16(stacksDir, project, "stack.json");
10379
+ for (const project of readdirSync5(stacksDir).sort()) {
10380
+ const path = join17(stacksDir, project, "stack.json");
10175
10381
  try {
10176
- const source = readFileSync13(path);
10382
+ const source = readFileSync14(path);
10177
10383
  if (source.byteLength > MAX_MIRROR_BYTES)
10178
10384
  continue;
10179
10385
  const record = parseStackRecord(source.toString("utf8"), path);
@@ -10325,11 +10531,11 @@ function internalEndpointAuthority(project, service) {
10325
10531
  function publicGatewaySocketPath() {
10326
10532
  const uid = process.getuid?.() ?? 501;
10327
10533
  const homeHash = createHash5("sha256").update(hestiaHome()).digest("hex").slice(0, 12);
10328
- return join16(tmpdir2(), `hestia-${uid}`, homeHash, "origin.sock");
10534
+ return join17(tmpdir2(), `hestia-${uid}`, homeHash, "origin.sock");
10329
10535
  }
10330
10536
  function prepareGatewaySocket(path) {
10331
10537
  const uid = process.getuid?.();
10332
- const directories = [join16(tmpdir2(), `hestia-${uid ?? 501}`), join16(tmpdir2(), `hestia-${uid ?? 501}`, createHash5("sha256").update(hestiaHome()).digest("hex").slice(0, 12))];
10538
+ const directories = [join17(tmpdir2(), `hestia-${uid ?? 501}`), join17(tmpdir2(), `hestia-${uid ?? 501}`, createHash5("sha256").update(hestiaHome()).digest("hex").slice(0, 12))];
10333
10539
  for (const directory of directories) {
10334
10540
  mkdirSync7(directory, { recursive: true, mode: 448 });
10335
10541
  const stat2 = lstatSync(directory);
@@ -10338,13 +10544,13 @@ function prepareGatewaySocket(path) {
10338
10544
  }
10339
10545
  chmodSync5(directory, 448);
10340
10546
  }
10341
- if (!existsSync13(path))
10547
+ if (!existsSync14(path))
10342
10548
  return;
10343
10549
  const stat = lstatSync(path);
10344
10550
  if (stat.isSymbolicLink() || !stat.isSocket() || uid !== undefined && stat.uid !== uid) {
10345
10551
  throw new Error(`refusing unsafe existing Hestia gateway socket ${path}`);
10346
10552
  }
10347
- rmSync8(path);
10553
+ rmSync9(path);
10348
10554
  }
10349
10555
  function sendRouterResponse(response, status, message) {
10350
10556
  response.writeHead(status, { "content-type": "text/plain; charset=utf-8", "x-hestia-router": "1" });
@@ -10357,6 +10563,7 @@ class HestiaLocalHttpRouter {
10357
10563
  #frontServer = createNetServer((socket) => this.#acceptFrontSocket(socket));
10358
10564
  #unixServer = createNetServer((socket) => this.#acceptFrontSocket(socket));
10359
10565
  #targets = new Map;
10566
+ #sharedRoutes = new Map;
10360
10567
  #timer;
10361
10568
  async start() {
10362
10569
  await new Promise((resolve2, reject) => {
@@ -10460,12 +10667,52 @@ class HestiaLocalHttpRouter {
10460
10667
  }
10461
10668
  }
10462
10669
  }
10670
+ const sharedRoutes = new Map;
10671
+ for (const shared of listSharedHostnames()) {
10672
+ const hostname = shared.hostname.toLowerCase();
10673
+ targets.delete(hostname);
10674
+ const holder = shared.holder;
10675
+ let target;
10676
+ if (holder !== undefined) {
10677
+ 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;
10682
+ const candidate = {
10683
+ hostname,
10684
+ project: holder.project,
10685
+ service: service === undefined || service.backend === "tunnel" || publishedPort === undefined ? undefined : { ...service, publishedPort }
10686
+ };
10687
+ const previous = this.#sharedRoutes.get(hostname)?.find((entry) => entry.name === shared.name)?.target;
10688
+ target = previous !== undefined && targetIdentity(previous) === targetIdentity(candidate) ? previous : candidate;
10689
+ }
10690
+ const list = sharedRoutes.get(hostname) ?? [];
10691
+ list.push({ path: shared.path, name: shared.name, claimed: holder !== undefined, target });
10692
+ sharedRoutes.set(hostname, list);
10693
+ }
10694
+ for (const list of sharedRoutes.values()) {
10695
+ list.sort((left, right) => (right.path?.length ?? -1) - (left.path?.length ?? -1));
10696
+ }
10463
10697
  reconcilePortlessAliases([...targets.keys()].filter((hostname) => hostname.endsWith(".localhost")), this.port);
10464
- for (const [hostname, target] of this.#targets) {
10465
- if (targets.get(hostname) !== target)
10698
+ const retained = new Set(targets.values());
10699
+ for (const list of sharedRoutes.values()) {
10700
+ for (const entry of list)
10701
+ if (entry.target !== undefined)
10702
+ retained.add(entry.target);
10703
+ }
10704
+ const priorTargets = [...this.#targets.values()];
10705
+ for (const list of this.#sharedRoutes.values()) {
10706
+ for (const entry of list)
10707
+ if (entry.target !== undefined)
10708
+ priorTargets.push(entry.target);
10709
+ }
10710
+ for (const target of priorTargets) {
10711
+ if (!retained.has(target))
10466
10712
  target.agent?.destroy();
10467
10713
  }
10468
10714
  this.#targets = targets;
10715
+ this.#sharedRoutes = sharedRoutes;
10469
10716
  }
10470
10717
  stop() {
10471
10718
  if (this.#timer)
@@ -10479,7 +10726,7 @@ class HestiaLocalHttpRouter {
10479
10726
  target.agent?.destroy();
10480
10727
  this.#frontServer.close();
10481
10728
  this.#unixServer.close();
10482
- rmSync8(this.socketPath, { force: true });
10729
+ rmSync9(this.socketPath, { force: true });
10483
10730
  this.#server.close();
10484
10731
  }
10485
10732
  #acceptFrontSocket(socket) {
@@ -10524,16 +10771,40 @@ Connection: close\r
10524
10771
  `));
10525
10772
  socket.once("error", () => internal.destroy());
10526
10773
  }
10774
+ #matchTarget(hostname, requestPath) {
10775
+ const routes = this.#sharedRoutes.get(hostname);
10776
+ if (routes !== undefined) {
10777
+ const entry = routes.find((candidate) => sharedPathMatches(candidate.path, requestPath));
10778
+ if (entry === undefined)
10779
+ return { status: 404, message: "Hestia route not found" };
10780
+ if (!entry.claimed) {
10781
+ return {
10782
+ status: 503,
10783
+ message: `Shared hostname unclaimed \u2014 run \`hestia claim ${entry.name}\` in the worktree that should receive this traffic`
10784
+ };
10785
+ }
10786
+ if (entry.target?.service?.publishedPort === undefined) {
10787
+ return { status: 503, message: "Hestia route origin unavailable" };
10788
+ }
10789
+ return { target: entry.target };
10790
+ }
10791
+ const target = this.#targets.get(hostname);
10792
+ if (target === undefined)
10793
+ return { status: 404, message: "Hestia route not found" };
10794
+ if (target.service?.publishedPort === undefined) {
10795
+ return { status: 503, message: "Hestia route origin unavailable" };
10796
+ }
10797
+ return { target };
10798
+ }
10527
10799
  async#proxyHttp(request, response) {
10528
10800
  const hostname = parseRouteAuthority(request.headers.host);
10529
10801
  if (hostname === null)
10530
10802
  return sendRouterResponse(response, 400, "Malformed Host authority");
10531
- const target = this.#targets.get(hostname);
10532
- if (target === undefined)
10533
- return sendRouterResponse(response, 404, "Hestia route not found");
10534
- const port = target.service?.publishedPort;
10535
- if (port === undefined)
10536
- return sendRouterResponse(response, 503, "Hestia route origin unavailable");
10803
+ const match = this.#matchTarget(hostname, pathnameOf(request.url));
10804
+ if ("status" in match)
10805
+ return sendRouterResponse(response, match.status, match.message);
10806
+ const target = match.target;
10807
+ const port = target.service.publishedPort;
10537
10808
  if (await verifyLocalRouterTarget(target) !== port) {
10538
10809
  return sendRouterResponse(response, 503, "Hestia route origin unavailable");
10539
10810
  }
@@ -10574,16 +10845,19 @@ Connection: close\r
10574
10845
  `);
10575
10846
  return;
10576
10847
  }
10577
- const target = this.#targets.get(host);
10578
- const port = target?.service?.publishedPort;
10579
- if (target === undefined || port === undefined) {
10580
- socket.end(`HTTP/1.1 ${target === undefined ? "404 Not Found" : "503 Service Unavailable"}\r
10848
+ const rawHeaderLines = header.slice(0, -4).split(`\r
10849
+ `);
10850
+ const requestPath = pathnameOf(rawHeaderLines[0]?.split(" ")[1]);
10851
+ const match = this.#matchTarget(host, requestPath);
10852
+ if ("status" in match) {
10853
+ const reason = match.status === 404 ? "404 Not Found" : "503 Service Unavailable";
10854
+ socket.end(`HTTP/1.1 ${reason}\r
10581
10855
  \r
10582
10856
  `);
10583
10857
  return;
10584
10858
  }
10585
- const rawHeaderLines = header.slice(0, -4).split(`\r
10586
- `);
10859
+ const target = match.target;
10860
+ const port = target.service.publishedPort;
10587
10861
  const connectionValues = rawHeaderLines.slice(1).filter((line) => /^connection:/i.test(line)).map((line) => line.slice(line.indexOf(":") + 1));
10588
10862
  const connectionNames = connectionHeaderTokens(connectionValues);
10589
10863
  const retainedHeaders = rawHeaderLines.slice(1).filter((line) => {
@@ -10639,6 +10913,7 @@ var init_local_http_router = __esm(() => {
10639
10913
  init_state();
10640
10914
  init_router_config();
10641
10915
  init_portless_adapter();
10916
+ init_shared();
10642
10917
  pexec6 = promisify6(execFile7);
10643
10918
  MAX_MIRROR_BYTES = 2 * 1024 * 1024;
10644
10919
  MAX_REQUEST_HEADER_BYTES = 64 * 1024;
@@ -10657,15 +10932,15 @@ var init_local_http_router = __esm(() => {
10657
10932
  });
10658
10933
 
10659
10934
  // packages/engine/src/wrangler/adapter.ts
10660
- import { existsSync as existsSync14, readFileSync as readFileSync14, statSync as statSync2 } from "fs";
10935
+ import { existsSync as existsSync15, readFileSync as readFileSync15, statSync as statSync2 } from "fs";
10661
10936
  import { homedir as homedir3 } from "os";
10662
- import { dirname as dirname5, join as join17, relative as relative3 } from "path";
10937
+ import { dirname as dirname5, join as join18, relative as relative3 } from "path";
10663
10938
  import { connect } from "net";
10664
10939
  function privateRegistryDir(worktreeRoot) {
10665
- return join17(worktreeRoot, ".hestia", "wrangler-registry");
10940
+ return join18(worktreeRoot, ".hestia", "wrangler-registry");
10666
10941
  }
10667
10942
  function globalRegistryDir() {
10668
- return process.env.WRANGLER_REGISTRY_PATH ?? join17(homedir3(), ".wrangler", "config", "registry");
10943
+ return process.env.WRANGLER_REGISTRY_PATH ?? join18(homedir3(), ".wrangler", "config", "registry");
10669
10944
  }
10670
10945
  function tcpConnectable(address, timeoutMs = 500) {
10671
10946
  const [host, portStr] = address.split(":");
@@ -10688,19 +10963,19 @@ function tcpConnectable(address, timeoutMs = 500) {
10688
10963
  }
10689
10964
  async function preflightForeignSessions(workers) {
10690
10965
  const dir = globalRegistryDir();
10691
- if (!existsSync14(dir))
10966
+ if (!existsSync15(dir))
10692
10967
  return;
10693
10968
  for (const w of workers) {
10694
10969
  if (w.name === null)
10695
10970
  continue;
10696
- const entryPath = join17(dir, w.name);
10697
- if (!existsSync14(entryPath))
10971
+ const entryPath = join18(dir, w.name);
10972
+ if (!existsSync15(entryPath))
10698
10973
  continue;
10699
10974
  const freshMs = Date.now() - statSync2(entryPath).mtimeMs;
10700
10975
  let live = freshMs < HEARTBEAT_FRESH_MS;
10701
10976
  if (!live) {
10702
10977
  try {
10703
- const entry = JSON.parse(readFileSync14(entryPath, "utf8"));
10978
+ const entry = JSON.parse(readFileSync15(entryPath, "utf8"));
10704
10979
  live = entry.debugPortAddress ? await tcpConnectable(entry.debugPortAddress) : false;
10705
10980
  } catch {
10706
10981
  live = false;
@@ -10723,8 +10998,8 @@ async function planWorkers(worktreeRoot, opts) {
10723
10998
  const wranglerBinFor = (configPath) => {
10724
10999
  let dir = dirname5(configPath);
10725
11000
  for (;; ) {
10726
- const candidate = join17(dir, "node_modules", ".bin", "wrangler");
10727
- if (existsSync14(candidate))
11001
+ const candidate = join18(dir, "node_modules", ".bin", "wrangler");
11002
+ if (existsSync15(candidate))
10728
11003
  return candidate;
10729
11004
  if (dir === worktreeRoot)
10730
11005
  return null;
@@ -10795,11 +11070,11 @@ var init_adapter = __esm(() => {
10795
11070
  });
10796
11071
 
10797
11072
  // packages/engine/src/wrangler/verify.ts
10798
- import { existsSync as existsSync15, readdirSync as readdirSync5 } from "fs";
11073
+ import { existsSync as existsSync16, readdirSync as readdirSync6 } from "fs";
10799
11074
  function namesIn(dir) {
10800
- if (!existsSync15(dir))
11075
+ if (!existsSync16(dir))
10801
11076
  return new Set;
10802
- return new Set(readdirSync5(dir));
11077
+ return new Set(readdirSync6(dir));
10803
11078
  }
10804
11079
  function snapshotGlobalRegistry() {
10805
11080
  return namesIn(globalRegistryDir());
@@ -10828,14 +11103,14 @@ var init_verify = __esm(() => {
10828
11103
  // packages/engine/src/tunnel/cloudflared.ts
10829
11104
  import { execFile as execFile8 } from "child_process";
10830
11105
  import { promisify as promisify7 } from "util";
10831
- import { existsSync as existsSync16 } from "fs";
11106
+ import { existsSync as existsSync17 } from "fs";
10832
11107
  import { homedir as homedir4 } from "os";
10833
- import { join as join18 } from "path";
11108
+ import { join as join19 } from "path";
10834
11109
  function cloudflaredHome() {
10835
- return process.env.HESTIA_CLOUDFLARED_HOME ?? join18(homedir4(), ".cloudflared");
11110
+ return process.env.HESTIA_CLOUDFLARED_HOME ?? join19(homedir4(), ".cloudflared");
10836
11111
  }
10837
11112
  function credFilePath(uuid) {
10838
- return process.env.TUNNEL_CRED_FILE ?? join18(cloudflaredHome(), `${uuid}.json`);
11113
+ return process.env.TUNNEL_CRED_FILE ?? join19(cloudflaredHome(), `${uuid}.json`);
10839
11114
  }
10840
11115
  async function run(args, timeoutMs = 30000) {
10841
11116
  try {
@@ -10869,7 +11144,7 @@ async function adoptTunnel(name) {
10869
11144
  throw new HestiaError("tunnel-not-found", `no tunnel named "${name}" on this account \u2014 create it once with ` + `\`cloudflared tunnel create ${name}\``);
10870
11145
  }
10871
11146
  const credFile = credFilePath(entry.id);
10872
- if (!existsSync16(credFile)) {
11147
+ if (!existsSync17(credFile)) {
10873
11148
  throw new HestiaError("tunnel-auth-missing", `credentials for tunnel "${name}" not found at ${credFile} \u2014 ` + `run \`cloudflared tunnel create\` on this machine or copy the JSON`);
10874
11149
  }
10875
11150
  return { uuid: entry.id, credFile, connections: entry.connections.length };
@@ -10882,8 +11157,8 @@ var init_cloudflared = __esm(() => {
10882
11157
 
10883
11158
  // packages/engine/src/tunnel/ingress.ts
10884
11159
  import { createHash as createHash6 } from "crypto";
10885
- import { existsSync as existsSync17, readFileSync as readFileSync15 } from "fs";
10886
- import { join as join19 } from "path";
11160
+ import { existsSync as existsSync18, readFileSync as readFileSync16 } from "fs";
11161
+ import { join as join20 } from "path";
10887
11162
  function hostnameFor(tunnelName, branch, service, zone) {
10888
11163
  const label = `${slug(tunnelName)}-${slug(branch)}-${slug(service)}`;
10889
11164
  if (label.length <= LABEL_MAX)
@@ -10901,12 +11176,12 @@ function inferZone(baseRules) {
10901
11176
  return zones.size === 1 ? [...zones][0] : undefined;
10902
11177
  }
10903
11178
  function importBaseRules(uuid, name) {
10904
- const p = join19(cloudflaredHome(), "config.yml");
10905
- if (!existsSync17(p))
11179
+ const p = join20(cloudflaredHome(), "config.yml");
11180
+ if (!existsSync18(p))
10906
11181
  return [];
10907
11182
  let parsed;
10908
11183
  try {
10909
- parsed = $parse(readFileSync15(p, "utf8"));
11184
+ parsed = $parse(readFileSync16(p, "utf8"));
10910
11185
  } catch {
10911
11186
  return [];
10912
11187
  }
@@ -10918,7 +11193,12 @@ function importBaseRules(uuid, name) {
10918
11193
  return parsed.ingress.filter((r) => r.hostname !== undefined || r.path !== undefined);
10919
11194
  }
10920
11195
  function generateMergedConfig(opts) {
10921
- assertDisjoint(opts.baseRules, opts.dynamicRules);
11196
+ assertDisjoint(opts.baseRules, opts.dynamicRules, opts.sharedRules ?? []);
11197
+ const sharedHostnames = [...new Set((opts.sharedRules ?? []).map((s) => s.hostname))];
11198
+ const shared = sharedHostnames.map((hostname) => ({
11199
+ hostname,
11200
+ service: `unix:${publicGatewaySocketPath()}`
11201
+ }));
10922
11202
  const dynamic = opts.dynamicRules.map((d) => ({
10923
11203
  hostname: d.hostname,
10924
11204
  service: `unix:${publicGatewaySocketPath()}`,
@@ -10929,17 +11209,30 @@ function generateMergedConfig(opts) {
10929
11209
  "credentials-file": opts.credFile,
10930
11210
  ingress: [
10931
11211
  ...opts.baseRules,
11212
+ ...shared,
10932
11213
  ...dynamic,
10933
11214
  { service: "http_status:404" }
10934
11215
  ]
10935
11216
  });
10936
11217
  }
10937
- function assertDisjoint(base, dynamic) {
11218
+ function assertDisjoint(base, dynamic, shared) {
10938
11219
  const seen = new Map;
10939
11220
  for (const r of base) {
10940
11221
  if (r.hostname !== undefined)
10941
11222
  seen.set(r.hostname, "the user's static rules");
10942
11223
  }
11224
+ const sharedByHost = new Map;
11225
+ for (const s of shared) {
11226
+ if (!sharedByHost.has(s.hostname))
11227
+ sharedByHost.set(s.hostname, s.name);
11228
+ }
11229
+ for (const [hostname, name] of sharedByHost) {
11230
+ const holder = seen.get(hostname);
11231
+ if (holder !== undefined) {
11232
+ throw new HestiaError("hostname-conflict", `shared hostname ${hostname} ("${name}") is already claimed by ${holder}`);
11233
+ }
11234
+ seen.set(hostname, `shared hostname "${name}"`);
11235
+ }
10943
11236
  for (const d of dynamic) {
10944
11237
  const holder = seen.get(d.hostname);
10945
11238
  if (holder !== undefined) {
@@ -10956,6 +11249,87 @@ var init_ingress = __esm(() => {
10956
11249
  init_local_http_router();
10957
11250
  });
10958
11251
 
11252
+ // packages/engine/src/tunnel/orphans.ts
11253
+ import { execFileSync as execFileSync4 } from "child_process";
11254
+ import { join as join21 } from "path";
11255
+ function hestiaTunnelMarker(uuid) {
11256
+ return join21(hestiaHome(), "tunnel", uuid);
11257
+ }
11258
+ function parseLocalHestiaConnectors(psOutput, marker) {
11259
+ const rows = [];
11260
+ for (const line of psOutput.split(`
11261
+ `)) {
11262
+ const trimmed = line.trim();
11263
+ if (trimmed === "")
11264
+ continue;
11265
+ const m = trimmed.match(/^(\d+)\s+(\d+)\s+(.*)$/);
11266
+ if (m === null)
11267
+ continue;
11268
+ const command = m[3];
11269
+ if (!command.includes(marker))
11270
+ continue;
11271
+ if (!/(^|[\/\s])cloudflared(\s|$)/.test(command))
11272
+ continue;
11273
+ rows.push({ pid: Number(m[1]), pgid: Number(m[2]), command });
11274
+ }
11275
+ return rows;
11276
+ }
11277
+ function listLocalHestiaConnectors(uuid) {
11278
+ let out;
11279
+ try {
11280
+ out = execFileSync4("ps", ["-Ao", "pid=,pgid=,command="], {
11281
+ encoding: "utf8",
11282
+ maxBuffer: 16 * 1024 * 1024,
11283
+ env: { ...process.env, LC_ALL: "C", LANG: "C" }
11284
+ });
11285
+ } catch {
11286
+ return [];
11287
+ }
11288
+ return parseLocalHestiaConnectors(out, hestiaTunnelMarker(uuid));
11289
+ }
11290
+ async function reapOrphanConnectors(uuid, keep) {
11291
+ const procs = listLocalHestiaConnectors(uuid).filter((p) => {
11292
+ if (keep === undefined)
11293
+ return true;
11294
+ return p.pgid !== keep.pgid && p.pid !== keep.pid;
11295
+ });
11296
+ if (procs.length === 0)
11297
+ return { reapedGroups: 0, pids: [] };
11298
+ const groups = new Set(procs.map((p) => p.pgid));
11299
+ const pids = procs.map((p) => p.pid);
11300
+ for (const pgid of groups) {
11301
+ try {
11302
+ process.kill(-pgid, "SIGTERM");
11303
+ } catch {}
11304
+ }
11305
+ const deadline = Date.now() + REAP_GRACE_MS;
11306
+ while (Date.now() < deadline) {
11307
+ const still = listLocalHestiaConnectors(uuid).filter((p) => {
11308
+ if (keep === undefined)
11309
+ return true;
11310
+ return p.pgid !== keep.pgid && p.pid !== keep.pid;
11311
+ });
11312
+ if (still.length === 0)
11313
+ return { reapedGroups: groups.size, pids };
11314
+ await new Promise((r) => setTimeout(r, REAP_POLL_MS));
11315
+ }
11316
+ for (const p of listLocalHestiaConnectors(uuid)) {
11317
+ if (keep !== undefined && (p.pgid === keep.pgid || p.pid === keep.pid))
11318
+ continue;
11319
+ try {
11320
+ process.kill(-p.pgid, "SIGKILL");
11321
+ } catch {}
11322
+ try {
11323
+ process.kill(p.pid, "SIGKILL");
11324
+ } catch {}
11325
+ }
11326
+ return { reapedGroups: groups.size, pids };
11327
+ }
11328
+ var REAP_GRACE_MS = 5000, REAP_POLL_MS = 100;
11329
+ var init_orphans = __esm(() => {
11330
+ init_state();
11331
+ });
11332
+
10959
11333
  // packages/engine/src/tunnel/verify.ts
10960
11334
  async function get2(port, path) {
10961
11335
  try {
@@ -11000,31 +11374,31 @@ var POLL_MS4 = 300;
11000
11374
 
11001
11375
  // packages/engine/src/tunnel/registry.ts
11002
11376
  import {
11003
- existsSync as existsSync18,
11377
+ existsSync as existsSync19,
11004
11378
  mkdirSync as mkdirSync8,
11005
- readFileSync as readFileSync16,
11006
- readdirSync as readdirSync6
11379
+ readFileSync as readFileSync17,
11380
+ readdirSync as readdirSync7
11007
11381
  } from "fs";
11008
- import { join as join20 } from "path";
11382
+ import { join as join22 } from "path";
11009
11383
  function tunnelDir(uuid) {
11010
- return join20(hestiaHome(), "tunnel", uuid);
11384
+ return join22(hestiaHome(), "tunnel", uuid);
11011
11385
  }
11012
11386
  function configPath(uuid) {
11013
- return join20(tunnelDir(uuid), "config.yml");
11387
+ return join22(tunnelDir(uuid), "config.yml");
11014
11388
  }
11015
11389
  function adoptedMarker(uuid) {
11016
- return join20(tunnelDir(uuid), "adopted.json");
11390
+ return join22(tunnelDir(uuid), "adopted.json");
11017
11391
  }
11018
11392
  function isAdopted(uuid) {
11019
- return existsSync18(adoptedMarker(uuid));
11393
+ return existsSync19(adoptedMarker(uuid));
11020
11394
  }
11021
11395
  function readAdopted(uuid) {
11022
11396
  const p = adoptedMarker(uuid);
11023
- if (!existsSync18(p))
11397
+ if (!existsSync19(p))
11024
11398
  return null;
11025
11399
  let marker;
11026
11400
  try {
11027
- marker = JSON.parse(readFileSync16(p, "utf8"));
11401
+ marker = JSON.parse(readFileSync17(p, "utf8"));
11028
11402
  } catch {
11029
11403
  marker = {};
11030
11404
  }
@@ -11032,14 +11406,14 @@ function readAdopted(uuid) {
11032
11406
  return { uuid, name: marker.name, credFile: marker.credFile, reconstructed: false };
11033
11407
  }
11034
11408
  let name;
11035
- const stacksDir = join20(hestiaHome(), "stacks");
11036
- if (existsSync18(stacksDir)) {
11037
- for (const project of readdirSync6(stacksDir)) {
11038
- const sp = join20(stacksDir, project, "stack.json");
11039
- if (!existsSync18(sp))
11409
+ const stacksDir = join22(hestiaHome(), "stacks");
11410
+ if (existsSync19(stacksDir)) {
11411
+ for (const project of readdirSync7(stacksDir)) {
11412
+ const sp = join22(stacksDir, project, "stack.json");
11413
+ if (!existsSync19(sp))
11040
11414
  continue;
11041
11415
  try {
11042
- const record = parseStackRecord(readFileSync16(sp, "utf8"), sp);
11416
+ const record = parseStackRecord(readFileSync17(sp, "utf8"), sp);
11043
11417
  if (record.tunnel?.uuid === uuid) {
11044
11418
  name = record.tunnel.name;
11045
11419
  break;
@@ -11055,24 +11429,24 @@ function readAdopted(uuid) {
11055
11429
  };
11056
11430
  }
11057
11431
  function listAdopted() {
11058
- const root = join20(hestiaHome(), "tunnel");
11059
- if (!existsSync18(root))
11432
+ const root = join22(hestiaHome(), "tunnel");
11433
+ if (!existsSync19(root))
11060
11434
  return [];
11061
- return readdirSync6(root).filter((uuid) => isAdopted(uuid));
11435
+ return readdirSync7(root).filter((uuid) => isAdopted(uuid));
11062
11436
  }
11063
11437
  function collectDynamicRules(uuid) {
11064
- const stacksDir = join20(hestiaHome(), "stacks");
11438
+ const stacksDir = join22(hestiaHome(), "stacks");
11065
11439
  const rules = [];
11066
11440
  const dropped = [];
11067
- if (!existsSync18(stacksDir))
11441
+ if (!existsSync19(stacksDir))
11068
11442
  return { rules, dropped };
11069
- for (const project of readdirSync6(stacksDir)) {
11070
- const p = join20(stacksDir, project, "stack.json");
11071
- if (!existsSync18(p))
11443
+ for (const project of readdirSync7(stacksDir)) {
11444
+ const p = join22(stacksDir, project, "stack.json");
11445
+ if (!existsSync19(p))
11072
11446
  continue;
11073
11447
  let record;
11074
11448
  try {
11075
- record = parseStackRecord(readFileSync16(p, "utf8"), p);
11449
+ record = parseStackRecord(readFileSync17(p, "utf8"), p);
11076
11450
  } catch {
11077
11451
  continue;
11078
11452
  }
@@ -11101,20 +11475,26 @@ async function reconcileTunnel(ref, opts) {
11101
11475
  for (const d of dropped) {
11102
11476
  warnings.push(`exposure ${d.hostname} dropped \u2014 service "${d.service}" of ` + `${d.project} is not running (hostname now 404s)`);
11103
11477
  }
11478
+ const sharedRules = listSharedHostnames().filter((record) => record.tunnelUuid === ref.uuid).map((record) => ({ name: record.name, hostname: record.hostname }));
11104
11479
  const cfgPath = configPath(ref.uuid);
11105
11480
  const nextConfig = generateMergedConfig({
11106
11481
  uuid: ref.uuid,
11107
11482
  credFile: ref.credFile,
11108
11483
  baseRules,
11109
- dynamicRules: rules
11484
+ dynamicRules: rules,
11485
+ sharedRules
11110
11486
  });
11111
11487
  const pf = readPidfile(dir, CONNECTOR);
11112
11488
  const live = pf !== null && isLive(pf);
11113
- const currentConfig = existsSync18(cfgPath) ? readFileSync16(cfgPath, "utf8") : null;
11489
+ const { reapedGroups } = await reapOrphanConnectors(ref.uuid, live ? pf : undefined);
11490
+ if (reapedGroups > 0) {
11491
+ warnings.push(`reaped ${reapedGroups} orphan hestia connector process group(s) for this tunnel`);
11492
+ }
11493
+ const currentConfig = existsSync19(cfgPath) ? readFileSync17(cfgPath, "utf8") : null;
11114
11494
  if (live && currentConfig === nextConfig) {
11115
11495
  return { restarted: false, metricsPort: pf.port, ready: true, warnings };
11116
11496
  }
11117
- if (baseRules.length === 0 && rules.length === 0) {
11497
+ if (baseRules.length === 0 && rules.length === 0 && sharedRules.length === 0) {
11118
11498
  if (pf !== null) {
11119
11499
  await stopProcTree(pf);
11120
11500
  removePidfile(dir, CONNECTOR);
@@ -11185,6 +11565,8 @@ var init_registry = __esm(() => {
11185
11565
  init_pidfile();
11186
11566
  init_shutdown();
11187
11567
  init_ingress();
11568
+ init_shared();
11569
+ init_orphans();
11188
11570
  init_atomic_json_file();
11189
11571
  });
11190
11572
 
@@ -11623,10 +12005,230 @@ var init_stream = __esm(() => {
11623
12005
  init_tail();
11624
12006
  });
11625
12007
 
12008
+ // packages/engine/src/daemon/shared-arbiter.ts
12009
+ class SharedArbiter {
12010
+ onChange;
12011
+ #mutex = Promise.resolve();
12012
+ #polls = new Map;
12013
+ constructor(onChange) {
12014
+ this.onChange = onChange;
12015
+ }
12016
+ #locked(fn) {
12017
+ const next = this.#mutex.then(fn, fn);
12018
+ this.#mutex = next.catch(() => {});
12019
+ return next;
12020
+ }
12021
+ async#notify() {
12022
+ try {
12023
+ await this.onChange?.();
12024
+ } catch {}
12025
+ }
12026
+ #result(record, granted) {
12027
+ return { granted, holder: record.holder, queued: [...record.queue ?? []] };
12028
+ }
12029
+ #wake(name, project, result) {
12030
+ const key = `${name}\x00${project}`;
12031
+ for (const poll of this.#polls.get(key) ?? []) {
12032
+ clearTimeout(poll.timer);
12033
+ poll.resolve(result);
12034
+ }
12035
+ this.#polls.delete(key);
12036
+ }
12037
+ #grantNext(record) {
12038
+ const queue = [...record.queue ?? []];
12039
+ for (;; ) {
12040
+ const head = queue.shift();
12041
+ if (head === undefined) {
12042
+ const next = { ...record, queue: [] };
12043
+ delete next.holder;
12044
+ return next;
12045
+ }
12046
+ if (readMirrorState(head.project) === null)
12047
+ continue;
12048
+ return {
12049
+ ...record,
12050
+ holder: {
12051
+ project: head.project,
12052
+ worktree: head.worktree,
12053
+ service: record.service,
12054
+ at: new Date().toISOString()
12055
+ },
12056
+ queue
12057
+ };
12058
+ }
12059
+ }
12060
+ async request(name, requester, waitMs) {
12061
+ const outcome = await this.#locked(async () => {
12062
+ let granted = false;
12063
+ const updated = await updateSharedHostname(name, (record) => {
12064
+ if (record.holder?.project === requester.project) {
12065
+ granted = true;
12066
+ return {
12067
+ ...record,
12068
+ holder: { ...record.holder, worktree: requester.worktree },
12069
+ queue: (record.queue ?? []).filter((waiter) => waiter.project !== requester.project)
12070
+ };
12071
+ }
12072
+ if (record.holder === undefined) {
12073
+ granted = true;
12074
+ return {
12075
+ ...record,
12076
+ holder: {
12077
+ project: requester.project,
12078
+ worktree: requester.worktree,
12079
+ service: record.service,
12080
+ at: new Date().toISOString()
12081
+ },
12082
+ queue: (record.queue ?? []).filter((waiter) => waiter.project !== requester.project)
12083
+ };
12084
+ }
12085
+ const queue = [...record.queue ?? []];
12086
+ const existing = queue.find((waiter) => waiter.project === requester.project);
12087
+ if (existing !== undefined)
12088
+ existing.worktree = requester.worktree;
12089
+ else
12090
+ queue.push({ project: requester.project, worktree: requester.worktree, at: new Date().toISOString() });
12091
+ return { ...record, queue };
12092
+ });
12093
+ if (updated === null) {
12094
+ throw new HestiaError("shared-not-found", `no shared hostname "${name}" is declared`);
12095
+ }
12096
+ return { granted, record: updated };
12097
+ });
12098
+ if (outcome.granted) {
12099
+ await this.#notify();
12100
+ return this.#result(outcome.record, true);
12101
+ }
12102
+ if (waitMs <= 0)
12103
+ return this.#result(outcome.record, false);
12104
+ return new Promise((resolve2) => {
12105
+ const key = `${name}\x00${requester.project}`;
12106
+ const polls = this.#polls.get(key) ?? [];
12107
+ const poll = {
12108
+ resolve: resolve2,
12109
+ timer: setTimeout(() => {
12110
+ const remaining = (this.#polls.get(key) ?? []).filter((candidate) => candidate !== poll);
12111
+ if (remaining.length === 0)
12112
+ this.#polls.delete(key);
12113
+ else
12114
+ this.#polls.set(key, remaining);
12115
+ const record = readSharedHostname(name);
12116
+ resolve2(record === null ? { granted: false, queued: [] } : this.#result(record, record.holder?.project === requester.project));
12117
+ }, waitMs)
12118
+ };
12119
+ polls.push(poll);
12120
+ this.#polls.set(key, polls);
12121
+ });
12122
+ }
12123
+ async cancel(name, project) {
12124
+ const record = await this.#locked(() => updateSharedHostname(name, (current) => ({
12125
+ ...current,
12126
+ queue: (current.queue ?? []).filter((waiter) => waiter.project !== project)
12127
+ })));
12128
+ if (record === null)
12129
+ throw new HestiaError("shared-not-found", `no shared hostname "${name}" is declared`);
12130
+ this.#wake(name, project, this.#result(record, false));
12131
+ return this.#result(record, false);
12132
+ }
12133
+ #assertHolder(record, project, verb) {
12134
+ if (record.holder?.project !== project) {
12135
+ throw new HestiaError("shared-not-holder", record.holder === undefined ? `cannot ${verb} "${record.name}" \u2014 it is unclaimed` : `cannot ${verb} "${record.name}" \u2014 it is held by ${record.holder.project}, not ${project}`);
12136
+ }
12137
+ }
12138
+ async allow(name, callerProject) {
12139
+ const record = await this.#transfer(name, callerProject, "allow");
12140
+ return this.#result(record, false);
12141
+ }
12142
+ async deny(name, callerProject) {
12143
+ const record = await this.#locked(async () => {
12144
+ const current = readSharedHostname(name);
12145
+ if (current === null)
12146
+ throw new HestiaError("shared-not-found", `no shared hostname "${name}" is declared`);
12147
+ this.#assertHolder(current, callerProject, "deny");
12148
+ const updated = await updateSharedHostname(name, (candidate) => {
12149
+ const queue = [...candidate.queue ?? []];
12150
+ if (queue.length > 0)
12151
+ queue[0] = { ...queue[0], denied: true };
12152
+ return { ...candidate, queue };
12153
+ });
12154
+ return updated;
12155
+ });
12156
+ return this.#result(record, false);
12157
+ }
12158
+ async release(name, callerProject) {
12159
+ const record = await this.#transfer(name, callerProject, "release");
12160
+ return this.#result(record, false);
12161
+ }
12162
+ async#transfer(name, callerProject, verb) {
12163
+ const record = await this.#locked(async () => {
12164
+ const current = readSharedHostname(name);
12165
+ if (current === null)
12166
+ throw new HestiaError("shared-not-found", `no shared hostname "${name}" is declared`);
12167
+ this.#assertHolder(current, callerProject, verb);
12168
+ return await updateSharedHostname(name, (candidate) => this.#grantNext(candidate));
12169
+ });
12170
+ if (record.holder !== undefined) {
12171
+ this.#wake(record.name, record.holder.project, this.#result(record, true));
12172
+ }
12173
+ await this.#notify();
12174
+ return record;
12175
+ }
12176
+ async releaseProject(project, service) {
12177
+ const held = listSharedHostnames().filter((record) => record.holder?.project === project && (service === undefined || record.service === service));
12178
+ let changed = false;
12179
+ for (const record of held) {
12180
+ const updated = await this.#locked(async () => {
12181
+ const current = readSharedHostname(record.name);
12182
+ if (current?.holder?.project !== project)
12183
+ return null;
12184
+ return await updateSharedHostname(record.name, (candidate) => this.#grantNext(candidate));
12185
+ });
12186
+ if (updated === null || updated === undefined)
12187
+ continue;
12188
+ changed = true;
12189
+ if (updated.holder !== undefined) {
12190
+ this.#wake(updated.name, updated.holder.project, this.#result(updated, true));
12191
+ }
12192
+ }
12193
+ if (changed)
12194
+ await this.#notify();
12195
+ }
12196
+ async sweep(occupied) {
12197
+ let changed = false;
12198
+ 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)
12202
+ continue;
12203
+ const updated = await this.#locked(() => updateSharedHostname(record.name, (candidate) => {
12204
+ const pruned = {
12205
+ ...candidate,
12206
+ queue: (candidate.queue ?? []).filter((waiter) => readMirrorState(waiter.project) !== null)
12207
+ };
12208
+ const currentHolderDead = pruned.holder !== undefined && !occupied.has(pruned.holder.project) && readMirrorState(pruned.holder.project) === null;
12209
+ return currentHolderDead ? this.#grantNext(pruned) : pruned;
12210
+ }));
12211
+ if (updated === null)
12212
+ continue;
12213
+ changed = true;
12214
+ if (updated.holder !== undefined && updated.holder.project !== record.holder?.project) {
12215
+ this.#wake(updated.name, updated.holder.project, this.#result(updated, true));
12216
+ }
12217
+ }
12218
+ if (changed)
12219
+ await this.#notify();
12220
+ }
12221
+ }
12222
+ var init_shared_arbiter = __esm(() => {
12223
+ init_src();
12224
+ init_state();
12225
+ init_shared();
12226
+ });
12227
+
11626
12228
  // packages/engine/src/doctor.ts
11627
- import { existsSync as existsSync19, readFileSync as readFileSync17, readdirSync as readdirSync7 } from "fs";
12229
+ import { existsSync as existsSync20, readFileSync as readFileSync18, readdirSync as readdirSync8 } from "fs";
11628
12230
  import { execFile as execFile9 } from "child_process";
11629
- import { dirname as dirname6, join as join21 } from "path";
12231
+ import { dirname as dirname6, join as join23 } from "path";
11630
12232
  function row(check, level, detail) {
11631
12233
  return { check, level, detail };
11632
12234
  }
@@ -11667,8 +12269,8 @@ async function envChecks(ctx) {
11667
12269
  }
11668
12270
  if (ctx !== null) {
11669
12271
  const ignored = await exec("git", ["-C", ctx.worktreeRoot, "check-ignore", "-q", ".hestia/"]);
11670
- rows.push(ignored.ok ? row("state-ignore", "ok", ".hestia/ is ignored") : row("state-ignore", "error", `add the exact line ".hestia/" to ${join21(ctx.worktreeRoot, ".gitignore")}`));
11671
- const schema = existsSync19(join21(ctx.worktreeRoot, ".env.schema"));
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"));
11672
12274
  if (schema) {
11673
12275
  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?)"));
11674
12276
  }
@@ -11677,7 +12279,7 @@ async function envChecks(ctx) {
11677
12279
  const missing = workers.filter((w) => {
11678
12280
  let dir = dirname6(w.configPath);
11679
12281
  for (;; ) {
11680
- if (existsSync19(join21(dir, "node_modules", ".bin", "wrangler")))
12282
+ if (existsSync20(join23(dir, "node_modules", ".bin", "wrangler")))
11681
12283
  return false;
11682
12284
  if (dir === ctx.worktreeRoot || dirname6(dir) === dir)
11683
12285
  return true;
@@ -11736,10 +12338,10 @@ async function stateChecks(ctx) {
11736
12338
  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`));
11737
12339
  }
11738
12340
  }
11739
- const lockPath2 = join21(hestiaDir(worktreeRoot), "lock");
11740
- if (existsSync19(lockPath2)) {
12341
+ const lockPath2 = join23(hestiaDir(worktreeRoot), "lock");
12342
+ if (existsSync20(lockPath2)) {
11741
12343
  try {
11742
- const holder = JSON.parse(readFileSync17(lockPath2, "utf8"));
12344
+ const holder = JSON.parse(readFileSync18(lockPath2, "utf8"));
11743
12345
  if (!isLive(holder)) {
11744
12346
  rows.push(row("lock", "warn", "stale worktree lock (dead holder) \u2014 auto-broken on next command"));
11745
12347
  }
@@ -11747,25 +12349,25 @@ async function stateChecks(ctx) {
11747
12349
  rows.push(row("lock", "warn", "unreadable worktree lock file"));
11748
12350
  }
11749
12351
  }
11750
- const mirror = join21(hestiaHome(), "stacks", record.project, "stack.json");
11751
- if (!existsSync19(mirror)) {
12352
+ const mirror = join23(hestiaHome(), "stacks", record.project, "stack.json");
12353
+ if (!existsSync20(mirror)) {
11752
12354
  rows.push(row("mirror", "warn", "no ~/.hestia mirror \u2014 `down --project` would not work after worktree deletion"));
11753
12355
  }
11754
12356
  return rows;
11755
12357
  }
11756
12358
  async function machineChecks() {
11757
12359
  const rows = [];
11758
- const stacksDir = join21(hestiaHome(), "stacks");
12360
+ const stacksDir = join23(hestiaHome(), "stacks");
11759
12361
  const mirrored = new Set;
11760
- if (existsSync19(stacksDir)) {
11761
- for (const project of readdirSync7(stacksDir)) {
11762
- const p = join21(stacksDir, project, "stack.json");
11763
- if (!existsSync19(p))
12362
+ if (existsSync20(stacksDir)) {
12363
+ for (const project of readdirSync8(stacksDir)) {
12364
+ const p = join23(stacksDir, project, "stack.json");
12365
+ if (!existsSync20(p))
11764
12366
  continue;
11765
12367
  mirrored.add(project);
11766
12368
  try {
11767
- const rec = parseStackRecord(readFileSync17(p, "utf8"), p);
11768
- if (!existsSync19(rec.worktree)) {
12369
+ const rec = parseStackRecord(readFileSync18(p, "utf8"), p);
12370
+ if (!existsSync20(rec.worktree)) {
11769
12371
  rows.push(row(`orphan-mirror:${project}`, "warn", `worktree ${rec.worktree} is gone \u2014 \`hestia down --project ${project}\` to clean up`));
11770
12372
  }
11771
12373
  } catch {
@@ -11812,17 +12414,44 @@ async function tunnelChecks() {
11812
12414
  } catch {
11813
12415
  connectors = null;
11814
12416
  }
12417
+ const local = listLocalHestiaConnectors(uuid);
12418
+ const localOrphans = live && pf !== null ? local.filter((p) => p.pgid !== pf.pgid && p.pid !== pf.pid) : local;
12419
+ if (localOrphans.length > 0) {
12420
+ rows.push(row(`${label}:local-orphans`, "error", `${localOrphans.length} untracked hestia connector(s) still running ` + `(pids ${localOrphans.map((p) => p.pid).join(", ")}) \u2014 the daemon sweep ` + `or next hestia command reaps them; do not start another cloudflared`));
12421
+ }
11815
12422
  if (connectors === null) {
11816
12423
  rows.push(row(`${label}:connectors`, "unknown", "cloudflare unreachable \u2014 cannot count connectors"));
11817
12424
  } else {
11818
12425
  const expected = live ? 1 : 0;
11819
12426
  if (connectors > expected) {
11820
- rows.push(row(`${label}:connectors`, "error", `${connectors} connector(s) registered but hestia runs ${expected} \u2014 a foreign ` + `replica cross-wires worktrees; stop the other cloudflared`));
12427
+ const detail = localOrphans.length > 0 ? `${connectors} connector(s) registered but hestia runs ${expected} \u2014 ` + `${localOrphans.length} are local hestia-owned orphans (auto-reaped on next sweep); ` + `any remainder is a foreign replica` : `${connectors} connector(s) registered but hestia runs ${expected} \u2014 a foreign ` + `replica cross-wires worktrees; stop the other cloudflared (only processes whose ` + `argv lacks ~/.hestia/tunnel/<uuid>/ are truly foreign)`;
12428
+ rows.push(row(`${label}:connectors`, "error", detail));
11821
12429
  }
11822
12430
  }
11823
12431
  }
11824
12432
  return rows;
11825
12433
  }
12434
+ async function sharedHostnameChecks() {
12435
+ const rows = [];
12436
+ for (const shared of listSharedHostnames()) {
12437
+ const label = `shared:${shared.name}`;
12438
+ const url = `https://${shared.hostname}${shared.path ?? ""}`;
12439
+ const pending = (shared.queue ?? []).length;
12440
+ const queueSuffix = pending > 0 ? ` (${pending} queued)` : "";
12441
+ if (shared.holder === undefined) {
12442
+ rows.push(row(label, "warn", `${url} unclaimed \u2014 requests answer 503 until \`hestia claim ${shared.name}\`${queueSuffix}`));
12443
+ continue;
12444
+ }
12445
+ const mirror = readMirrorState(shared.holder.project);
12446
+ if (mirror === null) {
12447
+ rows.push(row(label, "warn", `held by ${shared.holder.project} but its stack mirror is gone \u2014 the daemon sweep auto-releases it${queueSuffix}`));
12448
+ continue;
12449
+ }
12450
+ const endpoint = mirror.endpoints.find((candidate) => (candidate.alias ?? candidate.name) === shared.service);
12451
+ rows.push(endpoint === undefined ? row(label, "warn", `held by ${shared.holder.project} but endpoint "${shared.service}" is not running \u2014 requests answer 503${queueSuffix}`) : row(label, "ok", `${url} \u2192 ${shared.holder.project}${queueSuffix}`));
12452
+ }
12453
+ return rows;
12454
+ }
11826
12455
  async function daemonChecks() {
11827
12456
  const rows = [];
11828
12457
  const j = readDaemonJson();
@@ -11837,11 +12466,11 @@ async function daemonChecks() {
11837
12466
  rows.push(row("daemon-config", "warn", w));
11838
12467
  }
11839
12468
  const plist = plistPath();
11840
- if (existsSync19(plist)) {
11841
- const content = readFileSync17(plist, "utf8");
12469
+ if (existsSync20(plist)) {
12470
+ const content = readFileSync18(plist, "utf8");
11842
12471
  const args = [...content.matchAll(/<string>([^<]+)<\/string>/g)].map((m) => m[1]);
11843
12472
  const paths = args.filter((a) => a.startsWith("/") && !a.includes(":"));
11844
- const missing = paths.filter((p) => !existsSync19(p));
12473
+ const missing = paths.filter((p) => !existsSync20(p));
11845
12474
  rows.push(missing.length === 0 ? row("launchd", "ok", `installed (${plist})`) : row("launchd", "error", `plist references missing paths: ${missing.join(", ")} \u2014 re-run \`hestia daemon install\``));
11846
12475
  }
11847
12476
  return rows;
@@ -11880,6 +12509,7 @@ async function doctor(cwd) {
11880
12509
  ctx !== null ? bounded("state", () => stateChecks(ctx)) : Promise.resolve([]),
11881
12510
  bounded("machine", () => machineChecks()),
11882
12511
  bounded("tunnel", () => tunnelChecks()),
12512
+ bounded("shared", () => sharedHostnameChecks()),
11883
12513
  bounded("daemon", () => daemonChecks()),
11884
12514
  bounded("local-router", () => localRouterChecks())
11885
12515
  ]);
@@ -11891,11 +12521,13 @@ var init_doctor = __esm(() => {
11891
12521
  init_git();
11892
12522
  init_config();
11893
12523
  init_state();
12524
+ init_shared();
11894
12525
  init_pidfile();
11895
12526
  init_resolver();
11896
12527
  init_discover();
11897
12528
  init_registry();
11898
12529
  init_cloudflared();
12530
+ init_orphans();
11899
12531
  init_client();
11900
12532
  init_routes();
11901
12533
  init_launchd();
@@ -11907,8 +12539,8 @@ var init_doctor = __esm(() => {
11907
12539
  // packages/engine/src/daemon/fleet-monitor.ts
11908
12540
  import { execFile as execFile10 } from "child_process";
11909
12541
  import { promisify as promisify8 } from "util";
11910
- import { existsSync as existsSync20, readFileSync as readFileSync18, readdirSync as readdirSync8 } from "fs";
11911
- import { join as join22 } from "path";
12542
+ import { existsSync as existsSync21, readFileSync as readFileSync19, readdirSync as readdirSync9 } from "fs";
12543
+ import { join as join24 } from "path";
11912
12544
 
11913
12545
  class LatestFleetChannel {
11914
12546
  #pending;
@@ -11951,13 +12583,13 @@ class LatestFleetChannel {
11951
12583
  function readManagedMirrors() {
11952
12584
  const records = [];
11953
12585
  const warnings = [];
11954
- const stacksDirectory = join22(hestiaHome(), "stacks");
11955
- if (!existsSync20(stacksDirectory))
12586
+ const stacksDirectory = join24(hestiaHome(), "stacks");
12587
+ if (!existsSync21(stacksDirectory))
11956
12588
  return { records, warnings };
11957
- for (const project of readdirSync8(stacksDirectory).sort()) {
11958
- const path = join22(stacksDirectory, project, "stack.json");
12589
+ for (const project of readdirSync9(stacksDirectory).sort()) {
12590
+ const path = join24(stacksDirectory, project, "stack.json");
11959
12591
  try {
11960
- const source = readFileSync18(path);
12592
+ const source = readFileSync19(path);
11961
12593
  if (source.byteLength > MAX_MIRROR_BYTES2) {
11962
12594
  warnings.push(`Fleet mirror too large for ${project}`);
11963
12595
  continue;
@@ -11977,7 +12609,7 @@ function readManagedMirrors() {
11977
12609
  async function attributeLegacyRepoId(record) {
11978
12610
  if (record.repoId !== undefined)
11979
12611
  return record.repoId;
11980
- if (!existsSync20(record.worktree))
12612
+ if (!existsSync21(record.worktree))
11981
12613
  return null;
11982
12614
  const info = await getRepoInfo(record.worktree);
11983
12615
  if (projectName(info.repoId, info.repo, info.branch, info.worktreeRoot) !== record.project)
@@ -12083,6 +12715,25 @@ function reservationIdentity(reservation, records) {
12083
12715
  worktree: record.worktree
12084
12716
  };
12085
12717
  }
12718
+ function collectFleetShared(repoProjects) {
12719
+ return listSharedHostnames().map((record) => ({
12720
+ name: record.name,
12721
+ hostname: record.hostname,
12722
+ ...record.path === undefined ? {} : { path: record.path },
12723
+ url: `https://${record.hostname}${record.path ?? ""}`,
12724
+ holder: record.holder === undefined ? undefined : {
12725
+ project: record.holder.project,
12726
+ worktree: record.holder.worktree,
12727
+ mine: repoProjects.has(record.holder.project)
12728
+ },
12729
+ queue: (record.queue ?? []).map((waiter) => ({
12730
+ project: waiter.project,
12731
+ worktree: waiter.worktree,
12732
+ ...waiter.denied === true ? { denied: true } : {},
12733
+ mine: repoProjects.has(waiter.project)
12734
+ }))
12735
+ })).sort((left, right) => left.name.localeCompare(right.name));
12736
+ }
12086
12737
  async function collectFleetSnapshot(repoId, admission) {
12087
12738
  const mirrorResult = readManagedMirrors();
12088
12739
  const machineConfig = readHestiaMachineConfig();
@@ -12178,6 +12829,7 @@ async function collectFleetSnapshot(repoId, admission) {
12178
12829
  const allRecordedProjects = new Set(attributed.map((record) => record.project));
12179
12830
  const unbackedReservations = reservations.filter((reservation) => !allRecordedProjects.has(reservation.project));
12180
12831
  const { maxStacks, warnings: capWarnings } = resolveMaxStacks();
12832
+ const repoProjects = new Set(attributed.filter((record) => record.repoId === repoId).map((record) => record.project));
12181
12833
  return {
12182
12834
  repoId,
12183
12835
  observedAt: new Date().toISOString(),
@@ -12188,6 +12840,7 @@ async function collectFleetSnapshot(repoId, admission) {
12188
12840
  queued: queued.length
12189
12841
  },
12190
12842
  stacks: stackViews,
12843
+ shared: collectFleetShared(repoProjects),
12191
12844
  warnings: [...capWarnings, ...warnings].sort()
12192
12845
  };
12193
12846
  }
@@ -12196,6 +12849,7 @@ function semanticFleetSnapshot(snapshot) {
12196
12849
  repoId: snapshot.repoId,
12197
12850
  capacity: snapshot.capacity,
12198
12851
  stacks: snapshot.stacks,
12852
+ shared: snapshot.shared,
12199
12853
  warnings: snapshot.warnings
12200
12854
  });
12201
12855
  }
@@ -12307,6 +12961,7 @@ var init_fleet_monitor = __esm(() => {
12307
12961
  init_ports();
12308
12962
  init_pidfile();
12309
12963
  init_state();
12964
+ init_shared();
12310
12965
  init_router_config();
12311
12966
  init_local_http_router();
12312
12967
  init_portless_adapter();
@@ -12417,10 +13072,10 @@ var init_init_config = __esm(() => {
12417
13072
  });
12418
13073
 
12419
13074
  // packages/engine/src/index.ts
12420
- import { existsSync as existsSync21, rmSync as rmSync9 } from "fs";
13075
+ import { existsSync as existsSync22, rmSync as rmSync10 } from "fs";
12421
13076
  import { execFile as execFile11 } from "child_process";
12422
13077
  import { promisify as promisify9 } from "util";
12423
- import { join as join23, resolve as resolve2 } from "path";
13078
+ import { join as join25, resolve as resolve2 } from "path";
12424
13079
  import { createHash as createHash7 } from "crypto";
12425
13080
  import { resolve4, resolve6, resolveCname } from "dns/promises";
12426
13081
  function composeUnsupported(message) {
@@ -12530,7 +13185,7 @@ async function assertHestiaStateIgnored(worktreeRoot) {
12530
13185
  try {
12531
13186
  await pexec9("git", ["-C", worktreeRoot, "check-ignore", "-q", ".hestia/"], { timeout: 5000 });
12532
13187
  } catch {
12533
- throw new HestiaError("state-not-ignored", `.hestia is not ignored in ${worktreeRoot}; add the line ".hestia/" to ${join23(worktreeRoot, ".gitignore")}`, { remedy: `.hestia/`, path: join23(worktreeRoot, ".gitignore") });
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") });
12534
13189
  }
12535
13190
  }
12536
13191
  async function prepareCompose(worktreeRoot, project, repo, branch, opts, configuredBaseFile) {
@@ -12547,7 +13202,7 @@ async function prepareCompose(worktreeRoot, project, repo, branch, opts, configu
12547
13202
  services
12548
13203
  });
12549
13204
  ensureDir(hestiaDir(worktreeRoot));
12550
- const overridePath = join23(hestiaDir(worktreeRoot), OVERRIDE_FILE);
13205
+ const overridePath = join25(hestiaDir(worktreeRoot), OVERRIDE_FILE);
12551
13206
  writeAtomicTextFile(overridePath, yaml);
12552
13207
  return {
12553
13208
  ctx: {
@@ -12580,7 +13235,7 @@ function writeDockerfileComposeModel(worktreeRoot, workloads, conventionalCompos
12580
13235
  ...conventionalComposeFile === undefined ? {} : { include: [{ path: conventionalComposeFile }] },
12581
13236
  services
12582
13237
  };
12583
- const path = join23(hestiaDir(worktreeRoot), DOCKERFILE_COMPOSE_FILE);
13238
+ const path = join25(hestiaDir(worktreeRoot), DOCKERFILE_COMPOSE_FILE);
12584
13239
  writeAtomicTextFile(path, $stringify(model));
12585
13240
  return path;
12586
13241
  }
@@ -12600,7 +13255,7 @@ function freshRecord(project, repoId, repo, branch, worktree) {
12600
13255
  };
12601
13256
  }
12602
13257
  function assertCurrentStackIdentity(record, current) {
12603
- const path = join23(hestiaDir(current.worktreeRoot), "stack.json");
13258
+ const path = join25(hestiaDir(current.worktreeRoot), "stack.json");
12604
13259
  assertMutableStackRecord(record, path);
12605
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);
12606
13261
  if (!matches) {
@@ -12735,6 +13390,27 @@ function applyLocalRouteProjection(record) {
12735
13390
  record.env[directUrlKey(endpoint.name)] = endpoint.url;
12736
13391
  record.env[localUrlKey(endpoint.name)] = endpoint.localUrl;
12737
13392
  }
13393
+ syncSharedProjection(record);
13394
+ }
13395
+ function syncSharedProjection(record) {
13396
+ for (const shared of listSharedHostnames()) {
13397
+ const url = `https://${shared.hostname}${shared.path ?? ""}`;
13398
+ if (shared.holder?.project === record.project) {
13399
+ const endpoint = record.endpoints.find((candidate) => (candidate.alias ?? candidate.name) === shared.service);
13400
+ if (endpoint !== undefined) {
13401
+ endpoint.publicUrl = url;
13402
+ record.env[urlKey(shared.service)] = url;
13403
+ }
13404
+ continue;
13405
+ }
13406
+ for (const endpoint of record.endpoints) {
13407
+ if (endpoint.publicUrl === url)
13408
+ delete endpoint.publicUrl;
13409
+ }
13410
+ if (record.env[urlKey(shared.service)] === url) {
13411
+ delete record.env[urlKey(shared.service)];
13412
+ }
13413
+ }
12738
13414
  }
12739
13415
  function syncExposures(record) {
12740
13416
  const t = record.tunnel;
@@ -12758,7 +13434,7 @@ function matchesExpectedStack(record, expected) {
12758
13434
  return record !== null && (record.repoId === undefined || record.repoId === expected.repoId) && record.worktree === expected.worktree && record.createdAt === expected.createdAt;
12759
13435
  }
12760
13436
  function projectMutationRoot(project) {
12761
- return join23(hestiaHome(), "project-locks", project);
13437
+ return join25(hestiaHome(), "project-locks", project);
12762
13438
  }
12763
13439
  async function assertNamedTunnelDns(hostname, tunnelUuid) {
12764
13440
  const expected = `${tunnelUuid}.cfargotunnel.com`;
@@ -13164,6 +13840,7 @@ class ComposeEngine {
13164
13840
  const { repo, repoId, branch, worktreeRoot } = currentIdentity;
13165
13841
  let tunnel;
13166
13842
  let tunnelDirty = false;
13843
+ const stoppedAliases = [];
13167
13844
  const project = readState(worktreeRoot)?.project ?? projectName(repoId, repo, branch, worktreeRoot);
13168
13845
  await withLock(worktreeRoot, () => withLock(projectMutationRoot(project), async () => {
13169
13846
  const record = readState(worktreeRoot);
@@ -13172,6 +13849,11 @@ class ComposeEngine {
13172
13849
  if (record.services.some((service) => service.name === name && service.backend === "docker")) {
13173
13850
  throw new HestiaError("backend-not-stoppable", `Docker workload ${name} cannot be stopped individually; use hestia down`);
13174
13851
  }
13852
+ for (const endpoint of record.endpoints) {
13853
+ if ((endpoint.workload ?? endpoint.name) === name) {
13854
+ stoppedAliases.push(endpoint.alias ?? endpoint.name);
13855
+ }
13856
+ }
13175
13857
  }
13176
13858
  const pf = readPidfile(worktreeRoot, name);
13177
13859
  if (pf !== null) {
@@ -13217,6 +13899,9 @@ class ComposeEngine {
13217
13899
  }
13218
13900
  }
13219
13901
  }));
13902
+ for (const alias of new Set(stoppedAliases)) {
13903
+ await this.#releaseSharedHoldings(project, alias);
13904
+ }
13220
13905
  if (tunnelDirty)
13221
13906
  await this.#reconcileAdopted(tunnel);
13222
13907
  await this.#refreshLocalRoutes();
@@ -13352,12 +14037,12 @@ class ComposeEngine {
13352
14037
  await stopProcTree(pf);
13353
14038
  removePidfile(worktreeRoot, pf.name);
13354
14039
  }
13355
- rmSync9(privateRegistryDir(worktreeRoot), { recursive: true, force: true });
14040
+ rmSync10(privateRegistryDir(worktreeRoot), { recursive: true, force: true });
13356
14041
  const composeFile = record?.composeFile ?? tryLoadConfig(worktreeRoot)?.composeFile;
13357
14042
  if (composeFile !== undefined) {
13358
14043
  const project2 = record?.project ?? projectName(repoId, repo, branch, worktreeRoot);
13359
- const overrideFile = record?.overrideFile ?? join23(hestiaDir(worktreeRoot), OVERRIDE_FILE);
13360
- if (existsSync21(overrideFile)) {
14044
+ const overrideFile = record?.overrideFile ?? join25(hestiaDir(worktreeRoot), OVERRIDE_FILE);
14045
+ if (existsSync22(overrideFile)) {
13361
14046
  await composeDown({ project: project2, baseFile: composeFile, overrideFile, cwd: worktreeRoot }, opts?.destroy ?? false);
13362
14047
  } else if (record?.composeFile !== undefined) {
13363
14048
  const rest = ["compose", "-p", project2, "down", "--remove-orphans"];
@@ -13369,6 +14054,7 @@ class ComposeEngine {
13369
14054
  const project = record?.project ?? projectName(repoId, repo, branch, worktreeRoot);
13370
14055
  clearState(worktreeRoot, project);
13371
14056
  await this.#releaseAdmission(project);
14057
+ await this.#releaseSharedHoldings(project);
13372
14058
  }));
13373
14059
  await this.#reconcileAdopted(tunnel);
13374
14060
  await this.#refreshLocalRoutes();
@@ -13378,6 +14064,11 @@ class ComposeEngine {
13378
14064
  if (j !== null)
13379
14065
  await releaseSlot(j.port, project);
13380
14066
  }
14067
+ async#releaseSharedHoldings(project, service) {
14068
+ const j = readDaemonJson();
14069
+ if (j !== null)
14070
+ await releaseSharedForProject(j.port, project, service);
14071
+ }
13381
14072
  async downProject(project, opts) {
13382
14073
  if (!/^[a-z0-9][a-z0-9-]{0,99}$/.test(project)) {
13383
14074
  throw new HestiaError("usage", `invalid project name ${JSON.stringify(project)}`);
@@ -13420,9 +14111,9 @@ class ComposeEngine {
13420
14111
  throw new HestiaError("compose-failed", `docker compose -p ${project} down failed: ${err.message}`);
13421
14112
  }
13422
14113
  }
13423
- rmSync9(mirrorDir(project), { recursive: true, force: true });
14114
+ rmSync10(mirrorDir(project), { recursive: true, force: true });
13424
14115
  };
13425
- if (record !== null && existsSync21(record.worktree)) {
14116
+ if (record !== null && existsSync22(record.worktree)) {
13426
14117
  const info = await getRepoInfo(record.worktree);
13427
14118
  const identityMatches = resolve2(info.worktreeRoot) === resolve2(record.worktree) && (record.repoId === undefined ? record.repo === info.repo : record.repoId === info.repoId);
13428
14119
  const localRecord = readState(record.worktree);
@@ -13443,6 +14134,7 @@ class ComposeEngine {
13443
14134
  return;
13444
14135
  }
13445
14136
  await this.#releaseAdmission(project);
14137
+ await this.#releaseSharedHoldings(project);
13446
14138
  await this.#reconcileAdopted(record.tunnel);
13447
14139
  await this.#refreshLocalRoutes();
13448
14140
  return;
@@ -13451,6 +14143,7 @@ class ComposeEngine {
13451
14143
  const projectLockRoot = projectMutationRoot(project);
13452
14144
  await withLock(projectLockRoot, teardownFromMirror);
13453
14145
  await this.#releaseAdmission(project);
14146
+ await this.#releaseSharedHoldings(project);
13454
14147
  await this.#reconcileAdopted(record?.tunnel);
13455
14148
  await this.#refreshLocalRoutes();
13456
14149
  }
@@ -13469,11 +14162,100 @@ class ComposeEngine {
13469
14162
  }
13470
14163
  await ensureDaemon();
13471
14164
  await this.#refreshLocalRoutes(true);
14165
+ if (opts?.shared !== undefined) {
14166
+ if (tunnelName === undefined) {
14167
+ throw new HestiaError("shared-requires-named-tunnel", "shared hostnames need an adopted named tunnel (stable DNS is the point) \u2014 pass --tunnel <name>");
14168
+ }
14169
+ return this.#exposeShared(worktreeRoot, services, tunnelName, opts);
14170
+ }
13472
14171
  if (tunnelName === undefined) {
13473
14172
  return this.#exposeQuick(worktreeRoot, services, opts);
13474
14173
  }
13475
14174
  return this.#exposeNamed(worktreeRoot, services, tunnelName, opts);
13476
14175
  }
14176
+ async#exposeShared(worktreeRoot, services, tunnelName, opts) {
14177
+ if (services.length !== 1) {
14178
+ throw new HestiaError("usage", "--shared declares exactly one service per shared hostname");
14179
+ }
14180
+ const name = opts.shared;
14181
+ assertSharedName(name);
14182
+ const project = readState(worktreeRoot)?.project;
14183
+ if (project === undefined) {
14184
+ throw new HestiaError("service-not-found", "no stack in this worktree \u2014 `hestia up`/`run` something first");
14185
+ }
14186
+ const adopted = await adoptTunnel(tunnelName);
14187
+ if (adopted.connections > 0 && !isAdopted(adopted.uuid) && !opts?.force) {
14188
+ throw new HestiaError("tunnel-busy", `tunnel "${tunnelName}" already has ${adopted.connections} live edge ` + `connection(s) from a foreign connector \u2014 stop the other cloudflared first (hestia's ` + `connector serves your static hostnames too), or pass --force to ` + `accept nondeterministic routing`);
14189
+ }
14190
+ const preflightRecord = readState(worktreeRoot);
14191
+ if (preflightRecord === null) {
14192
+ throw new HestiaError("service-not-found", "stack disappeared while preparing the shared hostname");
14193
+ }
14194
+ const baseRules = importBaseRules(adopted.uuid, tunnelName);
14195
+ const zoneHint = opts?.zone ?? preflightRecord.tunnel?.zone ?? inferZone(baseRules);
14196
+ let hostname;
14197
+ let zone;
14198
+ if (opts?.hostname !== undefined) {
14199
+ hostname = opts.hostname.toLowerCase();
14200
+ assertSharedHostnameFqdn(hostname);
14201
+ zone = zoneOf(hostname) ?? zoneHint ?? hostname;
14202
+ } else {
14203
+ if (zoneHint === undefined) {
14204
+ throw new HestiaError("usage", "cannot infer a zone from the tunnel's existing rules \u2014 pass --zone or an explicit --hostname");
14205
+ }
14206
+ zone = zoneHint;
14207
+ hostname = `${name}.${zone}`;
14208
+ }
14209
+ const path = normalizeSharedPath(opts?.path);
14210
+ const selection = resolveEndpointSelection(preflightRecord, services[0]);
14211
+ if (selection.endpoint.kind !== undefined && selection.endpoint.kind !== "http") {
14212
+ throw new HestiaError("usage", `public tunnels require an HTTP endpoint; ${services[0]} is ${selection.endpoint.kind}`);
14213
+ }
14214
+ const alias = selection.endpoint.alias ?? selection.endpoint.name;
14215
+ if (baseRules.some((rule) => rule.hostname === hostname)) {
14216
+ throw new HestiaError("hostname-conflict", `shared hostname ${hostname} is already claimed by the user's static rules`);
14217
+ }
14218
+ const dynamicConflict = collectDynamicRules(adopted.uuid).rules.find((rule) => rule.hostname === hostname);
14219
+ if (dynamicConflict !== undefined) {
14220
+ throw new HestiaError("hostname-conflict", `shared hostname ${hostname} is already claimed by project ${dynamicConflict.project}`);
14221
+ }
14222
+ await assertNamedTunnelDns(hostname, adopted.uuid);
14223
+ await declareSharedHostname({
14224
+ name,
14225
+ hostname,
14226
+ path,
14227
+ tunnelUuid: adopted.uuid,
14228
+ zone,
14229
+ service: alias
14230
+ });
14231
+ const handle = await ensureDaemon();
14232
+ const claim = await sharedVerb(handle.port, "claim", {
14233
+ name,
14234
+ project,
14235
+ worktree: worktreeRoot,
14236
+ waitMs: 0
14237
+ });
14238
+ if (!claim.granted) {
14239
+ throw new HestiaError("shared-held", `shared hostname "${name}" is held by ${claim.holder?.project ?? "another stack"} ` + `(${claim.queued.length} queued) \u2014 \`hestia claim ${name} --wait\` to queue for it`);
14240
+ }
14241
+ const outcome = await reconcileTunnel({ name: tunnelName, uuid: adopted.uuid, credFile: adopted.credFile }, { force: opts?.force, readyTimeoutMs: opts?.readyTimeoutMs });
14242
+ for (const w of outcome.warnings)
14243
+ process.stderr.write(`warning: ${w}
14244
+ `);
14245
+ const final = await withLock(worktreeRoot, () => withLock(projectMutationRoot(project), async () => {
14246
+ const record = readState(worktreeRoot);
14247
+ if (record === null) {
14248
+ throw new HestiaError("service-not-found", "stack disappeared while exposing (concurrent down?)");
14249
+ }
14250
+ applyLocalRouteProjection(record);
14251
+ writeState(worktreeRoot, record);
14252
+ return record;
14253
+ }));
14254
+ if (outcome.error !== undefined)
14255
+ throw outcome.error;
14256
+ await this.#refreshLocalRoutes(true);
14257
+ return final;
14258
+ }
13477
14259
  async#exposeQuick(worktreeRoot, services, opts) {
13478
14260
  const project = readState(worktreeRoot)?.project;
13479
14261
  if (project === undefined) {
@@ -13492,7 +14274,7 @@ class ComposeEngine {
13492
14274
  const alias = selection.endpoint.alias ?? selection.endpoint.name;
13493
14275
  const authority = internalEndpointAuthority(record.project, alias);
13494
14276
  const name = `aux-quick-${createHash7("sha256").update(alias).digest("hex").slice(0, 10)}`;
13495
- const quickCfg = join23(hestiaDir(worktreeRoot), `quick-${name}.yml`);
14277
+ const quickCfg = join25(hestiaDir(worktreeRoot), `quick-${name}.yml`);
13496
14278
  writeAtomicTextFile(quickCfg, `ingress:
13497
14279
  - service: ${JSON.stringify(`unix:${publicGatewaySocketPath()}`)}
13498
14280
  ` + ` originRequest:
@@ -13684,6 +14466,80 @@ class ComposeEngine {
13684
14466
  await this.#refreshLocalRoutes(true);
13685
14467
  return final;
13686
14468
  }
14469
+ async#sharedContext(cwd, name) {
14470
+ assertSharedName(name);
14471
+ const { worktreeRoot } = await getRepoInfo(cwd);
14472
+ const record = readState(worktreeRoot);
14473
+ if (record === null) {
14474
+ throw new HestiaError("service-not-found", "no stack in this worktree \u2014 `hestia up`/`run` something first");
14475
+ }
14476
+ const shared = readSharedHostname(name);
14477
+ if (shared === null) {
14478
+ throw new HestiaError("shared-not-found", `no shared hostname "${name}" is declared \u2014 \`hestia expose <svc> --shared ${name}\` creates one`);
14479
+ }
14480
+ return { worktreeRoot, record, shared };
14481
+ }
14482
+ async#applySharedProjection(worktreeRoot, project) {
14483
+ return withLock(worktreeRoot, () => withLock(projectMutationRoot(project), async () => {
14484
+ const record = readState(worktreeRoot);
14485
+ if (record === null) {
14486
+ throw new HestiaError("service-not-found", "stack disappeared during the shared-hostname transition");
14487
+ }
14488
+ applyLocalRouteProjection(record);
14489
+ writeState(worktreeRoot, record);
14490
+ return record;
14491
+ }));
14492
+ }
14493
+ async claimShared(cwd, name, opts) {
14494
+ const { worktreeRoot, record, shared } = await this.#sharedContext(cwd, name);
14495
+ const endpoint = record.endpoints.find((candidate) => (candidate.alias ?? candidate.name) === shared.service);
14496
+ if (endpoint === undefined) {
14497
+ throw new HestiaError("service-not-found", `shared hostname "${name}" routes to endpoint alias "${shared.service}", which this ` + `stack does not expose \u2014 \`hestia up\`/\`run\` it first`);
14498
+ }
14499
+ if (endpoint.kind !== undefined && endpoint.kind !== "http") {
14500
+ throw new HestiaError("usage", `shared hostnames require an HTTP endpoint; ${shared.service} is ${endpoint.kind}`);
14501
+ }
14502
+ const handle = await ensureDaemon();
14503
+ const result = await sharedVerb(handle.port, "claim", {
14504
+ name,
14505
+ project: record.project,
14506
+ worktree: worktreeRoot,
14507
+ waitMs: opts?.waitMs ?? 0
14508
+ });
14509
+ if (!result.granted) {
14510
+ const position = result.queued.findIndex((waiter) => waiter.project === record.project) + 1;
14511
+ throw new HestiaError("shared-held", `shared hostname "${name}" is held by ${result.holder?.project ?? "another stack"}` + (position > 0 ? ` \u2014 queued durably at position ${position}/${result.queued.length}; the holder can ` + `\`hestia share allow ${name}\`, or re-run \`hestia claim ${name} --wait\` to keep waiting` : ""), { holder: result.holder, queued: result.queued });
14512
+ }
14513
+ const updated = await this.#applySharedProjection(worktreeRoot, record.project);
14514
+ await this.#refreshLocalRoutes();
14515
+ return { record: updated, result };
14516
+ }
14517
+ async releaseShared(cwd, name) {
14518
+ const { worktreeRoot, record } = await this.#sharedContext(cwd, name);
14519
+ const handle = await ensureDaemon();
14520
+ await sharedVerb(handle.port, "release", { name, project: record.project });
14521
+ const updated = await this.#applySharedProjection(worktreeRoot, record.project);
14522
+ await this.#refreshLocalRoutes();
14523
+ return updated;
14524
+ }
14525
+ async cancelSharedClaim(cwd, name) {
14526
+ const { record } = await this.#sharedContext(cwd, name);
14527
+ const handle = await ensureDaemon();
14528
+ await sharedVerb(handle.port, "cancel", { name, project: record.project });
14529
+ }
14530
+ async allowShared(cwd, name) {
14531
+ const { worktreeRoot, record } = await this.#sharedContext(cwd, name);
14532
+ const handle = await ensureDaemon();
14533
+ await sharedVerb(handle.port, "allow", { name, project: record.project });
14534
+ const updated = await this.#applySharedProjection(worktreeRoot, record.project);
14535
+ await this.#refreshLocalRoutes();
14536
+ return updated;
14537
+ }
14538
+ async denyShared(cwd, name) {
14539
+ const { record } = await this.#sharedContext(cwd, name);
14540
+ const handle = await ensureDaemon();
14541
+ await sharedVerb(handle.port, "deny", { name, project: record.project });
14542
+ }
13687
14543
  async status(cwd) {
13688
14544
  const { worktreeRoot } = await getRepoInfo(cwd);
13689
14545
  const record = readState(worktreeRoot);
@@ -13810,11 +14666,14 @@ var init_src2 = __esm(() => {
13810
14666
  init_cloudflared();
13811
14667
  init_ingress();
13812
14668
  init_registry();
14669
+ init_shared();
13813
14670
  init_ensure();
13814
14671
  init_client();
13815
14672
  init_stream();
13816
14673
  init_router_config();
13817
14674
  init_cli();
14675
+ init_shared();
14676
+ init_shared_arbiter();
13818
14677
  init_lock();
13819
14678
  init_supervisor();
13820
14679
  init_tail();
@@ -14003,6 +14862,18 @@ class DaemonFleetSource {
14003
14862
  diagnose(worktree) {
14004
14863
  return doctor(worktree);
14005
14864
  }
14865
+ async claimShared(worktree, name) {
14866
+ await engine.claimShared(worktree, name);
14867
+ }
14868
+ async allowShared(worktree, name) {
14869
+ await engine.allowShared(worktree, name);
14870
+ }
14871
+ async denyShared(worktree, name) {
14872
+ await engine.denyShared(worktree, name);
14873
+ }
14874
+ async releaseShared(worktree, name) {
14875
+ await engine.releaseShared(worktree, name);
14876
+ }
14006
14877
  down(stack) {
14007
14878
  if (stack.createdAt === undefined) {
14008
14879
  return Promise.reject(new Error(`stack ${stack.project} has no stable incarnation timestamp`));
@@ -14024,6 +14895,14 @@ var init_fleet_source = __esm(() => {
14024
14895
  init_src2();
14025
14896
  });
14026
14897
 
14898
+ // packages/tui/src/fleet-endpoints.ts
14899
+ function serviceEndpoints(service) {
14900
+ return service.endpoints ?? (service.endpoint === undefined ? [] : [service.endpoint]);
14901
+ }
14902
+ function endpointReach(endpoint) {
14903
+ return endpoint.localUrl ?? endpoint.publicUrl ?? endpoint.url ?? `${endpoint.host}:${endpoint.port}`;
14904
+ }
14905
+
14027
14906
  // packages/tui/src/fleet-controller.ts
14028
14907
  function createFleetUiState() {
14029
14908
  return {
@@ -14036,6 +14915,8 @@ function createFleetUiState() {
14036
14915
  unseenLines: 0,
14037
14916
  helpOpen: false,
14038
14917
  doctorOpen: false,
14918
+ sharedOpen: false,
14919
+ sharedSelection: 0,
14039
14920
  downPending: false
14040
14921
  };
14041
14922
  }
@@ -14059,7 +14940,7 @@ function reconcileFleetSelection(previous, snapshot, preferredProject) {
14059
14940
  const previousIndex = snapshot.stacks.findIndex((stack2) => stack2.project === previous.project);
14060
14941
  const stack = snapshot.stacks.find((candidate) => candidate.project === previous.project) ?? snapshot.stacks.find((candidate) => candidate.project === preferredProject) ?? nearestItem(snapshot.stacks, previousIndex < 0 ? 0 : previousIndex) ?? snapshot.stacks[0];
14061
14942
  const service = stack.services.find((candidate) => candidate.name === previous.service) ?? stack.services[0];
14062
- const endpoints = service?.endpoints ?? (service?.endpoint === undefined ? [] : [service.endpoint]);
14943
+ const endpoints = service === undefined ? [] : serviceEndpoints(service);
14063
14944
  const endpoint = endpoints.find((candidate) => candidate.name === previous.endpoint);
14064
14945
  return { project: stack.project, service: service?.name, endpoint: endpoint?.name };
14065
14946
  }
@@ -14147,7 +15028,7 @@ function reduceFleetUiState(state, action) {
14147
15028
  case "select-endpoint": {
14148
15029
  const stack = action.snapshot.stacks.find((candidate) => candidate.project === state.selection.project);
14149
15030
  const service = stack?.services.find((candidate) => candidate.name === action.service);
14150
- if (!service?.endpoints?.some((endpoint) => endpoint.name === action.endpoint))
15031
+ if (service === undefined || !serviceEndpoints(service).some((endpoint) => endpoint.name === action.endpoint))
14151
15032
  return state;
14152
15033
  return {
14153
15034
  ...state,
@@ -14179,12 +15060,10 @@ function reduceFleetUiState(state, action) {
14179
15060
  logOffset: action.follow ? 0 : state.logOffset,
14180
15061
  unseenLines: action.follow ? 0 : state.unseenLines
14181
15062
  };
14182
- case "scroll-logs":
14183
- return {
14184
- ...state,
14185
- follow: action.delta > 0 ? state.follow : false,
14186
- logOffset: Math.max(0, state.logOffset - action.delta)
14187
- };
15063
+ case "scroll-logs": {
15064
+ const offset = Math.min(action.maxOffset ?? Number.MAX_SAFE_INTEGER, Math.max(0, state.logOffset - action.delta));
15065
+ return action.delta > 0 && offset === 0 ? { ...state, follow: true, logOffset: 0, unseenLines: 0 } : { ...state, follow: action.delta > 0 ? state.follow : false, logOffset: offset };
15066
+ }
14188
15067
  case "new-lines":
14189
15068
  return state.follow ? state : {
14190
15069
  ...state,
@@ -14195,6 +15074,14 @@ function reduceFleetUiState(state, action) {
14195
15074
  return { ...state, helpOpen: action.open };
14196
15075
  case "doctor":
14197
15076
  return { ...state, doctorOpen: action.open };
15077
+ case "shared":
15078
+ return { ...state, sharedOpen: action.open, sharedSelection: action.open ? 0 : state.sharedSelection };
15079
+ case "move-shared": {
15080
+ if (action.count <= 0)
15081
+ return { ...state, sharedSelection: 0 };
15082
+ const next = Math.max(0, Math.min(action.count - 1, state.sharedSelection + action.delta));
15083
+ return { ...state, sharedSelection: next };
15084
+ }
14198
15085
  case "confirm-down":
14199
15086
  return {
14200
15087
  ...state,
@@ -14204,11 +15091,24 @@ function reduceFleetUiState(state, action) {
14204
15091
  return { ...state, downPending: action.pending };
14205
15092
  }
14206
15093
  }
15094
+ var init_fleet_controller = () => {};
14207
15095
 
14208
15096
  // packages/tui/src/terminal-text.ts
15097
+ function collapseCarriageReturns(text) {
15098
+ if (!text.includes("\r"))
15099
+ return text;
15100
+ return text.split(`
15101
+ `).map((line) => {
15102
+ const parts = line.replace(/\r+$/, "").split("\r");
15103
+ return parts[parts.length - 1] ?? "";
15104
+ }).join(`
15105
+ `);
15106
+ }
14209
15107
  function sanitizeFleetTerminalText(text, preserveNewlines = false) {
15108
+ const withoutEscapes = text.replace(ESCAPE_CONTROL_SEQUENCE, "").replace(C1_CONTROL_SEQUENCE, "");
15109
+ const collapsed = collapseCarriageReturns(withoutEscapes);
14210
15110
  const controls = preserveNewlines ? /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g : /[\x00-\x1f\x7f-\x9f]/g;
14211
- return text.replace(ESCAPE_CONTROL_SEQUENCE, "").replace(C1_CONTROL_SEQUENCE, "").replace(controls, "");
15111
+ return collapsed.replace(controls, "");
14212
15112
  }
14213
15113
  var ESCAPE_CONTROL_SEQUENCE, C1_CONTROL_SEQUENCE;
14214
15114
  var init_terminal_text = __esm(() => {
@@ -14216,24 +15116,6 @@ var init_terminal_text = __esm(() => {
14216
15116
  C1_CONTROL_SEQUENCE = /(?:[\x90\x98\x9d\x9e\x9f][\s\S]*?(?:\x07|\x9c)|\x9b[0-?]*[ -/]*[@-~])/g;
14217
15117
  });
14218
15118
 
14219
- // packages/tui/src/fleet-theme.ts
14220
- var fleetTheme;
14221
- var init_fleet_theme = __esm(() => {
14222
- fleetTheme = {
14223
- background: "#0d1117",
14224
- panel: "#161b22",
14225
- panelAlt: "#21262d",
14226
- border: "#30363d",
14227
- text: "#c9d1d9",
14228
- muted: "#8b949e",
14229
- accent: "#58a6ff",
14230
- healthy: "#3fb950",
14231
- warning: "#d29922",
14232
- danger: "#f85149",
14233
- selected: "#1f6feb"
14234
- };
14235
- });
14236
-
14237
15119
  // node_modules/.bun/ansi-regex@6.2.2/node_modules/ansi-regex/index.js
14238
15120
  function ansiRegex({ onlyFirst = false } = {}) {
14239
15121
  const ST = "(?:\\u0007|\\u001B\\u005C|\\u009C)";
@@ -14416,229 +15298,623 @@ function padFleetText(text, width) {
14416
15298
  const fitted = fitFleetText(text, width);
14417
15299
  return fitted + " ".repeat(Math.max(0, width - stringWidth(fitted)));
14418
15300
  }
15301
+ function wrapFleetText(text, width, maxLines = 3) {
15302
+ if (width <= 0)
15303
+ return [];
15304
+ if (text === "")
15305
+ return [""];
15306
+ if (stringWidth(text) <= width)
15307
+ return [text];
15308
+ const characters = Array.from(text);
15309
+ const lines = [];
15310
+ let index = 0;
15311
+ while (index < characters.length && lines.length < maxLines) {
15312
+ const last = lines.length === maxLines - 1;
15313
+ if (last) {
15314
+ lines.push(fitFleetText(characters.slice(index).join(""), width));
15315
+ break;
15316
+ }
15317
+ let line = "";
15318
+ let taken = 0;
15319
+ while (index + taken < characters.length) {
15320
+ const next = characters[index + taken];
15321
+ if (stringWidth(line + next) > width)
15322
+ break;
15323
+ line += next;
15324
+ taken += 1;
15325
+ }
15326
+ if (taken === 0) {
15327
+ lines.push(characters[index]);
15328
+ index += 1;
15329
+ continue;
15330
+ }
15331
+ lines.push(line);
15332
+ index += taken;
15333
+ }
15334
+ return lines;
15335
+ }
14419
15336
  var init_fleet_text = __esm(() => {
14420
15337
  init_string_width();
14421
15338
  });
14422
15339
 
14423
- // packages/tui/src/components/StackSidebar.tsx
14424
- import { jsxDEV } from "@opentui/react/jsx-dev-runtime";
14425
- function phaseColor(phase) {
15340
+ // packages/tui/src/fleet-theme.ts
15341
+ function blendHex(fg, bg, amount) {
15342
+ const parse = (hex2) => [
15343
+ Number.parseInt(hex2.slice(1, 3), 16),
15344
+ Number.parseInt(hex2.slice(3, 5), 16),
15345
+ Number.parseInt(hex2.slice(5, 7), 16)
15346
+ ];
15347
+ const [fr, fg2, fb] = parse(fg);
15348
+ const [br, bg2, bb] = parse(bg);
15349
+ const channel = (f, b) => Math.round(b + (f - b) * amount);
15350
+ const hex = (value) => value.toString(16).padStart(2, "0");
15351
+ return `#${hex(channel(fr, br))}${hex(channel(fg2, bg2))}${hex(channel(fb, bb))}`;
15352
+ }
15353
+ function backendColor(backend) {
15354
+ if (backend === "docker")
15355
+ return fleetTheme.backendDocker;
15356
+ if (backend === "tunnel")
15357
+ return fleetTheme.backendTunnel;
15358
+ if (backend === "wrangler" || backend === "proc")
15359
+ return fleetTheme.backendProc;
15360
+ return fleetTheme.muted;
15361
+ }
15362
+ function serviceStateColor(state) {
15363
+ if (state === "healthy")
15364
+ return fleetTheme.healthy;
15365
+ if (state === "unknown" || state === "unhealthy" || state === "starting")
15366
+ return fleetTheme.warning;
15367
+ return fleetTheme.danger;
15368
+ }
15369
+ function serviceStateGlyph(state) {
15370
+ if (state === "healthy")
15371
+ return "\u25CF";
15372
+ if (state === "unhealthy")
15373
+ return "\u25C6";
15374
+ if (state === "starting")
15375
+ return "\u25CC";
15376
+ return "\u25CB";
15377
+ }
15378
+ function stackPhaseColor(phase) {
14426
15379
  if (phase === "up")
14427
15380
  return fleetTheme.healthy;
14428
- if (phase === "degraded" || phase === "unknown")
15381
+ if (phase === "degraded" || phase === "unknown" || phase === "starting")
14429
15382
  return fleetTheme.warning;
15383
+ if (phase === "queued" || phase === "reserved")
15384
+ return fleetTheme.queued;
14430
15385
  if (phase === "stopped")
14431
- return fleetTheme.danger;
15386
+ return fleetTheme.faint;
14432
15387
  return fleetTheme.accent;
14433
15388
  }
14434
- function StackSidebar({
14435
- stacks,
14436
- selectedProject,
14437
- width,
14438
- onSelectProject
14439
- }) {
14440
- return /* @__PURE__ */ jsxDEV("box", {
14441
- style: { width, height: "100%", flexDirection: "column", border: true, borderColor: fleetTheme.border },
14442
- children: [
14443
- /* @__PURE__ */ jsxDEV("box", {
14444
- style: { height: 1, paddingLeft: 1, backgroundColor: fleetTheme.panelAlt },
14445
- children: /* @__PURE__ */ jsxDEV("text", {
14446
- fg: fleetTheme.text,
14447
- children: "Fleet"
14448
- }, undefined, false, undefined, this)
14449
- }, undefined, false, undefined, this),
14450
- stacks.length === 0 ? /* @__PURE__ */ jsxDEV("box", {
14451
- style: { paddingLeft: 1, paddingTop: 1 },
14452
- children: /* @__PURE__ */ jsxDEV("text", {
14453
- fg: fleetTheme.muted,
14454
- children: "No managed stacks"
14455
- }, undefined, false, undefined, this)
14456
- }, undefined, false, undefined, this) : stacks.map((stack) => {
14457
- const selected = stack.project === selectedProject;
14458
- const branch = sanitizeFleetTerminalText(stack.branch);
14459
- return /* @__PURE__ */ jsxDEV("box", {
14460
- onMouseDown: (event) => {
14461
- if (event.button !== 0)
14462
- return;
14463
- event.preventDefault();
14464
- event.stopPropagation();
14465
- onSelectProject?.(stack.project);
14466
- },
14467
- style: {
14468
- height: 1,
14469
- paddingLeft: 1,
14470
- paddingRight: 1,
14471
- flexDirection: "row",
14472
- backgroundColor: selected ? fleetTheme.selected : fleetTheme.background
14473
- },
14474
- children: [
14475
- /* @__PURE__ */ jsxDEV("text", {
14476
- fg: selected ? "#ffffff" : phaseColor(stack.phase),
14477
- children: selected ? "\u25B8 " : " "
14478
- }, undefined, false, undefined, this),
14479
- /* @__PURE__ */ jsxDEV("text", {
14480
- fg: selected ? "#ffffff" : fleetTheme.text,
14481
- children: padFleetText(branch, Math.max(4, width - 13))
14482
- }, undefined, false, undefined, this),
14483
- /* @__PURE__ */ jsxDEV("text", {
14484
- fg: selected ? "#ffffff" : phaseColor(stack.phase),
14485
- children: padFleetText(stack.phase, 9)
14486
- }, undefined, false, undefined, this)
14487
- ]
14488
- }, stack.project, true, undefined, this);
14489
- })
14490
- ]
14491
- }, undefined, true, undefined, this);
15389
+ function stackPhaseGlyph(phase, spinnerFrame = 0) {
15390
+ if (phase === "up")
15391
+ return "\u25CF";
15392
+ if (phase === "degraded")
15393
+ return "\u25C6";
15394
+ if (phase === "starting")
15395
+ return SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length];
15396
+ if (phase === "queued" || phase === "reserved")
15397
+ return "\u25CC";
15398
+ if (phase === "stopped")
15399
+ return "\u25CB";
15400
+ return "\u25CB";
14492
15401
  }
14493
- var init_StackSidebar = __esm(() => {
14494
- init_fleet_text();
14495
- init_fleet_theme();
14496
- init_terminal_text();
15402
+ var background = "#0d1117", text = "#c9d1d9", accent = "#58a6ff", fleetTheme, SPINNER_FRAMES;
15403
+ var init_fleet_theme = __esm(() => {
15404
+ fleetTheme = {
15405
+ background,
15406
+ panel: blendHex(text, background, 0.04),
15407
+ panelAlt: blendHex(text, background, 0.1),
15408
+ border: blendHex(text, background, 0.18),
15409
+ text,
15410
+ bright: "#e6edf3",
15411
+ muted: "#8b949e",
15412
+ faint: "#6e7681",
15413
+ accent,
15414
+ selectedBg: blendHex(accent, background, 0.16),
15415
+ stripe: accent,
15416
+ keycapBg: blendHex(text, background, 0.22),
15417
+ healthy: "#3fb950",
15418
+ warning: "#d29922",
15419
+ danger: "#f85149",
15420
+ queued: "#bc8cff",
15421
+ backendDocker: "#58a6ff",
15422
+ backendProc: "#bc8cff",
15423
+ backendWrangler: "#bc8cff",
15424
+ backendTunnel: "#ffa657",
15425
+ publicBadge: "#ffa657"
15426
+ };
15427
+ SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
14497
15428
  });
14498
15429
 
14499
- // packages/tui/src/components/ServicePane.tsx
14500
- import { jsxDEV as jsxDEV2 } from "@opentui/react/jsx-dev-runtime";
14501
- function serviceColor(service) {
14502
- if (service.state === "healthy")
14503
- return fleetTheme.healthy;
14504
- if (service.state === "unknown" || service.state === "unhealthy")
14505
- return fleetTheme.warning;
14506
- return fleetTheme.danger;
15430
+ // packages/tui/src/fleet-keys.ts
15431
+ function isEscapeKey(key) {
15432
+ const name = key.name.toLowerCase();
15433
+ return name === "escape" || name === "esc" || key.sequence === "\x1B";
14507
15434
  }
14508
- function endpointReach(endpoint) {
14509
- return endpoint.localUrl ?? endpoint.publicUrl ?? endpoint.url ?? `${endpoint.host}:${endpoint.port}`;
15435
+ function isEnterKey(key) {
15436
+ return key.name === "return" || key.name === "enter";
14510
15437
  }
14511
- function ServicePane({
14512
- stack,
14513
- selectedService,
14514
- selectedEndpoint,
14515
- onSelectService,
14516
- onSelectEndpoint
14517
- }) {
14518
- return /* @__PURE__ */ jsxDEV2("box", {
14519
- style: { height: "100%", width: "100%", flexDirection: "column", border: true, borderColor: fleetTheme.border },
14520
- children: [
14521
- /* @__PURE__ */ jsxDEV2("box", {
14522
- style: { height: 1, paddingLeft: 1, backgroundColor: fleetTheme.panelAlt },
14523
- children: /* @__PURE__ */ jsxDEV2("text", {
14524
- fg: fleetTheme.text,
14525
- children: stack === undefined ? "Workloads" : `Workloads \u2014 ${sanitizeFleetTerminalText(stack.branch)}`
14526
- }, undefined, false, undefined, this)
14527
- }, undefined, false, undefined, this),
14528
- stack?.services.length ? stack.services.flatMap((service) => {
14529
- const workloadSelected = service.name === selectedService && selectedEndpoint === undefined;
14530
- const endpoints = service.endpoints ?? (service.endpoint === undefined ? [] : [service.endpoint]);
14531
- const workloadRow = /* @__PURE__ */ jsxDEV2("box", {
14532
- onMouseDown: (event) => {
14533
- if (event.button !== 0)
14534
- return;
14535
- event.preventDefault();
14536
- event.stopPropagation();
14537
- onSelectService?.(service.name);
14538
- },
14539
- style: {
14540
- height: 1,
14541
- paddingLeft: 1,
14542
- paddingRight: 1,
14543
- flexDirection: "row",
14544
- backgroundColor: workloadSelected ? fleetTheme.selected : fleetTheme.background
14545
- },
15438
+ function isPlainKey(key, value) {
15439
+ return !key.ctrl && !key.meta && !key.option && !key.super && !key.hyper && (key.name === value || key.sequence === value);
15440
+ }
15441
+ function isUpKey(key) {
15442
+ return key.name === "up" || isPlainKey(key, "k");
15443
+ }
15444
+ function isDownKey(key) {
15445
+ return key.name === "down" || isPlainKey(key, "j");
15446
+ }
15447
+ function isShiftedKey(key, lower) {
15448
+ return key.sequence === lower.toUpperCase() || isPlainKey(key, lower) && key.shift === true;
15449
+ }
15450
+ function isFollowBottomKey(key) {
15451
+ return isShiftedKey(key, "g");
15452
+ }
15453
+ function isScrollTopKey(key) {
15454
+ return !isShiftedKey(key, "g") && (key.sequence === "g" || isPlainKey(key, "g"));
15455
+ }
15456
+ function isDoctorKey(key) {
15457
+ return isShiftedKey(key, "d");
15458
+ }
15459
+
15460
+ // packages/tui/src/fleet-layout.ts
15461
+ function resolveFleetLayout(mode, terminalWidth) {
15462
+ if (mode === "auto")
15463
+ return terminalWidth >= SPLIT_MIN_WIDTH ? "split" : "stack";
15464
+ return mode;
15465
+ }
15466
+ function fleetPaneWidths(layout, terminalWidth) {
15467
+ if (layout === "stack")
15468
+ return { sidebar: terminalWidth, main: terminalWidth };
15469
+ const sidebar = Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, Math.floor(terminalWidth * 0.26)));
15470
+ return { sidebar, main: Math.max(20, terminalWidth - sidebar) };
15471
+ }
15472
+ function servicePaneHeight(rows, maxHeight) {
15473
+ const content = Math.max(2, rows + 1);
15474
+ return Math.min(maxHeight, content + 3);
15475
+ }
15476
+ function stackSidebarHeight(stackCount) {
15477
+ return Math.min(9, Math.max(4, stackCount + 3));
15478
+ }
15479
+ var SPLIT_MIN_WIDTH = 110, SIDEBAR_MIN_WIDTH = 28, SIDEBAR_MAX_WIDTH = 44;
15480
+
15481
+ // packages/tui/src/fleet-log-rows.ts
15482
+ function lineId(line) {
15483
+ let id = lineIds.get(line);
15484
+ if (id === undefined) {
15485
+ id = nextLineId++;
15486
+ lineIds.set(line, id);
15487
+ }
15488
+ return id;
15489
+ }
15490
+ function wrappedBody(line, bodyWidth) {
15491
+ const cached = wrapCache.get(line);
15492
+ if (cached !== undefined && cached.width === bodyWidth)
15493
+ return cached.wrapped;
15494
+ const wrapped = wrapFleetText(line.text, bodyWidth, 3);
15495
+ wrapCache.set(line, { width: bodyWidth, wrapped });
15496
+ return wrapped;
15497
+ }
15498
+ function buildFleetLogRows(lines, contentWidth) {
15499
+ const rows = [];
15500
+ for (const line of lines) {
15501
+ const meta = line.meta === true;
15502
+ const bodyWidth = meta ? Math.max(1, contentWidth - META_TAG.length) : contentWidth;
15503
+ const wrapped = wrappedBody(line, bodyWidth);
15504
+ const id = lineId(line);
15505
+ for (let part = 0;part < wrapped.length; part += 1) {
15506
+ rows.push({
15507
+ key: `${id}:${part}`,
15508
+ text: wrapped[part],
15509
+ meta,
15510
+ tag: !meta ? undefined : part === 0 ? META_TAG : " ".repeat(META_TAG.length)
15511
+ });
15512
+ }
15513
+ }
15514
+ return rows;
15515
+ }
15516
+ var META_TAG = "hestia \u2502 ", nextLineId = 0, lineIds, wrapCache;
15517
+ var init_fleet_log_rows = __esm(() => {
15518
+ init_fleet_text();
15519
+ lineIds = new WeakMap;
15520
+ wrapCache = new WeakMap;
15521
+ });
15522
+
15523
+ // packages/tui/src/fleet-format.ts
15524
+ function formatUptime(createdAt, now) {
15525
+ const born = Date.parse(createdAt);
15526
+ if (!Number.isFinite(born))
15527
+ return;
15528
+ const seconds = Math.max(0, Math.floor((now - born) / 1000));
15529
+ if (seconds < 60)
15530
+ return `${seconds}s`;
15531
+ const minutes = Math.floor(seconds / 60);
15532
+ if (minutes < 60)
15533
+ return `${minutes}m`;
15534
+ const hours = Math.floor(minutes / 60);
15535
+ if (hours < 24)
15536
+ return `${hours}h${minutes % 60 === 0 ? "" : `${minutes % 60}m`}`;
15537
+ const days = Math.floor(hours / 24);
15538
+ return `${days}d${hours % 24 === 0 ? "" : `${hours % 24}h`}`;
15539
+ }
15540
+ function buildEnvBlock(stack) {
15541
+ const lines = [];
15542
+ for (const service of stack.services) {
15543
+ if (service.backend === "tunnel")
15544
+ continue;
15545
+ if (service.publishedPort !== undefined) {
15546
+ lines.push(`HESTIA_${envKey(service.name)}_PORT=${service.publishedPort}`);
15547
+ if (service.backend === "docker") {
15548
+ lines.push(`HESTIA_${envKey(service.name)}_MAIN_TCP_PORT=${service.publishedPort}`);
15549
+ }
15550
+ }
15551
+ for (const endpoint of serviceEndpoints(service)) {
15552
+ const alias = envKey(endpoint.name);
15553
+ lines.push(`HESTIA_${alias}_PORT=${endpoint.port}`);
15554
+ if (endpoint.publicUrl !== undefined)
15555
+ lines.push(`HESTIA_${alias}_URL=${endpoint.publicUrl}`);
15556
+ if (endpoint.localUrl !== undefined)
15557
+ lines.push(`HESTIA_${alias}_LOCAL_URL=${endpoint.localUrl}`);
15558
+ if (endpoint.url !== undefined)
15559
+ lines.push(`HESTIA_${alias}_DIRECT_URL=${endpoint.url}`);
15560
+ }
15561
+ }
15562
+ return [...new Set(lines)].join(`
15563
+ `);
15564
+ }
15565
+ var init_fleet_format = __esm(() => {
15566
+ init_src2();
15567
+ });
15568
+
15569
+ // packages/tui/src/fleet-status.ts
15570
+ function fleetCapacitySummary(capacity, connected) {
15571
+ if (!connected)
15572
+ return "daemon unreachable";
15573
+ const parts = [`${capacity.live}/${capacity.maxStacks}`];
15574
+ if (capacity.reserved > 0)
15575
+ parts.push(`${capacity.reserved} reserved`);
15576
+ if (capacity.queued > 0)
15577
+ parts.push(`${capacity.queued} queued`);
15578
+ parts.push("daemon ok");
15579
+ return parts.join(" \xB7 ");
15580
+ }
15581
+ function resolveStatusNotice(connectionNotice, toast) {
15582
+ return toast ?? connectionNotice;
15583
+ }
15584
+ var FLEET_KEY_HINTS;
15585
+ var init_fleet_status = __esm(() => {
15586
+ FLEET_KEY_HINTS = [
15587
+ { keys: ", .", label: "stack" },
15588
+ { keys: "[ ]", label: "svc" },
15589
+ { keys: "o", label: "open" },
15590
+ { keys: "y", label: "yank" },
15591
+ { keys: "?", label: "help" }
15592
+ ];
15593
+ });
15594
+
15595
+ // packages/tui/src/components/StackSidebar.tsx
15596
+ import { memo } from "react";
15597
+ import { jsxDEV } from "@opentui/react/jsx-dev-runtime";
15598
+ var StackSidebar;
15599
+ var init_StackSidebar = __esm(() => {
15600
+ init_string_width();
15601
+ init_fleet_text();
15602
+ init_fleet_theme();
15603
+ init_terminal_text();
15604
+ StackSidebar = memo(function StackSidebar2({
15605
+ stacks,
15606
+ capacity,
15607
+ selectedProject,
15608
+ width,
15609
+ focused = false,
15610
+ spinnerFrame = 0,
15611
+ onSelectProject
15612
+ }) {
15613
+ const branchWidth = Math.max(6, width - 17);
15614
+ const slots = capacity.maxStacks > 0 ? ` \xB7 ${capacity.live}/${capacity.maxStacks} slots` : "";
15615
+ return /* @__PURE__ */ jsxDEV("box", {
15616
+ style: {
15617
+ width,
15618
+ height: "100%",
15619
+ flexDirection: "column",
15620
+ border: true,
15621
+ borderColor: focused ? fleetTheme.accent : fleetTheme.border,
15622
+ backgroundColor: fleetTheme.background
15623
+ },
15624
+ children: [
15625
+ /* @__PURE__ */ jsxDEV("box", {
15626
+ style: { height: 1, paddingLeft: 1, paddingRight: 1, flexDirection: "row", backgroundColor: fleetTheme.panelAlt },
14546
15627
  children: [
14547
- /* @__PURE__ */ jsxDEV2("text", {
14548
- fg: workloadSelected ? "#ffffff" : serviceColor(service),
14549
- children: workloadSelected ? "\u25B8 " : " "
14550
- }, undefined, false, undefined, this),
14551
- /* @__PURE__ */ jsxDEV2("text", {
14552
- fg: workloadSelected ? "#ffffff" : fleetTheme.text,
14553
- children: padFleetText(sanitizeFleetTerminalText(service.name), 20)
15628
+ /* @__PURE__ */ jsxDEV("text", {
15629
+ fg: focused ? fleetTheme.accent : fleetTheme.text,
15630
+ children: "Stacks"
14554
15631
  }, undefined, false, undefined, this),
14555
- /* @__PURE__ */ jsxDEV2("text", {
14556
- fg: workloadSelected ? "#ffffff" : fleetTheme.muted,
14557
- children: padFleetText(service.backend, 10)
15632
+ /* @__PURE__ */ jsxDEV("text", {
15633
+ fg: fleetTheme.faint,
15634
+ children: slots
15635
+ }, undefined, false, undefined, this)
15636
+ ]
15637
+ }, undefined, true, undefined, this),
15638
+ stacks.length === 0 ? /* @__PURE__ */ jsxDEV("box", {
15639
+ style: { paddingLeft: 1, paddingTop: 1, flexDirection: "column" },
15640
+ children: [
15641
+ /* @__PURE__ */ jsxDEV("text", {
15642
+ fg: fleetTheme.muted,
15643
+ children: "No managed stacks."
14558
15644
  }, undefined, false, undefined, this),
14559
- /* @__PURE__ */ jsxDEV2("text", {
14560
- fg: workloadSelected ? "#ffffff" : serviceColor(service),
14561
- children: service.state
15645
+ /* @__PURE__ */ jsxDEV("text", {
15646
+ fg: fleetTheme.faint,
15647
+ children: "`hestia up` starts one here."
14562
15648
  }, undefined, false, undefined, this)
14563
15649
  ]
14564
- }, `workload:${service.name}`, true, undefined, this);
14565
- const endpointRows = endpoints.map((endpoint, index) => {
14566
- const endpointSelected = service.name === selectedService && endpoint.name === selectedEndpoint;
14567
- const connector = index === endpoints.length - 1 ? "\u2514\u2500" : "\u251C\u2500";
14568
- const kind = (endpoint.kind ?? "http").toUpperCase();
14569
- const selector = `${endpoint.workload ?? service.name}:${endpoint.binding ?? endpoint.port}`;
14570
- return /* @__PURE__ */ jsxDEV2("box", {
15650
+ }, undefined, true, undefined, this) : stacks.map((stack) => {
15651
+ const selected = stack.project === selectedProject;
15652
+ const branch = sanitizeFleetTerminalText(stack.branch);
15653
+ const phase = sanitizeFleetTerminalText(stack.phase);
15654
+ const glyph = stackPhaseGlyph(stack.phase, spinnerFrame);
15655
+ const meta = stack.services.length > 0 ? ` \xB7${stack.services.length}` : "";
15656
+ const warn = stack.warning === undefined ? "" : " \u26A0";
15657
+ const available = Math.max(4, branchWidth - stringWidth(meta) - stringWidth(warn));
15658
+ const rowBg = selected ? fleetTheme.selectedBg : fleetTheme.background;
15659
+ return /* @__PURE__ */ jsxDEV("box", {
14571
15660
  onMouseDown: (event) => {
14572
15661
  if (event.button !== 0)
14573
15662
  return;
14574
15663
  event.preventDefault();
14575
15664
  event.stopPropagation();
14576
- onSelectEndpoint?.(service.name, endpoint.name);
14577
- },
14578
- style: {
14579
- height: 1,
14580
- paddingLeft: 2,
14581
- paddingRight: 1,
14582
- flexDirection: "row",
14583
- backgroundColor: endpointSelected ? fleetTheme.selected : fleetTheme.background
15665
+ onSelectProject?.(stack.project);
14584
15666
  },
15667
+ style: { height: 1, paddingRight: 1, flexDirection: "row", backgroundColor: rowBg },
14585
15668
  children: [
14586
- /* @__PURE__ */ jsxDEV2("text", {
14587
- fg: endpointSelected ? "#ffffff" : fleetTheme.muted,
14588
- children: endpointSelected ? "\u25B8 " : `${connector} `
15669
+ /* @__PURE__ */ jsxDEV("text", {
15670
+ fg: fleetTheme.stripe,
15671
+ children: selected ? "\u258E" : " "
14589
15672
  }, undefined, false, undefined, this),
14590
- /* @__PURE__ */ jsxDEV2("text", {
14591
- fg: endpointSelected ? "#ffffff" : fleetTheme.accent,
14592
- children: padFleetText(sanitizeFleetTerminalText(endpoint.name), 18)
15673
+ /* @__PURE__ */ jsxDEV("text", {
15674
+ fg: stackPhaseColor(stack.phase),
15675
+ children: [
15676
+ glyph,
15677
+ " "
15678
+ ]
15679
+ }, undefined, true, undefined, this),
15680
+ /* @__PURE__ */ jsxDEV("text", {
15681
+ fg: selected ? fleetTheme.bright : fleetTheme.text,
15682
+ children: padFleetText(fitFleetText(branch, available), available)
14593
15683
  }, undefined, false, undefined, this),
14594
- /* @__PURE__ */ jsxDEV2("text", {
14595
- fg: endpointSelected ? "#ffffff" : fleetTheme.muted,
14596
- children: padFleetText(kind, 8)
15684
+ /* @__PURE__ */ jsxDEV("text", {
15685
+ fg: fleetTheme.warning,
15686
+ children: warn
14597
15687
  }, undefined, false, undefined, this),
14598
- /* @__PURE__ */ jsxDEV2("text", {
14599
- fg: endpointSelected ? "#ffffff" : fleetTheme.muted,
14600
- children: padFleetText(sanitizeFleetTerminalText(selector), 20)
15688
+ /* @__PURE__ */ jsxDEV("text", {
15689
+ fg: fleetTheme.faint,
15690
+ children: meta
14601
15691
  }, undefined, false, undefined, this),
14602
- /* @__PURE__ */ jsxDEV2("text", {
14603
- fg: endpointSelected ? "#ffffff" : fleetTheme.text,
14604
- children: sanitizeFleetTerminalText(endpointReach(endpoint))
15692
+ /* @__PURE__ */ jsxDEV("text", {
15693
+ fg: stackPhaseColor(stack.phase),
15694
+ children: ` ${padFleetText(phase, 8)}`
14605
15695
  }, undefined, false, undefined, this)
14606
15696
  ]
14607
- }, `endpoint:${service.name}:${endpoint.name}`, true, undefined, this);
14608
- });
14609
- return [workloadRow, ...endpointRows];
14610
- }) : /* @__PURE__ */ jsxDEV2("box", {
14611
- style: { paddingLeft: 1, paddingTop: 1 },
14612
- children: /* @__PURE__ */ jsxDEV2("text", {
14613
- fg: fleetTheme.muted,
14614
- children: "No workloads recorded"
14615
- }, undefined, false, undefined, this)
14616
- }, undefined, false, undefined, this)
14617
- ]
14618
- }, undefined, true, undefined, this);
14619
- }
15697
+ }, stack.project, true, undefined, this);
15698
+ })
15699
+ ]
15700
+ }, undefined, true, undefined, this);
15701
+ });
15702
+ });
15703
+
15704
+ // packages/tui/src/components/ServicePane.tsx
15705
+ import { memo as memo2 } from "react";
15706
+ import { jsxDEV as jsxDEV2, Fragment } from "@opentui/react/jsx-dev-runtime";
15707
+ function portLabel(service) {
15708
+ if (service.publishedPort === undefined)
15709
+ return "\u2014";
15710
+ return `:${service.publishedPort}`;
15711
+ }
15712
+ var ServicePane;
14620
15713
  var init_ServicePane = __esm(() => {
14621
15714
  init_fleet_text();
14622
15715
  init_fleet_theme();
14623
15716
  init_terminal_text();
14624
- });
14625
-
14626
- // packages/tui/src/components/LogPane.tsx
14627
- import { jsxDEV as jsxDEV3 } from "@opentui/react/jsx-dev-runtime";
15717
+ ServicePane = memo2(function ServicePane2({
15718
+ stack,
15719
+ uptime,
15720
+ selectedService,
15721
+ selectedEndpoint,
15722
+ width = 80,
15723
+ focused = false,
15724
+ onSelectService,
15725
+ onSelectEndpoint
15726
+ }) {
15727
+ const nameWidth = Math.min(24, Math.max(12, Math.floor(width * 0.28)));
15728
+ const backendWidth = 10;
15729
+ const stateWidth = 10;
15730
+ const portWidth = 7;
15731
+ const title = stack === undefined ? "Workloads" : `Workloads \u2014 ${sanitizeFleetTerminalText(stack.branch)}`;
15732
+ const warning = stack?.warning === undefined ? undefined : sanitizeFleetTerminalText(stack.warning);
15733
+ return /* @__PURE__ */ jsxDEV2("box", {
15734
+ style: {
15735
+ height: "100%",
15736
+ width: "100%",
15737
+ flexDirection: "column",
15738
+ border: true,
15739
+ borderColor: focused ? fleetTheme.accent : fleetTheme.border,
15740
+ backgroundColor: fleetTheme.background
15741
+ },
15742
+ children: [
15743
+ /* @__PURE__ */ jsxDEV2("box", {
15744
+ style: { height: 1, paddingLeft: 1, paddingRight: 1, flexDirection: "row", backgroundColor: fleetTheme.panelAlt },
15745
+ children: [
15746
+ /* @__PURE__ */ jsxDEV2("text", {
15747
+ fg: focused ? fleetTheme.accent : fleetTheme.text,
15748
+ children: fitFleetText(title, Math.max(8, width - 14))
15749
+ }, undefined, false, undefined, this),
15750
+ uptime === undefined ? null : /* @__PURE__ */ jsxDEV2("text", {
15751
+ fg: fleetTheme.faint,
15752
+ children: [
15753
+ " \xB7 up ",
15754
+ uptime
15755
+ ]
15756
+ }, undefined, true, undefined, this)
15757
+ ]
15758
+ }, undefined, true, undefined, this),
15759
+ warning === undefined ? null : /* @__PURE__ */ jsxDEV2("box", {
15760
+ style: { height: 1, paddingLeft: 1, paddingRight: 1 },
15761
+ children: /* @__PURE__ */ jsxDEV2("text", {
15762
+ fg: fleetTheme.warning,
15763
+ children: fitFleetText(`\u26A0 ${warning}`, Math.max(8, width - 4))
15764
+ }, undefined, false, undefined, this)
15765
+ }, undefined, false, undefined, this),
15766
+ stack?.services.length ? /* @__PURE__ */ jsxDEV2(Fragment, {
15767
+ children: [
15768
+ /* @__PURE__ */ jsxDEV2("box", {
15769
+ style: { height: 1, paddingLeft: 1, paddingRight: 1, flexDirection: "row" },
15770
+ children: [
15771
+ /* @__PURE__ */ jsxDEV2("text", {
15772
+ fg: fleetTheme.faint,
15773
+ children: padFleetText("", 4)
15774
+ }, undefined, false, undefined, this),
15775
+ /* @__PURE__ */ jsxDEV2("text", {
15776
+ fg: fleetTheme.faint,
15777
+ children: padFleetText("NAME", nameWidth)
15778
+ }, undefined, false, undefined, this),
15779
+ /* @__PURE__ */ jsxDEV2("text", {
15780
+ fg: fleetTheme.faint,
15781
+ children: padFleetText("BACKEND", backendWidth)
15782
+ }, undefined, false, undefined, this),
15783
+ /* @__PURE__ */ jsxDEV2("text", {
15784
+ fg: fleetTheme.faint,
15785
+ children: padFleetText("STATE", stateWidth)
15786
+ }, undefined, false, undefined, this),
15787
+ /* @__PURE__ */ jsxDEV2("text", {
15788
+ fg: fleetTheme.faint,
15789
+ children: padFleetText("PORT", portWidth)
15790
+ }, undefined, false, undefined, this)
15791
+ ]
15792
+ }, undefined, true, undefined, this),
15793
+ stack.services.flatMap((service) => {
15794
+ const workloadSelected = service.name === selectedService && selectedEndpoint === undefined;
15795
+ const endpoints = serviceEndpoints(service);
15796
+ const state = sanitizeFleetTerminalText(service.state);
15797
+ 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;
15846
+ return /* @__PURE__ */ jsxDEV2("box", {
15847
+ onMouseDown: (event) => {
15848
+ if (event.button !== 0)
15849
+ return;
15850
+ event.preventDefault();
15851
+ event.stopPropagation();
15852
+ onSelectEndpoint?.(service.name, endpoint.name);
15853
+ },
15854
+ style: { height: 1, paddingRight: 1, flexDirection: "row", backgroundColor: endpointBg },
15855
+ children: [
15856
+ /* @__PURE__ */ jsxDEV2("text", {
15857
+ fg: fleetTheme.stripe,
15858
+ children: endpointSelected ? "\u258E" : " "
15859
+ }, undefined, false, undefined, this),
15860
+ /* @__PURE__ */ jsxDEV2("text", {
15861
+ fg: fleetTheme.faint,
15862
+ children: ` ${connector} `
15863
+ }, undefined, false, undefined, this),
15864
+ /* @__PURE__ */ jsxDEV2("text", {
15865
+ fg: endpointSelected ? fleetTheme.bright : fleetTheme.accent,
15866
+ children: padFleetText(sanitizeFleetTerminalText(endpoint.name), aliasWidth)
15867
+ }, undefined, false, undefined, this),
15868
+ /* @__PURE__ */ jsxDEV2("text", {
15869
+ fg: fleetTheme.muted,
15870
+ children: padFleetText(kind, 6)
15871
+ }, undefined, false, undefined, this),
15872
+ /* @__PURE__ */ jsxDEV2("text", {
15873
+ fg: fleetTheme.text,
15874
+ children: fitFleetText(reach, Math.max(12, width - aliasWidth - 19 - pub.length))
15875
+ }, undefined, false, undefined, this),
15876
+ /* @__PURE__ */ jsxDEV2("text", {
15877
+ fg: fleetTheme.publicBadge,
15878
+ children: pub
15879
+ }, undefined, false, undefined, this)
15880
+ ]
15881
+ }, `endpoint:${service.name}:${endpoint.name}`, true, undefined, this);
15882
+ });
15883
+ return [workloadRow, ...endpointRows];
15884
+ })
15885
+ ]
15886
+ }, undefined, true, undefined, this) : /* @__PURE__ */ jsxDEV2("box", {
15887
+ style: { paddingLeft: 1, paddingTop: 1 },
15888
+ children: /* @__PURE__ */ jsxDEV2("text", {
15889
+ fg: fleetTheme.muted,
15890
+ children: stack?.phase === "queued" || stack?.phase === "reserved" ? "Waiting for a machine slot \u2014 services appear once startup begins." : "No workloads recorded"
15891
+ }, undefined, false, undefined, this)
15892
+ }, undefined, false, undefined, this)
15893
+ ]
15894
+ }, undefined, true, undefined, this);
15895
+ });
15896
+ });
15897
+
15898
+ // packages/tui/src/components/LogPane.tsx
15899
+ import { jsxDEV as jsxDEV3 } from "@opentui/react/jsx-dev-runtime";
14628
15900
  function LogPane({
14629
- lines,
15901
+ rows,
14630
15902
  height,
14631
15903
  width,
14632
15904
  offset,
14633
15905
  follow,
14634
15906
  unseen,
14635
15907
  label,
15908
+ focused = false,
14636
15909
  onScroll
14637
15910
  }) {
14638
15911
  const viewportRows = Math.max(1, height - 3);
14639
- const end = Math.max(0, lines.length - offset);
14640
- const start = Math.max(0, end - viewportRows - 2);
14641
- const visible = lines.slice(start, end).slice(-viewportRows);
15912
+ const maxOffset = Math.max(0, rows.length - viewportRows);
15913
+ const clamped = Math.min(offset, maxOffset);
15914
+ const end = Math.max(0, rows.length - clamped);
15915
+ const start = Math.max(0, end - viewportRows);
15916
+ const visible = rows.slice(start, end);
15917
+ const title = fitFleetText(`Logs \u2014 ${label}`, Math.max(8, width - 28));
14642
15918
  return /* @__PURE__ */ jsxDEV3("box", {
14643
15919
  onMouseScroll: (event) => {
14644
15920
  if (event.button !== 4 && event.button !== 5)
@@ -14647,14 +15923,21 @@ function LogPane({
14647
15923
  event.stopPropagation();
14648
15924
  onScroll?.(event.button === 4 ? -3 : 3);
14649
15925
  },
14650
- style: { height: "100%", width: "100%", flexDirection: "column", border: true, borderColor: fleetTheme.border },
15926
+ style: {
15927
+ height: "100%",
15928
+ width: "100%",
15929
+ flexDirection: "column",
15930
+ border: true,
15931
+ borderColor: focused ? fleetTheme.accent : fleetTheme.border,
15932
+ backgroundColor: fleetTheme.background
15933
+ },
14651
15934
  children: [
14652
15935
  /* @__PURE__ */ jsxDEV3("box", {
14653
15936
  style: { height: 1, paddingLeft: 1, paddingRight: 1, flexDirection: "row", backgroundColor: fleetTheme.panelAlt },
14654
15937
  children: [
14655
15938
  /* @__PURE__ */ jsxDEV3("text", {
14656
- fg: fleetTheme.text,
14657
- children: padFleetText(`Logs \u2014 ${label}`, Math.max(1, width - 24))
15939
+ fg: focused ? fleetTheme.accent : fleetTheme.text,
15940
+ children: padFleetText(title, Math.max(1, width - 24))
14658
15941
  }, undefined, false, undefined, this),
14659
15942
  /* @__PURE__ */ jsxDEV3("text", {
14660
15943
  fg: follow ? fleetTheme.healthy : fleetTheme.warning,
@@ -14666,15 +15949,21 @@ function LogPane({
14666
15949
  style: { paddingLeft: 1, paddingTop: 1 },
14667
15950
  children: /* @__PURE__ */ jsxDEV3("text", {
14668
15951
  fg: fleetTheme.muted,
14669
- children: "No log output yet \u2014 service is still being followed."
14670
- }, undefined, false, undefined, this)
14671
- }, undefined, false, undefined, this) : visible.map((line, index) => /* @__PURE__ */ jsxDEV3("box", {
14672
- style: { height: 1, paddingLeft: 1 },
14673
- children: /* @__PURE__ */ jsxDEV3("text", {
14674
- fg: line.meta ? fleetTheme.warning : fleetTheme.text,
14675
- children: fitFleetText(line.meta ? `[hestia] ${line.text}` : line.text, Math.max(1, width - 4))
15952
+ children: "No log output yet \u2014 waiting for this workload."
14676
15953
  }, undefined, false, undefined, this)
14677
- }, `${start + index}-${line.text}`, false, undefined, this))
15954
+ }, undefined, false, undefined, this) : visible.map((row2) => /* @__PURE__ */ jsxDEV3("box", {
15955
+ style: { height: 1, paddingLeft: 1, flexDirection: "row" },
15956
+ children: [
15957
+ row2.tag === undefined ? null : /* @__PURE__ */ jsxDEV3("text", {
15958
+ fg: fleetTheme.accent,
15959
+ children: row2.tag
15960
+ }, undefined, false, undefined, this),
15961
+ /* @__PURE__ */ jsxDEV3("text", {
15962
+ fg: row2.meta ? fleetTheme.warning : fleetTheme.text,
15963
+ children: row2.text
15964
+ }, undefined, false, undefined, this)
15965
+ ]
15966
+ }, row2.key, true, undefined, this))
14678
15967
  ]
14679
15968
  }, undefined, true, undefined, this);
14680
15969
  }
@@ -14684,16 +15973,16 @@ var init_LogPane = __esm(() => {
14684
15973
  });
14685
15974
 
14686
15975
  // packages/tui/src/components/FleetModal.tsx
14687
- import { jsxDEV as jsxDEV4, Fragment } from "@opentui/react/jsx-dev-runtime";
15976
+ import { jsxDEV as jsxDEV4, Fragment as Fragment2 } from "@opentui/react/jsx-dev-runtime";
14688
15977
  function FleetModal({
14689
15978
  title,
14690
15979
  terminalWidth,
14691
15980
  terminalHeight,
14692
15981
  children
14693
15982
  }) {
14694
- const width = Math.min(72, Math.max(30, terminalWidth - 4));
14695
- const height = Math.min(18, Math.max(7, terminalHeight - 4));
14696
- return /* @__PURE__ */ jsxDEV4(Fragment, {
15983
+ const width = Math.min(78, Math.max(36, terminalWidth - 4));
15984
+ const height = Math.min(22, Math.max(8, terminalHeight - 4));
15985
+ return /* @__PURE__ */ jsxDEV4(Fragment2, {
14697
15986
  children: [
14698
15987
  /* @__PURE__ */ jsxDEV4("box", {
14699
15988
  style: { position: "absolute", top: 0, left: 0, width: terminalWidth, height: terminalHeight, zIndex: 50 }
@@ -14715,10 +16004,22 @@ function FleetModal({
14715
16004
  paddingTop: 1
14716
16005
  },
14717
16006
  children: [
14718
- /* @__PURE__ */ jsxDEV4("text", {
14719
- fg: fleetTheme.accent,
14720
- children: title
14721
- }, undefined, false, undefined, this),
16007
+ /* @__PURE__ */ jsxDEV4("box", {
16008
+ style: { height: 1, flexDirection: "row" },
16009
+ children: [
16010
+ /* @__PURE__ */ jsxDEV4("box", {
16011
+ style: { flexGrow: 1 },
16012
+ children: /* @__PURE__ */ jsxDEV4("text", {
16013
+ fg: fleetTheme.accent,
16014
+ children: title
16015
+ }, undefined, false, undefined, this)
16016
+ }, undefined, false, undefined, this),
16017
+ /* @__PURE__ */ jsxDEV4("text", {
16018
+ fg: fleetTheme.faint,
16019
+ children: "Esc closes"
16020
+ }, undefined, false, undefined, this)
16021
+ ]
16022
+ }, undefined, true, undefined, this),
14722
16023
  /* @__PURE__ */ jsxDEV4("box", {
14723
16024
  style: { height: 1 }
14724
16025
  }, undefined, false, undefined, this),
@@ -14733,10 +16034,10 @@ var init_FleetModal = __esm(() => {
14733
16034
  });
14734
16035
 
14735
16036
  // packages/tui/src/FleetApp.tsx
14736
- import { useEffect, useMemo, useReducer, useRef, useState } from "react";
14737
- import { existsSync as existsSync22 } from "fs";
16037
+ import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
16038
+ import { existsSync as existsSync23 } from "fs";
14738
16039
  import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
14739
- import { jsxDEV as jsxDEV5, Fragment as Fragment2 } from "@opentui/react/jsx-dev-runtime";
16040
+ import { jsxDEV as jsxDEV5, Fragment as Fragment3 } from "@opentui/react/jsx-dev-runtime";
14740
16041
  function middleTruncateWorktreePath(path, width) {
14741
16042
  if (path.length <= width)
14742
16043
  return path;
@@ -14752,34 +16053,26 @@ function middleTruncateWorktreePath(path, width) {
14752
16053
  return `${path.slice(0, prefixWidth)}\u2026/${basename3}`;
14753
16054
  }
14754
16055
  function emptySnapshot(repoId) {
14755
- return { repoId, observedAt: new Date(0).toISOString(), capacity: EMPTY_CAPACITY, stacks: [], warnings: [] };
14756
- }
14757
- function isEscape(key) {
14758
- const name = key.name.toLowerCase();
14759
- return name === "escape" || name === "esc" || key.sequence === "\x1B";
14760
- }
14761
- function isFleetKey(key, value) {
14762
- return !key.ctrl && !key.meta && !key.option && !key.super && !key.hyper && (key.name === value || key.sequence === value);
16056
+ return { repoId, observedAt: new Date(0).toISOString(), capacity: EMPTY_CAPACITY, stacks: [], shared: [], warnings: [] };
14763
16057
  }
14764
16058
  function selectedStack(snapshot, project) {
14765
16059
  return snapshot.stacks.find((stack) => stack.project === project);
14766
16060
  }
14767
16061
  function fleetServiceRowCount(stack) {
14768
- return stack?.services.reduce((count, service) => count + 1 + (service.endpoints?.length ?? (service.endpoint === undefined ? 0 : 1)), 0) ?? 0;
16062
+ return stack?.services.reduce((count, service) => count + 1 + serviceEndpoints(service).length, 0) ?? 0;
14769
16063
  }
14770
16064
  function selectedFleetEndpoint(stack, serviceName, endpointName) {
14771
16065
  const service = stack?.services.find((candidate) => candidate.name === serviceName);
14772
- const endpoints = service?.endpoints ?? (service?.endpoint === undefined ? [] : [service.endpoint]);
16066
+ const endpoints = service === undefined ? [] : serviceEndpoints(service);
14773
16067
  return endpoints.find((endpoint) => endpoint.name === endpointName) ?? endpoints[0];
14774
16068
  }
14775
16069
  function usableEndpoint(stack, serviceName, endpointName) {
14776
16070
  const endpoint = selectedFleetEndpoint(stack, serviceName, endpointName);
14777
- if (endpoint === undefined)
14778
- return;
14779
- return endpoint.localUrl ?? endpoint.publicUrl ?? endpoint.url ?? `${endpoint.host}:${endpoint.port}`;
16071
+ return endpoint === undefined ? undefined : endpointReach(endpoint);
14780
16072
  }
14781
- function copyableEndpoint(stack, serviceName, endpointName) {
14782
- return selectedFleetEndpoint(stack, serviceName, endpointName);
16073
+ function downTargetChanged(confirmed, snapshot) {
16074
+ const current = snapshot.stacks.find((candidate) => candidate.project === confirmed.project);
16075
+ return current?.repoId !== confirmed.repoId || current.worktree !== confirmed.worktree || current.createdAt !== confirmed.createdAt;
14783
16076
  }
14784
16077
  function fleetLogSelectionKey(stack, serviceName) {
14785
16078
  if (stack === undefined || serviceName === undefined)
@@ -14805,6 +16098,20 @@ function doctorOmissionSummary(rows, visibleRows = 10) {
14805
16098
  const warnings = omitted.filter((row2) => row2.level === "warn").length;
14806
16099
  return `\u2026 ${omitted.length} more (${errors2} errors, ${warnings} warnings)`;
14807
16100
  }
16101
+ function budgetDoctorReport(rows, width, lineBudget) {
16102
+ const entries = [];
16103
+ let used = 0;
16104
+ for (const row2 of rows) {
16105
+ const detail = sanitizeFleetTerminalText(row2.detail);
16106
+ const detailLines = detail === "" ? [] : wrapFleetText(detail, Math.max(8, width - 6), 2);
16107
+ const cost = 1 + detailLines.length;
16108
+ if (used + cost > lineBudget)
16109
+ break;
16110
+ entries.push({ row: row2, detailLines });
16111
+ used += cost;
16112
+ }
16113
+ return { entries, shown: entries.length };
16114
+ }
14808
16115
  function FleetApp({
14809
16116
  source,
14810
16117
  preferredProject,
@@ -14817,12 +16124,29 @@ function FleetApp({
14817
16124
  const [state, dispatch] = useReducer(reduceFleetUiState, undefined, createFleetUiState);
14818
16125
  const [logs, setLogs] = useState([]);
14819
16126
  const [connected, setConnected] = useState(false);
14820
- const [notice, setNotice] = useState("connecting to hestiad\u2026");
16127
+ const [connectionNotice, setConnectionNotice] = useState("connecting to hestiad\u2026");
16128
+ const [toast, setToast] = useState(undefined);
14821
16129
  const [doctorRows, setDoctorRows] = useState([]);
14822
16130
  const [doctorRunning, setDoctorRunning] = useState(false);
16131
+ const [sharedBusy, setSharedBusy] = useState(false);
16132
+ const [spinnerFrame, setSpinnerFrame] = useState(0);
16133
+ const [now, setNow] = useState(() => Date.now());
14823
16134
  const lastFrameAt = useRef(Date.now());
16135
+ const toastTimer = useRef(undefined);
14824
16136
  const stateRef = useRef(state);
14825
16137
  stateRef.current = state;
16138
+ const showToast = (text2, ttl = TOAST_TTL_MS) => {
16139
+ if (toastTimer.current !== undefined)
16140
+ clearTimeout(toastTimer.current);
16141
+ setToast(text2);
16142
+ toastTimer.current = setTimeout(() => {
16143
+ setToast((current) => current === text2 ? undefined : current);
16144
+ }, ttl);
16145
+ };
16146
+ useEffect(() => () => {
16147
+ if (toastTimer.current !== undefined)
16148
+ clearTimeout(toastTimer.current);
16149
+ }, []);
14826
16150
  useEffect(() => {
14827
16151
  const controller = new AbortController;
14828
16152
  (async () => {
@@ -14830,23 +16154,24 @@ function FleetApp({
14830
16154
  lastFrameAt.current = Date.now();
14831
16155
  if (frame.type === "snapshot") {
14832
16156
  setConnected(true);
14833
- setNotice((current) => current === "connecting to hestiad\u2026" || current.startsWith("disconnected:") || current.startsWith("hestiad heartbeat") ? "" : current);
16157
+ setConnectionNotice(undefined);
14834
16158
  setSnapshot(frame.snapshot);
14835
16159
  dispatch({ type: "reconcile", snapshot: frame.snapshot, preferredProject });
14836
16160
  } else if (frame.sequence === -1) {
14837
16161
  setConnected(false);
14838
- setNotice(frame.at);
16162
+ setConnectionNotice(frame.at);
14839
16163
  } else {
14840
16164
  setConnected(true);
16165
+ setConnectionNotice(undefined);
14841
16166
  }
14842
16167
  }
14843
16168
  })();
14844
16169
  const staleTimer = setInterval(() => {
14845
16170
  if (Date.now() - lastFrameAt.current > 20000) {
14846
16171
  setConnected(false);
14847
- setNotice("hestiad heartbeat is stale; reconnecting\u2026");
16172
+ setConnectionNotice("hestiad heartbeat is stale; reconnecting\u2026");
14848
16173
  }
14849
- }, 1000);
16174
+ }, 5000);
14850
16175
  return () => {
14851
16176
  clearInterval(staleTimer);
14852
16177
  controller.abort();
@@ -14854,8 +16179,12 @@ function FleetApp({
14854
16179
  }, [preferredProject, source]);
14855
16180
  const selectedLogStack = selectedStack(snapshot, state.selection.project);
14856
16181
  const selectionKey = fleetLogSelectionKey(selectedLogStack, state.selection.service);
16182
+ const appendedLines = useRef(0);
16183
+ const seenAppendedLines = useRef(0);
14857
16184
  useEffect(() => {
14858
16185
  setLogs([]);
16186
+ appendedLines.current = 0;
16187
+ seenAppendedLines.current = 0;
14859
16188
  if (selectionKey === undefined)
14860
16189
  return;
14861
16190
  const [project, service] = selectionKey.split("\x00");
@@ -14868,57 +16197,90 @@ function FleetApp({
14868
16197
  service: sanitizeFleetTerminalText(line.service),
14869
16198
  text: sanitizeFleetTerminalText(line.text)
14870
16199
  };
16200
+ appendedLines.current += 1;
14871
16201
  setLogs((current) => [...current, sanitized].slice(-LOG_RING_CAPACITY));
14872
- dispatch({ type: "new-lines", count: 1, maxOffset: LOG_RING_CAPACITY - 1 });
14873
16202
  }
14874
16203
  })();
14875
16204
  return () => controller.abort();
14876
16205
  }, [selectionKey, source]);
14877
16206
  const stacks = useMemo(() => visibleFleetStacks(snapshot, state.filter), [snapshot, state.filter]);
14878
16207
  const stack = selectedStack(snapshot, state.selection.project);
14879
- const context = {
16208
+ const context = useMemo(() => ({
14880
16209
  repo: sanitizeFleetTerminalText(stack?.repo ?? invokingRepository.repo),
14881
16210
  branch: sanitizeFleetTerminalText(stack?.branch ?? invokingRepository.branch),
14882
16211
  worktree: sanitizeFleetTerminalText(stack?.worktree ?? invokingRepository.worktree),
14883
16212
  project: sanitizeFleetTerminalText(stack?.project ?? "\u2014")
14884
- };
14885
- const endpointView = copyableEndpoint(stack, state.selection.service, state.selection.endpoint);
16213
+ }), [stack, invokingRepository]);
16214
+ const endpointView = selectedFleetEndpoint(stack, state.selection.service, state.selection.endpoint);
14886
16215
  const endpoint = usableEndpoint(stack, state.selection.service, state.selection.endpoint);
14887
- const effectiveLayout = state.layout === "auto" ? terminal.width >= 110 ? "split" : "stack" : state.layout;
14888
- const focusOrder = ["stacks", "services", "logs"];
16216
+ const effectiveLayout = resolveFleetLayout(state.layout, terminal.width);
16217
+ const paneWidths = fleetPaneWidths(effectiveLayout, terminal.width);
16218
+ const headerRows = 1;
16219
+ const contextRows = 2;
16220
+ const footerRows = 1;
16221
+ const warningRows = stack?.warning === undefined ? 0 : 1;
16222
+ const svcRows = fleetServiceRowCount(stack) + warningRows;
16223
+ const stackedSidebarHeight = stackSidebarHeight(stacks.length);
16224
+ const svcPaneH = servicePaneHeight(svcRows, effectiveLayout === "split" ? 14 : 12);
16225
+ const logHeight = Math.max(5, terminal.height - headerRows - contextRows - footerRows - svcPaneH - (effectiveLayout === "stack" ? stackedSidebarHeight : 0));
16226
+ const logWidth = effectiveLayout === "split" ? paneWidths.main : terminal.width;
16227
+ const logViewportRows = Math.max(1, logHeight - 3);
16228
+ const logRows = useMemo(() => buildFleetLogRows(logs, Math.max(1, logWidth - 4)), [logs, logWidth]);
16229
+ const maxLogOffset = Math.max(0, logRows.length - logViewportRows);
16230
+ useEffect(() => {
16231
+ const appended = appendedLines.current - seenAppendedLines.current;
16232
+ seenAppendedLines.current = appendedLines.current;
16233
+ if (appended > 0 && !stateRef.current.follow) {
16234
+ const fresh = buildFleetLogRows(logs.slice(-Math.min(appended, logs.length)), Math.max(1, logWidth - 4)).length;
16235
+ dispatch({ type: "new-lines", count: fresh, maxOffset: maxLogOffset });
16236
+ }
16237
+ }, [logs, logWidth, maxLogOffset]);
16238
+ const anyStarting = snapshot.stacks.some((candidate) => candidate.phase === "starting");
16239
+ useEffect(() => {
16240
+ if (!anyStarting)
16241
+ return;
16242
+ const timer = setInterval(() => setSpinnerFrame((frame) => (frame + 1) % SPINNER_FRAMES.length), 120);
16243
+ return () => clearInterval(timer);
16244
+ }, [anyStarting]);
16245
+ useEffect(() => {
16246
+ const timer = setInterval(() => setNow(Date.now()), 30000);
16247
+ return () => clearInterval(timer);
16248
+ }, []);
14889
16249
  useEffect(() => {
14890
16250
  const confirmed = state.confirmDown;
14891
- const currentIncarnation = snapshot.stacks.find((candidate) => candidate.project === confirmed?.project);
14892
- if (confirmed !== undefined && !state.downPending && (currentIncarnation?.repoId !== confirmed.repoId || currentIncarnation.worktree !== confirmed.worktree || currentIncarnation.createdAt !== confirmed.createdAt)) {
16251
+ if (confirmed !== undefined && !state.downPending && downTargetChanged(confirmed, snapshot)) {
14893
16252
  dispatch({ type: "confirm-down" });
14894
- setNotice(`${confirmed.project} changed; down cancelled`);
16253
+ showToast(`${confirmed.project} changed; down cancelled`);
14895
16254
  }
14896
16255
  }, [snapshot, state.confirmDown, state.downPending]);
16256
+ const copyToClipboard = (value, label) => {
16257
+ const copied = renderer.copyToClipboardOSC52(value);
16258
+ showToast(copied ? `copied ${label ?? value}` : `copy unavailable: ${label ?? value}`);
16259
+ };
14897
16260
  useKeyboard((key) => {
14898
16261
  const current = stateRef.current;
14899
16262
  if (current.confirmDown !== undefined) {
14900
16263
  key.preventDefault();
14901
16264
  key.stopPropagation();
14902
- if (isEscape(key) && !current.downPending) {
16265
+ if (isEscapeKey(key) && !current.downPending) {
14903
16266
  dispatch({ type: "confirm-down" });
14904
- } else if (!current.downPending && (isFleetKey(key, "d") || key.name === "return" || key.name === "enter")) {
16267
+ } else if (!current.downPending && (isPlainKey(key, "d") || isEnterKey(key))) {
14905
16268
  const confirmed = current.confirmDown;
14906
- const currentIncarnation = snapshot.stacks.find((candidate) => candidate.project === confirmed.project);
14907
- if (currentIncarnation?.repoId !== confirmed.repoId || currentIncarnation.worktree !== confirmed.worktree || currentIncarnation.createdAt !== confirmed.createdAt) {
16269
+ if (downTargetChanged(confirmed, snapshot)) {
14908
16270
  dispatch({ type: "confirm-down" });
14909
- setNotice(`${confirmed.project} changed; down cancelled`);
16271
+ showToast(`${confirmed.project} changed; down cancelled`);
14910
16272
  return;
14911
16273
  }
14912
16274
  const project = confirmed.project;
14913
16275
  dispatch({ type: "down-pending", pending: true });
14914
- setNotice(`tearing down ${project}\u2026`);
16276
+ showToast(`tearing down ${project}\u2026`);
14915
16277
  source.down(confirmed).then(() => {
14916
16278
  dispatch({ type: "down-pending", pending: false });
14917
16279
  dispatch({ type: "confirm-down" });
14918
- setNotice(`${project} is down; named volumes retained`);
16280
+ showToast(`${project} is down; named volumes retained`);
14919
16281
  }).catch((error) => {
14920
16282
  dispatch({ type: "down-pending", pending: false });
14921
- setNotice(`down failed: ${error.message}`);
16283
+ showToast(`down failed: ${error.message}`);
14922
16284
  });
14923
16285
  }
14924
16286
  return;
@@ -14926,86 +16288,167 @@ function FleetApp({
14926
16288
  if (current.helpOpen || current.doctorOpen) {
14927
16289
  key.preventDefault();
14928
16290
  key.stopPropagation();
14929
- if (isEscape(key) || isFleetKey(key, "?")) {
16291
+ if (isEscapeKey(key) || isPlainKey(key, "?")) {
14930
16292
  dispatch({ type: current.helpOpen ? "help" : "doctor", open: false });
14931
16293
  }
14932
16294
  return;
14933
16295
  }
16296
+ if (current.sharedOpen) {
16297
+ key.preventDefault();
16298
+ key.stopPropagation();
16299
+ if (isEscapeKey(key) || isPlainKey(key, "s"))
16300
+ return dispatch({ type: "shared", open: false });
16301
+ const list = snapshot.shared;
16302
+ if (list.length === 0)
16303
+ return;
16304
+ if (isDownKey(key))
16305
+ return dispatch({ type: "move-shared", delta: 1, count: list.length });
16306
+ if (isUpKey(key))
16307
+ return dispatch({ type: "move-shared", delta: -1, count: list.length });
16308
+ const selected = list[Math.min(current.sharedSelection, list.length - 1)];
16309
+ if (selected === undefined || sharedBusy)
16310
+ return;
16311
+ const runShared = (label, action) => {
16312
+ setSharedBusy(true);
16313
+ showToast(`${label} ${selected.name}\u2026`);
16314
+ action.then(() => showToast(`${label} ${selected.name}: done`)).catch((error) => showToast(`${label} ${selected.name} failed: ${error.message}`)).finally(() => setSharedBusy(false));
16315
+ };
16316
+ if (isPlainKey(key, "a") || isPlainKey(key, "x") || isPlainKey(key, "r")) {
16317
+ if (selected.holder?.mine !== true) {
16318
+ showToast(`this worktree does not hold ${selected.name}`);
16319
+ return;
16320
+ }
16321
+ const worktree = selected.holder.worktree;
16322
+ if (isPlainKey(key, "a"))
16323
+ runShared("allowing", source.allowShared(worktree, selected.name));
16324
+ else if (isPlainKey(key, "x"))
16325
+ runShared("denying", source.denyShared(worktree, selected.name));
16326
+ else
16327
+ runShared("releasing", source.releaseShared(worktree, selected.name));
16328
+ return;
16329
+ }
16330
+ if (isPlainKey(key, "c")) {
16331
+ const acting = selectedStack(snapshot, current.selection.project);
16332
+ if (acting === undefined) {
16333
+ showToast("select a stack (in the fleet list) to claim as");
16334
+ return;
16335
+ }
16336
+ if (selected.holder?.project === acting.project) {
16337
+ showToast(`${acting.project} already holds ${selected.name}`);
16338
+ return;
16339
+ }
16340
+ runShared(`claiming as ${acting.project},`, source.claimShared(acting.worktree, selected.name));
16341
+ return;
16342
+ }
16343
+ return;
16344
+ }
14934
16345
  if (current.focus === "filter") {
14935
- if (isEscape(key) || key.name === "return" || key.name === "enter") {
16346
+ if (isEscapeKey(key)) {
16347
+ key.preventDefault();
16348
+ key.stopPropagation();
16349
+ if (current.filter !== "")
16350
+ dispatch({ type: "filter", filter: "", snapshot });
16351
+ else
16352
+ dispatch({ type: "focus", focus: "stacks" });
16353
+ return;
16354
+ }
16355
+ if (isEnterKey(key)) {
14936
16356
  key.preventDefault();
14937
16357
  key.stopPropagation();
14938
16358
  dispatch({ type: "focus", focus: "stacks" });
14939
16359
  }
14940
16360
  return;
14941
16361
  }
14942
- if (isFleetKey(key, "q"))
16362
+ if (isPlainKey(key, "q"))
14943
16363
  return onQuit();
14944
- if (isFleetKey(key, "?"))
16364
+ if (isPlainKey(key, "?"))
14945
16365
  return dispatch({ type: "help", open: true });
14946
- if (isFleetKey(key, "/"))
16366
+ if (isPlainKey(key, "s"))
16367
+ return dispatch({ type: "shared", open: true });
16368
+ if (isPlainKey(key, "/"))
14947
16369
  return dispatch({ type: "focus", focus: "filter" });
14948
- if (isFleetKey(key, "0"))
16370
+ if (isPlainKey(key, "0"))
14949
16371
  return dispatch({ type: "layout", layout: "auto" });
14950
- if (isFleetKey(key, "1"))
16372
+ if (isPlainKey(key, "1"))
14951
16373
  return dispatch({ type: "layout", layout: "split" });
14952
- if (isFleetKey(key, "2"))
16374
+ if (isPlainKey(key, "2"))
14953
16375
  return dispatch({ type: "layout", layout: "stack" });
14954
16376
  if (key.name === "tab") {
16377
+ const focusOrder = ["stacks", "services", "logs"];
14955
16378
  const index = focusOrder.indexOf(current.focus);
14956
16379
  const delta2 = key.shift ? -1 : 1;
14957
16380
  const next = (index + delta2 + focusOrder.length) % focusOrder.length;
14958
16381
  return dispatch({ type: "focus", focus: focusOrder[next] });
14959
16382
  }
14960
- if (isFleetKey(key, "["))
16383
+ if (isPlainKey(key, ","))
16384
+ return dispatch({ type: "move-stack", delta: -1, snapshot });
16385
+ if (isPlainKey(key, "."))
16386
+ return dispatch({ type: "move-stack", delta: 1, snapshot });
16387
+ if (isPlainKey(key, "["))
14961
16388
  return dispatch({ type: "move-service", delta: -1, snapshot });
14962
- if (isFleetKey(key, "]"))
16389
+ if (isPlainKey(key, "]"))
14963
16390
  return dispatch({ type: "move-service", delta: 1, snapshot });
14964
- if (isFleetKey(key, "g") && key.shift || key.sequence === "G") {
16391
+ if (isPlainKey(key, "f"))
16392
+ return dispatch({ type: "follow", follow: !current.follow });
16393
+ if (isFollowBottomKey(key))
14965
16394
  return dispatch({ type: "follow", follow: true });
16395
+ if (isScrollTopKey(key)) {
16396
+ return dispatch({ type: "scroll-logs", delta: -logRows.length, maxOffset: maxLogOffset });
14966
16397
  }
14967
- if (key.sequence === "D" || isFleetKey(key, "d") && key.shift) {
16398
+ if (isDoctorKey(key)) {
14968
16399
  if (doctorRunning)
14969
16400
  return;
14970
16401
  dispatch({ type: "doctor", open: true });
14971
16402
  setDoctorRunning(true);
14972
16403
  setDoctorRows([]);
14973
- const worktree = stack !== undefined && existsSync22(stack.worktree) ? stack.worktree : process.cwd();
16404
+ const worktree = stack !== undefined && existsSync23(stack.worktree) ? stack.worktree : process.cwd();
14974
16405
  source.diagnose(worktree).then(setDoctorRows).catch((error) => {
14975
- setNotice(`doctor failed: ${error.message}`);
16406
+ showToast(`doctor failed: ${error.message}`);
14976
16407
  }).finally(() => setDoctorRunning(false));
14977
16408
  return;
14978
16409
  }
14979
- if (isFleetKey(key, "d")) {
16410
+ if (isPlainKey(key, "d")) {
14980
16411
  if (stack?.phase === "queued" || stack?.phase === "reserved") {
14981
- setNotice(`${stack.project} is ${stack.phase}; down is available after startup begins`);
16412
+ showToast(`${stack.project} is ${stack.phase}; down is available after startup begins`);
14982
16413
  } else if (stack !== undefined) {
14983
16414
  dispatch({ type: "confirm-down", stack });
14984
16415
  }
14985
16416
  return;
14986
16417
  }
14987
- if (isFleetKey(key, "o") && endpoint !== undefined) {
16418
+ if (isPlainKey(key, "o") && endpoint !== undefined) {
14988
16419
  openFleetUrl(endpoint);
14989
- setNotice(endpoint);
16420
+ showToast(endpoint);
14990
16421
  return;
14991
16422
  }
14992
- if (isFleetKey(key, "y") && endpoint !== undefined) {
14993
- const copied = renderer.copyToClipboardOSC52(endpoint);
14994
- setNotice(copied ? `copied ${endpoint}` : `copy unavailable: ${endpoint}`);
16423
+ if (isShiftedKey(key, "y")) {
16424
+ if (stack === undefined)
16425
+ return;
16426
+ const env = buildEnvBlock(stack);
16427
+ if (env === "")
16428
+ showToast(`no env surface recorded for ${stack.project}`);
16429
+ else
16430
+ copyToClipboard(env, `env block (${env.split(`
16431
+ `).length} keys)`);
14995
16432
  return;
14996
16433
  }
14997
- const copySurfaceRequested = ["c", "l", "p"].some((name) => isFleetKey(key, name));
14998
- const surface = isFleetKey(key, "c") ? endpointView?.url : isFleetKey(key, "l") ? endpointView?.localUrl : isFleetKey(key, "p") ? endpointView?.publicUrl : undefined;
14999
- if (surface !== undefined) {
15000
- const copied = renderer.copyToClipboardOSC52(surface);
15001
- setNotice(copied ? `copied ${surface}` : `copy unavailable: ${surface}`);
16434
+ if (isPlainKey(key, "y") && endpoint !== undefined) {
16435
+ copyToClipboard(endpoint);
15002
16436
  return;
15003
16437
  }
15004
- if (copySurfaceRequested) {
15005
- setNotice("selected URL surface is unavailable");
16438
+ const copySurfaces = [
16439
+ ["c", endpointView?.url],
16440
+ ["l", endpointView?.localUrl],
16441
+ ["p", endpointView?.publicUrl]
16442
+ ];
16443
+ const copySurface = copySurfaces.find(([name]) => isPlainKey(key, name));
16444
+ if (copySurface !== undefined) {
16445
+ if (copySurface[1] !== undefined)
16446
+ copyToClipboard(copySurface[1]);
16447
+ else
16448
+ showToast("selected URL surface is unavailable");
15006
16449
  return;
15007
16450
  }
15008
- const delta = key.name === "down" || isFleetKey(key, "j") ? 1 : key.name === "up" || isFleetKey(key, "k") ? -1 : 0;
16451
+ const delta = isDownKey(key) ? 1 : isUpKey(key) ? -1 : 0;
15009
16452
  if (delta === 0)
15010
16453
  return;
15011
16454
  if (current.focus === "stacks")
@@ -15013,11 +16456,46 @@ function FleetApp({
15013
16456
  else if (current.focus === "services")
15014
16457
  dispatch({ type: "move-service", delta, snapshot });
15015
16458
  else
15016
- dispatch({ type: "scroll-logs", delta });
16459
+ dispatch({ type: "scroll-logs", delta, maxOffset: maxLogOffset });
15017
16460
  });
15018
- const capacity = snapshot.capacity;
15019
- const status = connected ? `${capacity.live}/${capacity.maxStacks} live \xB7 ${capacity.reserved} reserved \xB7 ${capacity.queued} queued` : "disconnected";
16461
+ const uptime = stack?.createdAt !== undefined && (stack.phase === "up" || stack.phase === "degraded" || stack.phase === "starting") ? formatUptime(stack.createdAt, now) : undefined;
15020
16462
  const logLabel = state.selection.service ?? "select a service";
16463
+ const statusNotice = resolveStatusNotice(connectionNotice, toast);
16464
+ const doctorWidth = Math.min(72, Math.max(30, terminal.width - 6));
16465
+ const wideFooter = terminal.width >= 100;
16466
+ const onSelectProject = useCallback((project) => dispatch({ type: "select-stack", project, snapshot }), [snapshot]);
16467
+ const onSelectService = useCallback((service) => dispatch({ type: "select-service", service, snapshot }), [snapshot]);
16468
+ const onSelectEndpoint = useCallback((service, endpointName) => dispatch({ type: "select-endpoint", service, endpoint: endpointName, snapshot }), [snapshot]);
16469
+ const servicePane = /* @__PURE__ */ jsxDEV5(ServicePane, {
16470
+ stack,
16471
+ uptime,
16472
+ selectedService: state.selection.service,
16473
+ selectedEndpoint: state.selection.endpoint,
16474
+ width: logWidth,
16475
+ focused: state.focus === "services",
16476
+ onSelectService,
16477
+ onSelectEndpoint
16478
+ }, undefined, false, undefined, this);
16479
+ const sidebar = (width) => /* @__PURE__ */ jsxDEV5(StackSidebar, {
16480
+ stacks,
16481
+ capacity: snapshot.capacity,
16482
+ selectedProject: state.selection.project,
16483
+ width,
16484
+ focused: state.focus === "stacks",
16485
+ spinnerFrame,
16486
+ onSelectProject
16487
+ }, undefined, false, undefined, this);
16488
+ const logPane = /* @__PURE__ */ jsxDEV5(LogPane, {
16489
+ rows: logRows,
16490
+ height: logHeight,
16491
+ width: logWidth,
16492
+ offset: state.logOffset,
16493
+ follow: state.follow,
16494
+ unseen: state.unseenLines,
16495
+ label: logLabel,
16496
+ focused: state.focus === "logs",
16497
+ onScroll: (delta) => dispatch({ type: "scroll-logs", delta, maxOffset: maxLogOffset })
16498
+ }, undefined, false, undefined, this);
15021
16499
  return /* @__PURE__ */ jsxDEV5("box", {
15022
16500
  style: { width: "100%", height: "100%", flexDirection: "column", backgroundColor: fleetTheme.background },
15023
16501
  children: [
@@ -15026,70 +16504,64 @@ function FleetApp({
15026
16504
  children: [
15027
16505
  /* @__PURE__ */ jsxDEV5("text", {
15028
16506
  fg: fleetTheme.accent,
15029
- children: [
15030
- "Hestia Fleet \u2014 ",
15031
- context.repo
15032
- ]
15033
- }, undefined, true, undefined, this),
15034
- /* @__PURE__ */ jsxDEV5("text", {
15035
- fg: fleetTheme.muted,
15036
- children: [
15037
- " ",
15038
- status
15039
- ]
15040
- }, undefined, true, undefined, this)
15041
- ]
15042
- }, undefined, true, undefined, this),
15043
- terminal.width >= 90 ? /* @__PURE__ */ jsxDEV5("box", {
15044
- style: { height: 4, paddingLeft: 1, flexDirection: "column", backgroundColor: fleetTheme.background },
15045
- children: [
15046
- /* @__PURE__ */ jsxDEV5("text", {
15047
- fg: fleetTheme.text,
15048
- children: [
15049
- "Repository ",
15050
- context.repo
15051
- ]
15052
- }, undefined, true, undefined, this),
16507
+ children: "Hestia Fleet"
16508
+ }, undefined, false, undefined, this),
15053
16509
  /* @__PURE__ */ jsxDEV5("text", {
15054
- fg: fleetTheme.text,
15055
- children: [
15056
- "Branch ",
15057
- context.branch
15058
- ]
15059
- }, undefined, true, undefined, this),
16510
+ fg: fleetTheme.faint,
16511
+ children: " \xB7 "
16512
+ }, undefined, false, undefined, this),
15060
16513
  /* @__PURE__ */ jsxDEV5("text", {
15061
16514
  fg: fleetTheme.text,
15062
- children: [
15063
- "Worktree ",
15064
- middleTruncateWorktreePath(context.worktree, Math.max(16, terminal.width - 13))
15065
- ]
15066
- }, undefined, true, undefined, this),
16515
+ children: fitFleetText(context.repo, Math.max(8, terminal.width - 30))
16516
+ }, undefined, false, undefined, this),
16517
+ /* @__PURE__ */ jsxDEV5("box", {
16518
+ style: { flexGrow: 1 }
16519
+ }, undefined, false, undefined, this),
15067
16520
  /* @__PURE__ */ jsxDEV5("text", {
15068
- fg: fleetTheme.muted,
15069
- children: [
15070
- "Project ",
15071
- context.project
15072
- ]
15073
- }, undefined, true, undefined, this)
16521
+ fg: connected ? fleetTheme.healthy : fleetTheme.warning,
16522
+ children: connected ? "\u25CF connected" : "\u25CB reconnecting"
16523
+ }, undefined, false, undefined, this)
15074
16524
  ]
15075
- }, undefined, true, undefined, this) : /* @__PURE__ */ jsxDEV5("box", {
16525
+ }, undefined, true, undefined, this),
16526
+ /* @__PURE__ */ jsxDEV5("box", {
15076
16527
  style: { height: 2, paddingLeft: 1, flexDirection: "column", backgroundColor: fleetTheme.background },
15077
16528
  children: [
15078
- /* @__PURE__ */ jsxDEV5("text", {
15079
- fg: fleetTheme.text,
16529
+ /* @__PURE__ */ jsxDEV5("box", {
16530
+ style: { height: 1, flexDirection: "row" },
15080
16531
  children: [
15081
- context.repo,
15082
- " \xB7 ",
15083
- context.branch,
15084
- " \xB7 ",
15085
- context.project
16532
+ /* @__PURE__ */ jsxDEV5("text", {
16533
+ fg: fleetTheme.muted,
16534
+ children: fitFleetText(context.repo, 24)
16535
+ }, undefined, false, undefined, this),
16536
+ /* @__PURE__ */ jsxDEV5("text", {
16537
+ fg: fleetTheme.faint,
16538
+ children: " / "
16539
+ }, undefined, false, undefined, this),
16540
+ /* @__PURE__ */ jsxDEV5("text", {
16541
+ fg: fleetTheme.text,
16542
+ children: context.branch
16543
+ }, undefined, false, undefined, this),
16544
+ /* @__PURE__ */ jsxDEV5("text", {
16545
+ fg: fleetTheme.faint,
16546
+ children: " \xB7 "
16547
+ }, undefined, false, undefined, this),
16548
+ /* @__PURE__ */ jsxDEV5("text", {
16549
+ fg: fleetTheme.muted,
16550
+ children: context.project
16551
+ }, undefined, false, undefined, this)
15086
16552
  ]
15087
16553
  }, undefined, true, undefined, this),
15088
- /* @__PURE__ */ jsxDEV5("text", {
15089
- fg: fleetTheme.muted,
16554
+ /* @__PURE__ */ jsxDEV5("box", {
16555
+ style: { height: 1, flexDirection: "row" },
15090
16556
  children: [
15091
- "worktree ",
15092
- middleTruncateWorktreePath(context.worktree, Math.max(12, terminal.width - 10))
16557
+ /* @__PURE__ */ jsxDEV5("text", {
16558
+ fg: fleetTheme.faint,
16559
+ children: "worktree "
16560
+ }, undefined, false, undefined, this),
16561
+ /* @__PURE__ */ jsxDEV5("text", {
16562
+ fg: fleetTheme.muted,
16563
+ children: middleTruncateWorktreePath(context.worktree, Math.max(16, terminal.width - 11))
16564
+ }, undefined, false, undefined, this)
15093
16565
  ]
15094
16566
  }, undefined, true, undefined, this)
15095
16567
  ]
@@ -15097,37 +16569,17 @@ function FleetApp({
15097
16569
  effectiveLayout === "split" ? /* @__PURE__ */ jsxDEV5("box", {
15098
16570
  style: { flexGrow: 1, flexDirection: "row" },
15099
16571
  children: [
15100
- /* @__PURE__ */ jsxDEV5(StackSidebar, {
15101
- stacks,
15102
- selectedProject: state.selection.project,
15103
- width: Math.max(28, Math.floor(terminal.width * 0.28)),
15104
- onSelectProject: (project) => dispatch({ type: "select-stack", project, snapshot })
15105
- }, undefined, false, undefined, this),
16572
+ sidebar(paneWidths.sidebar),
15106
16573
  /* @__PURE__ */ jsxDEV5("box", {
15107
16574
  style: { flexGrow: 1, flexDirection: "column" },
15108
16575
  children: [
15109
16576
  /* @__PURE__ */ jsxDEV5("box", {
15110
- style: { height: Math.min(12, Math.max(5, fleetServiceRowCount(stack) + 2)) },
15111
- children: /* @__PURE__ */ jsxDEV5(ServicePane, {
15112
- stack,
15113
- selectedService: state.selection.service,
15114
- selectedEndpoint: state.selection.endpoint,
15115
- onSelectService: (service) => dispatch({ type: "select-service", service, snapshot }),
15116
- onSelectEndpoint: (service, endpoint2) => dispatch({ type: "select-endpoint", service, endpoint: endpoint2, snapshot })
15117
- }, undefined, false, undefined, this)
16577
+ style: { height: svcPaneH },
16578
+ children: servicePane
15118
16579
  }, undefined, false, undefined, this),
15119
16580
  /* @__PURE__ */ jsxDEV5("box", {
15120
16581
  style: { flexGrow: 1 },
15121
- children: /* @__PURE__ */ jsxDEV5(LogPane, {
15122
- lines: logs,
15123
- height: Math.max(5, terminal.height - 13),
15124
- width: Math.max(20, Math.floor(terminal.width * 0.72)),
15125
- offset: state.logOffset,
15126
- follow: state.follow,
15127
- unseen: state.unseenLines,
15128
- label: logLabel,
15129
- onScroll: (delta) => dispatch({ type: "scroll-logs", delta })
15130
- }, undefined, false, undefined, this)
16582
+ children: logPane
15131
16583
  }, undefined, false, undefined, this)
15132
16584
  ]
15133
16585
  }, undefined, true, undefined, this)
@@ -15136,42 +16588,22 @@ function FleetApp({
15136
16588
  style: { flexGrow: 1, flexDirection: "column" },
15137
16589
  children: [
15138
16590
  /* @__PURE__ */ jsxDEV5("box", {
15139
- style: { height: Math.min(7, Math.max(4, stacks.length + 2)) },
15140
- children: /* @__PURE__ */ jsxDEV5(StackSidebar, {
15141
- stacks,
15142
- selectedProject: state.selection.project,
15143
- width: terminal.width,
15144
- onSelectProject: (project) => dispatch({ type: "select-stack", project, snapshot })
15145
- }, undefined, false, undefined, this)
16591
+ style: { height: stackedSidebarHeight },
16592
+ children: sidebar(terminal.width)
15146
16593
  }, undefined, false, undefined, this),
15147
16594
  /* @__PURE__ */ jsxDEV5("box", {
15148
- style: { height: Math.min(10, Math.max(4, fleetServiceRowCount(stack) + 2)) },
15149
- children: /* @__PURE__ */ jsxDEV5(ServicePane, {
15150
- stack,
15151
- selectedService: state.selection.service,
15152
- selectedEndpoint: state.selection.endpoint,
15153
- onSelectService: (service) => dispatch({ type: "select-service", service, snapshot }),
15154
- onSelectEndpoint: (service, endpoint2) => dispatch({ type: "select-endpoint", service, endpoint: endpoint2, snapshot })
15155
- }, undefined, false, undefined, this)
16595
+ style: { height: svcPaneH },
16596
+ children: servicePane
15156
16597
  }, undefined, false, undefined, this),
15157
16598
  /* @__PURE__ */ jsxDEV5("box", {
15158
16599
  style: { flexGrow: 1 },
15159
- children: /* @__PURE__ */ jsxDEV5(LogPane, {
15160
- lines: logs,
15161
- height: Math.max(5, terminal.height - 17),
15162
- width: terminal.width,
15163
- offset: state.logOffset,
15164
- follow: state.follow,
15165
- unseen: state.unseenLines,
15166
- label: logLabel,
15167
- onScroll: (delta) => dispatch({ type: "scroll-logs", delta })
15168
- }, undefined, false, undefined, this)
16600
+ children: logPane
15169
16601
  }, undefined, false, undefined, this)
15170
16602
  ]
15171
16603
  }, undefined, true, undefined, this),
15172
16604
  /* @__PURE__ */ jsxDEV5("box", {
15173
16605
  style: { height: 1, paddingLeft: 1, paddingRight: 1, flexDirection: "row", backgroundColor: fleetTheme.panelAlt },
15174
- children: state.focus === "filter" ? /* @__PURE__ */ jsxDEV5(Fragment2, {
16606
+ children: state.focus === "filter" ? /* @__PURE__ */ jsxDEV5(Fragment3, {
15175
16607
  children: [
15176
16608
  /* @__PURE__ */ jsxDEV5("text", {
15177
16609
  fg: fleetTheme.accent,
@@ -15186,73 +16618,137 @@ function FleetApp({
15186
16618
  onSubmit: () => dispatch({ type: "focus", focus: "stacks" })
15187
16619
  }, undefined, false, undefined, this)
15188
16620
  ]
15189
- }, undefined, true, undefined, this) : /* @__PURE__ */ jsxDEV5("text", {
15190
- fg: notice ? fleetTheme.warning : fleetTheme.muted,
15191
- children: notice || "click rows \xB7 wheel logs \xB7 j/k navigate \xB7 [/] service \xB7 / filter \xB7 d down \xB7 ? help \xB7 q quit"
15192
- }, undefined, false, undefined, this)
16621
+ }, undefined, true, undefined, this) : statusNotice !== undefined ? /* @__PURE__ */ jsxDEV5(Fragment3, {
16622
+ children: [
16623
+ /* @__PURE__ */ jsxDEV5("text", {
16624
+ fg: connectionNotice !== undefined ? fleetTheme.warning : fleetTheme.text,
16625
+ children: fitFleetText(statusNotice, Math.max(8, terminal.width - 20))
16626
+ }, undefined, false, undefined, this),
16627
+ /* @__PURE__ */ jsxDEV5("box", {
16628
+ style: { flexGrow: 1 }
16629
+ }, undefined, false, undefined, this),
16630
+ /* @__PURE__ */ jsxDEV5("text", {
16631
+ fg: connected ? fleetTheme.muted : fleetTheme.warning,
16632
+ children: fleetCapacitySummary(snapshot.capacity, connected)
16633
+ }, undefined, false, undefined, this)
16634
+ ]
16635
+ }, undefined, true, undefined, this) : /* @__PURE__ */ jsxDEV5(Fragment3, {
16636
+ children: [
16637
+ wideFooter ? /* @__PURE__ */ jsxDEV5("text", {
16638
+ children: FLEET_KEY_HINTS.map((hint) => /* @__PURE__ */ jsxDEV5("span", {
16639
+ children: [
16640
+ /* @__PURE__ */ jsxDEV5("span", {
16641
+ fg: fleetTheme.bright,
16642
+ bg: fleetTheme.keycapBg,
16643
+ children: ` ${hint.keys} `
16644
+ }, undefined, false, undefined, this),
16645
+ /* @__PURE__ */ jsxDEV5("span", {
16646
+ fg: fleetTheme.muted,
16647
+ children: ` ${hint.label} `
16648
+ }, undefined, false, undefined, this)
16649
+ ]
16650
+ }, hint.keys, true, undefined, this))
16651
+ }, undefined, false, undefined, this) : /* @__PURE__ */ jsxDEV5("text", {
16652
+ fg: fleetTheme.muted,
16653
+ children: "? help \xB7 q quit"
16654
+ }, undefined, false, undefined, this),
16655
+ /* @__PURE__ */ jsxDEV5("box", {
16656
+ style: { flexGrow: 1 }
16657
+ }, undefined, false, undefined, this),
16658
+ state.filter !== "" ? /* @__PURE__ */ jsxDEV5("text", {
16659
+ fg: fleetTheme.accent,
16660
+ children: `filter=${fitFleetText(state.filter, 16)} `
16661
+ }, undefined, false, undefined, this) : null,
16662
+ /* @__PURE__ */ jsxDEV5("text", {
16663
+ fg: connected ? fleetTheme.muted : fleetTheme.warning,
16664
+ children: fleetCapacitySummary(snapshot.capacity, connected)
16665
+ }, undefined, false, undefined, this)
16666
+ ]
16667
+ }, undefined, true, undefined, this)
15193
16668
  }, undefined, false, undefined, this),
15194
16669
  state.helpOpen ? /* @__PURE__ */ jsxDEV5(FleetModal, {
15195
16670
  title: "Fleet help",
15196
16671
  terminalWidth: terminal.width,
15197
16672
  terminalHeight: terminal.height,
15198
- children: [
15199
- /* @__PURE__ */ jsxDEV5("text", {
15200
- fg: fleetTheme.text,
15201
- children: "j/k or arrows navigate focused pane"
15202
- }, undefined, false, undefined, this),
15203
- /* @__PURE__ */ jsxDEV5("text", {
15204
- fg: fleetTheme.text,
15205
- children: "mouse click stacks/services \xB7 wheel scroll logs"
15206
- }, undefined, false, undefined, this),
15207
- /* @__PURE__ */ jsxDEV5("text", {
15208
- fg: fleetTheme.text,
15209
- children: "Tab / Shift-Tab change pane"
15210
- }, undefined, false, undefined, this),
15211
- /* @__PURE__ */ jsxDEV5("text", {
15212
- fg: fleetTheme.text,
15213
- children: "[ / ] previous / next service"
15214
- }, undefined, false, undefined, this),
15215
- /* @__PURE__ */ jsxDEV5("text", {
15216
- fg: fleetTheme.text,
15217
- children: "/ filter \xB7 G follow logs \xB7 0/1/2 layout"
15218
- }, undefined, false, undefined, this),
15219
- /* @__PURE__ */ jsxDEV5("text", {
15220
- fg: fleetTheme.text,
15221
- children: "o open \xB7 y primary \xB7 c direct \xB7 l local \xB7 p public"
15222
- }, undefined, false, undefined, this),
15223
- /* @__PURE__ */ jsxDEV5("text", {
15224
- fg: fleetTheme.text,
15225
- children: "D doctor"
15226
- }, undefined, false, undefined, this),
15227
- /* @__PURE__ */ jsxDEV5("text", {
15228
- fg: fleetTheme.danger,
15229
- children: "d confirmed down (named volumes retained)"
15230
- }, undefined, false, undefined, this),
15231
- /* @__PURE__ */ jsxDEV5("text", {
15232
- fg: fleetTheme.muted,
15233
- children: "Esc closes this dialog"
15234
- }, undefined, false, undefined, this)
15235
- ]
15236
- }, undefined, true, undefined, this) : null,
16673
+ children: HELP_ROWS.map(([keys, action]) => /* @__PURE__ */ jsxDEV5("box", {
16674
+ style: { height: 1, flexDirection: "row" },
16675
+ children: [
16676
+ /* @__PURE__ */ jsxDEV5("text", {
16677
+ fg: fleetTheme.accent,
16678
+ children: padFleetText(keys, 9)
16679
+ }, undefined, false, undefined, this),
16680
+ /* @__PURE__ */ jsxDEV5("text", {
16681
+ fg: fleetTheme.muted,
16682
+ children: action
16683
+ }, undefined, false, undefined, this)
16684
+ ]
16685
+ }, keys, true, undefined, this))
16686
+ }, undefined, false, undefined, this) : null,
15237
16687
  state.doctorOpen ? /* @__PURE__ */ jsxDEV5(FleetModal, {
15238
16688
  title: "hestia doctor",
15239
16689
  terminalWidth: terminal.width,
15240
16690
  terminalHeight: terminal.height,
16691
+ children: doctorRunning ? /* @__PURE__ */ jsxDEV5("text", {
16692
+ fg: fleetTheme.muted,
16693
+ children: "Running diagnostics\u2026"
16694
+ }, undefined, false, undefined, this) : (() => {
16695
+ const modalHeight = Math.min(22, Math.max(8, terminal.height - 4));
16696
+ const report = budgetDoctorReport(doctorRows, doctorWidth, Math.max(3, modalHeight - 6));
16697
+ const summary = doctorOmissionSummary(doctorRows, report.shown);
16698
+ return [
16699
+ ...report.entries.flatMap(({ row: row2, detailLines }) => {
16700
+ const mark = row2.level === "ok" ? "\u2713" : row2.level === "error" ? "\u2717" : row2.level === "warn" ? "!" : "?";
16701
+ const color = row2.level === "error" ? fleetTheme.danger : row2.level === "warn" ? fleetTheme.warning : row2.level === "ok" ? fleetTheme.healthy : fleetTheme.muted;
16702
+ const head = `${mark} ${sanitizeFleetTerminalText(row2.check)}`;
16703
+ return [
16704
+ /* @__PURE__ */ jsxDEV5("text", {
16705
+ fg: color,
16706
+ children: fitFleetText(head, doctorWidth - 4)
16707
+ }, `${row2.check}:head`, false, undefined, this),
16708
+ ...detailLines.map((part, index) => /* @__PURE__ */ jsxDEV5("text", {
16709
+ fg: fleetTheme.muted,
16710
+ children: ` ${part}`
16711
+ }, `${row2.check}:detail:${index}`, false, undefined, this))
16712
+ ];
16713
+ }),
16714
+ summary === undefined ? null : /* @__PURE__ */ jsxDEV5("text", {
16715
+ fg: fleetTheme.warning,
16716
+ children: summary
16717
+ }, "doctor:omitted", false, undefined, this)
16718
+ ];
16719
+ })()
16720
+ }, undefined, false, undefined, this) : null,
16721
+ state.sharedOpen ? /* @__PURE__ */ jsxDEV5(FleetModal, {
16722
+ title: "Shared hostnames",
16723
+ terminalWidth: terminal.width,
16724
+ terminalHeight: terminal.height,
15241
16725
  children: [
15242
- doctorRunning ? /* @__PURE__ */ jsxDEV5("text", {
16726
+ snapshot.shared.length === 0 ? /* @__PURE__ */ jsxDEV5("text", {
15243
16727
  fg: fleetTheme.muted,
15244
- children: "Running diagnostics\u2026"
15245
- }, undefined, false, undefined, this) : doctorRows.slice(0, 10).map((row2) => /* @__PURE__ */ jsxDEV5("text", {
15246
- fg: row2.level === "error" ? fleetTheme.danger : row2.level === "warn" ? fleetTheme.warning : fleetTheme.text,
15247
- children: `${row2.level === "ok" ? "\u2713" : row2.level === "error" ? "\u2717" : "!"} ${sanitizeFleetTerminalText(row2.check)} \u2014 ${sanitizeFleetTerminalText(row2.detail)}`
15248
- }, row2.check, false, undefined, this)),
15249
- !doctorRunning && doctorOmissionSummary(doctorRows) !== undefined ? /* @__PURE__ */ jsxDEV5("text", {
15250
- fg: fleetTheme.warning,
15251
- children: doctorOmissionSummary(doctorRows)
15252
- }, undefined, false, undefined, this) : null,
16728
+ children: "No shared hostnames declared. `hestia expose <svc> --shared <name>` creates one."
16729
+ }, undefined, false, undefined, this) : snapshot.shared.map((entry, index) => {
16730
+ const active = index === Math.min(state.sharedSelection, snapshot.shared.length - 1);
16731
+ const holder = entry.holder === undefined ? "unclaimed" : `held by ${entry.holder.project}${entry.holder.mine ? " (yours)" : ""}`;
16732
+ return /* @__PURE__ */ jsxDEV5("box", {
16733
+ style: { flexDirection: "column" },
16734
+ children: [
16735
+ /* @__PURE__ */ jsxDEV5("text", {
16736
+ fg: active ? fleetTheme.accent : fleetTheme.text,
16737
+ children: `${active ? "\u258E" : " "} ${sanitizeFleetTerminalText(entry.name)} ${sanitizeFleetTerminalText(entry.url)} ${holder}`
16738
+ }, undefined, false, undefined, this),
16739
+ entry.queue.map((waiter, position) => /* @__PURE__ */ jsxDEV5("text", {
16740
+ fg: fleetTheme.muted,
16741
+ children: ` ${position + 1}. ${sanitizeFleetTerminalText(waiter.project)}${waiter.mine ? " (yours)" : ""}${waiter.denied ? " \u2014 denied, still queued" : ""}`
16742
+ }, `${entry.name}:${waiter.project}`, false, undefined, this))
16743
+ ]
16744
+ }, entry.name, true, undefined, this);
16745
+ }),
16746
+ /* @__PURE__ */ jsxDEV5("box", {
16747
+ style: { height: 1 }
16748
+ }, undefined, false, undefined, this),
15253
16749
  /* @__PURE__ */ jsxDEV5("text", {
15254
16750
  fg: fleetTheme.muted,
15255
- children: "Esc closes this report"
16751
+ children: sharedBusy ? "working\u2026" : "j/k select \xB7 c claim (as selected stack) \xB7 a allow \xB7 x deny \xB7 r release \xB7 Esc/s close"
15256
16752
  }, undefined, false, undefined, this)
15257
16753
  ]
15258
16754
  }, undefined, true, undefined, this) : null,
@@ -15261,32 +16757,56 @@ function FleetApp({
15261
16757
  terminalWidth: terminal.width,
15262
16758
  terminalHeight: terminal.height,
15263
16759
  children: [
15264
- /* @__PURE__ */ jsxDEV5("text", {
15265
- fg: fleetTheme.text,
16760
+ /* @__PURE__ */ jsxDEV5("box", {
16761
+ style: { height: 1, flexDirection: "row" },
15266
16762
  children: [
15267
- "Branch: ",
15268
- sanitizeFleetTerminalText(state.confirmDown.branch)
16763
+ /* @__PURE__ */ jsxDEV5("text", {
16764
+ fg: fleetTheme.faint,
16765
+ children: padFleetText("Branch", 12)
16766
+ }, undefined, false, undefined, this),
16767
+ /* @__PURE__ */ jsxDEV5("text", {
16768
+ fg: fleetTheme.bright,
16769
+ children: sanitizeFleetTerminalText(state.confirmDown.branch)
16770
+ }, undefined, false, undefined, this)
15269
16771
  ]
15270
16772
  }, undefined, true, undefined, this),
15271
- /* @__PURE__ */ jsxDEV5("text", {
15272
- fg: fleetTheme.text,
16773
+ /* @__PURE__ */ jsxDEV5("box", {
16774
+ style: { height: 1, flexDirection: "row" },
15273
16775
  children: [
15274
- "Repository: ",
15275
- sanitizeFleetTerminalText(state.confirmDown.repo)
16776
+ /* @__PURE__ */ jsxDEV5("text", {
16777
+ fg: fleetTheme.faint,
16778
+ children: padFleetText("Repository", 12)
16779
+ }, undefined, false, undefined, this),
16780
+ /* @__PURE__ */ jsxDEV5("text", {
16781
+ fg: fleetTheme.text,
16782
+ children: sanitizeFleetTerminalText(state.confirmDown.repo)
16783
+ }, undefined, false, undefined, this)
15276
16784
  ]
15277
16785
  }, undefined, true, undefined, this),
15278
- /* @__PURE__ */ jsxDEV5("text", {
15279
- fg: fleetTheme.text,
16786
+ /* @__PURE__ */ jsxDEV5("box", {
16787
+ style: { height: 1, flexDirection: "row" },
15280
16788
  children: [
15281
- "Worktree: ",
15282
- sanitizeFleetTerminalText(state.confirmDown.worktree)
16789
+ /* @__PURE__ */ jsxDEV5("text", {
16790
+ fg: fleetTheme.faint,
16791
+ children: padFleetText("Worktree", 12)
16792
+ }, undefined, false, undefined, this),
16793
+ /* @__PURE__ */ jsxDEV5("text", {
16794
+ fg: fleetTheme.text,
16795
+ children: sanitizeFleetTerminalText(state.confirmDown.worktree)
16796
+ }, undefined, false, undefined, this)
15283
16797
  ]
15284
16798
  }, undefined, true, undefined, this),
15285
- /* @__PURE__ */ jsxDEV5("text", {
15286
- fg: fleetTheme.text,
16799
+ /* @__PURE__ */ jsxDEV5("box", {
16800
+ style: { height: 1, flexDirection: "row" },
15287
16801
  children: [
15288
- "Project: ",
15289
- sanitizeFleetTerminalText(state.confirmDown.project)
16802
+ /* @__PURE__ */ jsxDEV5("text", {
16803
+ fg: fleetTheme.faint,
16804
+ children: padFleetText("Project", 12)
16805
+ }, undefined, false, undefined, this),
16806
+ /* @__PURE__ */ jsxDEV5("text", {
16807
+ fg: fleetTheme.text,
16808
+ children: sanitizeFleetTerminalText(state.confirmDown.project)
16809
+ }, undefined, false, undefined, this)
15290
16810
  ]
15291
16811
  }, undefined, true, undefined, this),
15292
16812
  /* @__PURE__ */ jsxDEV5("box", {
@@ -15305,15 +16825,33 @@ function FleetApp({
15305
16825
  ]
15306
16826
  }, undefined, true, undefined, this);
15307
16827
  }
15308
- var LOG_RING_CAPACITY = 2000, EMPTY_CAPACITY;
16828
+ var LOG_RING_CAPACITY = 2000, TOAST_TTL_MS = 3500, EMPTY_CAPACITY, HELP_ROWS;
15309
16829
  var init_FleetApp = __esm(() => {
16830
+ init_fleet_controller();
15310
16831
  init_terminal_text();
16832
+ init_fleet_text();
15311
16833
  init_fleet_theme();
16834
+ init_fleet_log_rows();
16835
+ init_fleet_format();
16836
+ init_fleet_status();
15312
16837
  init_StackSidebar();
15313
16838
  init_ServicePane();
15314
16839
  init_LogPane();
15315
16840
  init_FleetModal();
15316
16841
  EMPTY_CAPACITY = { maxStacks: 0, live: 0, reserved: 0, queued: 0 };
16842
+ HELP_ROWS = [
16843
+ ["j k \u2191\u2193", "move in focused pane \xB7 Tab cycles panes"],
16844
+ [", . [ ]", "previous / next stack \xB7 service"],
16845
+ ["/", "filter stacks (Esc clears, then exits)"],
16846
+ ["f \xB7 g G", "pause/resume follow \xB7 top / bottom of logs"],
16847
+ ["o", "open selected endpoint in browser"],
16848
+ ["y \xB7 Y", "yank endpoint URL \xB7 stack env block"],
16849
+ ["c l p", "yank direct / local / public URL"],
16850
+ ["s", "shared hostnames (claim, allow, deny, release)"],
16851
+ ["D \xB7 d", "doctor report \xB7 down stack (volumes retained)"],
16852
+ ["1 2 0", "split / stacked / auto layout"],
16853
+ ["q", "quit \xB7 mouse click and wheel work everywhere"]
16854
+ ];
15317
16855
  });
15318
16856
 
15319
16857
  // packages/tui/src/job-control.ts
@@ -15464,16 +17002,51 @@ __export(exports_src, {
15464
17002
  var init_src3 = __esm(() => {
15465
17003
  init_runtime();
15466
17004
  init_fleet_source();
17005
+ init_fleet_controller();
15467
17006
  init_terminal_text();
15468
17007
  });
15469
17008
 
15470
17009
  // packages/cli/src/index.ts
15471
17010
  init_src2();
15472
17011
  init_src();
15473
- import { execFileSync as execFileSync4 } from "child_process";
15474
- import { existsSync as existsSync23 } from "fs";
15475
- import { dirname as dirname8, join as join24 } from "path";
17012
+ import { execFileSync as execFileSync5 } from "child_process";
17013
+ import { existsSync as existsSync24 } from "fs";
17014
+ import { dirname as dirname8, join as join26 } from "path";
15476
17015
  import { fileURLToPath as fileURLToPath3 } from "url";
17016
+ var CLI_VERSION = "1.2.0";
17017
+ var PACKAGE_NAME = "@tridha643/hestia";
17018
+ function compareVersions(a, b) {
17019
+ const parse = (value) => {
17020
+ const [core, pre] = value.split("-", 2);
17021
+ return {
17022
+ core: (core ?? "").split(".").map((part) => Number.parseInt(part, 10) || 0),
17023
+ pre: pre !== undefined && pre.length > 0
17024
+ };
17025
+ };
17026
+ const left = parse(a);
17027
+ const right = parse(b);
17028
+ const length = Math.max(left.core.length, right.core.length);
17029
+ for (let index = 0;index < length; index += 1) {
17030
+ const diff = (left.core[index] ?? 0) - (right.core[index] ?? 0);
17031
+ if (diff !== 0)
17032
+ return diff;
17033
+ }
17034
+ if (left.pre === right.pre)
17035
+ return 0;
17036
+ return left.pre ? -1 : 1;
17037
+ }
17038
+ async function fetchLatestVersion() {
17039
+ const response = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
17040
+ headers: { accept: "application/json" }
17041
+ });
17042
+ if (!response.ok)
17043
+ throw new Error(`npm registry responded ${response.status}`);
17044
+ const body = await response.json();
17045
+ if (typeof body.version !== "string" || body.version.length === 0) {
17046
+ throw new Error("npm registry response has no version");
17047
+ }
17048
+ return body.version;
17049
+ }
15477
17050
  function parseFlags(argv) {
15478
17051
  const f = {
15479
17052
  json: false,
@@ -15485,6 +17058,8 @@ function parseFlags(argv) {
15485
17058
  noPort: false,
15486
17059
  keepHostHeader: false,
15487
17060
  overwriteDns: false,
17061
+ cancel: false,
17062
+ check: false,
15488
17063
  noDaemon: false,
15489
17064
  print: false,
15490
17065
  interactive: false,
@@ -15566,6 +17141,22 @@ function parseFlags(argv) {
15566
17141
  f.keepHostHeader = true;
15567
17142
  else if (a === "--overwrite-dns")
15568
17143
  f.overwriteDns = true;
17144
+ else if (a === "--shared") {
17145
+ f.shared = takeValue();
17146
+ if (!f.shared || f.shared.startsWith("--"))
17147
+ f.errors.push("--shared requires a value");
17148
+ } else if (a === "--hostname") {
17149
+ f.hostname = takeValue();
17150
+ if (!f.hostname || f.hostname.startsWith("--"))
17151
+ f.errors.push("--hostname requires a value");
17152
+ } else if (a === "--path") {
17153
+ f.path = takeValue();
17154
+ if (!f.path || f.path.startsWith("--"))
17155
+ f.errors.push("--path requires a value");
17156
+ } else if (a === "--cancel")
17157
+ f.cancel = true;
17158
+ else if (a === "--check")
17159
+ f.check = true;
15569
17160
  else if (a === "--no-daemon")
15570
17161
  f.noDaemon = true;
15571
17162
  else if (a === "--print")
@@ -15637,12 +17228,16 @@ function parseFlags(argv) {
15637
17228
  function validateCommandOptions(argv, command) {
15638
17229
  const allowedByCommand = {
15639
17230
  version: new Set(["--json"]),
17231
+ upgrade: new Set(["--json", "--check"]),
15640
17232
  skill: new Set(["--json"]),
15641
17233
  discover: new Set(["--json"]),
15642
17234
  init: new Set(["--json", "--print", "--scope", "--write", "--no-port"]),
15643
17235
  up: new Set(["--json", "--services", "--workers", "--allow-remote", "--force", "--no-varlock", "--wait", "--no-daemon"]),
15644
17236
  run: new Set(["--json", "--name", "--env", "--no-port", "--varlock", "--signal", "--ready-timeout", "--cwd", "--wait", "--no-daemon"]),
15645
- expose: new Set(["--json", "--tunnel", "--zone", "--keep-host-header", "--overwrite-dns", "--force", "--ready-timeout"]),
17237
+ expose: new Set(["--json", "--tunnel", "--zone", "--keep-host-header", "--overwrite-dns", "--force", "--ready-timeout", "--shared", "--hostname", "--path"]),
17238
+ claim: new Set(["--json", "--wait", "--cancel"]),
17239
+ release: new Set(["--json"]),
17240
+ share: new Set(["--json"]),
15646
17241
  route: new Set(["--json"]),
15647
17242
  router: new Set(["--json", "--interactive"]),
15648
17243
  config: new Set(["--json", "--file"]),
@@ -15673,7 +17268,10 @@ function validateCommandOptions(argv, command) {
15673
17268
  "--zone",
15674
17269
  "--file",
15675
17270
  "--scope",
15676
- "--tail"
17271
+ "--tail",
17272
+ "--shared",
17273
+ "--hostname",
17274
+ "--path"
15677
17275
  ]);
15678
17276
  for (let index = 0;index < argv.length; index += 1) {
15679
17277
  const raw = argv[index];
@@ -15715,6 +17313,15 @@ function printStackHuman(r) {
15715
17313
  const pub = r.endpoints.find((e) => e.name === s.name)?.publicUrl;
15716
17314
  out.push(` \u25CF ${s.name.padEnd(12)} ${s.backend.padEnd(7)} ${port.padEnd(20)} ${s.state}` + (pub !== undefined ? ` ${pub}` : ""));
15717
17315
  }
17316
+ for (const shared of listSharedHostnames()) {
17317
+ const holds = shared.holder?.project === r.project;
17318
+ const queuedHere = (shared.queue ?? []).some((waiter) => waiter.project === r.project);
17319
+ if (!holds && !queuedHere)
17320
+ continue;
17321
+ const pending = (shared.queue ?? []).length;
17322
+ const url = `https://${shared.hostname}${shared.path ?? ""}`;
17323
+ out.push(holds ? ` \u21C4 shared ${shared.name.padEnd(10)} ${url} HELD` + (pending > 0 ? ` (${pending} queued \u2014 \`hestia share requests\`)` : "") : ` \u21C4 shared ${shared.name.padEnd(10)} ${url} queued behind ${shared.holder?.project ?? "?"}`);
17324
+ }
15718
17325
  process.stdout.write(out.join(`
15719
17326
  `) + `
15720
17327
  `);
@@ -15724,6 +17331,9 @@ var HELP = `hestia \u2014 per-worktree isolated dev stacks
15724
17331
  usage:
15725
17332
  hestia version [--json]
15726
17333
  print CLI version, state schema, and daemon protocol
17334
+ hestia upgrade [--check] [--json]
17335
+ upgrade hestia to the latest npm release via \`bun add --global\`;
17336
+ --check only reports whether a newer version is available
15727
17337
  hestia skill path [--json]
15728
17338
  print the packaged Hestia agent skill path
15729
17339
  hestia discover [--json]
@@ -15765,6 +17375,27 @@ usage:
15765
17375
  worktrees). URLs land in endpoints[] + HESTIA_<SVC>_URL. These are
15766
17376
  fail-closed but unauthenticated public URLs. Named v1 performs no DNS
15767
17377
  writes and requires wildcard CNAME *.<zone> \u2192 <uuid>.cfargotunnel.com.
17378
+ hestia expose <endpoint> --shared <name> [--hostname <fqdn>] [--path <prefix>]
17379
+ [--tunnel <t>] [--zone <zone>] [--json]
17380
+ declare + claim a machine-owned SHARED hostname \u2014 a stable public URL
17381
+ for externally-pinned integrations (Slack request URLs, webhook
17382
+ consumers) that cannot chase per-branch hostnames. Exactly one worktree
17383
+ holds it at a time; handoffs are consent-based and switch inside
17384
+ hestiad with zero connector restarts. The target defaults to
17385
+ <name>.<zone>; --hostname points ANY FQDN you control (any zone) at it,
17386
+ and --path /prefix lets several handles share one hostname routed by
17387
+ longest path prefix (e.g. acme.com/slack vs acme.com/stripe). Prefer
17388
+ plain per-branch expose whenever the external side can be configured
17389
+ per branch (dashboards, multi-redirect OAuth)
17390
+ hestia claim <name> [--wait[=secs]] [--cancel] [--json]
17391
+ claim a shared hostname for this worktree. Held names queue DURABLY
17392
+ (position survives daemon restarts and CLI timeouts); --wait blocks
17393
+ until the holder allows or releases, --cancel leaves the queue
17394
+ hestia release <name> [--json]
17395
+ release a held shared hostname; the queue head is granted immediately
17396
+ hestia share list|requests [--json] | share allow|deny <name> [--json]
17397
+ inspect shared hostnames and arbitrate queued claims as the holder:
17398
+ allow hands over now; deny keeps the requester queued until release
15768
17399
  hestia open <service> [path] [--json]
15769
17400
  resolve a service's public URL (from \`expose\`) and open it in the
15770
17401
  browser; the URL is always printed too, so a headless agent can hand
@@ -15819,7 +17450,7 @@ async function main() {
15819
17450
  switch (cmd) {
15820
17451
  case "version": {
15821
17452
  const version = {
15822
- cliVersion: "1.0.1",
17453
+ cliVersion: CLI_VERSION,
15823
17454
  stateSchema: STATE_SCHEMA_VERSION,
15824
17455
  daemonProtocol: HESTIAD_PROTOCOL_VERSION,
15825
17456
  runtime: "bun"
@@ -15829,6 +17460,45 @@ async function main() {
15829
17460
  `);
15830
17461
  else
15831
17462
  process.stdout.write(`hestia ${version.cliVersion} (state v${version.stateSchema}, daemon v${version.daemonProtocol}, Bun)
17463
+ `);
17464
+ break;
17465
+ }
17466
+ case "upgrade": {
17467
+ let latest;
17468
+ try {
17469
+ latest = await fetchLatestVersion();
17470
+ } catch (error) {
17471
+ fail("upgrade-failed", `could not check the latest ${PACKAGE_NAME} version: ${error.message}`, flags.json);
17472
+ }
17473
+ const available = compareVersions(latest, CLI_VERSION) > 0;
17474
+ if (flags.check || !available) {
17475
+ if (flags.json) {
17476
+ process.stdout.write(JSON.stringify({ current: CLI_VERSION, latest, upgradeAvailable: available }) + `
17477
+ `);
17478
+ } else if (available) {
17479
+ process.stdout.write(`hestia ${latest} is available (you have ${CLI_VERSION}) \u2014 run \`hestia upgrade\`
17480
+ `);
17481
+ } else {
17482
+ process.stdout.write(`hestia ${CLI_VERSION} is the latest release
17483
+ `);
17484
+ }
17485
+ break;
17486
+ }
17487
+ const installArgs = ["add", "--global", `${PACKAGE_NAME}@${latest}`];
17488
+ if (!flags.json) {
17489
+ process.stderr.write(`upgrading hestia ${CLI_VERSION} \u2192 ${latest} via \`bun ${installArgs.join(" ")}\`\u2026
17490
+ `);
17491
+ }
17492
+ try {
17493
+ execFileSync5("bun", installArgs, { stdio: flags.json ? "ignore" : "inherit" });
17494
+ } catch (error) {
17495
+ fail("upgrade-failed", `\`bun ${installArgs.join(" ")}\` failed: ${error.message}`, flags.json);
17496
+ }
17497
+ if (flags.json)
17498
+ process.stdout.write(JSON.stringify({ current: CLI_VERSION, latest, upgraded: true }) + `
17499
+ `);
17500
+ else
17501
+ process.stdout.write(`hestia upgraded ${CLI_VERSION} \u2192 ${latest}
15832
17502
  `);
15833
17503
  break;
15834
17504
  }
@@ -15837,10 +17507,10 @@ async function main() {
15837
17507
  fail("usage", "usage: hestia skill path", flags.json);
15838
17508
  const packageRoot = dirname8(dirname8(fileURLToPath3(import.meta.url)));
15839
17509
  const candidates = [
15840
- join24(packageRoot, "skills", "hestia", "SKILL.md"),
15841
- join24(process.cwd(), "skills", "hestia", "SKILL.md")
17510
+ join26(packageRoot, "skills", "hestia", "SKILL.md"),
17511
+ join26(process.cwd(), "skills", "hestia", "SKILL.md")
15842
17512
  ];
15843
- const path = candidates.find(existsSync23);
17513
+ const path = candidates.find(existsSync24);
15844
17514
  if (path === undefined)
15845
17515
  fail("config-missing", "packaged Hestia skill was not found", flags.json);
15846
17516
  if (flags.json)
@@ -15992,11 +17662,17 @@ ${result.proposed}`);
15992
17662
  if (flags.overwriteDns) {
15993
17663
  fail("usage", "--overwrite-dns was removed; named v1 requires user-managed wildcard DNS", flags.json);
15994
17664
  }
17665
+ if ((flags.hostname !== undefined || flags.path !== undefined) && flags.shared === undefined) {
17666
+ fail("usage", "--hostname/--path only apply with --shared <name>", flags.json);
17667
+ }
15995
17668
  const r = await engine.expose(cwd, services, {
15996
17669
  tunnel: flags.tunnel,
15997
17670
  zone: flags.zone,
15998
17671
  keepHostHeader: flags.keepHostHeader,
15999
17672
  overwriteDns: flags.overwriteDns,
17673
+ shared: flags.shared,
17674
+ hostname: flags.hostname,
17675
+ path: flags.path,
16000
17676
  force: flags.force,
16001
17677
  readyTimeoutMs: flags.readyTimeout
16002
17678
  });
@@ -16007,6 +17683,103 @@ ${result.proposed}`);
16007
17683
  printStackHuman(r);
16008
17684
  break;
16009
17685
  }
17686
+ case "claim": {
17687
+ const name = flags._[1];
17688
+ if (!name || flags._.length > 2) {
17689
+ fail("usage", "usage: hestia claim <name> [--wait[=secs]] [--cancel]", flags.json);
17690
+ }
17691
+ if (flags.cancel) {
17692
+ await engine.cancelSharedClaim(cwd, name);
17693
+ if (flags.json)
17694
+ process.stdout.write(JSON.stringify({ ok: true, cancelled: name }) + `
17695
+ `);
17696
+ else
17697
+ process.stdout.write(`left the queue for "${name}"
17698
+ `);
17699
+ break;
17700
+ }
17701
+ const { record, result } = await engine.claimShared(cwd, name, {
17702
+ waitMs: flags.wait !== undefined ? flags.wait * 1000 : 0
17703
+ });
17704
+ const hostname = listSharedHostnames().find((candidate) => candidate.name === name)?.hostname;
17705
+ if (flags.json) {
17706
+ process.stdout.write(JSON.stringify({ granted: true, holder: result.holder, record }, null, 2) + `
17707
+ `);
17708
+ } else {
17709
+ process.stdout.write(`claimed "${name}"${hostname !== undefined ? ` \u2014 https://${hostname}` : ""} now routes here
17710
+ `);
17711
+ }
17712
+ break;
17713
+ }
17714
+ case "release": {
17715
+ const name = flags._[1];
17716
+ if (!name || flags._.length > 2)
17717
+ fail("usage", "usage: hestia release <name>", flags.json);
17718
+ await engine.releaseShared(cwd, name);
17719
+ if (flags.json)
17720
+ process.stdout.write(JSON.stringify({ ok: true, released: name }) + `
17721
+ `);
17722
+ else
17723
+ process.stdout.write(`released "${name}" \u2014 next in queue (if any) now holds it
17724
+ `);
17725
+ break;
17726
+ }
17727
+ case "share": {
17728
+ const action = flags._[1];
17729
+ if (action === "list" || action === "requests") {
17730
+ const records = listSharedHostnames();
17731
+ const view = records.map((record) => ({
17732
+ name: record.name,
17733
+ hostname: record.hostname,
17734
+ path: record.path,
17735
+ url: `https://${record.hostname}${record.path ?? ""}`,
17736
+ service: record.service,
17737
+ holder: record.holder,
17738
+ queued: record.queue ?? []
17739
+ })).filter((entry) => action === "list" || entry.queued.length > 0);
17740
+ if (flags.json)
17741
+ process.stdout.write(JSON.stringify(view, null, 2) + `
17742
+ `);
17743
+ else if (view.length === 0) {
17744
+ process.stdout.write(action === "list" ? `no shared hostnames declared
17745
+ ` : `no pending claim requests
17746
+ `);
17747
+ } else {
17748
+ for (const entry of view) {
17749
+ const holder = entry.holder === undefined ? "unclaimed" : `held by ${entry.holder.project}`;
17750
+ process.stdout.write(`${entry.name.padEnd(16)} ${entry.url} ${holder}
17751
+ `);
17752
+ entry.queued.forEach((waiter, index) => {
17753
+ process.stdout.write(` ${String(index + 1).padStart(2)}. ${waiter.project}${waiter.denied ? " (denied \u2014 still queued)" : ""}
17754
+ `);
17755
+ });
17756
+ }
17757
+ }
17758
+ break;
17759
+ }
17760
+ const name = flags._[2];
17761
+ if (action !== "allow" && action !== "deny" || !name) {
17762
+ fail("usage", "usage: hestia share list|requests | share allow|deny <name>", flags.json);
17763
+ }
17764
+ if (action === "allow") {
17765
+ await engine.allowShared(cwd, name);
17766
+ if (flags.json)
17767
+ process.stdout.write(JSON.stringify({ ok: true, allowed: name }) + `
17768
+ `);
17769
+ else
17770
+ process.stdout.write(`allowed \u2014 "${name}" handed to the next in queue
17771
+ `);
17772
+ } else {
17773
+ await engine.denyShared(cwd, name);
17774
+ if (flags.json)
17775
+ process.stdout.write(JSON.stringify({ ok: true, denied: name }) + `
17776
+ `);
17777
+ else
17778
+ process.stdout.write(`denied \u2014 the requester stays queued until you release "${name}"
17779
+ `);
17780
+ }
17781
+ break;
17782
+ }
16010
17783
  case "route": {
16011
17784
  const action = flags._[1];
16012
17785
  const services = flags._.slice(2);
@@ -16270,8 +18043,8 @@ ${JSON.stringify(result.config, null, 2)}
16270
18043
  process.stdout.write(JSON.stringify(line) + `
16271
18044
  `);
16272
18045
  } else {
16273
- const text = line.meta ? `[hestia] ${line.text}` : line.text;
16274
- process.stdout.write(`${line.service.padEnd(nameWidth)} \u2502 ${text}
18046
+ const text2 = line.meta ? `[hestia] ${line.text}` : line.text;
18047
+ process.stdout.write(`${line.service.padEnd(nameWidth)} \u2502 ${text2}
16275
18048
  `);
16276
18049
  }
16277
18050
  }
@@ -16336,7 +18109,7 @@ ${JSON.stringify(result.config, null, 2)}
16336
18109
  } else if (sub === "stop") {
16337
18110
  if (launchdManagesThisHome()) {
16338
18111
  const uid = process.getuid?.() ?? 501;
16339
- execFileSync4("launchctl", ["bootout", `gui/${uid}/dev.hestia.daemon`]);
18112
+ execFileSync5("launchctl", ["bootout", `gui/${uid}/dev.hestia.daemon`]);
16340
18113
  process.stderr.write("warning: launchd agent booted out \u2014 `hestia daemon install` re-enables reboot revival\n");
16341
18114
  } else {
16342
18115
  await stopDaemonProcess();
@@ -16402,6 +18175,10 @@ ${JSON.stringify(result.config, null, 2)}
16402
18175
  fail("internal", err.message ?? String(err), flags.json);
16403
18176
  }
16404
18177
  }
16405
- main();
18178
+ if (import.meta.main)
18179
+ main();
18180
+ export {
18181
+ compareVersions
18182
+ };
16406
18183
 
16407
- //# debugId=7C129FA1100BA38864756E2164756E21
18184
+ //# debugId=E655EED0C9FC8A1364756E2164756E21