@tridha643/hestia 1.0.0 → 1.1.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 {
@@ -10858,13 +11133,18 @@ async function listTunnels() {
10858
11133
  connections: Array.isArray(t.connections) ? t.connections : []
10859
11134
  }));
10860
11135
  }
11136
+ async function countConnectors(uuid) {
11137
+ const out = await run(["tunnel", "info", "-o", "json", uuid]);
11138
+ const parsed = JSON.parse(out);
11139
+ return Array.isArray(parsed.conns) ? parsed.conns.length : 0;
11140
+ }
10861
11141
  async function adoptTunnel(name) {
10862
11142
  const entry = (await listTunnels()).find((t) => t.name === name);
10863
11143
  if (entry === undefined) {
10864
11144
  throw new HestiaError("tunnel-not-found", `no tunnel named "${name}" on this account \u2014 create it once with ` + `\`cloudflared tunnel create ${name}\``);
10865
11145
  }
10866
11146
  const credFile = credFilePath(entry.id);
10867
- if (!existsSync16(credFile)) {
11147
+ if (!existsSync17(credFile)) {
10868
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`);
10869
11149
  }
10870
11150
  return { uuid: entry.id, credFile, connections: entry.connections.length };
@@ -10877,8 +11157,8 @@ var init_cloudflared = __esm(() => {
10877
11157
 
10878
11158
  // packages/engine/src/tunnel/ingress.ts
10879
11159
  import { createHash as createHash6 } from "crypto";
10880
- import { existsSync as existsSync17, readFileSync as readFileSync15 } from "fs";
10881
- import { join as join19 } from "path";
11160
+ import { existsSync as existsSync18, readFileSync as readFileSync16 } from "fs";
11161
+ import { join as join20 } from "path";
10882
11162
  function hostnameFor(tunnelName, branch, service, zone) {
10883
11163
  const label = `${slug(tunnelName)}-${slug(branch)}-${slug(service)}`;
10884
11164
  if (label.length <= LABEL_MAX)
@@ -10896,12 +11176,12 @@ function inferZone(baseRules) {
10896
11176
  return zones.size === 1 ? [...zones][0] : undefined;
10897
11177
  }
10898
11178
  function importBaseRules(uuid, name) {
10899
- const p = join19(cloudflaredHome(), "config.yml");
10900
- if (!existsSync17(p))
11179
+ const p = join20(cloudflaredHome(), "config.yml");
11180
+ if (!existsSync18(p))
10901
11181
  return [];
10902
11182
  let parsed;
10903
11183
  try {
10904
- parsed = $parse(readFileSync15(p, "utf8"));
11184
+ parsed = $parse(readFileSync16(p, "utf8"));
10905
11185
  } catch {
10906
11186
  return [];
10907
11187
  }
@@ -10913,7 +11193,12 @@ function importBaseRules(uuid, name) {
10913
11193
  return parsed.ingress.filter((r) => r.hostname !== undefined || r.path !== undefined);
10914
11194
  }
10915
11195
  function generateMergedConfig(opts) {
10916
- 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
+ }));
10917
11202
  const dynamic = opts.dynamicRules.map((d) => ({
10918
11203
  hostname: d.hostname,
10919
11204
  service: `unix:${publicGatewaySocketPath()}`,
@@ -10924,17 +11209,30 @@ function generateMergedConfig(opts) {
10924
11209
  "credentials-file": opts.credFile,
10925
11210
  ingress: [
10926
11211
  ...opts.baseRules,
11212
+ ...shared,
10927
11213
  ...dynamic,
10928
11214
  { service: "http_status:404" }
10929
11215
  ]
10930
11216
  });
10931
11217
  }
10932
- function assertDisjoint(base, dynamic) {
11218
+ function assertDisjoint(base, dynamic, shared) {
10933
11219
  const seen = new Map;
10934
11220
  for (const r of base) {
10935
11221
  if (r.hostname !== undefined)
10936
11222
  seen.set(r.hostname, "the user's static rules");
10937
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
+ }
10938
11236
  for (const d of dynamic) {
10939
11237
  const holder = seen.get(d.hostname);
10940
11238
  if (holder !== undefined) {
@@ -10995,31 +11293,31 @@ var POLL_MS4 = 300;
10995
11293
 
10996
11294
  // packages/engine/src/tunnel/registry.ts
10997
11295
  import {
10998
- existsSync as existsSync18,
11296
+ existsSync as existsSync19,
10999
11297
  mkdirSync as mkdirSync8,
11000
- readFileSync as readFileSync16,
11001
- readdirSync as readdirSync6
11298
+ readFileSync as readFileSync17,
11299
+ readdirSync as readdirSync7
11002
11300
  } from "fs";
11003
- import { join as join20 } from "path";
11301
+ import { join as join21 } from "path";
11004
11302
  function tunnelDir(uuid) {
11005
- return join20(hestiaHome(), "tunnel", uuid);
11303
+ return join21(hestiaHome(), "tunnel", uuid);
11006
11304
  }
11007
11305
  function configPath(uuid) {
11008
- return join20(tunnelDir(uuid), "config.yml");
11306
+ return join21(tunnelDir(uuid), "config.yml");
11009
11307
  }
11010
11308
  function adoptedMarker(uuid) {
11011
- return join20(tunnelDir(uuid), "adopted.json");
11309
+ return join21(tunnelDir(uuid), "adopted.json");
11012
11310
  }
11013
11311
  function isAdopted(uuid) {
11014
- return existsSync18(adoptedMarker(uuid));
11312
+ return existsSync19(adoptedMarker(uuid));
11015
11313
  }
11016
11314
  function readAdopted(uuid) {
11017
11315
  const p = adoptedMarker(uuid);
11018
- if (!existsSync18(p))
11316
+ if (!existsSync19(p))
11019
11317
  return null;
11020
11318
  let marker;
11021
11319
  try {
11022
- marker = JSON.parse(readFileSync16(p, "utf8"));
11320
+ marker = JSON.parse(readFileSync17(p, "utf8"));
11023
11321
  } catch {
11024
11322
  marker = {};
11025
11323
  }
@@ -11027,14 +11325,14 @@ function readAdopted(uuid) {
11027
11325
  return { uuid, name: marker.name, credFile: marker.credFile, reconstructed: false };
11028
11326
  }
11029
11327
  let name;
11030
- const stacksDir = join20(hestiaHome(), "stacks");
11031
- if (existsSync18(stacksDir)) {
11032
- for (const project of readdirSync6(stacksDir)) {
11033
- const sp = join20(stacksDir, project, "stack.json");
11034
- if (!existsSync18(sp))
11328
+ const stacksDir = join21(hestiaHome(), "stacks");
11329
+ if (existsSync19(stacksDir)) {
11330
+ for (const project of readdirSync7(stacksDir)) {
11331
+ const sp = join21(stacksDir, project, "stack.json");
11332
+ if (!existsSync19(sp))
11035
11333
  continue;
11036
11334
  try {
11037
- const record = parseStackRecord(readFileSync16(sp, "utf8"), sp);
11335
+ const record = parseStackRecord(readFileSync17(sp, "utf8"), sp);
11038
11336
  if (record.tunnel?.uuid === uuid) {
11039
11337
  name = record.tunnel.name;
11040
11338
  break;
@@ -11050,24 +11348,24 @@ function readAdopted(uuid) {
11050
11348
  };
11051
11349
  }
11052
11350
  function listAdopted() {
11053
- const root = join20(hestiaHome(), "tunnel");
11054
- if (!existsSync18(root))
11351
+ const root = join21(hestiaHome(), "tunnel");
11352
+ if (!existsSync19(root))
11055
11353
  return [];
11056
- return readdirSync6(root).filter((uuid) => isAdopted(uuid));
11354
+ return readdirSync7(root).filter((uuid) => isAdopted(uuid));
11057
11355
  }
11058
11356
  function collectDynamicRules(uuid) {
11059
- const stacksDir = join20(hestiaHome(), "stacks");
11357
+ const stacksDir = join21(hestiaHome(), "stacks");
11060
11358
  const rules = [];
11061
11359
  const dropped = [];
11062
- if (!existsSync18(stacksDir))
11360
+ if (!existsSync19(stacksDir))
11063
11361
  return { rules, dropped };
11064
- for (const project of readdirSync6(stacksDir)) {
11065
- const p = join20(stacksDir, project, "stack.json");
11066
- if (!existsSync18(p))
11362
+ for (const project of readdirSync7(stacksDir)) {
11363
+ const p = join21(stacksDir, project, "stack.json");
11364
+ if (!existsSync19(p))
11067
11365
  continue;
11068
11366
  let record;
11069
11367
  try {
11070
- record = parseStackRecord(readFileSync16(p, "utf8"), p);
11368
+ record = parseStackRecord(readFileSync17(p, "utf8"), p);
11071
11369
  } catch {
11072
11370
  continue;
11073
11371
  }
@@ -11096,20 +11394,22 @@ async function reconcileTunnel(ref, opts) {
11096
11394
  for (const d of dropped) {
11097
11395
  warnings.push(`exposure ${d.hostname} dropped \u2014 service "${d.service}" of ` + `${d.project} is not running (hostname now 404s)`);
11098
11396
  }
11397
+ const sharedRules = listSharedHostnames().filter((record) => record.tunnelUuid === ref.uuid).map((record) => ({ name: record.name, hostname: record.hostname }));
11099
11398
  const cfgPath = configPath(ref.uuid);
11100
11399
  const nextConfig = generateMergedConfig({
11101
11400
  uuid: ref.uuid,
11102
11401
  credFile: ref.credFile,
11103
11402
  baseRules,
11104
- dynamicRules: rules
11403
+ dynamicRules: rules,
11404
+ sharedRules
11105
11405
  });
11106
11406
  const pf = readPidfile(dir, CONNECTOR);
11107
11407
  const live = pf !== null && isLive(pf);
11108
- const currentConfig = existsSync18(cfgPath) ? readFileSync16(cfgPath, "utf8") : null;
11408
+ const currentConfig = existsSync19(cfgPath) ? readFileSync17(cfgPath, "utf8") : null;
11109
11409
  if (live && currentConfig === nextConfig) {
11110
11410
  return { restarted: false, metricsPort: pf.port, ready: true, warnings };
11111
11411
  }
11112
- if (baseRules.length === 0 && rules.length === 0) {
11412
+ if (baseRules.length === 0 && rules.length === 0 && sharedRules.length === 0) {
11113
11413
  if (pf !== null) {
11114
11414
  await stopProcTree(pf);
11115
11415
  removePidfile(dir, CONNECTOR);
@@ -11180,6 +11480,7 @@ var init_registry = __esm(() => {
11180
11480
  init_pidfile();
11181
11481
  init_shutdown();
11182
11482
  init_ingress();
11483
+ init_shared();
11183
11484
  init_atomic_json_file();
11184
11485
  });
11185
11486
 
@@ -11618,10 +11919,230 @@ var init_stream = __esm(() => {
11618
11919
  init_tail();
11619
11920
  });
11620
11921
 
11922
+ // packages/engine/src/daemon/shared-arbiter.ts
11923
+ class SharedArbiter {
11924
+ onChange;
11925
+ #mutex = Promise.resolve();
11926
+ #polls = new Map;
11927
+ constructor(onChange) {
11928
+ this.onChange = onChange;
11929
+ }
11930
+ #locked(fn) {
11931
+ const next = this.#mutex.then(fn, fn);
11932
+ this.#mutex = next.catch(() => {});
11933
+ return next;
11934
+ }
11935
+ async#notify() {
11936
+ try {
11937
+ await this.onChange?.();
11938
+ } catch {}
11939
+ }
11940
+ #result(record, granted) {
11941
+ return { granted, holder: record.holder, queued: [...record.queue ?? []] };
11942
+ }
11943
+ #wake(name, project, result) {
11944
+ const key = `${name}\x00${project}`;
11945
+ for (const poll of this.#polls.get(key) ?? []) {
11946
+ clearTimeout(poll.timer);
11947
+ poll.resolve(result);
11948
+ }
11949
+ this.#polls.delete(key);
11950
+ }
11951
+ #grantNext(record) {
11952
+ const queue = [...record.queue ?? []];
11953
+ for (;; ) {
11954
+ const head = queue.shift();
11955
+ if (head === undefined) {
11956
+ const next = { ...record, queue: [] };
11957
+ delete next.holder;
11958
+ return next;
11959
+ }
11960
+ if (readMirrorState(head.project) === null)
11961
+ continue;
11962
+ return {
11963
+ ...record,
11964
+ holder: {
11965
+ project: head.project,
11966
+ worktree: head.worktree,
11967
+ service: record.service,
11968
+ at: new Date().toISOString()
11969
+ },
11970
+ queue
11971
+ };
11972
+ }
11973
+ }
11974
+ async request(name, requester, waitMs) {
11975
+ const outcome = await this.#locked(async () => {
11976
+ let granted = false;
11977
+ const updated = await updateSharedHostname(name, (record) => {
11978
+ if (record.holder?.project === requester.project) {
11979
+ granted = true;
11980
+ return {
11981
+ ...record,
11982
+ holder: { ...record.holder, worktree: requester.worktree },
11983
+ queue: (record.queue ?? []).filter((waiter) => waiter.project !== requester.project)
11984
+ };
11985
+ }
11986
+ if (record.holder === undefined) {
11987
+ granted = true;
11988
+ return {
11989
+ ...record,
11990
+ holder: {
11991
+ project: requester.project,
11992
+ worktree: requester.worktree,
11993
+ service: record.service,
11994
+ at: new Date().toISOString()
11995
+ },
11996
+ queue: (record.queue ?? []).filter((waiter) => waiter.project !== requester.project)
11997
+ };
11998
+ }
11999
+ const queue = [...record.queue ?? []];
12000
+ const existing = queue.find((waiter) => waiter.project === requester.project);
12001
+ if (existing !== undefined)
12002
+ existing.worktree = requester.worktree;
12003
+ else
12004
+ queue.push({ project: requester.project, worktree: requester.worktree, at: new Date().toISOString() });
12005
+ return { ...record, queue };
12006
+ });
12007
+ if (updated === null) {
12008
+ throw new HestiaError("shared-not-found", `no shared hostname "${name}" is declared`);
12009
+ }
12010
+ return { granted, record: updated };
12011
+ });
12012
+ if (outcome.granted) {
12013
+ await this.#notify();
12014
+ return this.#result(outcome.record, true);
12015
+ }
12016
+ if (waitMs <= 0)
12017
+ return this.#result(outcome.record, false);
12018
+ return new Promise((resolve2) => {
12019
+ const key = `${name}\x00${requester.project}`;
12020
+ const polls = this.#polls.get(key) ?? [];
12021
+ const poll = {
12022
+ resolve: resolve2,
12023
+ timer: setTimeout(() => {
12024
+ const remaining = (this.#polls.get(key) ?? []).filter((candidate) => candidate !== poll);
12025
+ if (remaining.length === 0)
12026
+ this.#polls.delete(key);
12027
+ else
12028
+ this.#polls.set(key, remaining);
12029
+ const record = readSharedHostname(name);
12030
+ resolve2(record === null ? { granted: false, queued: [] } : this.#result(record, record.holder?.project === requester.project));
12031
+ }, waitMs)
12032
+ };
12033
+ polls.push(poll);
12034
+ this.#polls.set(key, polls);
12035
+ });
12036
+ }
12037
+ async cancel(name, project) {
12038
+ const record = await this.#locked(() => updateSharedHostname(name, (current) => ({
12039
+ ...current,
12040
+ queue: (current.queue ?? []).filter((waiter) => waiter.project !== project)
12041
+ })));
12042
+ if (record === null)
12043
+ throw new HestiaError("shared-not-found", `no shared hostname "${name}" is declared`);
12044
+ this.#wake(name, project, this.#result(record, false));
12045
+ return this.#result(record, false);
12046
+ }
12047
+ #assertHolder(record, project, verb) {
12048
+ if (record.holder?.project !== project) {
12049
+ 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}`);
12050
+ }
12051
+ }
12052
+ async allow(name, callerProject) {
12053
+ const record = await this.#transfer(name, callerProject, "allow");
12054
+ return this.#result(record, false);
12055
+ }
12056
+ async deny(name, callerProject) {
12057
+ const record = await this.#locked(async () => {
12058
+ const current = readSharedHostname(name);
12059
+ if (current === null)
12060
+ throw new HestiaError("shared-not-found", `no shared hostname "${name}" is declared`);
12061
+ this.#assertHolder(current, callerProject, "deny");
12062
+ const updated = await updateSharedHostname(name, (candidate) => {
12063
+ const queue = [...candidate.queue ?? []];
12064
+ if (queue.length > 0)
12065
+ queue[0] = { ...queue[0], denied: true };
12066
+ return { ...candidate, queue };
12067
+ });
12068
+ return updated;
12069
+ });
12070
+ return this.#result(record, false);
12071
+ }
12072
+ async release(name, callerProject) {
12073
+ const record = await this.#transfer(name, callerProject, "release");
12074
+ return this.#result(record, false);
12075
+ }
12076
+ async#transfer(name, callerProject, verb) {
12077
+ const record = await this.#locked(async () => {
12078
+ const current = readSharedHostname(name);
12079
+ if (current === null)
12080
+ throw new HestiaError("shared-not-found", `no shared hostname "${name}" is declared`);
12081
+ this.#assertHolder(current, callerProject, verb);
12082
+ return await updateSharedHostname(name, (candidate) => this.#grantNext(candidate));
12083
+ });
12084
+ if (record.holder !== undefined) {
12085
+ this.#wake(record.name, record.holder.project, this.#result(record, true));
12086
+ }
12087
+ await this.#notify();
12088
+ return record;
12089
+ }
12090
+ async releaseProject(project, service) {
12091
+ const held = listSharedHostnames().filter((record) => record.holder?.project === project && (service === undefined || record.service === service));
12092
+ let changed = false;
12093
+ for (const record of held) {
12094
+ const updated = await this.#locked(async () => {
12095
+ const current = readSharedHostname(record.name);
12096
+ if (current?.holder?.project !== project)
12097
+ return null;
12098
+ return await updateSharedHostname(record.name, (candidate) => this.#grantNext(candidate));
12099
+ });
12100
+ if (updated === null || updated === undefined)
12101
+ continue;
12102
+ changed = true;
12103
+ if (updated.holder !== undefined) {
12104
+ this.#wake(updated.name, updated.holder.project, this.#result(updated, true));
12105
+ }
12106
+ }
12107
+ if (changed)
12108
+ await this.#notify();
12109
+ }
12110
+ async sweep(occupied) {
12111
+ let changed = false;
12112
+ for (const record of listSharedHostnames()) {
12113
+ const holderDead = record.holder !== undefined && !occupied.has(record.holder.project) && readMirrorState(record.holder.project) === null;
12114
+ const deadWaiters = (record.queue ?? []).filter((waiter) => readMirrorState(waiter.project) === null);
12115
+ if (!holderDead && deadWaiters.length === 0)
12116
+ continue;
12117
+ const updated = await this.#locked(() => updateSharedHostname(record.name, (candidate) => {
12118
+ const pruned = {
12119
+ ...candidate,
12120
+ queue: (candidate.queue ?? []).filter((waiter) => readMirrorState(waiter.project) !== null)
12121
+ };
12122
+ const currentHolderDead = pruned.holder !== undefined && !occupied.has(pruned.holder.project) && readMirrorState(pruned.holder.project) === null;
12123
+ return currentHolderDead ? this.#grantNext(pruned) : pruned;
12124
+ }));
12125
+ if (updated === null)
12126
+ continue;
12127
+ changed = true;
12128
+ if (updated.holder !== undefined && updated.holder.project !== record.holder?.project) {
12129
+ this.#wake(updated.name, updated.holder.project, this.#result(updated, true));
12130
+ }
12131
+ }
12132
+ if (changed)
12133
+ await this.#notify();
12134
+ }
12135
+ }
12136
+ var init_shared_arbiter = __esm(() => {
12137
+ init_src();
12138
+ init_state();
12139
+ init_shared();
12140
+ });
12141
+
11621
12142
  // packages/engine/src/doctor.ts
11622
- import { existsSync as existsSync19, readFileSync as readFileSync17, readdirSync as readdirSync7 } from "fs";
12143
+ import { existsSync as existsSync20, readFileSync as readFileSync18, readdirSync as readdirSync8 } from "fs";
11623
12144
  import { execFile as execFile9 } from "child_process";
11624
- import { dirname as dirname6, join as join21 } from "path";
12145
+ import { dirname as dirname6, join as join22 } from "path";
11625
12146
  function row(check, level, detail) {
11626
12147
  return { check, level, detail };
11627
12148
  }
@@ -11662,8 +12183,8 @@ async function envChecks(ctx) {
11662
12183
  }
11663
12184
  if (ctx !== null) {
11664
12185
  const ignored = await exec("git", ["-C", ctx.worktreeRoot, "check-ignore", "-q", ".hestia/"]);
11665
- 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")}`));
11666
- const schema = existsSync19(join21(ctx.worktreeRoot, ".env.schema"));
12186
+ rows.push(ignored.ok ? row("state-ignore", "ok", ".hestia/ is ignored") : row("state-ignore", "error", `add the exact line ".hestia/" to ${join22(ctx.worktreeRoot, ".gitignore")}`));
12187
+ const schema = existsSync20(join22(ctx.worktreeRoot, ".env.schema"));
11667
12188
  if (schema) {
11668
12189
  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?)"));
11669
12190
  }
@@ -11672,7 +12193,7 @@ async function envChecks(ctx) {
11672
12193
  const missing = workers.filter((w) => {
11673
12194
  let dir = dirname6(w.configPath);
11674
12195
  for (;; ) {
11675
- if (existsSync19(join21(dir, "node_modules", ".bin", "wrangler")))
12196
+ if (existsSync20(join22(dir, "node_modules", ".bin", "wrangler")))
11676
12197
  return false;
11677
12198
  if (dir === ctx.worktreeRoot || dirname6(dir) === dir)
11678
12199
  return true;
@@ -11731,10 +12252,10 @@ async function stateChecks(ctx) {
11731
12252
  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`));
11732
12253
  }
11733
12254
  }
11734
- const lockPath2 = join21(hestiaDir(worktreeRoot), "lock");
11735
- if (existsSync19(lockPath2)) {
12255
+ const lockPath2 = join22(hestiaDir(worktreeRoot), "lock");
12256
+ if (existsSync20(lockPath2)) {
11736
12257
  try {
11737
- const holder = JSON.parse(readFileSync17(lockPath2, "utf8"));
12258
+ const holder = JSON.parse(readFileSync18(lockPath2, "utf8"));
11738
12259
  if (!isLive(holder)) {
11739
12260
  rows.push(row("lock", "warn", "stale worktree lock (dead holder) \u2014 auto-broken on next command"));
11740
12261
  }
@@ -11742,25 +12263,25 @@ async function stateChecks(ctx) {
11742
12263
  rows.push(row("lock", "warn", "unreadable worktree lock file"));
11743
12264
  }
11744
12265
  }
11745
- const mirror = join21(hestiaHome(), "stacks", record.project, "stack.json");
11746
- if (!existsSync19(mirror)) {
12266
+ const mirror = join22(hestiaHome(), "stacks", record.project, "stack.json");
12267
+ if (!existsSync20(mirror)) {
11747
12268
  rows.push(row("mirror", "warn", "no ~/.hestia mirror \u2014 `down --project` would not work after worktree deletion"));
11748
12269
  }
11749
12270
  return rows;
11750
12271
  }
11751
12272
  async function machineChecks() {
11752
12273
  const rows = [];
11753
- const stacksDir = join21(hestiaHome(), "stacks");
12274
+ const stacksDir = join22(hestiaHome(), "stacks");
11754
12275
  const mirrored = new Set;
11755
- if (existsSync19(stacksDir)) {
11756
- for (const project of readdirSync7(stacksDir)) {
11757
- const p = join21(stacksDir, project, "stack.json");
11758
- if (!existsSync19(p))
12276
+ if (existsSync20(stacksDir)) {
12277
+ for (const project of readdirSync8(stacksDir)) {
12278
+ const p = join22(stacksDir, project, "stack.json");
12279
+ if (!existsSync20(p))
11759
12280
  continue;
11760
12281
  mirrored.add(project);
11761
12282
  try {
11762
- const rec = parseStackRecord(readFileSync17(p, "utf8"), p);
11763
- if (!existsSync19(rec.worktree)) {
12283
+ const rec = parseStackRecord(readFileSync18(p, "utf8"), p);
12284
+ if (!existsSync20(rec.worktree)) {
11764
12285
  rows.push(row(`orphan-mirror:${project}`, "warn", `worktree ${rec.worktree} is gone \u2014 \`hestia down --project ${project}\` to clean up`));
11765
12286
  }
11766
12287
  } catch {
@@ -11791,12 +12312,6 @@ async function tunnelChecks() {
11791
12312
  const adopted = listAdopted();
11792
12313
  if (adopted.length === 0)
11793
12314
  return rows;
11794
- let tunnels = null;
11795
- try {
11796
- tunnels = await listTunnels();
11797
- } catch {
11798
- tunnels = null;
11799
- }
11800
12315
  for (const uuid of adopted) {
11801
12316
  const ref = readAdopted(uuid);
11802
12317
  const label = `tunnel:${ref?.name ?? uuid}`;
@@ -11807,19 +12322,44 @@ async function tunnelChecks() {
11807
12322
  } else if (pf.port !== undefined) {
11808
12323
  rows.push(await isReady(pf.port) ? row(label, "ok", "connector live with an edge connection") : row(label, "warn", "connector running but no edge connection yet"));
11809
12324
  }
11810
- if (tunnels === null) {
12325
+ let connectors;
12326
+ try {
12327
+ connectors = await countConnectors(uuid);
12328
+ } catch {
12329
+ connectors = null;
12330
+ }
12331
+ if (connectors === null) {
11811
12332
  rows.push(row(`${label}:connectors`, "unknown", "cloudflare unreachable \u2014 cannot count connectors"));
11812
12333
  } else {
11813
- const info = tunnels.find((t) => t.id === uuid);
11814
- const conns = info?.connections?.length ?? 0;
11815
12334
  const expected = live ? 1 : 0;
11816
- if (conns > expected) {
11817
- rows.push(row(`${label}:connectors`, "error", `${conns} connector(s) registered but hestia runs ${expected} \u2014 a foreign ` + `replica cross-wires worktrees; stop the other cloudflared`));
12335
+ if (connectors > expected) {
12336
+ 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`));
11818
12337
  }
11819
12338
  }
11820
12339
  }
11821
12340
  return rows;
11822
12341
  }
12342
+ async function sharedHostnameChecks() {
12343
+ const rows = [];
12344
+ for (const shared of listSharedHostnames()) {
12345
+ const label = `shared:${shared.name}`;
12346
+ const url = `https://${shared.hostname}${shared.path ?? ""}`;
12347
+ const pending = (shared.queue ?? []).length;
12348
+ const queueSuffix = pending > 0 ? ` (${pending} queued)` : "";
12349
+ if (shared.holder === undefined) {
12350
+ rows.push(row(label, "warn", `${url} unclaimed \u2014 requests answer 503 until \`hestia claim ${shared.name}\`${queueSuffix}`));
12351
+ continue;
12352
+ }
12353
+ const mirror = readMirrorState(shared.holder.project);
12354
+ if (mirror === null) {
12355
+ rows.push(row(label, "warn", `held by ${shared.holder.project} but its stack mirror is gone \u2014 the daemon sweep auto-releases it${queueSuffix}`));
12356
+ continue;
12357
+ }
12358
+ const endpoint = mirror.endpoints.find((candidate) => (candidate.alias ?? candidate.name) === shared.service);
12359
+ 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}`));
12360
+ }
12361
+ return rows;
12362
+ }
11823
12363
  async function daemonChecks() {
11824
12364
  const rows = [];
11825
12365
  const j = readDaemonJson();
@@ -11834,11 +12374,11 @@ async function daemonChecks() {
11834
12374
  rows.push(row("daemon-config", "warn", w));
11835
12375
  }
11836
12376
  const plist = plistPath();
11837
- if (existsSync19(plist)) {
11838
- const content = readFileSync17(plist, "utf8");
12377
+ if (existsSync20(plist)) {
12378
+ const content = readFileSync18(plist, "utf8");
11839
12379
  const args = [...content.matchAll(/<string>([^<]+)<\/string>/g)].map((m) => m[1]);
11840
12380
  const paths = args.filter((a) => a.startsWith("/") && !a.includes(":"));
11841
- const missing = paths.filter((p) => !existsSync19(p));
12381
+ const missing = paths.filter((p) => !existsSync20(p));
11842
12382
  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\``));
11843
12383
  }
11844
12384
  return rows;
@@ -11877,6 +12417,7 @@ async function doctor(cwd) {
11877
12417
  ctx !== null ? bounded("state", () => stateChecks(ctx)) : Promise.resolve([]),
11878
12418
  bounded("machine", () => machineChecks()),
11879
12419
  bounded("tunnel", () => tunnelChecks()),
12420
+ bounded("shared", () => sharedHostnameChecks()),
11880
12421
  bounded("daemon", () => daemonChecks()),
11881
12422
  bounded("local-router", () => localRouterChecks())
11882
12423
  ]);
@@ -11888,6 +12429,7 @@ var init_doctor = __esm(() => {
11888
12429
  init_git();
11889
12430
  init_config();
11890
12431
  init_state();
12432
+ init_shared();
11891
12433
  init_pidfile();
11892
12434
  init_resolver();
11893
12435
  init_discover();
@@ -11904,8 +12446,8 @@ var init_doctor = __esm(() => {
11904
12446
  // packages/engine/src/daemon/fleet-monitor.ts
11905
12447
  import { execFile as execFile10 } from "child_process";
11906
12448
  import { promisify as promisify8 } from "util";
11907
- import { existsSync as existsSync20, readFileSync as readFileSync18, readdirSync as readdirSync8 } from "fs";
11908
- import { join as join22 } from "path";
12449
+ import { existsSync as existsSync21, readFileSync as readFileSync19, readdirSync as readdirSync9 } from "fs";
12450
+ import { join as join23 } from "path";
11909
12451
 
11910
12452
  class LatestFleetChannel {
11911
12453
  #pending;
@@ -11948,13 +12490,13 @@ class LatestFleetChannel {
11948
12490
  function readManagedMirrors() {
11949
12491
  const records = [];
11950
12492
  const warnings = [];
11951
- const stacksDirectory = join22(hestiaHome(), "stacks");
11952
- if (!existsSync20(stacksDirectory))
12493
+ const stacksDirectory = join23(hestiaHome(), "stacks");
12494
+ if (!existsSync21(stacksDirectory))
11953
12495
  return { records, warnings };
11954
- for (const project of readdirSync8(stacksDirectory).sort()) {
11955
- const path = join22(stacksDirectory, project, "stack.json");
12496
+ for (const project of readdirSync9(stacksDirectory).sort()) {
12497
+ const path = join23(stacksDirectory, project, "stack.json");
11956
12498
  try {
11957
- const source = readFileSync18(path);
12499
+ const source = readFileSync19(path);
11958
12500
  if (source.byteLength > MAX_MIRROR_BYTES2) {
11959
12501
  warnings.push(`Fleet mirror too large for ${project}`);
11960
12502
  continue;
@@ -11974,7 +12516,7 @@ function readManagedMirrors() {
11974
12516
  async function attributeLegacyRepoId(record) {
11975
12517
  if (record.repoId !== undefined)
11976
12518
  return record.repoId;
11977
- if (!existsSync20(record.worktree))
12519
+ if (!existsSync21(record.worktree))
11978
12520
  return null;
11979
12521
  const info = await getRepoInfo(record.worktree);
11980
12522
  if (projectName(info.repoId, info.repo, info.branch, info.worktreeRoot) !== record.project)
@@ -12080,6 +12622,25 @@ function reservationIdentity(reservation, records) {
12080
12622
  worktree: record.worktree
12081
12623
  };
12082
12624
  }
12625
+ function collectFleetShared(repoProjects) {
12626
+ return listSharedHostnames().map((record) => ({
12627
+ name: record.name,
12628
+ hostname: record.hostname,
12629
+ ...record.path === undefined ? {} : { path: record.path },
12630
+ url: `https://${record.hostname}${record.path ?? ""}`,
12631
+ holder: record.holder === undefined ? undefined : {
12632
+ project: record.holder.project,
12633
+ worktree: record.holder.worktree,
12634
+ mine: repoProjects.has(record.holder.project)
12635
+ },
12636
+ queue: (record.queue ?? []).map((waiter) => ({
12637
+ project: waiter.project,
12638
+ worktree: waiter.worktree,
12639
+ ...waiter.denied === true ? { denied: true } : {},
12640
+ mine: repoProjects.has(waiter.project)
12641
+ }))
12642
+ })).sort((left, right) => left.name.localeCompare(right.name));
12643
+ }
12083
12644
  async function collectFleetSnapshot(repoId, admission) {
12084
12645
  const mirrorResult = readManagedMirrors();
12085
12646
  const machineConfig = readHestiaMachineConfig();
@@ -12175,6 +12736,7 @@ async function collectFleetSnapshot(repoId, admission) {
12175
12736
  const allRecordedProjects = new Set(attributed.map((record) => record.project));
12176
12737
  const unbackedReservations = reservations.filter((reservation) => !allRecordedProjects.has(reservation.project));
12177
12738
  const { maxStacks, warnings: capWarnings } = resolveMaxStacks();
12739
+ const repoProjects = new Set(attributed.filter((record) => record.repoId === repoId).map((record) => record.project));
12178
12740
  return {
12179
12741
  repoId,
12180
12742
  observedAt: new Date().toISOString(),
@@ -12185,6 +12747,7 @@ async function collectFleetSnapshot(repoId, admission) {
12185
12747
  queued: queued.length
12186
12748
  },
12187
12749
  stacks: stackViews,
12750
+ shared: collectFleetShared(repoProjects),
12188
12751
  warnings: [...capWarnings, ...warnings].sort()
12189
12752
  };
12190
12753
  }
@@ -12193,6 +12756,7 @@ function semanticFleetSnapshot(snapshot) {
12193
12756
  repoId: snapshot.repoId,
12194
12757
  capacity: snapshot.capacity,
12195
12758
  stacks: snapshot.stacks,
12759
+ shared: snapshot.shared,
12196
12760
  warnings: snapshot.warnings
12197
12761
  });
12198
12762
  }
@@ -12304,6 +12868,7 @@ var init_fleet_monitor = __esm(() => {
12304
12868
  init_ports();
12305
12869
  init_pidfile();
12306
12870
  init_state();
12871
+ init_shared();
12307
12872
  init_router_config();
12308
12873
  init_local_http_router();
12309
12874
  init_portless_adapter();
@@ -12414,12 +12979,12 @@ var init_init_config = __esm(() => {
12414
12979
  });
12415
12980
 
12416
12981
  // packages/engine/src/index.ts
12417
- import { existsSync as existsSync21, rmSync as rmSync9 } from "fs";
12982
+ import { existsSync as existsSync22, rmSync as rmSync10 } from "fs";
12418
12983
  import { execFile as execFile11 } from "child_process";
12419
12984
  import { promisify as promisify9 } from "util";
12420
- import { join as join23, resolve as resolve2 } from "path";
12985
+ import { join as join24, resolve as resolve2 } from "path";
12421
12986
  import { createHash as createHash7 } from "crypto";
12422
- import { resolveCname } from "dns/promises";
12987
+ import { resolve4, resolve6, resolveCname } from "dns/promises";
12423
12988
  function composeUnsupported(message) {
12424
12989
  throw new HestiaError("compose-unsupported", message);
12425
12990
  }
@@ -12527,7 +13092,7 @@ async function assertHestiaStateIgnored(worktreeRoot) {
12527
13092
  try {
12528
13093
  await pexec9("git", ["-C", worktreeRoot, "check-ignore", "-q", ".hestia/"], { timeout: 5000 });
12529
13094
  } catch {
12530
- 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") });
13095
+ throw new HestiaError("state-not-ignored", `.hestia is not ignored in ${worktreeRoot}; add the line ".hestia/" to ${join24(worktreeRoot, ".gitignore")}`, { remedy: `.hestia/`, path: join24(worktreeRoot, ".gitignore") });
12531
13096
  }
12532
13097
  }
12533
13098
  async function prepareCompose(worktreeRoot, project, repo, branch, opts, configuredBaseFile) {
@@ -12544,7 +13109,7 @@ async function prepareCompose(worktreeRoot, project, repo, branch, opts, configu
12544
13109
  services
12545
13110
  });
12546
13111
  ensureDir(hestiaDir(worktreeRoot));
12547
- const overridePath = join23(hestiaDir(worktreeRoot), OVERRIDE_FILE);
13112
+ const overridePath = join24(hestiaDir(worktreeRoot), OVERRIDE_FILE);
12548
13113
  writeAtomicTextFile(overridePath, yaml);
12549
13114
  return {
12550
13115
  ctx: {
@@ -12577,7 +13142,7 @@ function writeDockerfileComposeModel(worktreeRoot, workloads, conventionalCompos
12577
13142
  ...conventionalComposeFile === undefined ? {} : { include: [{ path: conventionalComposeFile }] },
12578
13143
  services
12579
13144
  };
12580
- const path = join23(hestiaDir(worktreeRoot), DOCKERFILE_COMPOSE_FILE);
13145
+ const path = join24(hestiaDir(worktreeRoot), DOCKERFILE_COMPOSE_FILE);
12581
13146
  writeAtomicTextFile(path, $stringify(model));
12582
13147
  return path;
12583
13148
  }
@@ -12597,7 +13162,7 @@ function freshRecord(project, repoId, repo, branch, worktree) {
12597
13162
  };
12598
13163
  }
12599
13164
  function assertCurrentStackIdentity(record, current) {
12600
- const path = join23(hestiaDir(current.worktreeRoot), "stack.json");
13165
+ const path = join24(hestiaDir(current.worktreeRoot), "stack.json");
12601
13166
  assertMutableStackRecord(record, path);
12602
13167
  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);
12603
13168
  if (!matches) {
@@ -12732,6 +13297,27 @@ function applyLocalRouteProjection(record) {
12732
13297
  record.env[directUrlKey(endpoint.name)] = endpoint.url;
12733
13298
  record.env[localUrlKey(endpoint.name)] = endpoint.localUrl;
12734
13299
  }
13300
+ syncSharedProjection(record);
13301
+ }
13302
+ function syncSharedProjection(record) {
13303
+ for (const shared of listSharedHostnames()) {
13304
+ const url = `https://${shared.hostname}${shared.path ?? ""}`;
13305
+ if (shared.holder?.project === record.project) {
13306
+ const endpoint = record.endpoints.find((candidate) => (candidate.alias ?? candidate.name) === shared.service);
13307
+ if (endpoint !== undefined) {
13308
+ endpoint.publicUrl = url;
13309
+ record.env[urlKey(shared.service)] = url;
13310
+ }
13311
+ continue;
13312
+ }
13313
+ for (const endpoint of record.endpoints) {
13314
+ if (endpoint.publicUrl === url)
13315
+ delete endpoint.publicUrl;
13316
+ }
13317
+ if (record.env[urlKey(shared.service)] === url) {
13318
+ delete record.env[urlKey(shared.service)];
13319
+ }
13320
+ }
12735
13321
  }
12736
13322
  function syncExposures(record) {
12737
13323
  const t = record.tunnel;
@@ -12755,7 +13341,7 @@ function matchesExpectedStack(record, expected) {
12755
13341
  return record !== null && (record.repoId === undefined || record.repoId === expected.repoId) && record.worktree === expected.worktree && record.createdAt === expected.createdAt;
12756
13342
  }
12757
13343
  function projectMutationRoot(project) {
12758
- return join23(hestiaHome(), "project-locks", project);
13344
+ return join24(hestiaHome(), "project-locks", project);
12759
13345
  }
12760
13346
  async function assertNamedTunnelDns(hostname, tunnelUuid) {
12761
13347
  const expected = `${tunnelUuid}.cfargotunnel.com`;
@@ -12766,6 +13352,14 @@ async function assertNamedTunnelDns(hostname, tunnelUuid) {
12766
13352
  if (records.includes(expected.toLowerCase()))
12767
13353
  return;
12768
13354
  } catch {}
13355
+ try {
13356
+ if ((await resolve4(hostname)).length > 0)
13357
+ return;
13358
+ } catch {}
13359
+ try {
13360
+ if ((await resolve6(hostname)).length > 0)
13361
+ return;
13362
+ } catch {}
12769
13363
  throw new HestiaError("dns-route-required", `${hostname} does not resolve through Hestia's adopted tunnel; configure wildcard CNAME *.${hostname.split(".").slice(1).join(".")} -> ${expected}`, { hostname, wildcardTarget: expected });
12770
13364
  }
12771
13365
 
@@ -13153,6 +13747,7 @@ class ComposeEngine {
13153
13747
  const { repo, repoId, branch, worktreeRoot } = currentIdentity;
13154
13748
  let tunnel;
13155
13749
  let tunnelDirty = false;
13750
+ const stoppedAliases = [];
13156
13751
  const project = readState(worktreeRoot)?.project ?? projectName(repoId, repo, branch, worktreeRoot);
13157
13752
  await withLock(worktreeRoot, () => withLock(projectMutationRoot(project), async () => {
13158
13753
  const record = readState(worktreeRoot);
@@ -13161,6 +13756,11 @@ class ComposeEngine {
13161
13756
  if (record.services.some((service) => service.name === name && service.backend === "docker")) {
13162
13757
  throw new HestiaError("backend-not-stoppable", `Docker workload ${name} cannot be stopped individually; use hestia down`);
13163
13758
  }
13759
+ for (const endpoint of record.endpoints) {
13760
+ if ((endpoint.workload ?? endpoint.name) === name) {
13761
+ stoppedAliases.push(endpoint.alias ?? endpoint.name);
13762
+ }
13763
+ }
13164
13764
  }
13165
13765
  const pf = readPidfile(worktreeRoot, name);
13166
13766
  if (pf !== null) {
@@ -13206,6 +13806,9 @@ class ComposeEngine {
13206
13806
  }
13207
13807
  }
13208
13808
  }));
13809
+ for (const alias of new Set(stoppedAliases)) {
13810
+ await this.#releaseSharedHoldings(project, alias);
13811
+ }
13209
13812
  if (tunnelDirty)
13210
13813
  await this.#reconcileAdopted(tunnel);
13211
13814
  await this.#refreshLocalRoutes();
@@ -13341,12 +13944,12 @@ class ComposeEngine {
13341
13944
  await stopProcTree(pf);
13342
13945
  removePidfile(worktreeRoot, pf.name);
13343
13946
  }
13344
- rmSync9(privateRegistryDir(worktreeRoot), { recursive: true, force: true });
13947
+ rmSync10(privateRegistryDir(worktreeRoot), { recursive: true, force: true });
13345
13948
  const composeFile = record?.composeFile ?? tryLoadConfig(worktreeRoot)?.composeFile;
13346
13949
  if (composeFile !== undefined) {
13347
13950
  const project2 = record?.project ?? projectName(repoId, repo, branch, worktreeRoot);
13348
- const overrideFile = record?.overrideFile ?? join23(hestiaDir(worktreeRoot), OVERRIDE_FILE);
13349
- if (existsSync21(overrideFile)) {
13951
+ const overrideFile = record?.overrideFile ?? join24(hestiaDir(worktreeRoot), OVERRIDE_FILE);
13952
+ if (existsSync22(overrideFile)) {
13350
13953
  await composeDown({ project: project2, baseFile: composeFile, overrideFile, cwd: worktreeRoot }, opts?.destroy ?? false);
13351
13954
  } else if (record?.composeFile !== undefined) {
13352
13955
  const rest = ["compose", "-p", project2, "down", "--remove-orphans"];
@@ -13358,6 +13961,7 @@ class ComposeEngine {
13358
13961
  const project = record?.project ?? projectName(repoId, repo, branch, worktreeRoot);
13359
13962
  clearState(worktreeRoot, project);
13360
13963
  await this.#releaseAdmission(project);
13964
+ await this.#releaseSharedHoldings(project);
13361
13965
  }));
13362
13966
  await this.#reconcileAdopted(tunnel);
13363
13967
  await this.#refreshLocalRoutes();
@@ -13367,6 +13971,11 @@ class ComposeEngine {
13367
13971
  if (j !== null)
13368
13972
  await releaseSlot(j.port, project);
13369
13973
  }
13974
+ async#releaseSharedHoldings(project, service) {
13975
+ const j = readDaemonJson();
13976
+ if (j !== null)
13977
+ await releaseSharedForProject(j.port, project, service);
13978
+ }
13370
13979
  async downProject(project, opts) {
13371
13980
  if (!/^[a-z0-9][a-z0-9-]{0,99}$/.test(project)) {
13372
13981
  throw new HestiaError("usage", `invalid project name ${JSON.stringify(project)}`);
@@ -13409,9 +14018,9 @@ class ComposeEngine {
13409
14018
  throw new HestiaError("compose-failed", `docker compose -p ${project} down failed: ${err.message}`);
13410
14019
  }
13411
14020
  }
13412
- rmSync9(mirrorDir(project), { recursive: true, force: true });
14021
+ rmSync10(mirrorDir(project), { recursive: true, force: true });
13413
14022
  };
13414
- if (record !== null && existsSync21(record.worktree)) {
14023
+ if (record !== null && existsSync22(record.worktree)) {
13415
14024
  const info = await getRepoInfo(record.worktree);
13416
14025
  const identityMatches = resolve2(info.worktreeRoot) === resolve2(record.worktree) && (record.repoId === undefined ? record.repo === info.repo : record.repoId === info.repoId);
13417
14026
  const localRecord = readState(record.worktree);
@@ -13432,6 +14041,7 @@ class ComposeEngine {
13432
14041
  return;
13433
14042
  }
13434
14043
  await this.#releaseAdmission(project);
14044
+ await this.#releaseSharedHoldings(project);
13435
14045
  await this.#reconcileAdopted(record.tunnel);
13436
14046
  await this.#refreshLocalRoutes();
13437
14047
  return;
@@ -13440,6 +14050,7 @@ class ComposeEngine {
13440
14050
  const projectLockRoot = projectMutationRoot(project);
13441
14051
  await withLock(projectLockRoot, teardownFromMirror);
13442
14052
  await this.#releaseAdmission(project);
14053
+ await this.#releaseSharedHoldings(project);
13443
14054
  await this.#reconcileAdopted(record?.tunnel);
13444
14055
  await this.#refreshLocalRoutes();
13445
14056
  }
@@ -13458,11 +14069,100 @@ class ComposeEngine {
13458
14069
  }
13459
14070
  await ensureDaemon();
13460
14071
  await this.#refreshLocalRoutes(true);
14072
+ if (opts?.shared !== undefined) {
14073
+ if (tunnelName === undefined) {
14074
+ throw new HestiaError("shared-requires-named-tunnel", "shared hostnames need an adopted named tunnel (stable DNS is the point) \u2014 pass --tunnel <name>");
14075
+ }
14076
+ return this.#exposeShared(worktreeRoot, services, tunnelName, opts);
14077
+ }
13461
14078
  if (tunnelName === undefined) {
13462
14079
  return this.#exposeQuick(worktreeRoot, services, opts);
13463
14080
  }
13464
14081
  return this.#exposeNamed(worktreeRoot, services, tunnelName, opts);
13465
14082
  }
14083
+ async#exposeShared(worktreeRoot, services, tunnelName, opts) {
14084
+ if (services.length !== 1) {
14085
+ throw new HestiaError("usage", "--shared declares exactly one service per shared hostname");
14086
+ }
14087
+ const name = opts.shared;
14088
+ assertSharedName(name);
14089
+ const project = readState(worktreeRoot)?.project;
14090
+ if (project === undefined) {
14091
+ throw new HestiaError("service-not-found", "no stack in this worktree \u2014 `hestia up`/`run` something first");
14092
+ }
14093
+ const adopted = await adoptTunnel(tunnelName);
14094
+ if (adopted.connections > 0 && !isAdopted(adopted.uuid) && !opts?.force) {
14095
+ 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`);
14096
+ }
14097
+ const preflightRecord = readState(worktreeRoot);
14098
+ if (preflightRecord === null) {
14099
+ throw new HestiaError("service-not-found", "stack disappeared while preparing the shared hostname");
14100
+ }
14101
+ const baseRules = importBaseRules(adopted.uuid, tunnelName);
14102
+ const zoneHint = opts?.zone ?? preflightRecord.tunnel?.zone ?? inferZone(baseRules);
14103
+ let hostname;
14104
+ let zone;
14105
+ if (opts?.hostname !== undefined) {
14106
+ hostname = opts.hostname.toLowerCase();
14107
+ assertSharedHostnameFqdn(hostname);
14108
+ zone = zoneOf(hostname) ?? zoneHint ?? hostname;
14109
+ } else {
14110
+ if (zoneHint === undefined) {
14111
+ throw new HestiaError("usage", "cannot infer a zone from the tunnel's existing rules \u2014 pass --zone or an explicit --hostname");
14112
+ }
14113
+ zone = zoneHint;
14114
+ hostname = `${name}.${zone}`;
14115
+ }
14116
+ const path = normalizeSharedPath(opts?.path);
14117
+ const selection = resolveEndpointSelection(preflightRecord, services[0]);
14118
+ if (selection.endpoint.kind !== undefined && selection.endpoint.kind !== "http") {
14119
+ throw new HestiaError("usage", `public tunnels require an HTTP endpoint; ${services[0]} is ${selection.endpoint.kind}`);
14120
+ }
14121
+ const alias = selection.endpoint.alias ?? selection.endpoint.name;
14122
+ if (baseRules.some((rule) => rule.hostname === hostname)) {
14123
+ throw new HestiaError("hostname-conflict", `shared hostname ${hostname} is already claimed by the user's static rules`);
14124
+ }
14125
+ const dynamicConflict = collectDynamicRules(adopted.uuid).rules.find((rule) => rule.hostname === hostname);
14126
+ if (dynamicConflict !== undefined) {
14127
+ throw new HestiaError("hostname-conflict", `shared hostname ${hostname} is already claimed by project ${dynamicConflict.project}`);
14128
+ }
14129
+ await assertNamedTunnelDns(hostname, adopted.uuid);
14130
+ await declareSharedHostname({
14131
+ name,
14132
+ hostname,
14133
+ path,
14134
+ tunnelUuid: adopted.uuid,
14135
+ zone,
14136
+ service: alias
14137
+ });
14138
+ const handle = await ensureDaemon();
14139
+ const claim = await sharedVerb(handle.port, "claim", {
14140
+ name,
14141
+ project,
14142
+ worktree: worktreeRoot,
14143
+ waitMs: 0
14144
+ });
14145
+ if (!claim.granted) {
14146
+ 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`);
14147
+ }
14148
+ const outcome = await reconcileTunnel({ name: tunnelName, uuid: adopted.uuid, credFile: adopted.credFile }, { force: opts?.force, readyTimeoutMs: opts?.readyTimeoutMs });
14149
+ for (const w of outcome.warnings)
14150
+ process.stderr.write(`warning: ${w}
14151
+ `);
14152
+ const final = await withLock(worktreeRoot, () => withLock(projectMutationRoot(project), async () => {
14153
+ const record = readState(worktreeRoot);
14154
+ if (record === null) {
14155
+ throw new HestiaError("service-not-found", "stack disappeared while exposing (concurrent down?)");
14156
+ }
14157
+ applyLocalRouteProjection(record);
14158
+ writeState(worktreeRoot, record);
14159
+ return record;
14160
+ }));
14161
+ if (outcome.error !== undefined)
14162
+ throw outcome.error;
14163
+ await this.#refreshLocalRoutes(true);
14164
+ return final;
14165
+ }
13466
14166
  async#exposeQuick(worktreeRoot, services, opts) {
13467
14167
  const project = readState(worktreeRoot)?.project;
13468
14168
  if (project === undefined) {
@@ -13481,7 +14181,7 @@ class ComposeEngine {
13481
14181
  const alias = selection.endpoint.alias ?? selection.endpoint.name;
13482
14182
  const authority = internalEndpointAuthority(record.project, alias);
13483
14183
  const name = `aux-quick-${createHash7("sha256").update(alias).digest("hex").slice(0, 10)}`;
13484
- const quickCfg = join23(hestiaDir(worktreeRoot), `quick-${name}.yml`);
14184
+ const quickCfg = join24(hestiaDir(worktreeRoot), `quick-${name}.yml`);
13485
14185
  writeAtomicTextFile(quickCfg, `ingress:
13486
14186
  - service: ${JSON.stringify(`unix:${publicGatewaySocketPath()}`)}
13487
14187
  ` + ` originRequest:
@@ -13569,7 +14269,7 @@ class ComposeEngine {
13569
14269
  }
13570
14270
  const adopted = await adoptTunnel(tunnelName);
13571
14271
  if (adopted.connections > 0 && !isAdopted(adopted.uuid) && !opts?.force) {
13572
- throw new HestiaError("tunnel-busy", `tunnel "${tunnelName}" already has ${adopted.connections} live ` + `connector(s) \u2014 stop the other cloudflared first (hestia's ` + `connector serves your static hostnames too), or pass --force to ` + `accept nondeterministic routing`);
14272
+ 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`);
13573
14273
  }
13574
14274
  const preflightRecord = readState(worktreeRoot);
13575
14275
  if (preflightRecord === null) {
@@ -13673,6 +14373,80 @@ class ComposeEngine {
13673
14373
  await this.#refreshLocalRoutes(true);
13674
14374
  return final;
13675
14375
  }
14376
+ async#sharedContext(cwd, name) {
14377
+ assertSharedName(name);
14378
+ const { worktreeRoot } = await getRepoInfo(cwd);
14379
+ const record = readState(worktreeRoot);
14380
+ if (record === null) {
14381
+ throw new HestiaError("service-not-found", "no stack in this worktree \u2014 `hestia up`/`run` something first");
14382
+ }
14383
+ const shared = readSharedHostname(name);
14384
+ if (shared === null) {
14385
+ throw new HestiaError("shared-not-found", `no shared hostname "${name}" is declared \u2014 \`hestia expose <svc> --shared ${name}\` creates one`);
14386
+ }
14387
+ return { worktreeRoot, record, shared };
14388
+ }
14389
+ async#applySharedProjection(worktreeRoot, project) {
14390
+ return withLock(worktreeRoot, () => withLock(projectMutationRoot(project), async () => {
14391
+ const record = readState(worktreeRoot);
14392
+ if (record === null) {
14393
+ throw new HestiaError("service-not-found", "stack disappeared during the shared-hostname transition");
14394
+ }
14395
+ applyLocalRouteProjection(record);
14396
+ writeState(worktreeRoot, record);
14397
+ return record;
14398
+ }));
14399
+ }
14400
+ async claimShared(cwd, name, opts) {
14401
+ const { worktreeRoot, record, shared } = await this.#sharedContext(cwd, name);
14402
+ const endpoint = record.endpoints.find((candidate) => (candidate.alias ?? candidate.name) === shared.service);
14403
+ if (endpoint === undefined) {
14404
+ 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`);
14405
+ }
14406
+ if (endpoint.kind !== undefined && endpoint.kind !== "http") {
14407
+ throw new HestiaError("usage", `shared hostnames require an HTTP endpoint; ${shared.service} is ${endpoint.kind}`);
14408
+ }
14409
+ const handle = await ensureDaemon();
14410
+ const result = await sharedVerb(handle.port, "claim", {
14411
+ name,
14412
+ project: record.project,
14413
+ worktree: worktreeRoot,
14414
+ waitMs: opts?.waitMs ?? 0
14415
+ });
14416
+ if (!result.granted) {
14417
+ const position = result.queued.findIndex((waiter) => waiter.project === record.project) + 1;
14418
+ 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 });
14419
+ }
14420
+ const updated = await this.#applySharedProjection(worktreeRoot, record.project);
14421
+ await this.#refreshLocalRoutes();
14422
+ return { record: updated, result };
14423
+ }
14424
+ async releaseShared(cwd, name) {
14425
+ const { worktreeRoot, record } = await this.#sharedContext(cwd, name);
14426
+ const handle = await ensureDaemon();
14427
+ await sharedVerb(handle.port, "release", { name, project: record.project });
14428
+ const updated = await this.#applySharedProjection(worktreeRoot, record.project);
14429
+ await this.#refreshLocalRoutes();
14430
+ return updated;
14431
+ }
14432
+ async cancelSharedClaim(cwd, name) {
14433
+ const { record } = await this.#sharedContext(cwd, name);
14434
+ const handle = await ensureDaemon();
14435
+ await sharedVerb(handle.port, "cancel", { name, project: record.project });
14436
+ }
14437
+ async allowShared(cwd, name) {
14438
+ const { worktreeRoot, record } = await this.#sharedContext(cwd, name);
14439
+ const handle = await ensureDaemon();
14440
+ await sharedVerb(handle.port, "allow", { name, project: record.project });
14441
+ const updated = await this.#applySharedProjection(worktreeRoot, record.project);
14442
+ await this.#refreshLocalRoutes();
14443
+ return updated;
14444
+ }
14445
+ async denyShared(cwd, name) {
14446
+ const { record } = await this.#sharedContext(cwd, name);
14447
+ const handle = await ensureDaemon();
14448
+ await sharedVerb(handle.port, "deny", { name, project: record.project });
14449
+ }
13676
14450
  async status(cwd) {
13677
14451
  const { worktreeRoot } = await getRepoInfo(cwd);
13678
14452
  const record = readState(worktreeRoot);
@@ -13799,11 +14573,14 @@ var init_src2 = __esm(() => {
13799
14573
  init_cloudflared();
13800
14574
  init_ingress();
13801
14575
  init_registry();
14576
+ init_shared();
13802
14577
  init_ensure();
13803
14578
  init_client();
13804
14579
  init_stream();
13805
14580
  init_router_config();
13806
14581
  init_cli();
14582
+ init_shared();
14583
+ init_shared_arbiter();
13807
14584
  init_lock();
13808
14585
  init_supervisor();
13809
14586
  init_tail();
@@ -13992,6 +14769,18 @@ class DaemonFleetSource {
13992
14769
  diagnose(worktree) {
13993
14770
  return doctor(worktree);
13994
14771
  }
14772
+ async claimShared(worktree, name) {
14773
+ await engine.claimShared(worktree, name);
14774
+ }
14775
+ async allowShared(worktree, name) {
14776
+ await engine.allowShared(worktree, name);
14777
+ }
14778
+ async denyShared(worktree, name) {
14779
+ await engine.denyShared(worktree, name);
14780
+ }
14781
+ async releaseShared(worktree, name) {
14782
+ await engine.releaseShared(worktree, name);
14783
+ }
13995
14784
  down(stack) {
13996
14785
  if (stack.createdAt === undefined) {
13997
14786
  return Promise.reject(new Error(`stack ${stack.project} has no stable incarnation timestamp`));
@@ -14025,6 +14814,8 @@ function createFleetUiState() {
14025
14814
  unseenLines: 0,
14026
14815
  helpOpen: false,
14027
14816
  doctorOpen: false,
14817
+ sharedOpen: false,
14818
+ sharedSelection: 0,
14028
14819
  downPending: false
14029
14820
  };
14030
14821
  }
@@ -14184,6 +14975,14 @@ function reduceFleetUiState(state, action) {
14184
14975
  return { ...state, helpOpen: action.open };
14185
14976
  case "doctor":
14186
14977
  return { ...state, doctorOpen: action.open };
14978
+ case "shared":
14979
+ return { ...state, sharedOpen: action.open, sharedSelection: action.open ? 0 : state.sharedSelection };
14980
+ case "move-shared": {
14981
+ if (action.count <= 0)
14982
+ return { ...state, sharedSelection: 0 };
14983
+ const next = Math.max(0, Math.min(action.count - 1, state.sharedSelection + action.delta));
14984
+ return { ...state, sharedSelection: next };
14985
+ }
14187
14986
  case "confirm-down":
14188
14987
  return {
14189
14988
  ...state,
@@ -14723,7 +15522,7 @@ var init_FleetModal = __esm(() => {
14723
15522
 
14724
15523
  // packages/tui/src/FleetApp.tsx
14725
15524
  import { useEffect, useMemo, useReducer, useRef, useState } from "react";
14726
- import { existsSync as existsSync22 } from "fs";
15525
+ import { existsSync as existsSync23 } from "fs";
14727
15526
  import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
14728
15527
  import { jsxDEV as jsxDEV5, Fragment as Fragment2 } from "@opentui/react/jsx-dev-runtime";
14729
15528
  function middleTruncateWorktreePath(path, width) {
@@ -14741,7 +15540,7 @@ function middleTruncateWorktreePath(path, width) {
14741
15540
  return `${path.slice(0, prefixWidth)}\u2026/${basename3}`;
14742
15541
  }
14743
15542
  function emptySnapshot(repoId) {
14744
- return { repoId, observedAt: new Date(0).toISOString(), capacity: EMPTY_CAPACITY, stacks: [], warnings: [] };
15543
+ return { repoId, observedAt: new Date(0).toISOString(), capacity: EMPTY_CAPACITY, stacks: [], shared: [], warnings: [] };
14745
15544
  }
14746
15545
  function isEscape(key) {
14747
15546
  const name = key.name.toLowerCase();
@@ -14809,6 +15608,7 @@ function FleetApp({
14809
15608
  const [notice, setNotice] = useState("connecting to hestiad\u2026");
14810
15609
  const [doctorRows, setDoctorRows] = useState([]);
14811
15610
  const [doctorRunning, setDoctorRunning] = useState(false);
15611
+ const [sharedBusy, setSharedBusy] = useState(false);
14812
15612
  const lastFrameAt = useRef(Date.now());
14813
15613
  const stateRef = useRef(state);
14814
15614
  stateRef.current = state;
@@ -14920,6 +15720,55 @@ function FleetApp({
14920
15720
  }
14921
15721
  return;
14922
15722
  }
15723
+ if (current.sharedOpen) {
15724
+ key.preventDefault();
15725
+ key.stopPropagation();
15726
+ if (isEscape(key) || isFleetKey(key, "s"))
15727
+ return dispatch({ type: "shared", open: false });
15728
+ const list = snapshot.shared;
15729
+ if (list.length === 0)
15730
+ return;
15731
+ if (key.name === "down" || isFleetKey(key, "j"))
15732
+ return dispatch({ type: "move-shared", delta: 1, count: list.length });
15733
+ if (key.name === "up" || isFleetKey(key, "k"))
15734
+ return dispatch({ type: "move-shared", delta: -1, count: list.length });
15735
+ const selected = list[Math.min(current.sharedSelection, list.length - 1)];
15736
+ if (selected === undefined || sharedBusy)
15737
+ return;
15738
+ const runShared = (label, action) => {
15739
+ setSharedBusy(true);
15740
+ setNotice(`${label} ${selected.name}\u2026`);
15741
+ action.then(() => setNotice(`${label} ${selected.name}: done`)).catch((error) => setNotice(`${label} ${selected.name} failed: ${error.message}`)).finally(() => setSharedBusy(false));
15742
+ };
15743
+ if (isFleetKey(key, "a") || isFleetKey(key, "x") || isFleetKey(key, "r")) {
15744
+ if (selected.holder?.mine !== true) {
15745
+ setNotice(`this worktree does not hold ${selected.name}`);
15746
+ return;
15747
+ }
15748
+ const worktree = selected.holder.worktree;
15749
+ if (isFleetKey(key, "a"))
15750
+ runShared("allowing", source.allowShared(worktree, selected.name));
15751
+ else if (isFleetKey(key, "x"))
15752
+ runShared("denying", source.denyShared(worktree, selected.name));
15753
+ else
15754
+ runShared("releasing", source.releaseShared(worktree, selected.name));
15755
+ return;
15756
+ }
15757
+ if (isFleetKey(key, "c")) {
15758
+ const acting = selectedStack(snapshot, current.selection.project);
15759
+ if (acting === undefined) {
15760
+ setNotice("select a stack (in the fleet list) to claim as");
15761
+ return;
15762
+ }
15763
+ if (selected.holder?.project === acting.project) {
15764
+ setNotice(`${acting.project} already holds ${selected.name}`);
15765
+ return;
15766
+ }
15767
+ runShared(`claiming as ${acting.project},`, source.claimShared(acting.worktree, selected.name));
15768
+ return;
15769
+ }
15770
+ return;
15771
+ }
14923
15772
  if (current.focus === "filter") {
14924
15773
  if (isEscape(key) || key.name === "return" || key.name === "enter") {
14925
15774
  key.preventDefault();
@@ -14932,6 +15781,8 @@ function FleetApp({
14932
15781
  return onQuit();
14933
15782
  if (isFleetKey(key, "?"))
14934
15783
  return dispatch({ type: "help", open: true });
15784
+ if (isFleetKey(key, "s"))
15785
+ return dispatch({ type: "shared", open: true });
14935
15786
  if (isFleetKey(key, "/"))
14936
15787
  return dispatch({ type: "focus", focus: "filter" });
14937
15788
  if (isFleetKey(key, "0"))
@@ -14959,7 +15810,7 @@ function FleetApp({
14959
15810
  dispatch({ type: "doctor", open: true });
14960
15811
  setDoctorRunning(true);
14961
15812
  setDoctorRows([]);
14962
- const worktree = stack !== undefined && existsSync22(stack.worktree) ? stack.worktree : process.cwd();
15813
+ const worktree = stack !== undefined && existsSync23(stack.worktree) ? stack.worktree : process.cwd();
14963
15814
  source.diagnose(worktree).then(setDoctorRows).catch((error) => {
14964
15815
  setNotice(`doctor failed: ${error.message}`);
14965
15816
  }).finally(() => setDoctorRunning(false));
@@ -15211,7 +16062,7 @@ function FleetApp({
15211
16062
  }, undefined, false, undefined, this),
15212
16063
  /* @__PURE__ */ jsxDEV5("text", {
15213
16064
  fg: fleetTheme.text,
15214
- children: "D doctor"
16065
+ children: "D doctor \xB7 s shared hostnames (claim / allow / deny / release)"
15215
16066
  }, undefined, false, undefined, this),
15216
16067
  /* @__PURE__ */ jsxDEV5("text", {
15217
16068
  fg: fleetTheme.danger,
@@ -15245,6 +16096,40 @@ function FleetApp({
15245
16096
  }, undefined, false, undefined, this)
15246
16097
  ]
15247
16098
  }, undefined, true, undefined, this) : null,
16099
+ state.sharedOpen ? /* @__PURE__ */ jsxDEV5(FleetModal, {
16100
+ title: "Shared hostnames",
16101
+ terminalWidth: terminal.width,
16102
+ terminalHeight: terminal.height,
16103
+ children: [
16104
+ snapshot.shared.length === 0 ? /* @__PURE__ */ jsxDEV5("text", {
16105
+ fg: fleetTheme.muted,
16106
+ children: "No shared hostnames declared. `hestia expose <svc> --shared <name>` creates one."
16107
+ }, undefined, false, undefined, this) : snapshot.shared.map((entry, index) => {
16108
+ const active = index === Math.min(state.sharedSelection, snapshot.shared.length - 1);
16109
+ const holder = entry.holder === undefined ? "unclaimed" : `held by ${entry.holder.project}${entry.holder.mine ? " (yours)" : ""}`;
16110
+ return /* @__PURE__ */ jsxDEV5("box", {
16111
+ style: { flexDirection: "column" },
16112
+ children: [
16113
+ /* @__PURE__ */ jsxDEV5("text", {
16114
+ fg: active ? fleetTheme.accent : fleetTheme.text,
16115
+ children: `${active ? "\u25B8 " : " "}${sanitizeFleetTerminalText(entry.name)} ${sanitizeFleetTerminalText(entry.url)} ${holder}`
16116
+ }, undefined, false, undefined, this),
16117
+ entry.queue.map((waiter, position) => /* @__PURE__ */ jsxDEV5("text", {
16118
+ fg: fleetTheme.muted,
16119
+ children: ` ${position + 1}. ${sanitizeFleetTerminalText(waiter.project)}${waiter.mine ? " (yours)" : ""}${waiter.denied ? " \u2014 denied, still queued" : ""}`
16120
+ }, `${entry.name}:${waiter.project}`, false, undefined, this))
16121
+ ]
16122
+ }, entry.name, true, undefined, this);
16123
+ }),
16124
+ /* @__PURE__ */ jsxDEV5("box", {
16125
+ style: { height: 1 }
16126
+ }, undefined, false, undefined, this),
16127
+ /* @__PURE__ */ jsxDEV5("text", {
16128
+ fg: fleetTheme.muted,
16129
+ 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"
16130
+ }, undefined, false, undefined, this)
16131
+ ]
16132
+ }, undefined, true, undefined, this) : null,
15248
16133
  state.confirmDown !== undefined ? /* @__PURE__ */ jsxDEV5(FleetModal, {
15249
16134
  title: "Confirm stack down",
15250
16135
  terminalWidth: terminal.width,
@@ -15460,9 +16345,43 @@ var init_src3 = __esm(() => {
15460
16345
  init_src2();
15461
16346
  init_src();
15462
16347
  import { execFileSync as execFileSync4 } from "child_process";
15463
- import { existsSync as existsSync23 } from "fs";
15464
- import { dirname as dirname8, join as join24 } from "path";
16348
+ import { existsSync as existsSync24 } from "fs";
16349
+ import { dirname as dirname8, join as join25 } from "path";
15465
16350
  import { fileURLToPath as fileURLToPath3 } from "url";
16351
+ var CLI_VERSION = "1.1.0";
16352
+ var PACKAGE_NAME = "@tridha643/hestia";
16353
+ function compareVersions(a, b) {
16354
+ const parse = (value) => {
16355
+ const [core, pre] = value.split("-", 2);
16356
+ return {
16357
+ core: (core ?? "").split(".").map((part) => Number.parseInt(part, 10) || 0),
16358
+ pre: pre !== undefined && pre.length > 0
16359
+ };
16360
+ };
16361
+ const left = parse(a);
16362
+ const right = parse(b);
16363
+ const length = Math.max(left.core.length, right.core.length);
16364
+ for (let index = 0;index < length; index += 1) {
16365
+ const diff = (left.core[index] ?? 0) - (right.core[index] ?? 0);
16366
+ if (diff !== 0)
16367
+ return diff;
16368
+ }
16369
+ if (left.pre === right.pre)
16370
+ return 0;
16371
+ return left.pre ? -1 : 1;
16372
+ }
16373
+ async function fetchLatestVersion() {
16374
+ const response = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
16375
+ headers: { accept: "application/json" }
16376
+ });
16377
+ if (!response.ok)
16378
+ throw new Error(`npm registry responded ${response.status}`);
16379
+ const body = await response.json();
16380
+ if (typeof body.version !== "string" || body.version.length === 0) {
16381
+ throw new Error("npm registry response has no version");
16382
+ }
16383
+ return body.version;
16384
+ }
15466
16385
  function parseFlags(argv) {
15467
16386
  const f = {
15468
16387
  json: false,
@@ -15474,6 +16393,8 @@ function parseFlags(argv) {
15474
16393
  noPort: false,
15475
16394
  keepHostHeader: false,
15476
16395
  overwriteDns: false,
16396
+ cancel: false,
16397
+ check: false,
15477
16398
  noDaemon: false,
15478
16399
  print: false,
15479
16400
  interactive: false,
@@ -15555,6 +16476,22 @@ function parseFlags(argv) {
15555
16476
  f.keepHostHeader = true;
15556
16477
  else if (a === "--overwrite-dns")
15557
16478
  f.overwriteDns = true;
16479
+ else if (a === "--shared") {
16480
+ f.shared = takeValue();
16481
+ if (!f.shared || f.shared.startsWith("--"))
16482
+ f.errors.push("--shared requires a value");
16483
+ } else if (a === "--hostname") {
16484
+ f.hostname = takeValue();
16485
+ if (!f.hostname || f.hostname.startsWith("--"))
16486
+ f.errors.push("--hostname requires a value");
16487
+ } else if (a === "--path") {
16488
+ f.path = takeValue();
16489
+ if (!f.path || f.path.startsWith("--"))
16490
+ f.errors.push("--path requires a value");
16491
+ } else if (a === "--cancel")
16492
+ f.cancel = true;
16493
+ else if (a === "--check")
16494
+ f.check = true;
15558
16495
  else if (a === "--no-daemon")
15559
16496
  f.noDaemon = true;
15560
16497
  else if (a === "--print")
@@ -15626,12 +16563,16 @@ function parseFlags(argv) {
15626
16563
  function validateCommandOptions(argv, command) {
15627
16564
  const allowedByCommand = {
15628
16565
  version: new Set(["--json"]),
16566
+ upgrade: new Set(["--json", "--check"]),
15629
16567
  skill: new Set(["--json"]),
15630
16568
  discover: new Set(["--json"]),
15631
16569
  init: new Set(["--json", "--print", "--scope", "--write", "--no-port"]),
15632
16570
  up: new Set(["--json", "--services", "--workers", "--allow-remote", "--force", "--no-varlock", "--wait", "--no-daemon"]),
15633
16571
  run: new Set(["--json", "--name", "--env", "--no-port", "--varlock", "--signal", "--ready-timeout", "--cwd", "--wait", "--no-daemon"]),
15634
- expose: new Set(["--json", "--tunnel", "--zone", "--keep-host-header", "--overwrite-dns", "--force", "--ready-timeout"]),
16572
+ expose: new Set(["--json", "--tunnel", "--zone", "--keep-host-header", "--overwrite-dns", "--force", "--ready-timeout", "--shared", "--hostname", "--path"]),
16573
+ claim: new Set(["--json", "--wait", "--cancel"]),
16574
+ release: new Set(["--json"]),
16575
+ share: new Set(["--json"]),
15635
16576
  route: new Set(["--json"]),
15636
16577
  router: new Set(["--json", "--interactive"]),
15637
16578
  config: new Set(["--json", "--file"]),
@@ -15662,7 +16603,10 @@ function validateCommandOptions(argv, command) {
15662
16603
  "--zone",
15663
16604
  "--file",
15664
16605
  "--scope",
15665
- "--tail"
16606
+ "--tail",
16607
+ "--shared",
16608
+ "--hostname",
16609
+ "--path"
15666
16610
  ]);
15667
16611
  for (let index = 0;index < argv.length; index += 1) {
15668
16612
  const raw = argv[index];
@@ -15704,6 +16648,15 @@ function printStackHuman(r) {
15704
16648
  const pub = r.endpoints.find((e) => e.name === s.name)?.publicUrl;
15705
16649
  out.push(` \u25CF ${s.name.padEnd(12)} ${s.backend.padEnd(7)} ${port.padEnd(20)} ${s.state}` + (pub !== undefined ? ` ${pub}` : ""));
15706
16650
  }
16651
+ for (const shared of listSharedHostnames()) {
16652
+ const holds = shared.holder?.project === r.project;
16653
+ const queuedHere = (shared.queue ?? []).some((waiter) => waiter.project === r.project);
16654
+ if (!holds && !queuedHere)
16655
+ continue;
16656
+ const pending = (shared.queue ?? []).length;
16657
+ const url = `https://${shared.hostname}${shared.path ?? ""}`;
16658
+ 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 ?? "?"}`);
16659
+ }
15707
16660
  process.stdout.write(out.join(`
15708
16661
  `) + `
15709
16662
  `);
@@ -15713,6 +16666,9 @@ var HELP = `hestia \u2014 per-worktree isolated dev stacks
15713
16666
  usage:
15714
16667
  hestia version [--json]
15715
16668
  print CLI version, state schema, and daemon protocol
16669
+ hestia upgrade [--check] [--json]
16670
+ upgrade hestia to the latest npm release via \`bun add --global\`;
16671
+ --check only reports whether a newer version is available
15716
16672
  hestia skill path [--json]
15717
16673
  print the packaged Hestia agent skill path
15718
16674
  hestia discover [--json]
@@ -15754,6 +16710,27 @@ usage:
15754
16710
  worktrees). URLs land in endpoints[] + HESTIA_<SVC>_URL. These are
15755
16711
  fail-closed but unauthenticated public URLs. Named v1 performs no DNS
15756
16712
  writes and requires wildcard CNAME *.<zone> \u2192 <uuid>.cfargotunnel.com.
16713
+ hestia expose <endpoint> --shared <name> [--hostname <fqdn>] [--path <prefix>]
16714
+ [--tunnel <t>] [--zone <zone>] [--json]
16715
+ declare + claim a machine-owned SHARED hostname \u2014 a stable public URL
16716
+ for externally-pinned integrations (Slack request URLs, webhook
16717
+ consumers) that cannot chase per-branch hostnames. Exactly one worktree
16718
+ holds it at a time; handoffs are consent-based and switch inside
16719
+ hestiad with zero connector restarts. The target defaults to
16720
+ <name>.<zone>; --hostname points ANY FQDN you control (any zone) at it,
16721
+ and --path /prefix lets several handles share one hostname routed by
16722
+ longest path prefix (e.g. acme.com/slack vs acme.com/stripe). Prefer
16723
+ plain per-branch expose whenever the external side can be configured
16724
+ per branch (dashboards, multi-redirect OAuth)
16725
+ hestia claim <name> [--wait[=secs]] [--cancel] [--json]
16726
+ claim a shared hostname for this worktree. Held names queue DURABLY
16727
+ (position survives daemon restarts and CLI timeouts); --wait blocks
16728
+ until the holder allows or releases, --cancel leaves the queue
16729
+ hestia release <name> [--json]
16730
+ release a held shared hostname; the queue head is granted immediately
16731
+ hestia share list|requests [--json] | share allow|deny <name> [--json]
16732
+ inspect shared hostnames and arbitrate queued claims as the holder:
16733
+ allow hands over now; deny keeps the requester queued until release
15757
16734
  hestia open <service> [path] [--json]
15758
16735
  resolve a service's public URL (from \`expose\`) and open it in the
15759
16736
  browser; the URL is always printed too, so a headless agent can hand
@@ -15808,7 +16785,7 @@ async function main() {
15808
16785
  switch (cmd) {
15809
16786
  case "version": {
15810
16787
  const version = {
15811
- cliVersion: "1.0.0",
16788
+ cliVersion: CLI_VERSION,
15812
16789
  stateSchema: STATE_SCHEMA_VERSION,
15813
16790
  daemonProtocol: HESTIAD_PROTOCOL_VERSION,
15814
16791
  runtime: "bun"
@@ -15818,6 +16795,45 @@ async function main() {
15818
16795
  `);
15819
16796
  else
15820
16797
  process.stdout.write(`hestia ${version.cliVersion} (state v${version.stateSchema}, daemon v${version.daemonProtocol}, Bun)
16798
+ `);
16799
+ break;
16800
+ }
16801
+ case "upgrade": {
16802
+ let latest;
16803
+ try {
16804
+ latest = await fetchLatestVersion();
16805
+ } catch (error) {
16806
+ fail("upgrade-failed", `could not check the latest ${PACKAGE_NAME} version: ${error.message}`, flags.json);
16807
+ }
16808
+ const available = compareVersions(latest, CLI_VERSION) > 0;
16809
+ if (flags.check || !available) {
16810
+ if (flags.json) {
16811
+ process.stdout.write(JSON.stringify({ current: CLI_VERSION, latest, upgradeAvailable: available }) + `
16812
+ `);
16813
+ } else if (available) {
16814
+ process.stdout.write(`hestia ${latest} is available (you have ${CLI_VERSION}) \u2014 run \`hestia upgrade\`
16815
+ `);
16816
+ } else {
16817
+ process.stdout.write(`hestia ${CLI_VERSION} is the latest release
16818
+ `);
16819
+ }
16820
+ break;
16821
+ }
16822
+ const installArgs = ["add", "--global", `${PACKAGE_NAME}@${latest}`];
16823
+ if (!flags.json) {
16824
+ process.stderr.write(`upgrading hestia ${CLI_VERSION} \u2192 ${latest} via \`bun ${installArgs.join(" ")}\`\u2026
16825
+ `);
16826
+ }
16827
+ try {
16828
+ execFileSync4("bun", installArgs, { stdio: flags.json ? "ignore" : "inherit" });
16829
+ } catch (error) {
16830
+ fail("upgrade-failed", `\`bun ${installArgs.join(" ")}\` failed: ${error.message}`, flags.json);
16831
+ }
16832
+ if (flags.json)
16833
+ process.stdout.write(JSON.stringify({ current: CLI_VERSION, latest, upgraded: true }) + `
16834
+ `);
16835
+ else
16836
+ process.stdout.write(`hestia upgraded ${CLI_VERSION} \u2192 ${latest}
15821
16837
  `);
15822
16838
  break;
15823
16839
  }
@@ -15826,10 +16842,10 @@ async function main() {
15826
16842
  fail("usage", "usage: hestia skill path", flags.json);
15827
16843
  const packageRoot = dirname8(dirname8(fileURLToPath3(import.meta.url)));
15828
16844
  const candidates = [
15829
- join24(packageRoot, "skills", "hestia", "SKILL.md"),
15830
- join24(process.cwd(), "skills", "hestia", "SKILL.md")
16845
+ join25(packageRoot, "skills", "hestia", "SKILL.md"),
16846
+ join25(process.cwd(), "skills", "hestia", "SKILL.md")
15831
16847
  ];
15832
- const path = candidates.find(existsSync23);
16848
+ const path = candidates.find(existsSync24);
15833
16849
  if (path === undefined)
15834
16850
  fail("config-missing", "packaged Hestia skill was not found", flags.json);
15835
16851
  if (flags.json)
@@ -15981,11 +16997,17 @@ ${result.proposed}`);
15981
16997
  if (flags.overwriteDns) {
15982
16998
  fail("usage", "--overwrite-dns was removed; named v1 requires user-managed wildcard DNS", flags.json);
15983
16999
  }
17000
+ if ((flags.hostname !== undefined || flags.path !== undefined) && flags.shared === undefined) {
17001
+ fail("usage", "--hostname/--path only apply with --shared <name>", flags.json);
17002
+ }
15984
17003
  const r = await engine.expose(cwd, services, {
15985
17004
  tunnel: flags.tunnel,
15986
17005
  zone: flags.zone,
15987
17006
  keepHostHeader: flags.keepHostHeader,
15988
17007
  overwriteDns: flags.overwriteDns,
17008
+ shared: flags.shared,
17009
+ hostname: flags.hostname,
17010
+ path: flags.path,
15989
17011
  force: flags.force,
15990
17012
  readyTimeoutMs: flags.readyTimeout
15991
17013
  });
@@ -15996,6 +17018,103 @@ ${result.proposed}`);
15996
17018
  printStackHuman(r);
15997
17019
  break;
15998
17020
  }
17021
+ case "claim": {
17022
+ const name = flags._[1];
17023
+ if (!name || flags._.length > 2) {
17024
+ fail("usage", "usage: hestia claim <name> [--wait[=secs]] [--cancel]", flags.json);
17025
+ }
17026
+ if (flags.cancel) {
17027
+ await engine.cancelSharedClaim(cwd, name);
17028
+ if (flags.json)
17029
+ process.stdout.write(JSON.stringify({ ok: true, cancelled: name }) + `
17030
+ `);
17031
+ else
17032
+ process.stdout.write(`left the queue for "${name}"
17033
+ `);
17034
+ break;
17035
+ }
17036
+ const { record, result } = await engine.claimShared(cwd, name, {
17037
+ waitMs: flags.wait !== undefined ? flags.wait * 1000 : 0
17038
+ });
17039
+ const hostname = listSharedHostnames().find((candidate) => candidate.name === name)?.hostname;
17040
+ if (flags.json) {
17041
+ process.stdout.write(JSON.stringify({ granted: true, holder: result.holder, record }, null, 2) + `
17042
+ `);
17043
+ } else {
17044
+ process.stdout.write(`claimed "${name}"${hostname !== undefined ? ` \u2014 https://${hostname}` : ""} now routes here
17045
+ `);
17046
+ }
17047
+ break;
17048
+ }
17049
+ case "release": {
17050
+ const name = flags._[1];
17051
+ if (!name || flags._.length > 2)
17052
+ fail("usage", "usage: hestia release <name>", flags.json);
17053
+ await engine.releaseShared(cwd, name);
17054
+ if (flags.json)
17055
+ process.stdout.write(JSON.stringify({ ok: true, released: name }) + `
17056
+ `);
17057
+ else
17058
+ process.stdout.write(`released "${name}" \u2014 next in queue (if any) now holds it
17059
+ `);
17060
+ break;
17061
+ }
17062
+ case "share": {
17063
+ const action = flags._[1];
17064
+ if (action === "list" || action === "requests") {
17065
+ const records = listSharedHostnames();
17066
+ const view = records.map((record) => ({
17067
+ name: record.name,
17068
+ hostname: record.hostname,
17069
+ path: record.path,
17070
+ url: `https://${record.hostname}${record.path ?? ""}`,
17071
+ service: record.service,
17072
+ holder: record.holder,
17073
+ queued: record.queue ?? []
17074
+ })).filter((entry) => action === "list" || entry.queued.length > 0);
17075
+ if (flags.json)
17076
+ process.stdout.write(JSON.stringify(view, null, 2) + `
17077
+ `);
17078
+ else if (view.length === 0) {
17079
+ process.stdout.write(action === "list" ? `no shared hostnames declared
17080
+ ` : `no pending claim requests
17081
+ `);
17082
+ } else {
17083
+ for (const entry of view) {
17084
+ const holder = entry.holder === undefined ? "unclaimed" : `held by ${entry.holder.project}`;
17085
+ process.stdout.write(`${entry.name.padEnd(16)} ${entry.url} ${holder}
17086
+ `);
17087
+ entry.queued.forEach((waiter, index) => {
17088
+ process.stdout.write(` ${String(index + 1).padStart(2)}. ${waiter.project}${waiter.denied ? " (denied \u2014 still queued)" : ""}
17089
+ `);
17090
+ });
17091
+ }
17092
+ }
17093
+ break;
17094
+ }
17095
+ const name = flags._[2];
17096
+ if (action !== "allow" && action !== "deny" || !name) {
17097
+ fail("usage", "usage: hestia share list|requests | share allow|deny <name>", flags.json);
17098
+ }
17099
+ if (action === "allow") {
17100
+ await engine.allowShared(cwd, name);
17101
+ if (flags.json)
17102
+ process.stdout.write(JSON.stringify({ ok: true, allowed: name }) + `
17103
+ `);
17104
+ else
17105
+ process.stdout.write(`allowed \u2014 "${name}" handed to the next in queue
17106
+ `);
17107
+ } else {
17108
+ await engine.denyShared(cwd, name);
17109
+ if (flags.json)
17110
+ process.stdout.write(JSON.stringify({ ok: true, denied: name }) + `
17111
+ `);
17112
+ else
17113
+ process.stdout.write(`denied \u2014 the requester stays queued until you release "${name}"
17114
+ `);
17115
+ }
17116
+ break;
17117
+ }
15999
17118
  case "route": {
16000
17119
  const action = flags._[1];
16001
17120
  const services = flags._.slice(2);
@@ -16391,6 +17510,10 @@ ${JSON.stringify(result.config, null, 2)}
16391
17510
  fail("internal", err.message ?? String(err), flags.json);
16392
17511
  }
16393
17512
  }
16394
- main();
17513
+ if (import.meta.main)
17514
+ main();
17515
+ export {
17516
+ compareVersions
17517
+ };
16395
17518
 
16396
- //# debugId=A4ADFA741120BECE64756E2164756E21
17519
+ //# debugId=D1A8C01922C41BA164756E2164756E21