@tridha643/hestia 1.0.1 → 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 {
@@ -10869,7 +11144,7 @@ async function adoptTunnel(name) {
10869
11144
  throw new HestiaError("tunnel-not-found", `no tunnel named "${name}" on this account \u2014 create it once with ` + `\`cloudflared tunnel create ${name}\``);
10870
11145
  }
10871
11146
  const credFile = credFilePath(entry.id);
10872
- if (!existsSync16(credFile)) {
11147
+ if (!existsSync17(credFile)) {
10873
11148
  throw new HestiaError("tunnel-auth-missing", `credentials for tunnel "${name}" not found at ${credFile} \u2014 ` + `run \`cloudflared tunnel create\` on this machine or copy the JSON`);
10874
11149
  }
10875
11150
  return { uuid: entry.id, credFile, connections: entry.connections.length };
@@ -10882,8 +11157,8 @@ var init_cloudflared = __esm(() => {
10882
11157
 
10883
11158
  // packages/engine/src/tunnel/ingress.ts
10884
11159
  import { createHash as createHash6 } from "crypto";
10885
- import { existsSync as existsSync17, readFileSync as readFileSync15 } from "fs";
10886
- import { join as join19 } from "path";
11160
+ import { existsSync as existsSync18, readFileSync as readFileSync16 } from "fs";
11161
+ import { join as join20 } from "path";
10887
11162
  function hostnameFor(tunnelName, branch, service, zone) {
10888
11163
  const label = `${slug(tunnelName)}-${slug(branch)}-${slug(service)}`;
10889
11164
  if (label.length <= LABEL_MAX)
@@ -10901,12 +11176,12 @@ function inferZone(baseRules) {
10901
11176
  return zones.size === 1 ? [...zones][0] : undefined;
10902
11177
  }
10903
11178
  function importBaseRules(uuid, name) {
10904
- const p = join19(cloudflaredHome(), "config.yml");
10905
- if (!existsSync17(p))
11179
+ const p = join20(cloudflaredHome(), "config.yml");
11180
+ if (!existsSync18(p))
10906
11181
  return [];
10907
11182
  let parsed;
10908
11183
  try {
10909
- parsed = $parse(readFileSync15(p, "utf8"));
11184
+ parsed = $parse(readFileSync16(p, "utf8"));
10910
11185
  } catch {
10911
11186
  return [];
10912
11187
  }
@@ -10918,7 +11193,12 @@ function importBaseRules(uuid, name) {
10918
11193
  return parsed.ingress.filter((r) => r.hostname !== undefined || r.path !== undefined);
10919
11194
  }
10920
11195
  function generateMergedConfig(opts) {
10921
- assertDisjoint(opts.baseRules, opts.dynamicRules);
11196
+ assertDisjoint(opts.baseRules, opts.dynamicRules, opts.sharedRules ?? []);
11197
+ const sharedHostnames = [...new Set((opts.sharedRules ?? []).map((s) => s.hostname))];
11198
+ const shared = sharedHostnames.map((hostname) => ({
11199
+ hostname,
11200
+ service: `unix:${publicGatewaySocketPath()}`
11201
+ }));
10922
11202
  const dynamic = opts.dynamicRules.map((d) => ({
10923
11203
  hostname: d.hostname,
10924
11204
  service: `unix:${publicGatewaySocketPath()}`,
@@ -10929,17 +11209,30 @@ function generateMergedConfig(opts) {
10929
11209
  "credentials-file": opts.credFile,
10930
11210
  ingress: [
10931
11211
  ...opts.baseRules,
11212
+ ...shared,
10932
11213
  ...dynamic,
10933
11214
  { service: "http_status:404" }
10934
11215
  ]
10935
11216
  });
10936
11217
  }
10937
- function assertDisjoint(base, dynamic) {
11218
+ function assertDisjoint(base, dynamic, shared) {
10938
11219
  const seen = new Map;
10939
11220
  for (const r of base) {
10940
11221
  if (r.hostname !== undefined)
10941
11222
  seen.set(r.hostname, "the user's static rules");
10942
11223
  }
11224
+ const sharedByHost = new Map;
11225
+ for (const s of shared) {
11226
+ if (!sharedByHost.has(s.hostname))
11227
+ sharedByHost.set(s.hostname, s.name);
11228
+ }
11229
+ for (const [hostname, name] of sharedByHost) {
11230
+ const holder = seen.get(hostname);
11231
+ if (holder !== undefined) {
11232
+ throw new HestiaError("hostname-conflict", `shared hostname ${hostname} ("${name}") is already claimed by ${holder}`);
11233
+ }
11234
+ seen.set(hostname, `shared hostname "${name}"`);
11235
+ }
10943
11236
  for (const d of dynamic) {
10944
11237
  const holder = seen.get(d.hostname);
10945
11238
  if (holder !== undefined) {
@@ -11000,31 +11293,31 @@ var POLL_MS4 = 300;
11000
11293
 
11001
11294
  // packages/engine/src/tunnel/registry.ts
11002
11295
  import {
11003
- existsSync as existsSync18,
11296
+ existsSync as existsSync19,
11004
11297
  mkdirSync as mkdirSync8,
11005
- readFileSync as readFileSync16,
11006
- readdirSync as readdirSync6
11298
+ readFileSync as readFileSync17,
11299
+ readdirSync as readdirSync7
11007
11300
  } from "fs";
11008
- import { join as join20 } from "path";
11301
+ import { join as join21 } from "path";
11009
11302
  function tunnelDir(uuid) {
11010
- return join20(hestiaHome(), "tunnel", uuid);
11303
+ return join21(hestiaHome(), "tunnel", uuid);
11011
11304
  }
11012
11305
  function configPath(uuid) {
11013
- return join20(tunnelDir(uuid), "config.yml");
11306
+ return join21(tunnelDir(uuid), "config.yml");
11014
11307
  }
11015
11308
  function adoptedMarker(uuid) {
11016
- return join20(tunnelDir(uuid), "adopted.json");
11309
+ return join21(tunnelDir(uuid), "adopted.json");
11017
11310
  }
11018
11311
  function isAdopted(uuid) {
11019
- return existsSync18(adoptedMarker(uuid));
11312
+ return existsSync19(adoptedMarker(uuid));
11020
11313
  }
11021
11314
  function readAdopted(uuid) {
11022
11315
  const p = adoptedMarker(uuid);
11023
- if (!existsSync18(p))
11316
+ if (!existsSync19(p))
11024
11317
  return null;
11025
11318
  let marker;
11026
11319
  try {
11027
- marker = JSON.parse(readFileSync16(p, "utf8"));
11320
+ marker = JSON.parse(readFileSync17(p, "utf8"));
11028
11321
  } catch {
11029
11322
  marker = {};
11030
11323
  }
@@ -11032,14 +11325,14 @@ function readAdopted(uuid) {
11032
11325
  return { uuid, name: marker.name, credFile: marker.credFile, reconstructed: false };
11033
11326
  }
11034
11327
  let name;
11035
- const stacksDir = join20(hestiaHome(), "stacks");
11036
- if (existsSync18(stacksDir)) {
11037
- for (const project of readdirSync6(stacksDir)) {
11038
- const sp = join20(stacksDir, project, "stack.json");
11039
- if (!existsSync18(sp))
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))
11040
11333
  continue;
11041
11334
  try {
11042
- const record = parseStackRecord(readFileSync16(sp, "utf8"), sp);
11335
+ const record = parseStackRecord(readFileSync17(sp, "utf8"), sp);
11043
11336
  if (record.tunnel?.uuid === uuid) {
11044
11337
  name = record.tunnel.name;
11045
11338
  break;
@@ -11055,24 +11348,24 @@ function readAdopted(uuid) {
11055
11348
  };
11056
11349
  }
11057
11350
  function listAdopted() {
11058
- const root = join20(hestiaHome(), "tunnel");
11059
- if (!existsSync18(root))
11351
+ const root = join21(hestiaHome(), "tunnel");
11352
+ if (!existsSync19(root))
11060
11353
  return [];
11061
- return readdirSync6(root).filter((uuid) => isAdopted(uuid));
11354
+ return readdirSync7(root).filter((uuid) => isAdopted(uuid));
11062
11355
  }
11063
11356
  function collectDynamicRules(uuid) {
11064
- const stacksDir = join20(hestiaHome(), "stacks");
11357
+ const stacksDir = join21(hestiaHome(), "stacks");
11065
11358
  const rules = [];
11066
11359
  const dropped = [];
11067
- if (!existsSync18(stacksDir))
11360
+ if (!existsSync19(stacksDir))
11068
11361
  return { rules, dropped };
11069
- for (const project of readdirSync6(stacksDir)) {
11070
- const p = join20(stacksDir, project, "stack.json");
11071
- if (!existsSync18(p))
11362
+ for (const project of readdirSync7(stacksDir)) {
11363
+ const p = join21(stacksDir, project, "stack.json");
11364
+ if (!existsSync19(p))
11072
11365
  continue;
11073
11366
  let record;
11074
11367
  try {
11075
- record = parseStackRecord(readFileSync16(p, "utf8"), p);
11368
+ record = parseStackRecord(readFileSync17(p, "utf8"), p);
11076
11369
  } catch {
11077
11370
  continue;
11078
11371
  }
@@ -11101,20 +11394,22 @@ async function reconcileTunnel(ref, opts) {
11101
11394
  for (const d of dropped) {
11102
11395
  warnings.push(`exposure ${d.hostname} dropped \u2014 service "${d.service}" of ` + `${d.project} is not running (hostname now 404s)`);
11103
11396
  }
11397
+ const sharedRules = listSharedHostnames().filter((record) => record.tunnelUuid === ref.uuid).map((record) => ({ name: record.name, hostname: record.hostname }));
11104
11398
  const cfgPath = configPath(ref.uuid);
11105
11399
  const nextConfig = generateMergedConfig({
11106
11400
  uuid: ref.uuid,
11107
11401
  credFile: ref.credFile,
11108
11402
  baseRules,
11109
- dynamicRules: rules
11403
+ dynamicRules: rules,
11404
+ sharedRules
11110
11405
  });
11111
11406
  const pf = readPidfile(dir, CONNECTOR);
11112
11407
  const live = pf !== null && isLive(pf);
11113
- const currentConfig = existsSync18(cfgPath) ? readFileSync16(cfgPath, "utf8") : null;
11408
+ const currentConfig = existsSync19(cfgPath) ? readFileSync17(cfgPath, "utf8") : null;
11114
11409
  if (live && currentConfig === nextConfig) {
11115
11410
  return { restarted: false, metricsPort: pf.port, ready: true, warnings };
11116
11411
  }
11117
- if (baseRules.length === 0 && rules.length === 0) {
11412
+ if (baseRules.length === 0 && rules.length === 0 && sharedRules.length === 0) {
11118
11413
  if (pf !== null) {
11119
11414
  await stopProcTree(pf);
11120
11415
  removePidfile(dir, CONNECTOR);
@@ -11185,6 +11480,7 @@ var init_registry = __esm(() => {
11185
11480
  init_pidfile();
11186
11481
  init_shutdown();
11187
11482
  init_ingress();
11483
+ init_shared();
11188
11484
  init_atomic_json_file();
11189
11485
  });
11190
11486
 
@@ -11623,10 +11919,230 @@ var init_stream = __esm(() => {
11623
11919
  init_tail();
11624
11920
  });
11625
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
+
11626
12142
  // packages/engine/src/doctor.ts
11627
- 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";
11628
12144
  import { execFile as execFile9 } from "child_process";
11629
- import { dirname as dirname6, join as join21 } from "path";
12145
+ import { dirname as dirname6, join as join22 } from "path";
11630
12146
  function row(check, level, detail) {
11631
12147
  return { check, level, detail };
11632
12148
  }
@@ -11667,8 +12183,8 @@ async function envChecks(ctx) {
11667
12183
  }
11668
12184
  if (ctx !== null) {
11669
12185
  const ignored = await exec("git", ["-C", ctx.worktreeRoot, "check-ignore", "-q", ".hestia/"]);
11670
- rows.push(ignored.ok ? row("state-ignore", "ok", ".hestia/ is ignored") : row("state-ignore", "error", `add the exact line ".hestia/" to ${join21(ctx.worktreeRoot, ".gitignore")}`));
11671
- const schema = existsSync19(join21(ctx.worktreeRoot, ".env.schema"));
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"));
11672
12188
  if (schema) {
11673
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?)"));
11674
12190
  }
@@ -11677,7 +12193,7 @@ async function envChecks(ctx) {
11677
12193
  const missing = workers.filter((w) => {
11678
12194
  let dir = dirname6(w.configPath);
11679
12195
  for (;; ) {
11680
- if (existsSync19(join21(dir, "node_modules", ".bin", "wrangler")))
12196
+ if (existsSync20(join22(dir, "node_modules", ".bin", "wrangler")))
11681
12197
  return false;
11682
12198
  if (dir === ctx.worktreeRoot || dirname6(dir) === dir)
11683
12199
  return true;
@@ -11736,10 +12252,10 @@ async function stateChecks(ctx) {
11736
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`));
11737
12253
  }
11738
12254
  }
11739
- const lockPath2 = join21(hestiaDir(worktreeRoot), "lock");
11740
- if (existsSync19(lockPath2)) {
12255
+ const lockPath2 = join22(hestiaDir(worktreeRoot), "lock");
12256
+ if (existsSync20(lockPath2)) {
11741
12257
  try {
11742
- const holder = JSON.parse(readFileSync17(lockPath2, "utf8"));
12258
+ const holder = JSON.parse(readFileSync18(lockPath2, "utf8"));
11743
12259
  if (!isLive(holder)) {
11744
12260
  rows.push(row("lock", "warn", "stale worktree lock (dead holder) \u2014 auto-broken on next command"));
11745
12261
  }
@@ -11747,25 +12263,25 @@ async function stateChecks(ctx) {
11747
12263
  rows.push(row("lock", "warn", "unreadable worktree lock file"));
11748
12264
  }
11749
12265
  }
11750
- const mirror = join21(hestiaHome(), "stacks", record.project, "stack.json");
11751
- if (!existsSync19(mirror)) {
12266
+ const mirror = join22(hestiaHome(), "stacks", record.project, "stack.json");
12267
+ if (!existsSync20(mirror)) {
11752
12268
  rows.push(row("mirror", "warn", "no ~/.hestia mirror \u2014 `down --project` would not work after worktree deletion"));
11753
12269
  }
11754
12270
  return rows;
11755
12271
  }
11756
12272
  async function machineChecks() {
11757
12273
  const rows = [];
11758
- const stacksDir = join21(hestiaHome(), "stacks");
12274
+ const stacksDir = join22(hestiaHome(), "stacks");
11759
12275
  const mirrored = new Set;
11760
- if (existsSync19(stacksDir)) {
11761
- for (const project of readdirSync7(stacksDir)) {
11762
- const p = join21(stacksDir, project, "stack.json");
11763
- if (!existsSync19(p))
12276
+ if (existsSync20(stacksDir)) {
12277
+ for (const project of readdirSync8(stacksDir)) {
12278
+ const p = join22(stacksDir, project, "stack.json");
12279
+ if (!existsSync20(p))
11764
12280
  continue;
11765
12281
  mirrored.add(project);
11766
12282
  try {
11767
- const rec = parseStackRecord(readFileSync17(p, "utf8"), p);
11768
- if (!existsSync19(rec.worktree)) {
12283
+ const rec = parseStackRecord(readFileSync18(p, "utf8"), p);
12284
+ if (!existsSync20(rec.worktree)) {
11769
12285
  rows.push(row(`orphan-mirror:${project}`, "warn", `worktree ${rec.worktree} is gone \u2014 \`hestia down --project ${project}\` to clean up`));
11770
12286
  }
11771
12287
  } catch {
@@ -11823,6 +12339,27 @@ async function tunnelChecks() {
11823
12339
  }
11824
12340
  return rows;
11825
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
+ }
11826
12363
  async function daemonChecks() {
11827
12364
  const rows = [];
11828
12365
  const j = readDaemonJson();
@@ -11837,11 +12374,11 @@ async function daemonChecks() {
11837
12374
  rows.push(row("daemon-config", "warn", w));
11838
12375
  }
11839
12376
  const plist = plistPath();
11840
- if (existsSync19(plist)) {
11841
- const content = readFileSync17(plist, "utf8");
12377
+ if (existsSync20(plist)) {
12378
+ const content = readFileSync18(plist, "utf8");
11842
12379
  const args = [...content.matchAll(/<string>([^<]+)<\/string>/g)].map((m) => m[1]);
11843
12380
  const paths = args.filter((a) => a.startsWith("/") && !a.includes(":"));
11844
- const missing = paths.filter((p) => !existsSync19(p));
12381
+ const missing = paths.filter((p) => !existsSync20(p));
11845
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\``));
11846
12383
  }
11847
12384
  return rows;
@@ -11880,6 +12417,7 @@ async function doctor(cwd) {
11880
12417
  ctx !== null ? bounded("state", () => stateChecks(ctx)) : Promise.resolve([]),
11881
12418
  bounded("machine", () => machineChecks()),
11882
12419
  bounded("tunnel", () => tunnelChecks()),
12420
+ bounded("shared", () => sharedHostnameChecks()),
11883
12421
  bounded("daemon", () => daemonChecks()),
11884
12422
  bounded("local-router", () => localRouterChecks())
11885
12423
  ]);
@@ -11891,6 +12429,7 @@ var init_doctor = __esm(() => {
11891
12429
  init_git();
11892
12430
  init_config();
11893
12431
  init_state();
12432
+ init_shared();
11894
12433
  init_pidfile();
11895
12434
  init_resolver();
11896
12435
  init_discover();
@@ -11907,8 +12446,8 @@ var init_doctor = __esm(() => {
11907
12446
  // packages/engine/src/daemon/fleet-monitor.ts
11908
12447
  import { execFile as execFile10 } from "child_process";
11909
12448
  import { promisify as promisify8 } from "util";
11910
- import { existsSync as existsSync20, readFileSync as readFileSync18, readdirSync as readdirSync8 } from "fs";
11911
- import { join as join22 } from "path";
12449
+ import { existsSync as existsSync21, readFileSync as readFileSync19, readdirSync as readdirSync9 } from "fs";
12450
+ import { join as join23 } from "path";
11912
12451
 
11913
12452
  class LatestFleetChannel {
11914
12453
  #pending;
@@ -11951,13 +12490,13 @@ class LatestFleetChannel {
11951
12490
  function readManagedMirrors() {
11952
12491
  const records = [];
11953
12492
  const warnings = [];
11954
- const stacksDirectory = join22(hestiaHome(), "stacks");
11955
- if (!existsSync20(stacksDirectory))
12493
+ const stacksDirectory = join23(hestiaHome(), "stacks");
12494
+ if (!existsSync21(stacksDirectory))
11956
12495
  return { records, warnings };
11957
- for (const project of readdirSync8(stacksDirectory).sort()) {
11958
- const path = join22(stacksDirectory, project, "stack.json");
12496
+ for (const project of readdirSync9(stacksDirectory).sort()) {
12497
+ const path = join23(stacksDirectory, project, "stack.json");
11959
12498
  try {
11960
- const source = readFileSync18(path);
12499
+ const source = readFileSync19(path);
11961
12500
  if (source.byteLength > MAX_MIRROR_BYTES2) {
11962
12501
  warnings.push(`Fleet mirror too large for ${project}`);
11963
12502
  continue;
@@ -11977,7 +12516,7 @@ function readManagedMirrors() {
11977
12516
  async function attributeLegacyRepoId(record) {
11978
12517
  if (record.repoId !== undefined)
11979
12518
  return record.repoId;
11980
- if (!existsSync20(record.worktree))
12519
+ if (!existsSync21(record.worktree))
11981
12520
  return null;
11982
12521
  const info = await getRepoInfo(record.worktree);
11983
12522
  if (projectName(info.repoId, info.repo, info.branch, info.worktreeRoot) !== record.project)
@@ -12083,6 +12622,25 @@ function reservationIdentity(reservation, records) {
12083
12622
  worktree: record.worktree
12084
12623
  };
12085
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
+ }
12086
12644
  async function collectFleetSnapshot(repoId, admission) {
12087
12645
  const mirrorResult = readManagedMirrors();
12088
12646
  const machineConfig = readHestiaMachineConfig();
@@ -12178,6 +12736,7 @@ async function collectFleetSnapshot(repoId, admission) {
12178
12736
  const allRecordedProjects = new Set(attributed.map((record) => record.project));
12179
12737
  const unbackedReservations = reservations.filter((reservation) => !allRecordedProjects.has(reservation.project));
12180
12738
  const { maxStacks, warnings: capWarnings } = resolveMaxStacks();
12739
+ const repoProjects = new Set(attributed.filter((record) => record.repoId === repoId).map((record) => record.project));
12181
12740
  return {
12182
12741
  repoId,
12183
12742
  observedAt: new Date().toISOString(),
@@ -12188,6 +12747,7 @@ async function collectFleetSnapshot(repoId, admission) {
12188
12747
  queued: queued.length
12189
12748
  },
12190
12749
  stacks: stackViews,
12750
+ shared: collectFleetShared(repoProjects),
12191
12751
  warnings: [...capWarnings, ...warnings].sort()
12192
12752
  };
12193
12753
  }
@@ -12196,6 +12756,7 @@ function semanticFleetSnapshot(snapshot) {
12196
12756
  repoId: snapshot.repoId,
12197
12757
  capacity: snapshot.capacity,
12198
12758
  stacks: snapshot.stacks,
12759
+ shared: snapshot.shared,
12199
12760
  warnings: snapshot.warnings
12200
12761
  });
12201
12762
  }
@@ -12307,6 +12868,7 @@ var init_fleet_monitor = __esm(() => {
12307
12868
  init_ports();
12308
12869
  init_pidfile();
12309
12870
  init_state();
12871
+ init_shared();
12310
12872
  init_router_config();
12311
12873
  init_local_http_router();
12312
12874
  init_portless_adapter();
@@ -12417,10 +12979,10 @@ var init_init_config = __esm(() => {
12417
12979
  });
12418
12980
 
12419
12981
  // packages/engine/src/index.ts
12420
- import { existsSync as existsSync21, rmSync as rmSync9 } from "fs";
12982
+ import { existsSync as existsSync22, rmSync as rmSync10 } from "fs";
12421
12983
  import { execFile as execFile11 } from "child_process";
12422
12984
  import { promisify as promisify9 } from "util";
12423
- import { join as join23, resolve as resolve2 } from "path";
12985
+ import { join as join24, resolve as resolve2 } from "path";
12424
12986
  import { createHash as createHash7 } from "crypto";
12425
12987
  import { resolve4, resolve6, resolveCname } from "dns/promises";
12426
12988
  function composeUnsupported(message) {
@@ -12530,7 +13092,7 @@ async function assertHestiaStateIgnored(worktreeRoot) {
12530
13092
  try {
12531
13093
  await pexec9("git", ["-C", worktreeRoot, "check-ignore", "-q", ".hestia/"], { timeout: 5000 });
12532
13094
  } catch {
12533
- throw new HestiaError("state-not-ignored", `.hestia is not ignored in ${worktreeRoot}; add the line ".hestia/" to ${join23(worktreeRoot, ".gitignore")}`, { remedy: `.hestia/`, path: join23(worktreeRoot, ".gitignore") });
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") });
12534
13096
  }
12535
13097
  }
12536
13098
  async function prepareCompose(worktreeRoot, project, repo, branch, opts, configuredBaseFile) {
@@ -12547,7 +13109,7 @@ async function prepareCompose(worktreeRoot, project, repo, branch, opts, configu
12547
13109
  services
12548
13110
  });
12549
13111
  ensureDir(hestiaDir(worktreeRoot));
12550
- const overridePath = join23(hestiaDir(worktreeRoot), OVERRIDE_FILE);
13112
+ const overridePath = join24(hestiaDir(worktreeRoot), OVERRIDE_FILE);
12551
13113
  writeAtomicTextFile(overridePath, yaml);
12552
13114
  return {
12553
13115
  ctx: {
@@ -12580,7 +13142,7 @@ function writeDockerfileComposeModel(worktreeRoot, workloads, conventionalCompos
12580
13142
  ...conventionalComposeFile === undefined ? {} : { include: [{ path: conventionalComposeFile }] },
12581
13143
  services
12582
13144
  };
12583
- const path = join23(hestiaDir(worktreeRoot), DOCKERFILE_COMPOSE_FILE);
13145
+ const path = join24(hestiaDir(worktreeRoot), DOCKERFILE_COMPOSE_FILE);
12584
13146
  writeAtomicTextFile(path, $stringify(model));
12585
13147
  return path;
12586
13148
  }
@@ -12600,7 +13162,7 @@ function freshRecord(project, repoId, repo, branch, worktree) {
12600
13162
  };
12601
13163
  }
12602
13164
  function assertCurrentStackIdentity(record, current) {
12603
- const path = join23(hestiaDir(current.worktreeRoot), "stack.json");
13165
+ const path = join24(hestiaDir(current.worktreeRoot), "stack.json");
12604
13166
  assertMutableStackRecord(record, path);
12605
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);
12606
13168
  if (!matches) {
@@ -12735,6 +13297,27 @@ function applyLocalRouteProjection(record) {
12735
13297
  record.env[directUrlKey(endpoint.name)] = endpoint.url;
12736
13298
  record.env[localUrlKey(endpoint.name)] = endpoint.localUrl;
12737
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
+ }
12738
13321
  }
12739
13322
  function syncExposures(record) {
12740
13323
  const t = record.tunnel;
@@ -12758,7 +13341,7 @@ function matchesExpectedStack(record, expected) {
12758
13341
  return record !== null && (record.repoId === undefined || record.repoId === expected.repoId) && record.worktree === expected.worktree && record.createdAt === expected.createdAt;
12759
13342
  }
12760
13343
  function projectMutationRoot(project) {
12761
- return join23(hestiaHome(), "project-locks", project);
13344
+ return join24(hestiaHome(), "project-locks", project);
12762
13345
  }
12763
13346
  async function assertNamedTunnelDns(hostname, tunnelUuid) {
12764
13347
  const expected = `${tunnelUuid}.cfargotunnel.com`;
@@ -13164,6 +13747,7 @@ class ComposeEngine {
13164
13747
  const { repo, repoId, branch, worktreeRoot } = currentIdentity;
13165
13748
  let tunnel;
13166
13749
  let tunnelDirty = false;
13750
+ const stoppedAliases = [];
13167
13751
  const project = readState(worktreeRoot)?.project ?? projectName(repoId, repo, branch, worktreeRoot);
13168
13752
  await withLock(worktreeRoot, () => withLock(projectMutationRoot(project), async () => {
13169
13753
  const record = readState(worktreeRoot);
@@ -13172,6 +13756,11 @@ class ComposeEngine {
13172
13756
  if (record.services.some((service) => service.name === name && service.backend === "docker")) {
13173
13757
  throw new HestiaError("backend-not-stoppable", `Docker workload ${name} cannot be stopped individually; use hestia down`);
13174
13758
  }
13759
+ for (const endpoint of record.endpoints) {
13760
+ if ((endpoint.workload ?? endpoint.name) === name) {
13761
+ stoppedAliases.push(endpoint.alias ?? endpoint.name);
13762
+ }
13763
+ }
13175
13764
  }
13176
13765
  const pf = readPidfile(worktreeRoot, name);
13177
13766
  if (pf !== null) {
@@ -13217,6 +13806,9 @@ class ComposeEngine {
13217
13806
  }
13218
13807
  }
13219
13808
  }));
13809
+ for (const alias of new Set(stoppedAliases)) {
13810
+ await this.#releaseSharedHoldings(project, alias);
13811
+ }
13220
13812
  if (tunnelDirty)
13221
13813
  await this.#reconcileAdopted(tunnel);
13222
13814
  await this.#refreshLocalRoutes();
@@ -13352,12 +13944,12 @@ class ComposeEngine {
13352
13944
  await stopProcTree(pf);
13353
13945
  removePidfile(worktreeRoot, pf.name);
13354
13946
  }
13355
- rmSync9(privateRegistryDir(worktreeRoot), { recursive: true, force: true });
13947
+ rmSync10(privateRegistryDir(worktreeRoot), { recursive: true, force: true });
13356
13948
  const composeFile = record?.composeFile ?? tryLoadConfig(worktreeRoot)?.composeFile;
13357
13949
  if (composeFile !== undefined) {
13358
13950
  const project2 = record?.project ?? projectName(repoId, repo, branch, worktreeRoot);
13359
- const overrideFile = record?.overrideFile ?? join23(hestiaDir(worktreeRoot), OVERRIDE_FILE);
13360
- if (existsSync21(overrideFile)) {
13951
+ const overrideFile = record?.overrideFile ?? join24(hestiaDir(worktreeRoot), OVERRIDE_FILE);
13952
+ if (existsSync22(overrideFile)) {
13361
13953
  await composeDown({ project: project2, baseFile: composeFile, overrideFile, cwd: worktreeRoot }, opts?.destroy ?? false);
13362
13954
  } else if (record?.composeFile !== undefined) {
13363
13955
  const rest = ["compose", "-p", project2, "down", "--remove-orphans"];
@@ -13369,6 +13961,7 @@ class ComposeEngine {
13369
13961
  const project = record?.project ?? projectName(repoId, repo, branch, worktreeRoot);
13370
13962
  clearState(worktreeRoot, project);
13371
13963
  await this.#releaseAdmission(project);
13964
+ await this.#releaseSharedHoldings(project);
13372
13965
  }));
13373
13966
  await this.#reconcileAdopted(tunnel);
13374
13967
  await this.#refreshLocalRoutes();
@@ -13378,6 +13971,11 @@ class ComposeEngine {
13378
13971
  if (j !== null)
13379
13972
  await releaseSlot(j.port, project);
13380
13973
  }
13974
+ async#releaseSharedHoldings(project, service) {
13975
+ const j = readDaemonJson();
13976
+ if (j !== null)
13977
+ await releaseSharedForProject(j.port, project, service);
13978
+ }
13381
13979
  async downProject(project, opts) {
13382
13980
  if (!/^[a-z0-9][a-z0-9-]{0,99}$/.test(project)) {
13383
13981
  throw new HestiaError("usage", `invalid project name ${JSON.stringify(project)}`);
@@ -13420,9 +14018,9 @@ class ComposeEngine {
13420
14018
  throw new HestiaError("compose-failed", `docker compose -p ${project} down failed: ${err.message}`);
13421
14019
  }
13422
14020
  }
13423
- rmSync9(mirrorDir(project), { recursive: true, force: true });
14021
+ rmSync10(mirrorDir(project), { recursive: true, force: true });
13424
14022
  };
13425
- if (record !== null && existsSync21(record.worktree)) {
14023
+ if (record !== null && existsSync22(record.worktree)) {
13426
14024
  const info = await getRepoInfo(record.worktree);
13427
14025
  const identityMatches = resolve2(info.worktreeRoot) === resolve2(record.worktree) && (record.repoId === undefined ? record.repo === info.repo : record.repoId === info.repoId);
13428
14026
  const localRecord = readState(record.worktree);
@@ -13443,6 +14041,7 @@ class ComposeEngine {
13443
14041
  return;
13444
14042
  }
13445
14043
  await this.#releaseAdmission(project);
14044
+ await this.#releaseSharedHoldings(project);
13446
14045
  await this.#reconcileAdopted(record.tunnel);
13447
14046
  await this.#refreshLocalRoutes();
13448
14047
  return;
@@ -13451,6 +14050,7 @@ class ComposeEngine {
13451
14050
  const projectLockRoot = projectMutationRoot(project);
13452
14051
  await withLock(projectLockRoot, teardownFromMirror);
13453
14052
  await this.#releaseAdmission(project);
14053
+ await this.#releaseSharedHoldings(project);
13454
14054
  await this.#reconcileAdopted(record?.tunnel);
13455
14055
  await this.#refreshLocalRoutes();
13456
14056
  }
@@ -13469,11 +14069,100 @@ class ComposeEngine {
13469
14069
  }
13470
14070
  await ensureDaemon();
13471
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
+ }
13472
14078
  if (tunnelName === undefined) {
13473
14079
  return this.#exposeQuick(worktreeRoot, services, opts);
13474
14080
  }
13475
14081
  return this.#exposeNamed(worktreeRoot, services, tunnelName, opts);
13476
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
+ }
13477
14166
  async#exposeQuick(worktreeRoot, services, opts) {
13478
14167
  const project = readState(worktreeRoot)?.project;
13479
14168
  if (project === undefined) {
@@ -13492,7 +14181,7 @@ class ComposeEngine {
13492
14181
  const alias = selection.endpoint.alias ?? selection.endpoint.name;
13493
14182
  const authority = internalEndpointAuthority(record.project, alias);
13494
14183
  const name = `aux-quick-${createHash7("sha256").update(alias).digest("hex").slice(0, 10)}`;
13495
- const quickCfg = join23(hestiaDir(worktreeRoot), `quick-${name}.yml`);
14184
+ const quickCfg = join24(hestiaDir(worktreeRoot), `quick-${name}.yml`);
13496
14185
  writeAtomicTextFile(quickCfg, `ingress:
13497
14186
  - service: ${JSON.stringify(`unix:${publicGatewaySocketPath()}`)}
13498
14187
  ` + ` originRequest:
@@ -13684,6 +14373,80 @@ class ComposeEngine {
13684
14373
  await this.#refreshLocalRoutes(true);
13685
14374
  return final;
13686
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
+ }
13687
14450
  async status(cwd) {
13688
14451
  const { worktreeRoot } = await getRepoInfo(cwd);
13689
14452
  const record = readState(worktreeRoot);
@@ -13810,11 +14573,14 @@ var init_src2 = __esm(() => {
13810
14573
  init_cloudflared();
13811
14574
  init_ingress();
13812
14575
  init_registry();
14576
+ init_shared();
13813
14577
  init_ensure();
13814
14578
  init_client();
13815
14579
  init_stream();
13816
14580
  init_router_config();
13817
14581
  init_cli();
14582
+ init_shared();
14583
+ init_shared_arbiter();
13818
14584
  init_lock();
13819
14585
  init_supervisor();
13820
14586
  init_tail();
@@ -14003,6 +14769,18 @@ class DaemonFleetSource {
14003
14769
  diagnose(worktree) {
14004
14770
  return doctor(worktree);
14005
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
+ }
14006
14784
  down(stack) {
14007
14785
  if (stack.createdAt === undefined) {
14008
14786
  return Promise.reject(new Error(`stack ${stack.project} has no stable incarnation timestamp`));
@@ -14036,6 +14814,8 @@ function createFleetUiState() {
14036
14814
  unseenLines: 0,
14037
14815
  helpOpen: false,
14038
14816
  doctorOpen: false,
14817
+ sharedOpen: false,
14818
+ sharedSelection: 0,
14039
14819
  downPending: false
14040
14820
  };
14041
14821
  }
@@ -14195,6 +14975,14 @@ function reduceFleetUiState(state, action) {
14195
14975
  return { ...state, helpOpen: action.open };
14196
14976
  case "doctor":
14197
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
+ }
14198
14986
  case "confirm-down":
14199
14987
  return {
14200
14988
  ...state,
@@ -14734,7 +15522,7 @@ var init_FleetModal = __esm(() => {
14734
15522
 
14735
15523
  // packages/tui/src/FleetApp.tsx
14736
15524
  import { useEffect, useMemo, useReducer, useRef, useState } from "react";
14737
- import { existsSync as existsSync22 } from "fs";
15525
+ import { existsSync as existsSync23 } from "fs";
14738
15526
  import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
14739
15527
  import { jsxDEV as jsxDEV5, Fragment as Fragment2 } from "@opentui/react/jsx-dev-runtime";
14740
15528
  function middleTruncateWorktreePath(path, width) {
@@ -14752,7 +15540,7 @@ function middleTruncateWorktreePath(path, width) {
14752
15540
  return `${path.slice(0, prefixWidth)}\u2026/${basename3}`;
14753
15541
  }
14754
15542
  function emptySnapshot(repoId) {
14755
- 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: [] };
14756
15544
  }
14757
15545
  function isEscape(key) {
14758
15546
  const name = key.name.toLowerCase();
@@ -14820,6 +15608,7 @@ function FleetApp({
14820
15608
  const [notice, setNotice] = useState("connecting to hestiad\u2026");
14821
15609
  const [doctorRows, setDoctorRows] = useState([]);
14822
15610
  const [doctorRunning, setDoctorRunning] = useState(false);
15611
+ const [sharedBusy, setSharedBusy] = useState(false);
14823
15612
  const lastFrameAt = useRef(Date.now());
14824
15613
  const stateRef = useRef(state);
14825
15614
  stateRef.current = state;
@@ -14931,6 +15720,55 @@ function FleetApp({
14931
15720
  }
14932
15721
  return;
14933
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
+ }
14934
15772
  if (current.focus === "filter") {
14935
15773
  if (isEscape(key) || key.name === "return" || key.name === "enter") {
14936
15774
  key.preventDefault();
@@ -14943,6 +15781,8 @@ function FleetApp({
14943
15781
  return onQuit();
14944
15782
  if (isFleetKey(key, "?"))
14945
15783
  return dispatch({ type: "help", open: true });
15784
+ if (isFleetKey(key, "s"))
15785
+ return dispatch({ type: "shared", open: true });
14946
15786
  if (isFleetKey(key, "/"))
14947
15787
  return dispatch({ type: "focus", focus: "filter" });
14948
15788
  if (isFleetKey(key, "0"))
@@ -14970,7 +15810,7 @@ function FleetApp({
14970
15810
  dispatch({ type: "doctor", open: true });
14971
15811
  setDoctorRunning(true);
14972
15812
  setDoctorRows([]);
14973
- const worktree = stack !== undefined && existsSync22(stack.worktree) ? stack.worktree : process.cwd();
15813
+ const worktree = stack !== undefined && existsSync23(stack.worktree) ? stack.worktree : process.cwd();
14974
15814
  source.diagnose(worktree).then(setDoctorRows).catch((error) => {
14975
15815
  setNotice(`doctor failed: ${error.message}`);
14976
15816
  }).finally(() => setDoctorRunning(false));
@@ -15222,7 +16062,7 @@ function FleetApp({
15222
16062
  }, undefined, false, undefined, this),
15223
16063
  /* @__PURE__ */ jsxDEV5("text", {
15224
16064
  fg: fleetTheme.text,
15225
- children: "D doctor"
16065
+ children: "D doctor \xB7 s shared hostnames (claim / allow / deny / release)"
15226
16066
  }, undefined, false, undefined, this),
15227
16067
  /* @__PURE__ */ jsxDEV5("text", {
15228
16068
  fg: fleetTheme.danger,
@@ -15256,6 +16096,40 @@ function FleetApp({
15256
16096
  }, undefined, false, undefined, this)
15257
16097
  ]
15258
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,
15259
16133
  state.confirmDown !== undefined ? /* @__PURE__ */ jsxDEV5(FleetModal, {
15260
16134
  title: "Confirm stack down",
15261
16135
  terminalWidth: terminal.width,
@@ -15471,9 +16345,43 @@ var init_src3 = __esm(() => {
15471
16345
  init_src2();
15472
16346
  init_src();
15473
16347
  import { execFileSync as execFileSync4 } from "child_process";
15474
- import { existsSync as existsSync23 } from "fs";
15475
- 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";
15476
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
+ }
15477
16385
  function parseFlags(argv) {
15478
16386
  const f = {
15479
16387
  json: false,
@@ -15485,6 +16393,8 @@ function parseFlags(argv) {
15485
16393
  noPort: false,
15486
16394
  keepHostHeader: false,
15487
16395
  overwriteDns: false,
16396
+ cancel: false,
16397
+ check: false,
15488
16398
  noDaemon: false,
15489
16399
  print: false,
15490
16400
  interactive: false,
@@ -15566,6 +16476,22 @@ function parseFlags(argv) {
15566
16476
  f.keepHostHeader = true;
15567
16477
  else if (a === "--overwrite-dns")
15568
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;
15569
16495
  else if (a === "--no-daemon")
15570
16496
  f.noDaemon = true;
15571
16497
  else if (a === "--print")
@@ -15637,12 +16563,16 @@ function parseFlags(argv) {
15637
16563
  function validateCommandOptions(argv, command) {
15638
16564
  const allowedByCommand = {
15639
16565
  version: new Set(["--json"]),
16566
+ upgrade: new Set(["--json", "--check"]),
15640
16567
  skill: new Set(["--json"]),
15641
16568
  discover: new Set(["--json"]),
15642
16569
  init: new Set(["--json", "--print", "--scope", "--write", "--no-port"]),
15643
16570
  up: new Set(["--json", "--services", "--workers", "--allow-remote", "--force", "--no-varlock", "--wait", "--no-daemon"]),
15644
16571
  run: new Set(["--json", "--name", "--env", "--no-port", "--varlock", "--signal", "--ready-timeout", "--cwd", "--wait", "--no-daemon"]),
15645
- expose: new Set(["--json", "--tunnel", "--zone", "--keep-host-header", "--overwrite-dns", "--force", "--ready-timeout"]),
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"]),
15646
16576
  route: new Set(["--json"]),
15647
16577
  router: new Set(["--json", "--interactive"]),
15648
16578
  config: new Set(["--json", "--file"]),
@@ -15673,7 +16603,10 @@ function validateCommandOptions(argv, command) {
15673
16603
  "--zone",
15674
16604
  "--file",
15675
16605
  "--scope",
15676
- "--tail"
16606
+ "--tail",
16607
+ "--shared",
16608
+ "--hostname",
16609
+ "--path"
15677
16610
  ]);
15678
16611
  for (let index = 0;index < argv.length; index += 1) {
15679
16612
  const raw = argv[index];
@@ -15715,6 +16648,15 @@ function printStackHuman(r) {
15715
16648
  const pub = r.endpoints.find((e) => e.name === s.name)?.publicUrl;
15716
16649
  out.push(` \u25CF ${s.name.padEnd(12)} ${s.backend.padEnd(7)} ${port.padEnd(20)} ${s.state}` + (pub !== undefined ? ` ${pub}` : ""));
15717
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
+ }
15718
16660
  process.stdout.write(out.join(`
15719
16661
  `) + `
15720
16662
  `);
@@ -15724,6 +16666,9 @@ var HELP = `hestia \u2014 per-worktree isolated dev stacks
15724
16666
  usage:
15725
16667
  hestia version [--json]
15726
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
15727
16672
  hestia skill path [--json]
15728
16673
  print the packaged Hestia agent skill path
15729
16674
  hestia discover [--json]
@@ -15765,6 +16710,27 @@ usage:
15765
16710
  worktrees). URLs land in endpoints[] + HESTIA_<SVC>_URL. These are
15766
16711
  fail-closed but unauthenticated public URLs. Named v1 performs no DNS
15767
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
15768
16734
  hestia open <service> [path] [--json]
15769
16735
  resolve a service's public URL (from \`expose\`) and open it in the
15770
16736
  browser; the URL is always printed too, so a headless agent can hand
@@ -15819,7 +16785,7 @@ async function main() {
15819
16785
  switch (cmd) {
15820
16786
  case "version": {
15821
16787
  const version = {
15822
- cliVersion: "1.0.1",
16788
+ cliVersion: CLI_VERSION,
15823
16789
  stateSchema: STATE_SCHEMA_VERSION,
15824
16790
  daemonProtocol: HESTIAD_PROTOCOL_VERSION,
15825
16791
  runtime: "bun"
@@ -15829,6 +16795,45 @@ async function main() {
15829
16795
  `);
15830
16796
  else
15831
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}
15832
16837
  `);
15833
16838
  break;
15834
16839
  }
@@ -15837,10 +16842,10 @@ async function main() {
15837
16842
  fail("usage", "usage: hestia skill path", flags.json);
15838
16843
  const packageRoot = dirname8(dirname8(fileURLToPath3(import.meta.url)));
15839
16844
  const candidates = [
15840
- join24(packageRoot, "skills", "hestia", "SKILL.md"),
15841
- join24(process.cwd(), "skills", "hestia", "SKILL.md")
16845
+ join25(packageRoot, "skills", "hestia", "SKILL.md"),
16846
+ join25(process.cwd(), "skills", "hestia", "SKILL.md")
15842
16847
  ];
15843
- const path = candidates.find(existsSync23);
16848
+ const path = candidates.find(existsSync24);
15844
16849
  if (path === undefined)
15845
16850
  fail("config-missing", "packaged Hestia skill was not found", flags.json);
15846
16851
  if (flags.json)
@@ -15992,11 +16997,17 @@ ${result.proposed}`);
15992
16997
  if (flags.overwriteDns) {
15993
16998
  fail("usage", "--overwrite-dns was removed; named v1 requires user-managed wildcard DNS", flags.json);
15994
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
+ }
15995
17003
  const r = await engine.expose(cwd, services, {
15996
17004
  tunnel: flags.tunnel,
15997
17005
  zone: flags.zone,
15998
17006
  keepHostHeader: flags.keepHostHeader,
15999
17007
  overwriteDns: flags.overwriteDns,
17008
+ shared: flags.shared,
17009
+ hostname: flags.hostname,
17010
+ path: flags.path,
16000
17011
  force: flags.force,
16001
17012
  readyTimeoutMs: flags.readyTimeout
16002
17013
  });
@@ -16007,6 +17018,103 @@ ${result.proposed}`);
16007
17018
  printStackHuman(r);
16008
17019
  break;
16009
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
+ }
16010
17118
  case "route": {
16011
17119
  const action = flags._[1];
16012
17120
  const services = flags._.slice(2);
@@ -16402,6 +17510,10 @@ ${JSON.stringify(result.config, null, 2)}
16402
17510
  fail("internal", err.message ?? String(err), flags.json);
16403
17511
  }
16404
17512
  }
16405
- main();
17513
+ if (import.meta.main)
17514
+ main();
17515
+ export {
17516
+ compareVersions
17517
+ };
16406
17518
 
16407
- //# debugId=7C129FA1100BA38864756E2164756E21
17519
+ //# debugId=D1A8C01922C41BA164756E2164756E21