@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 +1247 -135
- package/dist/cli.js.map +19 -17
- package/dist/daemon.js +889 -108
- package/dist/daemon.js.map +16 -14
- package/package.json +1 -1
- package/skills/hestia/SKILL.md +61 -0
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
|
|
10164
|
-
import { chmodSync as chmodSync5, lstatSync, mkdirSync as mkdirSync7, rmSync as
|
|
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
|
|
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 =
|
|
10170
|
-
if (!
|
|
10375
|
+
const stacksDir = join17(hestiaHome(), "stacks");
|
|
10376
|
+
if (!existsSync14(stacksDir))
|
|
10171
10377
|
return [];
|
|
10172
10378
|
const records = [];
|
|
10173
|
-
for (const project of
|
|
10174
|
-
const path =
|
|
10379
|
+
for (const project of readdirSync5(stacksDir).sort()) {
|
|
10380
|
+
const path = join17(stacksDir, project, "stack.json");
|
|
10175
10381
|
try {
|
|
10176
|
-
const source =
|
|
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
|
|
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 = [
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
10465
|
-
|
|
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
|
-
|
|
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
|
|
10532
|
-
if (
|
|
10533
|
-
return sendRouterResponse(response,
|
|
10534
|
-
const
|
|
10535
|
-
|
|
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
|
|
10578
|
-
|
|
10579
|
-
|
|
10580
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
10940
|
+
return join18(worktreeRoot, ".hestia", "wrangler-registry");
|
|
10666
10941
|
}
|
|
10667
10942
|
function globalRegistryDir() {
|
|
10668
|
-
return process.env.WRANGLER_REGISTRY_PATH ??
|
|
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 (!
|
|
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 =
|
|
10697
|
-
if (!
|
|
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(
|
|
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 =
|
|
10727
|
-
if (
|
|
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
|
|
11073
|
+
import { existsSync as existsSync16, readdirSync as readdirSync6 } from "fs";
|
|
10799
11074
|
function namesIn(dir) {
|
|
10800
|
-
if (!
|
|
11075
|
+
if (!existsSync16(dir))
|
|
10801
11076
|
return new Set;
|
|
10802
|
-
return new Set(
|
|
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
|
|
11106
|
+
import { existsSync as existsSync17 } from "fs";
|
|
10832
11107
|
import { homedir as homedir4 } from "os";
|
|
10833
|
-
import { join as
|
|
11108
|
+
import { join as join19 } from "path";
|
|
10834
11109
|
function cloudflaredHome() {
|
|
10835
|
-
return process.env.HESTIA_CLOUDFLARED_HOME ??
|
|
11110
|
+
return process.env.HESTIA_CLOUDFLARED_HOME ?? join19(homedir4(), ".cloudflared");
|
|
10836
11111
|
}
|
|
10837
11112
|
function credFilePath(uuid) {
|
|
10838
|
-
return process.env.TUNNEL_CRED_FILE ??
|
|
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 (!
|
|
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
|
|
10886
|
-
import { join as
|
|
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 =
|
|
10905
|
-
if (!
|
|
11179
|
+
const p = join20(cloudflaredHome(), "config.yml");
|
|
11180
|
+
if (!existsSync18(p))
|
|
10906
11181
|
return [];
|
|
10907
11182
|
let parsed;
|
|
10908
11183
|
try {
|
|
10909
|
-
parsed = $parse(
|
|
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
|
|
11296
|
+
existsSync as existsSync19,
|
|
11004
11297
|
mkdirSync as mkdirSync8,
|
|
11005
|
-
readFileSync as
|
|
11006
|
-
readdirSync as
|
|
11298
|
+
readFileSync as readFileSync17,
|
|
11299
|
+
readdirSync as readdirSync7
|
|
11007
11300
|
} from "fs";
|
|
11008
|
-
import { join as
|
|
11301
|
+
import { join as join21 } from "path";
|
|
11009
11302
|
function tunnelDir(uuid) {
|
|
11010
|
-
return
|
|
11303
|
+
return join21(hestiaHome(), "tunnel", uuid);
|
|
11011
11304
|
}
|
|
11012
11305
|
function configPath(uuid) {
|
|
11013
|
-
return
|
|
11306
|
+
return join21(tunnelDir(uuid), "config.yml");
|
|
11014
11307
|
}
|
|
11015
11308
|
function adoptedMarker(uuid) {
|
|
11016
|
-
return
|
|
11309
|
+
return join21(tunnelDir(uuid), "adopted.json");
|
|
11017
11310
|
}
|
|
11018
11311
|
function isAdopted(uuid) {
|
|
11019
|
-
return
|
|
11312
|
+
return existsSync19(adoptedMarker(uuid));
|
|
11020
11313
|
}
|
|
11021
11314
|
function readAdopted(uuid) {
|
|
11022
11315
|
const p = adoptedMarker(uuid);
|
|
11023
|
-
if (!
|
|
11316
|
+
if (!existsSync19(p))
|
|
11024
11317
|
return null;
|
|
11025
11318
|
let marker;
|
|
11026
11319
|
try {
|
|
11027
|
-
marker = JSON.parse(
|
|
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 =
|
|
11036
|
-
if (
|
|
11037
|
-
for (const project of
|
|
11038
|
-
const sp =
|
|
11039
|
-
if (!
|
|
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(
|
|
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 =
|
|
11059
|
-
if (!
|
|
11351
|
+
const root = join21(hestiaHome(), "tunnel");
|
|
11352
|
+
if (!existsSync19(root))
|
|
11060
11353
|
return [];
|
|
11061
|
-
return
|
|
11354
|
+
return readdirSync7(root).filter((uuid) => isAdopted(uuid));
|
|
11062
11355
|
}
|
|
11063
11356
|
function collectDynamicRules(uuid) {
|
|
11064
|
-
const stacksDir =
|
|
11357
|
+
const stacksDir = join21(hestiaHome(), "stacks");
|
|
11065
11358
|
const rules = [];
|
|
11066
11359
|
const dropped = [];
|
|
11067
|
-
if (!
|
|
11360
|
+
if (!existsSync19(stacksDir))
|
|
11068
11361
|
return { rules, dropped };
|
|
11069
|
-
for (const project of
|
|
11070
|
-
const p =
|
|
11071
|
-
if (!
|
|
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(
|
|
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 =
|
|
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
|
|
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
|
|
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 ${
|
|
11671
|
-
const 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 (
|
|
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 =
|
|
11740
|
-
if (
|
|
12255
|
+
const lockPath2 = join22(hestiaDir(worktreeRoot), "lock");
|
|
12256
|
+
if (existsSync20(lockPath2)) {
|
|
11741
12257
|
try {
|
|
11742
|
-
const holder = JSON.parse(
|
|
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 =
|
|
11751
|
-
if (!
|
|
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 =
|
|
12274
|
+
const stacksDir = join22(hestiaHome(), "stacks");
|
|
11759
12275
|
const mirrored = new Set;
|
|
11760
|
-
if (
|
|
11761
|
-
for (const project of
|
|
11762
|
-
const p =
|
|
11763
|
-
if (!
|
|
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(
|
|
11768
|
-
if (!
|
|
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 (
|
|
11841
|
-
const content =
|
|
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) => !
|
|
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
|
|
11911
|
-
import { join as
|
|
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 =
|
|
11955
|
-
if (!
|
|
12493
|
+
const stacksDirectory = join23(hestiaHome(), "stacks");
|
|
12494
|
+
if (!existsSync21(stacksDirectory))
|
|
11956
12495
|
return { records, warnings };
|
|
11957
|
-
for (const project of
|
|
11958
|
-
const path =
|
|
12496
|
+
for (const project of readdirSync9(stacksDirectory).sort()) {
|
|
12497
|
+
const path = join23(stacksDirectory, project, "stack.json");
|
|
11959
12498
|
try {
|
|
11960
|
-
const source =
|
|
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 (!
|
|
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
|
|
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
|
|
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 ${
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 ??
|
|
13360
|
-
if (
|
|
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
|
-
|
|
14021
|
+
rmSync10(mirrorDir(project), { recursive: true, force: true });
|
|
13424
14022
|
};
|
|
13425
|
-
if (record !== null &&
|
|
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 =
|
|
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
|
|
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 &&
|
|
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
|
|
15475
|
-
import { dirname as dirname8, join as
|
|
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:
|
|
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
|
-
|
|
15841
|
-
|
|
16845
|
+
join25(packageRoot, "skills", "hestia", "SKILL.md"),
|
|
16846
|
+
join25(process.cwd(), "skills", "hestia", "SKILL.md")
|
|
15842
16847
|
];
|
|
15843
|
-
const path = candidates.find(
|
|
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=
|
|
17519
|
+
//# debugId=D1A8C01922C41BA164756E2164756E21
|