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