home-hosted 0.5.0 → 0.6.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/AGENTS.md +25 -11
- package/README.md +65 -42
- package/dist/cli.js +546 -128
- package/dist/cli.js.map +1 -1
- package/docs/SERVERS.md +46 -4
- package/package.json +3 -2
- package/uis/stock/dist/assets/{index-C7Rc5zP8.js → index-4_Q69j7h.js} +7 -7
- package/uis/stock/dist/index.html +1 -1
package/dist/cli.js
CHANGED
|
@@ -88,9 +88,11 @@ function applyDirFlags(flags) {
|
|
|
88
88
|
*
|
|
89
89
|
* `help`/`version` are commands here, not flags, and a help or version flag on a
|
|
90
90
|
* command is answered the same way instead of being parsed as one of that
|
|
91
|
-
* command's options. A
|
|
92
|
-
*
|
|
93
|
-
*
|
|
91
|
+
* command's options. A help flag after a command keeps that command's name, so
|
|
92
|
+
* the curated text can stay scoped to it; the bare `help` (or a top-level flag)
|
|
93
|
+
* is the whole reference. A first token that starts with `-` is the one-shot
|
|
94
|
+
* form (`home-hosted -p 4000`), so `up` is prepended. Anything else has to name
|
|
95
|
+
* a command, which keeps the old `unknown command:` text exact.
|
|
94
96
|
*/
|
|
95
97
|
function resolveInvocation(argv, commands) {
|
|
96
98
|
if (argv.length === 0) return {
|
|
@@ -109,7 +111,10 @@ function resolveInvocation(argv, commands) {
|
|
|
109
111
|
command: first
|
|
110
112
|
};
|
|
111
113
|
for (const arg of argv.slice(1)) {
|
|
112
|
-
if (HELP_FLAGS.has(arg)) return {
|
|
114
|
+
if (HELP_FLAGS.has(arg)) return {
|
|
115
|
+
kind: "help",
|
|
116
|
+
command: first
|
|
117
|
+
};
|
|
113
118
|
if (VERSION_FLAGS.has(arg)) return { kind: "version" };
|
|
114
119
|
}
|
|
115
120
|
return {
|
|
@@ -242,13 +247,15 @@ async function confirm(question, fallback) {
|
|
|
242
247
|
if (answer.length === 0) return fallback;
|
|
243
248
|
return answer === "y" || answer === "yes";
|
|
244
249
|
}
|
|
245
|
-
var isTty, paint, dim, bold, green, style;
|
|
250
|
+
var isTty, paint, dim, bold, cyan, green, heading, style;
|
|
246
251
|
var init_io = __esmMin((() => {
|
|
247
252
|
isTty = () => process.stdout.isTTY === true;
|
|
248
253
|
paint = (code, text) => isTty() ? `\x1B[${code}m${text}\x1B[0m` : text;
|
|
249
254
|
dim = (text) => paint("2", text);
|
|
250
255
|
bold = (text) => paint("1", text);
|
|
256
|
+
cyan = (text) => paint("36", text);
|
|
251
257
|
green = (text) => paint("32", text);
|
|
258
|
+
heading = (text) => paint("1;4", text);
|
|
252
259
|
style = {
|
|
253
260
|
bold,
|
|
254
261
|
dim,
|
|
@@ -297,10 +304,12 @@ function parseBind(value) {
|
|
|
297
304
|
const parsed = bindSchema(value);
|
|
298
305
|
return parsed instanceof type.errors ? null : parsed;
|
|
299
306
|
}
|
|
300
|
-
var bindSchema, portSchema, restartSchema, httpCheckSchema, resourcesSchema, healthSchema, stopSchema, bootstrapSchema, bootstrapOrNullSchema, logBufferLinesSchema, serverSchema, authSchema, telegramSchema, notificationsSchema, logsSchema, tlsSchema, hostSchema, backupsSchema, controlSchema, defaultsSchema, restartPatchSchema, httpCheckPatchSchema, resourcesPatchSchema, healthPatchSchema, stopPatchSchema, authPatchSchema, telegramPatchSchema, notificationsPatchSchema, hostPatchSchema, backupsPatchSchema, logsPatchSchema, tlsPatchSchema, controlPatchSchema, defaultsPatchSchema, editableFields, serverPatchSchema, serverCreateSchema, settingsPatchSchema, authStatusSchema, tlsStatusSchema, telegramStatusSchema, notificationViewSchema, sessionViewSchema, loginSchema, passwordValueSchema, passwordSchema, serverStatusSchema, healthStateSchema, portStateSchema, logStreamSchema, logLineSchema, historyEventSchema, serverHistorySchema, processResourcesSchema, hostDiskSchema, hostViewSchema, backupFileSchema, backupPathSchema, backupsViewSchema, restoreItemSchema, restorePlanSchema, backupCreateSchema, restoreRequestSchema, serverViewSchema, controlViewSchema, appStateSchema, logQuerySchema, freePortResultSchema, logHistoryQuerySchema, logFileInfoSchema, logServerViewSchema, logServersViewSchema, notificationActionSchema, telegramTokenSchema, apiErrorSchema, uiMetaSchema, uiStatusSchema, tlsUploadSchema, settingsViewSchema, settingsSavedSchema;
|
|
307
|
+
var bindSchema, portSchema, onPortConflictSchema, onPortConflictDefaultSchema, restartSchema, httpCheckSchema, resourcesSchema, healthSchema, stopSchema, bootstrapSchema, bootstrapOrNullSchema, logBufferLinesSchema, serverSchema, authSchema, telegramSchema, notificationsSchema, logsSchema, tlsSchema, hostSchema, backupsSchema, controlSchema, defaultsSchema, restartPatchSchema, httpCheckPatchSchema, resourcesPatchSchema, healthPatchSchema, stopPatchSchema, authPatchSchema, telegramPatchSchema, notificationsPatchSchema, hostPatchSchema, backupsPatchSchema, logsPatchSchema, tlsPatchSchema, controlPatchSchema, defaultsPatchSchema, editableFields, serverPatchSchema, serverCreateSchema, settingsPatchSchema, authStatusSchema, tlsStatusSchema, telegramStatusSchema, notificationViewSchema, sessionViewSchema, loginSchema, passwordValueSchema, passwordSchema, serverStatusSchema, healthStateSchema, portStateSchema, logStreamSchema, logLineSchema, historyEventSchema, serverHistorySchema, processResourcesSchema, hostDiskSchema, hostViewSchema, backupFileSchema, backupPathSchema, backupsViewSchema, restoreItemSchema, restorePlanSchema, backupCreateSchema, restoreRequestSchema, serverViewSchema, controlViewSchema, appStateSchema, logQuerySchema, freePortResultSchema, logHistoryQuerySchema, logFileInfoSchema, logServerViewSchema, logServersViewSchema, notificationActionSchema, telegramTokenSchema, apiErrorSchema, uiMetaSchema, uiStatusSchema, tlsUploadSchema, settingsViewSchema, settingsSavedSchema;
|
|
301
308
|
var init_contracts = __esmMin((() => {
|
|
302
309
|
bindSchema = type("\"local\" | \"lan\" | /^\\d{1,3}(?:\\.\\d{1,3}){3}$/");
|
|
303
310
|
portSchema = type("1 <= number.integer <= 65535 | null");
|
|
311
|
+
onPortConflictSchema = type.enumerated("block", "warn", "follow", "reclaim", "kill");
|
|
312
|
+
onPortConflictDefaultSchema = onPortConflictSchema.default("block");
|
|
304
313
|
restartSchema = type({
|
|
305
314
|
enabled: "boolean = true",
|
|
306
315
|
maxRetries: "number.integer >= 0 = 3",
|
|
@@ -371,7 +380,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
371
380
|
bootstrap: bootstrapOrNullSchema.optional(),
|
|
372
381
|
port: portSchema.optional(),
|
|
373
382
|
bind: bindSchema.default(() => "local"),
|
|
374
|
-
onPortConflict:
|
|
383
|
+
onPortConflict: onPortConflictDefaultSchema,
|
|
375
384
|
restart: restartSchema.default(() => ({})),
|
|
376
385
|
health: healthSchema.default(() => ({})),
|
|
377
386
|
stop: stopSchema.default(() => ({})),
|
|
@@ -452,7 +461,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
452
461
|
enabled: "boolean = true",
|
|
453
462
|
autostart: "boolean = false",
|
|
454
463
|
bind: bindSchema.default(() => "local"),
|
|
455
|
-
onPortConflict:
|
|
464
|
+
onPortConflict: onPortConflictDefaultSchema,
|
|
456
465
|
restart: restartSchema.default(() => ({})),
|
|
457
466
|
health: healthSchema.default(() => ({})),
|
|
458
467
|
stop: stopSchema.default(() => ({})),
|
|
@@ -543,7 +552,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
543
552
|
enabled: "boolean?",
|
|
544
553
|
autostart: "boolean?",
|
|
545
554
|
bind: bindSchema.optional(),
|
|
546
|
-
onPortConflict:
|
|
555
|
+
onPortConflict: onPortConflictSchema.optional(),
|
|
547
556
|
restart: restartPatchSchema.optional(),
|
|
548
557
|
health: healthPatchSchema.optional(),
|
|
549
558
|
stop: stopPatchSchema.optional(),
|
|
@@ -561,7 +570,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
561
570
|
bootstrap: bootstrapOrNullSchema.optional(),
|
|
562
571
|
port: portSchema.optional(),
|
|
563
572
|
bind: bindSchema.optional(),
|
|
564
|
-
onPortConflict:
|
|
573
|
+
onPortConflict: onPortConflictSchema.optional(),
|
|
565
574
|
restart: restartPatchSchema.optional(),
|
|
566
575
|
health: healthPatchSchema.optional(),
|
|
567
576
|
stop: stopPatchSchema.optional(),
|
|
@@ -1206,7 +1215,7 @@ var SESSION_COOKIE, MAX_SESSIONS, MAX_LOCKOUT_MS, MAX_ATTEMPT_RECORDS, ATTEMPT_R
|
|
|
1206
1215
|
var init_auth$1 = __esmMin((() => {
|
|
1207
1216
|
init_secrets();
|
|
1208
1217
|
init_cookies();
|
|
1209
|
-
SESSION_COOKIE = "
|
|
1218
|
+
SESSION_COOKIE = "hh_session";
|
|
1210
1219
|
MAX_SESSIONS = 100;
|
|
1211
1220
|
MAX_LOCKOUT_MS = 9e5;
|
|
1212
1221
|
MAX_ATTEMPT_RECORDS = 1e4;
|
|
@@ -1294,7 +1303,7 @@ var init_auth$1 = __esmMin((() => {
|
|
|
1294
1303
|
};
|
|
1295
1304
|
}
|
|
1296
1305
|
tokenFromCookie(cookieHeader) {
|
|
1297
|
-
return parseCookies(cookieHeader)["
|
|
1306
|
+
return parseCookies(cookieHeader)["hh_session"] ?? null;
|
|
1298
1307
|
}
|
|
1299
1308
|
/** Sliding expiry: an active panel stays logged in, an idle one does not. */
|
|
1300
1309
|
validate(token) {
|
|
@@ -2307,19 +2316,19 @@ function createMetricsRoute(deps) {
|
|
|
2307
2316
|
if (samples.length === 0) return;
|
|
2308
2317
|
lines.push(`# HELP ${name} ${help}`, `# TYPE ${name} gauge`, ...samples);
|
|
2309
2318
|
};
|
|
2310
|
-
metric("
|
|
2311
|
-
metric("
|
|
2312
|
-
metric("
|
|
2313
|
-
metric("
|
|
2314
|
-
metric("
|
|
2315
|
-
metric("
|
|
2316
|
-
metric("
|
|
2317
|
-
metric("
|
|
2318
|
-
metric("
|
|
2319
|
-
metric("
|
|
2320
|
-
metric("
|
|
2321
|
-
metric("
|
|
2322
|
-
metric("
|
|
2319
|
+
metric("hh_control_up", "Control plane is serving", ["hh_control_up 1"]);
|
|
2320
|
+
metric("hh_servers_total", "Configured servers", [`hh_servers_total ${state.servers.length}`]);
|
|
2321
|
+
metric("hh_server_up", "Server process is running", state.servers.map((server) => `hh_server_up{server="${server.id}"} ${server.status === "running" ? 1 : 0}`));
|
|
2322
|
+
metric("hh_server_restarts_total", "Restarts since the control plane started", state.servers.map((server) => `hh_server_restarts_total{server="${server.id}"} ${server.restarts}`));
|
|
2323
|
+
metric("hh_server_crashes_24h", "Crashes in the last 24 hours", state.servers.map((server) => `hh_server_crashes_24h{server="${server.id}"} ${server.history.crashes}`));
|
|
2324
|
+
metric("hh_server_uptime_ratio_24h", "Share of the last 24 hours the server was up", state.servers.filter((server) => server.history.uptimeRatio !== null).map((server) => `hh_server_uptime_ratio_24h{server="${server.id}"} ${server.history.uptimeRatio.toFixed(4)}`));
|
|
2325
|
+
metric("hh_server_response_ms", "Last health probe latency in milliseconds", state.servers.filter((server) => server.responseMs !== null).map((server) => `hh_server_response_ms{server="${server.id}"} ${server.responseMs}`));
|
|
2326
|
+
metric("hh_server_rss_bytes", "RSS of the server process tree", state.servers.filter((server) => server.resources?.rssBytes != null).map((server) => `hh_server_rss_bytes{server="${server.id}"} ${server.resources.rssBytes}`));
|
|
2327
|
+
metric("hh_server_cpu_percent", "CPU percent of the server process tree", state.servers.filter((server) => server.resources?.cpuPercent != null).map((server) => `hh_server_cpu_percent{server="${server.id}"} ${server.resources.cpuPercent}`));
|
|
2328
|
+
metric("hh_host_disk_used_percent", "Disk usage percent per configured path", state.host.disks.map((disk) => `hh_host_disk_used_percent{mount="${disk.path}"} ${disk.usedPercent.toFixed(2)}`));
|
|
2329
|
+
metric("hh_host_memory_used_percent", "Memory usage percent", [`hh_host_memory_used_percent ${state.host.memoryUsedPercent.toFixed(2)}`]);
|
|
2330
|
+
metric("hh_host_swap_used_percent", "Swap usage percent", [`hh_host_swap_used_percent ${state.host.swapUsedPercent.toFixed(2)}`]);
|
|
2331
|
+
metric("hh_host_load1_per_cpu", "1 minute load average per cpu", [`hh_host_load1_per_cpu ${((state.host.loadAvg[0] ?? 0) / Math.max(1, state.host.cpus)).toFixed(3)}`]);
|
|
2323
2332
|
return c.text(`${lines.join("\n")}\n`, 200, { "Content-Type": "text/plain; version=0.0.4; charset=utf-8" });
|
|
2324
2333
|
});
|
|
2325
2334
|
}
|
|
@@ -4093,7 +4102,7 @@ function parseNetstatListeners(output, port) {
|
|
|
4093
4102
|
}
|
|
4094
4103
|
async function listPortHolders(port) {
|
|
4095
4104
|
if (process.platform === "win32") try {
|
|
4096
|
-
const { stdout } = await execFileAsync$
|
|
4105
|
+
const { stdout } = await execFileAsync$4("netstat", [
|
|
4097
4106
|
"-ano",
|
|
4098
4107
|
"-p",
|
|
4099
4108
|
"tcp"
|
|
@@ -4103,7 +4112,7 @@ async function listPortHolders(port) {
|
|
|
4103
4112
|
return [];
|
|
4104
4113
|
}
|
|
4105
4114
|
try {
|
|
4106
|
-
const { stdout } = await execFileAsync$
|
|
4115
|
+
const { stdout } = await execFileAsync$4("lsof", [
|
|
4107
4116
|
"-ti",
|
|
4108
4117
|
`tcp:${port}`,
|
|
4109
4118
|
"-sTCP:LISTEN"
|
|
@@ -4111,7 +4120,7 @@ async function listPortHolders(port) {
|
|
|
4111
4120
|
return parsePids(stdout);
|
|
4112
4121
|
} catch {}
|
|
4113
4122
|
try {
|
|
4114
|
-
const { stdout } = await execFileAsync$
|
|
4123
|
+
const { stdout } = await execFileAsync$4("fuser", [`${port}/tcp`], { timeout: 3e3 });
|
|
4115
4124
|
return parsePids(stdout);
|
|
4116
4125
|
} catch {
|
|
4117
4126
|
return [];
|
|
@@ -4120,9 +4129,9 @@ async function listPortHolders(port) {
|
|
|
4120
4129
|
function parsePids(stdout) {
|
|
4121
4130
|
return [...new Set(stdout.split(/\s+/).map((entry) => Number.parseInt(entry, 10)).filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid))];
|
|
4122
4131
|
}
|
|
4123
|
-
var execFileAsync$
|
|
4132
|
+
var execFileAsync$4;
|
|
4124
4133
|
var init_port = __esmMin((() => {
|
|
4125
|
-
execFileAsync$
|
|
4134
|
+
execFileAsync$4 = promisify(execFile);
|
|
4126
4135
|
}));
|
|
4127
4136
|
//#endregion
|
|
4128
4137
|
//#region src/helpers/env-file.ts
|
|
@@ -4489,7 +4498,7 @@ function parseWindowsCsv(text) {
|
|
|
4489
4498
|
async function getClockTicks() {
|
|
4490
4499
|
if (clockTicks !== null) return clockTicks;
|
|
4491
4500
|
try {
|
|
4492
|
-
const { stdout } = await execFileAsync$
|
|
4501
|
+
const { stdout } = await execFileAsync$3("getconf", ["CLK_TCK"], { timeout: 2e3 });
|
|
4493
4502
|
const parsed = Number.parseInt(stdout.trim(), 10);
|
|
4494
4503
|
clockTicks = Number.isFinite(parsed) && parsed > 0 ? parsed : 100;
|
|
4495
4504
|
} catch {
|
|
@@ -4541,7 +4550,7 @@ async function readLinux() {
|
|
|
4541
4550
|
return rows;
|
|
4542
4551
|
}
|
|
4543
4552
|
async function readPosix() {
|
|
4544
|
-
const { stdout } = await execFileAsync$
|
|
4553
|
+
const { stdout } = await execFileAsync$3("ps", ["-Ao", "pid=,ppid=,rss=,%cpu="], {
|
|
4545
4554
|
timeout: 5e3,
|
|
4546
4555
|
maxBuffer: 16777216
|
|
4547
4556
|
});
|
|
@@ -4550,7 +4559,7 @@ async function readPosix() {
|
|
|
4550
4559
|
async function readWindows() {
|
|
4551
4560
|
const script = "Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,WorkingSetSize,KernelModeTime,UserModeTime | ConvertTo-Csv -NoTypeInformation";
|
|
4552
4561
|
try {
|
|
4553
|
-
const { stdout } = await execFileAsync$
|
|
4562
|
+
const { stdout } = await execFileAsync$3("powershell.exe", [
|
|
4554
4563
|
"-NoProfile",
|
|
4555
4564
|
"-NonInteractive",
|
|
4556
4565
|
"-Command",
|
|
@@ -4562,7 +4571,7 @@ async function readWindows() {
|
|
|
4562
4571
|
return parseWindowsCsv(stdout);
|
|
4563
4572
|
} catch {
|
|
4564
4573
|
try {
|
|
4565
|
-
const { stdout } = await execFileAsync$
|
|
4574
|
+
const { stdout } = await execFileAsync$3("wmic", [
|
|
4566
4575
|
"process",
|
|
4567
4576
|
"get",
|
|
4568
4577
|
"ProcessId,ParentProcessId,WorkingSetSize,KernelModeTime,UserModeTime",
|
|
@@ -4614,7 +4623,7 @@ async function processCarriesServerId(pid, serverId) {
|
|
|
4614
4623
|
return false;
|
|
4615
4624
|
}
|
|
4616
4625
|
if (process.platform === "darwin") try {
|
|
4617
|
-
const { stdout } = await execFileAsync$
|
|
4626
|
+
const { stdout } = await execFileAsync$3("ps", [
|
|
4618
4627
|
"-p",
|
|
4619
4628
|
String(pid),
|
|
4620
4629
|
"-E",
|
|
@@ -4628,9 +4637,9 @@ async function processCarriesServerId(pid, serverId) {
|
|
|
4628
4637
|
}
|
|
4629
4638
|
return false;
|
|
4630
4639
|
}
|
|
4631
|
-
var execFileAsync$
|
|
4640
|
+
var execFileAsync$3, clockTicks, ProcessSampler;
|
|
4632
4641
|
var init_proc = __esmMin((() => {
|
|
4633
|
-
execFileAsync$
|
|
4642
|
+
execFileAsync$3 = promisify(execFile);
|
|
4634
4643
|
clockTicks = null;
|
|
4635
4644
|
ProcessSampler = class {
|
|
4636
4645
|
previous = /* @__PURE__ */ new Map();
|
|
@@ -4703,6 +4712,224 @@ var init_proc = __esmMin((() => {
|
|
|
4703
4712
|
};
|
|
4704
4713
|
}));
|
|
4705
4714
|
//#endregion
|
|
4715
|
+
//#region src/providers/identity.ts
|
|
4716
|
+
/**
|
|
4717
|
+
* Splits a command line into words, undoing the quoting Windows put there. `CommandLine`
|
|
4718
|
+
* is the raw string from the spawn call, so `"C:\a b\x.cmd" /c` is one argv entry plus
|
|
4719
|
+
* two words, and a quoted argument that contains spaces has to come back as one word.
|
|
4720
|
+
*
|
|
4721
|
+
* Windows also *escapes* a quote inside an argument as `\"` (Node does this for any argv
|
|
4722
|
+
* containing a quote), so the escape is undone here — otherwise every argument with a
|
|
4723
|
+
* quote in it survives as a stray backslash and never matches what the panel spawned.
|
|
4724
|
+
*/
|
|
4725
|
+
function splitCommandLine(line) {
|
|
4726
|
+
const words = [];
|
|
4727
|
+
let current = "";
|
|
4728
|
+
let quoted = false;
|
|
4729
|
+
let started = false;
|
|
4730
|
+
for (const char of line.trim()) {
|
|
4731
|
+
if (char === "\"") {
|
|
4732
|
+
quoted = !quoted;
|
|
4733
|
+
started = true;
|
|
4734
|
+
continue;
|
|
4735
|
+
}
|
|
4736
|
+
if (!quoted && /\s/.test(char)) {
|
|
4737
|
+
if (started) words.push(current);
|
|
4738
|
+
current = "";
|
|
4739
|
+
started = false;
|
|
4740
|
+
continue;
|
|
4741
|
+
}
|
|
4742
|
+
current += char;
|
|
4743
|
+
started = true;
|
|
4744
|
+
}
|
|
4745
|
+
if (started) words.push(current);
|
|
4746
|
+
return words;
|
|
4747
|
+
}
|
|
4748
|
+
function comparable(target) {
|
|
4749
|
+
const value = target.replace(/^"(.*)"$/, "$1").replace(/\\(?=")/g, "");
|
|
4750
|
+
return process.platform === "win32" ? value.toLowerCase() : value;
|
|
4751
|
+
}
|
|
4752
|
+
/**
|
|
4753
|
+
* Literal comparison for an argument: the same text, modulo the case folding Windows needs
|
|
4754
|
+
* and the quoting a command line shuffles around.
|
|
4755
|
+
*
|
|
4756
|
+
* Deliberately *not* `sameWord`: folding an argument to its basename would let this
|
|
4757
|
+
* entry's `/srv/web/build/server.js` equal a stranger's `/tmp/evil/build/server.js`, and a
|
|
4758
|
+
* match here is what `reclaim` kills. Nor is it a plain string equality: reading an argv
|
|
4759
|
+
* back out of a Windows `CommandLine` cannot preserve quotes exactly — the OS escapes an
|
|
4760
|
+
* argument's own quote as `\"` and strips the structural ones — so quotes and backslashes
|
|
4761
|
+
* are dropped from both sides. That leaves the arguments' actual text, which is what the
|
|
4762
|
+
* match is about.
|
|
4763
|
+
*/
|
|
4764
|
+
function sameArg(a, b) {
|
|
4765
|
+
const normalize = (value) => comparable(value).replace(/["\\]/g, "");
|
|
4766
|
+
return normalize(a) === normalize(b);
|
|
4767
|
+
}
|
|
4768
|
+
/** The same file spelled differently (`node`, `node.exe`, a relative path) compares equal. */
|
|
4769
|
+
function sameWord(a, b) {
|
|
4770
|
+
const left = comparable(a);
|
|
4771
|
+
const right = comparable(b);
|
|
4772
|
+
if (left === right || path.basename(left) === path.basename(right)) return true;
|
|
4773
|
+
return path.extname(b) === "" && path.basename(left, path.extname(left)) === right;
|
|
4774
|
+
}
|
|
4775
|
+
/**
|
|
4776
|
+
* True when the argv a process is running is the entry's own: the image must match where
|
|
4777
|
+
* `spawn` would have looked it up — the image Path, or the first word of the command line
|
|
4778
|
+
* — and the words after it must open with the entry's args, compared literally. So
|
|
4779
|
+
* `spawn --port 4000` also covers `spawn -p 4000 --extra`, which is what a self-restarting
|
|
4780
|
+
* wrapper does, while `/tmp/evil/server.js` never covers `/srv/web/server.js`.
|
|
4781
|
+
*
|
|
4782
|
+
* `words` must already be the process's own argv. A command-line *string* is only correct
|
|
4783
|
+
* on Windows, where the OS hands one out; `/proc/<pid>/cmdline` quotes are literal bytes
|
|
4784
|
+
* of an argument, so re-joining that argv into one string corrupts it.
|
|
4785
|
+
*/
|
|
4786
|
+
function matchesSpawn(info, spawn) {
|
|
4787
|
+
const { words } = info;
|
|
4788
|
+
const first = words[0] ?? "";
|
|
4789
|
+
if (!(sameWord(first, spawn.command) || info.imagePath != null && info.imagePath !== "" && sameWord(info.imagePath, spawn.command))) return false;
|
|
4790
|
+
const offset = sameWord(first, spawn.command) ? 1 : 0;
|
|
4791
|
+
if (words.length < spawn.args.length + offset) return false;
|
|
4792
|
+
return spawn.args.every((arg, index) => sameArg(words[index + offset], arg));
|
|
4793
|
+
}
|
|
4794
|
+
/** The argv of a pid on the platforms whose process table can answer it; null otherwise. */
|
|
4795
|
+
async function processArgv(pid) {
|
|
4796
|
+
if (process.platform === "win32") return null;
|
|
4797
|
+
if (process.platform === "linux") try {
|
|
4798
|
+
const argv = (await fs.promises.readFile(`/proc/${pid}/cmdline`)).toString("utf8").split("\0").filter((part) => part.length > 0);
|
|
4799
|
+
return argv.length > 0 ? argv : null;
|
|
4800
|
+
} catch {
|
|
4801
|
+
return null;
|
|
4802
|
+
}
|
|
4803
|
+
try {
|
|
4804
|
+
const { stdout } = await execFileAsync$2("ps", [
|
|
4805
|
+
"-p",
|
|
4806
|
+
String(pid),
|
|
4807
|
+
"-ww",
|
|
4808
|
+
"-o",
|
|
4809
|
+
"command="
|
|
4810
|
+
], { timeout: 3e3 });
|
|
4811
|
+
const words = splitCommandLine(stdout.trim());
|
|
4812
|
+
return words.length > 0 ? words : null;
|
|
4813
|
+
} catch {
|
|
4814
|
+
return null;
|
|
4815
|
+
}
|
|
4816
|
+
}
|
|
4817
|
+
/**
|
|
4818
|
+
* The first two rows of `ConvertTo-Csv` output: the header and the first record. PowerShell
|
|
4819
|
+
* quotes and doubles its way around CSV, so `a,"b""c"` is three fields with the second
|
|
4820
|
+
* reading `b"c`.
|
|
4821
|
+
*/
|
|
4822
|
+
function parseCsvRows(output) {
|
|
4823
|
+
const rows = [];
|
|
4824
|
+
let row = [];
|
|
4825
|
+
let field = "";
|
|
4826
|
+
let quoted = false;
|
|
4827
|
+
for (let index = 0; index < output.length; index++) {
|
|
4828
|
+
const char = output[index];
|
|
4829
|
+
if (quoted) {
|
|
4830
|
+
if (char !== "\"") {
|
|
4831
|
+
field += char;
|
|
4832
|
+
continue;
|
|
4833
|
+
}
|
|
4834
|
+
if (output[index + 1] === "\"") {
|
|
4835
|
+
field += "\"";
|
|
4836
|
+
index++;
|
|
4837
|
+
continue;
|
|
4838
|
+
}
|
|
4839
|
+
quoted = false;
|
|
4840
|
+
continue;
|
|
4841
|
+
}
|
|
4842
|
+
if (char === "\"") {
|
|
4843
|
+
quoted = true;
|
|
4844
|
+
continue;
|
|
4845
|
+
}
|
|
4846
|
+
if (char === ",") {
|
|
4847
|
+
row.push(field);
|
|
4848
|
+
field = "";
|
|
4849
|
+
continue;
|
|
4850
|
+
}
|
|
4851
|
+
if (char === "\n") {
|
|
4852
|
+
row.push(field.replace(/\r$/, ""));
|
|
4853
|
+
rows.push(row);
|
|
4854
|
+
row = [];
|
|
4855
|
+
field = "";
|
|
4856
|
+
continue;
|
|
4857
|
+
}
|
|
4858
|
+
field += char;
|
|
4859
|
+
}
|
|
4860
|
+
if (field.length > 0 || row.length > 0) rows.push([...row, field.replace(/\r$/, "")]);
|
|
4861
|
+
return [rows[0] ?? null, rows[1] ?? null];
|
|
4862
|
+
}
|
|
4863
|
+
/**
|
|
4864
|
+
* `Win32_Process` for one pid, or null when it cannot be read.
|
|
4865
|
+
*
|
|
4866
|
+
* The whole round trip — launching PowerShell, loading the CIM provider, serializing —
|
|
4867
|
+
* costs seconds on a cold runner, so the timeout is generous and the result is converted
|
|
4868
|
+
* to CSV rather than JSON: CSV survives a value that contains a quote or a newline, which
|
|
4869
|
+
* an argv legitimately can.
|
|
4870
|
+
*/
|
|
4871
|
+
async function windowsProcessInfo(pid) {
|
|
4872
|
+
if (!windowsFilter.test(String(pid))) return null;
|
|
4873
|
+
try {
|
|
4874
|
+
const script = `Get-CimInstance Win32_Process -Filter "ProcessId=${pid}" | Select-Object CommandLine,ExecutablePath | ConvertTo-Csv -NoTypeInformation`;
|
|
4875
|
+
const { stdout } = await execFileAsync$2("powershell", [
|
|
4876
|
+
"-NoProfile",
|
|
4877
|
+
"-NonInteractive",
|
|
4878
|
+
"-Command",
|
|
4879
|
+
script
|
|
4880
|
+
], {
|
|
4881
|
+
timeout: 2e4,
|
|
4882
|
+
windowsHide: true
|
|
4883
|
+
});
|
|
4884
|
+
const [headers, values] = parseCsvRows(stdout);
|
|
4885
|
+
if (!headers || !values) return null;
|
|
4886
|
+
const commandLineIndex = headers.indexOf("CommandLine");
|
|
4887
|
+
const imageIndex = headers.indexOf("ExecutablePath");
|
|
4888
|
+
const commandLine = commandLineIndex >= 0 ? values[commandLineIndex] : void 0;
|
|
4889
|
+
if (commandLine === void 0 || commandLine.length === 0) return null;
|
|
4890
|
+
const imagePath = imageIndex >= 0 ? values[imageIndex] : void 0;
|
|
4891
|
+
return {
|
|
4892
|
+
commandLine,
|
|
4893
|
+
imagePath: imagePath && imagePath.length > 0 ? imagePath : null
|
|
4894
|
+
};
|
|
4895
|
+
} catch {
|
|
4896
|
+
return null;
|
|
4897
|
+
}
|
|
4898
|
+
}
|
|
4899
|
+
/**
|
|
4900
|
+
* Which of these pids look like this entry's own detached successor. The environment
|
|
4901
|
+
* marker is authoritative where the platform can read it; otherwise the answer rests on
|
|
4902
|
+
* the entry's own argv, which is the only signal a detached successor is obliged to keep
|
|
4903
|
+
* — and the only one Windows exposes at all.
|
|
4904
|
+
*/
|
|
4905
|
+
async function identifyHolders(serverId, spawn, pids) {
|
|
4906
|
+
const found = [];
|
|
4907
|
+
for (const pid of pids) {
|
|
4908
|
+
if (await processCarriesServerId(pid, serverId)) {
|
|
4909
|
+
found.push(pid);
|
|
4910
|
+
continue;
|
|
4911
|
+
}
|
|
4912
|
+
if (spawn.args.length === 0) continue;
|
|
4913
|
+
if (process.platform === "win32") {
|
|
4914
|
+
const info = await windowsProcessInfo(pid);
|
|
4915
|
+
if (info && matchesSpawn({
|
|
4916
|
+
words: splitCommandLine(info.commandLine),
|
|
4917
|
+
imagePath: info.imagePath
|
|
4918
|
+
}, spawn)) found.push(pid);
|
|
4919
|
+
continue;
|
|
4920
|
+
}
|
|
4921
|
+
const argv = await processArgv(pid);
|
|
4922
|
+
if (argv !== null && matchesSpawn({ words: argv }, spawn)) found.push(pid);
|
|
4923
|
+
}
|
|
4924
|
+
return found;
|
|
4925
|
+
}
|
|
4926
|
+
var execFileAsync$2, windowsFilter;
|
|
4927
|
+
var init_identity = __esmMin((() => {
|
|
4928
|
+
init_proc();
|
|
4929
|
+
execFileAsync$2 = promisify(execFile);
|
|
4930
|
+
windowsFilter = /^\d+$/;
|
|
4931
|
+
}));
|
|
4932
|
+
//#endregion
|
|
4706
4933
|
//#region src/providers/process.ts
|
|
4707
4934
|
/**
|
|
4708
4935
|
* Resolves a bare command through the entry's own directory and the project's
|
|
@@ -4966,6 +5193,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
4966
5193
|
init_paths();
|
|
4967
5194
|
init_template();
|
|
4968
5195
|
init_health_check();
|
|
5196
|
+
init_identity();
|
|
4969
5197
|
init_port();
|
|
4970
5198
|
init_proc();
|
|
4971
5199
|
init_process();
|
|
@@ -5110,11 +5338,8 @@ var init_supervisor = __esmMin((() => {
|
|
|
5110
5338
|
port,
|
|
5111
5339
|
error: `server "${id}" is busy — try again in a moment`
|
|
5112
5340
|
};
|
|
5113
|
-
const
|
|
5114
|
-
|
|
5115
|
-
const ours = holders.filter((pid) => supervised.has(pid));
|
|
5116
|
-
const foreign = holders.filter((pid) => !supervised.has(pid));
|
|
5117
|
-
if (holders.length === 0) return {
|
|
5341
|
+
const { ours, foreign } = await this.portHolders(port);
|
|
5342
|
+
if ([...ours, ...foreign].length === 0) return {
|
|
5118
5343
|
...empty,
|
|
5119
5344
|
port,
|
|
5120
5345
|
error: `nothing is listening on port ${port} any more`
|
|
@@ -5129,7 +5354,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
5129
5354
|
};
|
|
5130
5355
|
}
|
|
5131
5356
|
this.log(entry, "system", `freeing port ${port}: asking pid ${foreign.join(", ")} to stop`);
|
|
5132
|
-
const { stopped, forced } = await terminatePids(foreign);
|
|
5357
|
+
const { stopped, forced } = await terminatePids(foreign, { graceMs: entry.config.stop.graceMs });
|
|
5133
5358
|
if (forced.length > 0) this.log(entry, "system", `pid ${forced.join(", ")} ignored SIGTERM and was killed`);
|
|
5134
5359
|
const free = await this.waitForPortRelease(entry, port);
|
|
5135
5360
|
if (free) {
|
|
@@ -5154,6 +5379,20 @@ var init_supervisor = __esmMin((() => {
|
|
|
5154
5379
|
free
|
|
5155
5380
|
};
|
|
5156
5381
|
}
|
|
5382
|
+
/**
|
|
5383
|
+
* Splits the port's listeners into the processes this panel owns and everyone
|
|
5384
|
+
* else. Only the foreign half may ever be signalled — a port held by a sibling
|
|
5385
|
+
* is a config mistake, not a stray process. That also keeps a stale `(pid 1234)`
|
|
5386
|
+
* in an old banner from killing a recycled pid.
|
|
5387
|
+
*/
|
|
5388
|
+
async portHolders(port) {
|
|
5389
|
+
const supervised = this.supervisedPids();
|
|
5390
|
+
const holders = await listPortHolders(port);
|
|
5391
|
+
return {
|
|
5392
|
+
ours: holders.filter((pid) => supervised.has(pid)),
|
|
5393
|
+
foreign: holders.filter((pid) => !supervised.has(pid))
|
|
5394
|
+
};
|
|
5395
|
+
}
|
|
5157
5396
|
/** Pids of the child processes this panel owns, plus itself. */
|
|
5158
5397
|
supervisedPids() {
|
|
5159
5398
|
const pids = /* @__PURE__ */ new Set([process.pid]);
|
|
@@ -5320,7 +5559,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
5320
5559
|
this.publishState();
|
|
5321
5560
|
}
|
|
5322
5561
|
isActive(entry) {
|
|
5323
|
-
return entry.child !== null || entry.status === "backoff";
|
|
5562
|
+
return entry.child !== null || entry.pid !== null || entry.adopted || entry.status === "backoff";
|
|
5324
5563
|
}
|
|
5325
5564
|
/** Loopback first, then the configured address, so a custom bind is still probed. */
|
|
5326
5565
|
probeHosts(entry) {
|
|
@@ -5343,15 +5582,24 @@ var init_supervisor = __esmMin((() => {
|
|
|
5343
5582
|
return (await Promise.all(this.occupancyHosts(entry).map((host) => probePort(host, port, timeoutMs)))).some(Boolean);
|
|
5344
5583
|
}
|
|
5345
5584
|
/**
|
|
5346
|
-
* The port
|
|
5347
|
-
* itself leaves a
|
|
5348
|
-
* not a stranger to kill.
|
|
5349
|
-
*
|
|
5585
|
+
* The port holder that is this entry's own detached successor — a program that
|
|
5586
|
+
* restarted itself leaves a process behind, and that process is the *same server*,
|
|
5587
|
+
* not a stranger to kill.
|
|
5588
|
+
*
|
|
5589
|
+
* The environment marker is authoritative where the platform can read it (Linux,
|
|
5590
|
+
* macOS). Failing that — Windows has no per-process environment at all — the entry's
|
|
5591
|
+
* own argv answers, which a successor keeps unless it re-execs under a different
|
|
5592
|
+
* image. Ambiguity is not resolved by guessing: two holders that both look like this
|
|
5593
|
+
* entry means we do not know which one is ours, so we act on neither.
|
|
5350
5594
|
*/
|
|
5351
|
-
async
|
|
5352
|
-
const
|
|
5353
|
-
|
|
5354
|
-
|
|
5595
|
+
async ownPortHolder(entry, holders) {
|
|
5596
|
+
const spawn = this.resolveSpawn(entry);
|
|
5597
|
+
const candidates = await identifyHolders(entry.config.id, spawn, holders);
|
|
5598
|
+
if (candidates.length > 1) {
|
|
5599
|
+
this.log(entry, "system", `pid ${candidates.join(", ")} all look like this entry: refusing to guess which is ours, set onPortConflict to "kill" to clear the port anyway`);
|
|
5600
|
+
return null;
|
|
5601
|
+
}
|
|
5602
|
+
return candidates[0] ?? null;
|
|
5355
5603
|
}
|
|
5356
5604
|
async preflight(entry) {
|
|
5357
5605
|
const port = entry.config.port;
|
|
@@ -5367,16 +5615,49 @@ var init_supervisor = __esmMin((() => {
|
|
|
5367
5615
|
}
|
|
5368
5616
|
entry.portState = free ? "free" : "in-use";
|
|
5369
5617
|
if (free) return { kind: "free" };
|
|
5370
|
-
const
|
|
5371
|
-
const
|
|
5618
|
+
const { ours, foreign } = await this.portHolders(port);
|
|
5619
|
+
const holders = [...ours, ...foreign];
|
|
5620
|
+
const own = await this.ownPortHolder(entry, foreign);
|
|
5372
5621
|
const suffix = holders.length > 0 ? ` (pid ${holders.join(", ")})` : "";
|
|
5373
|
-
if (
|
|
5622
|
+
if (entry.config.onPortConflict === "kill") {
|
|
5623
|
+
if (ours.length > 0) {
|
|
5624
|
+
entry.status = "conflict";
|
|
5625
|
+
entry.lastError = `port ${port} is held by pid ${ours.join(", ")}, which this panel supervises — stop that server instead`;
|
|
5626
|
+
this.log(entry, "system", `${entry.lastError} — not starting (onPortConflict: kill)`);
|
|
5627
|
+
this.publishServer(entry);
|
|
5628
|
+
return {
|
|
5629
|
+
kind: "blocked",
|
|
5630
|
+
error: entry.lastError
|
|
5631
|
+
};
|
|
5632
|
+
}
|
|
5633
|
+
if (foreign.length === 0) {
|
|
5634
|
+
this.log(entry, "system", `port ${port} looks busy but no listener could be found — starting anyway`);
|
|
5635
|
+
return { kind: "free" };
|
|
5636
|
+
}
|
|
5637
|
+
this.log(entry, "system", `port ${port} is held by pid ${foreign.join(", ")} — onPortConflict: kill, stopping the holder`);
|
|
5638
|
+
const { forced } = await terminatePids(foreign, { graceMs: entry.config.stop.graceMs });
|
|
5639
|
+
if (forced.length > 0) this.log(entry, "system", `pid ${forced.join(", ")} ignored SIGTERM and was killed`);
|
|
5640
|
+
await delay(PORT_RELEASE_RECHECK_MS);
|
|
5641
|
+
if (await freeOnAll()) {
|
|
5642
|
+
entry.portState = "free";
|
|
5643
|
+
return { kind: "free" };
|
|
5644
|
+
}
|
|
5645
|
+
entry.status = "conflict";
|
|
5646
|
+
entry.lastError = `port ${port} is still in use after killing pid ${foreign.join(", ")}`;
|
|
5647
|
+
this.log(entry, "system", entry.lastError);
|
|
5648
|
+
this.publishServer(entry);
|
|
5649
|
+
return {
|
|
5650
|
+
kind: "blocked",
|
|
5651
|
+
error: entry.lastError
|
|
5652
|
+
};
|
|
5653
|
+
}
|
|
5654
|
+
if (own !== null && entry.config.onPortConflict === "follow") return {
|
|
5374
5655
|
kind: "adopt",
|
|
5375
|
-
pid: own
|
|
5656
|
+
pid: own
|
|
5376
5657
|
};
|
|
5377
|
-
if (own
|
|
5378
|
-
this.log(entry, "system", `port ${port} is held by pid ${own
|
|
5379
|
-
const { forced } = await terminatePids(own);
|
|
5658
|
+
if (own !== null && entry.config.onPortConflict === "reclaim") {
|
|
5659
|
+
this.log(entry, "system", `port ${port} is held by pid ${own}, a detached restart of this entry — replacing it with a supervised process`);
|
|
5660
|
+
const { forced } = await terminatePids([own], { graceMs: entry.config.stop.graceMs });
|
|
5380
5661
|
if (forced.length > 0) this.log(entry, "system", `pid ${forced.join(", ")} ignored SIGTERM and was killed`);
|
|
5381
5662
|
await delay(PORT_RELEASE_RECHECK_MS);
|
|
5382
5663
|
if (await freeOnAll()) {
|
|
@@ -5384,7 +5665,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
5384
5665
|
return { kind: "free" };
|
|
5385
5666
|
}
|
|
5386
5667
|
entry.status = "conflict";
|
|
5387
|
-
entry.lastError = `port ${port} is still in use after replacing pid ${own
|
|
5668
|
+
entry.lastError = `port ${port} is still in use after replacing pid ${own}`;
|
|
5388
5669
|
this.log(entry, "system", entry.lastError);
|
|
5389
5670
|
this.publishServer(entry);
|
|
5390
5671
|
return {
|
|
@@ -5392,7 +5673,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
5392
5673
|
error: entry.lastError
|
|
5393
5674
|
};
|
|
5394
5675
|
}
|
|
5395
|
-
const hint = own
|
|
5676
|
+
const hint = own !== null ? ` — pid ${own} is a detached restart of this entry: set onPortConflict to "follow" to adopt it, "reclaim" to replace it with a supervised process, or "kill" to stop whatever holds the port` : "";
|
|
5396
5677
|
if (entry.config.onPortConflict !== "warn") {
|
|
5397
5678
|
entry.status = "conflict";
|
|
5398
5679
|
entry.lastError = `port ${port} is already in use${suffix}${hint}`;
|
|
@@ -5487,7 +5768,13 @@ var init_supervisor = __esmMin((() => {
|
|
|
5487
5768
|
if (code === 0) this.log(entry, "system", "bootstrap finished");
|
|
5488
5769
|
else if (code !== null) this.log(entry, "system", `bootstrap exited with code ${code} — continuing anyway`);
|
|
5489
5770
|
}
|
|
5490
|
-
|
|
5771
|
+
/**
|
|
5772
|
+
* Everything `spawn` needs for an entry: the resolved image, the expanded argv, the
|
|
5773
|
+
* cwd and the environment. The argv is also what the preflight recognizes the entry's
|
|
5774
|
+
* own detached successor by, so spawn and identification must never resolve it twice
|
|
5775
|
+
* with two different rules.
|
|
5776
|
+
*/
|
|
5777
|
+
resolveSpawn(entry) {
|
|
5491
5778
|
const vars = this.buildVars(entry);
|
|
5492
5779
|
const cwd = resolveCwd(entry.config.cwd);
|
|
5493
5780
|
const command = resolveCommand(entry.config.command, cwd, projectDir);
|
|
@@ -5503,15 +5790,26 @@ var init_supervisor = __esmMin((() => {
|
|
|
5503
5790
|
...process.env,
|
|
5504
5791
|
...fileEnv
|
|
5505
5792
|
};
|
|
5506
|
-
const
|
|
5793
|
+
const dataEnvs = resolveRecord(entry.config.dataEnvs, vars);
|
|
5507
5794
|
const env = {
|
|
5508
5795
|
...expandEnvRecord(resolveRecord(entry.config.env, vars), expansionVars),
|
|
5509
5796
|
...fileEnv,
|
|
5510
|
-
...expandEnvRecord(
|
|
5797
|
+
...expandEnvRecord(dataEnvs, expansionVars),
|
|
5511
5798
|
HHOSTED_SERVER_ID: entry.config.id,
|
|
5512
5799
|
HHOSTED_CONTROL_PORT: String(this.options.control.port)
|
|
5513
5800
|
};
|
|
5514
|
-
|
|
5801
|
+
for (const key of Object.keys(dataEnvs)) env[key] = path.resolve(cwd, env[key]);
|
|
5802
|
+
return {
|
|
5803
|
+
command,
|
|
5804
|
+
args: expandEnvList(resolveTemplates(entry.config.args, vars), expansionVars),
|
|
5805
|
+
env,
|
|
5806
|
+
cwd,
|
|
5807
|
+
loggedArgs: resolveTemplates(entry.config.args, vars)
|
|
5808
|
+
};
|
|
5809
|
+
}
|
|
5810
|
+
spawnEntry(entry) {
|
|
5811
|
+
const { command, args, env, cwd, loggedArgs } = this.resolveSpawn(entry);
|
|
5812
|
+
this.log(entry, "system", `start: ${command} ${loggedArgs.join(" ")}`);
|
|
5515
5813
|
let child;
|
|
5516
5814
|
try {
|
|
5517
5815
|
child = spawnManaged({
|
|
@@ -8978,7 +9276,7 @@ async function downloadToTemp(url, headers, context) {
|
|
|
8978
9276
|
if (!Number.isFinite(declared) || declared < 0) throw new Error(`the download from ${url} reported an unusable size`);
|
|
8979
9277
|
if (declared > 536870912) throw new Error(tooLargeMessage(declared));
|
|
8980
9278
|
if (response.body === null) throw new Error(`the download from ${url} had no body`);
|
|
8981
|
-
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "
|
|
9279
|
+
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "hh-ui-"));
|
|
8982
9280
|
const file = path.join(dir, "ui.zip");
|
|
8983
9281
|
const handle = await fs.promises.open(file, "w");
|
|
8984
9282
|
let received = 0;
|
|
@@ -9132,66 +9430,186 @@ init_io();
|
|
|
9132
9430
|
* module) is a dynamic import behind `subCommands`.
|
|
9133
9431
|
*/
|
|
9134
9432
|
var CLI_ENTRY = fileURLToPath(import.meta.url);
|
|
9135
|
-
|
|
9136
|
-
|
|
9137
|
-
|
|
9138
|
-
|
|
9139
|
-
|
|
9140
|
-
|
|
9141
|
-
|
|
9142
|
-
|
|
9143
|
-
|
|
9144
|
-
|
|
9145
|
-
|
|
9146
|
-
|
|
9147
|
-
|
|
9148
|
-
|
|
9149
|
-
|
|
9150
|
-
|
|
9151
|
-
|
|
9152
|
-
|
|
9153
|
-
|
|
9154
|
-
|
|
9155
|
-
|
|
9156
|
-
|
|
9157
|
-
|
|
9158
|
-
|
|
9159
|
-
|
|
9160
|
-
|
|
9161
|
-
|
|
9162
|
-
|
|
9163
|
-
|
|
9164
|
-
|
|
9165
|
-
|
|
9166
|
-
|
|
9167
|
-
|
|
9168
|
-
|
|
9169
|
-
|
|
9170
|
-
|
|
9171
|
-
|
|
9172
|
-
|
|
9173
|
-
|
|
9174
|
-
|
|
9175
|
-
|
|
9176
|
-
|
|
9177
|
-
|
|
9178
|
-
|
|
9179
|
-
|
|
9180
|
-
|
|
9181
|
-
|
|
9182
|
-
|
|
9183
|
-
|
|
9184
|
-
|
|
9185
|
-
|
|
9186
|
-
|
|
9187
|
-
|
|
9188
|
-
|
|
9189
|
-
|
|
9190
|
-
|
|
9191
|
-
|
|
9192
|
-
|
|
9193
|
-
|
|
9194
|
-
|
|
9433
|
+
/**
|
|
9434
|
+
* The curated prose, kept here because citty cannot generate it. One named
|
|
9435
|
+
* piece per command, so the full reference and a single command's help are
|
|
9436
|
+
* composed from the same lines and can never drift apart. Every section lays
|
|
9437
|
+
* its left column out at the same width, so the two views read alike.
|
|
9438
|
+
*/
|
|
9439
|
+
var HEADER = "home-hosted — a control panel for the processes on your home server";
|
|
9440
|
+
/** The one line the full reference lists for a command. */
|
|
9441
|
+
var SYNOPSIS = {
|
|
9442
|
+
"up": "home-hosted up [options]",
|
|
9443
|
+
"down": "home-hosted down",
|
|
9444
|
+
"restart": "home-hosted restart [options]",
|
|
9445
|
+
"status": "home-hosted status [--json]",
|
|
9446
|
+
"set-password": "home-hosted set-password",
|
|
9447
|
+
"set-token": "home-hosted set-token",
|
|
9448
|
+
"migrate": "home-hosted migrate",
|
|
9449
|
+
"init": "home-hosted init",
|
|
9450
|
+
"ui-switch": "home-hosted ui-switch",
|
|
9451
|
+
"ui-revert": "home-hosted ui-revert"
|
|
9452
|
+
};
|
|
9453
|
+
var SUMMARIES = {
|
|
9454
|
+
"up": "start it in the background (detached)",
|
|
9455
|
+
"down": "stop it, and everything it supervises",
|
|
9456
|
+
"restart": "down, then up",
|
|
9457
|
+
"status": "is it running, where, and how to reach it",
|
|
9458
|
+
"set-password": "set the panel password without the API",
|
|
9459
|
+
"set-token": "set the API token that scripts and agents use",
|
|
9460
|
+
"migrate": "bring the config up to this release's schema",
|
|
9461
|
+
"init": "scaffold a project that keeps its state in the repo",
|
|
9462
|
+
"ui-switch": "install a UI from a release asset, a zip file or a URL",
|
|
9463
|
+
"ui-revert": "go back to the stock control panel UI"
|
|
9464
|
+
};
|
|
9465
|
+
/** Every section lays its left column out at this width, so the two views align. */
|
|
9466
|
+
var OPTION_WIDTH = 19;
|
|
9467
|
+
var UP_SECTION = {
|
|
9468
|
+
heading: "Options for up/restart",
|
|
9469
|
+
lines: [
|
|
9470
|
+
["-c, --config <file>", "servers config (default: <state>/servers.config.json)"],
|
|
9471
|
+
["-p, --port <port>", "control panel port (default: 3999)"],
|
|
9472
|
+
["--host <bind>", "local | lan | an ipv4 address (default: local)"],
|
|
9473
|
+
["--open", "open the panel in a browser once it is up"],
|
|
9474
|
+
["--no-autostart", "do not start the entries marked autostart"],
|
|
9475
|
+
["--foreground", "run in this process instead of detaching (systemd/docker)"],
|
|
9476
|
+
["--print-config", "print the effective config and exit"]
|
|
9477
|
+
]
|
|
9478
|
+
};
|
|
9479
|
+
var STATUS_SECTION = {
|
|
9480
|
+
heading: "Options for status",
|
|
9481
|
+
lines: [["--json", "print machine-readable JSON"]]
|
|
9482
|
+
};
|
|
9483
|
+
var SET_PASSWORD_SECTION = {
|
|
9484
|
+
heading: "Options for set-password",
|
|
9485
|
+
lines: [["--clear", "remove the password, which disables authentication"]]
|
|
9486
|
+
};
|
|
9487
|
+
var SET_TOKEN_SECTION = {
|
|
9488
|
+
heading: "Options for set-token",
|
|
9489
|
+
lines: [["--generate", "create a strong token and print it once"], ["--clear", "remove the token, so it stops working"]]
|
|
9490
|
+
};
|
|
9491
|
+
var MIGRATE_SECTION = {
|
|
9492
|
+
heading: "Options for migrate",
|
|
9493
|
+
lines: [["--dry-run", "print what would change, write nothing"], ["-y, --yes", "apply without asking (or set HHOSTED_MIGRATE=allow)"]]
|
|
9494
|
+
};
|
|
9495
|
+
var INIT_SECTION = {
|
|
9496
|
+
heading: "Options for init",
|
|
9497
|
+
lines: [
|
|
9498
|
+
["--dir <dir>", "where to scaffold (default: ./my-servers)"],
|
|
9499
|
+
["--name <name>", "package name (default: the directory name)"],
|
|
9500
|
+
["--pm <manager>", "pnpm | npm | yarn | bun (default: the first one installed)"],
|
|
9501
|
+
["--no-install", "write the files, install nothing"],
|
|
9502
|
+
["-y, --yes", "take every default, ask nothing"]
|
|
9503
|
+
]
|
|
9504
|
+
};
|
|
9505
|
+
var UI_SWITCH_SECTION = {
|
|
9506
|
+
heading: "Options for ui-switch",
|
|
9507
|
+
lines: [
|
|
9508
|
+
["--repo <owner/name>", "release repo (default: NamesMT/home-hosted)"],
|
|
9509
|
+
["--tag <tag>", "release tag (default: this release's tag, or latest for another repo)"],
|
|
9510
|
+
["--asset <name>", "asset to install (exact or unambiguous match)"],
|
|
9511
|
+
["--file <path|url>", "install a zip from a local path or an http(s) URL"],
|
|
9512
|
+
["--list", "list the usable assets and install nothing"],
|
|
9513
|
+
["--token <token>", "GitHub token (or GITHUB_TOKEN / GH_TOKEN)"],
|
|
9514
|
+
["-y, --yes", "take the only asset instead of asking"]
|
|
9515
|
+
]
|
|
9516
|
+
};
|
|
9517
|
+
var EVERYWHERE_SECTION = {
|
|
9518
|
+
heading: "Everywhere",
|
|
9519
|
+
lines: [
|
|
9520
|
+
["--home <dir>", "state directory (default: $HHOSTED_HOME or ~/.home-hosted)"],
|
|
9521
|
+
["--project <dir>", "base for relative entry paths (default: the current directory)"],
|
|
9522
|
+
["-h, --help", "this text"],
|
|
9523
|
+
["-v, --version", "the version"]
|
|
9524
|
+
]
|
|
9525
|
+
};
|
|
9526
|
+
var ALIAS_SECTION = {
|
|
9527
|
+
heading: "Alias",
|
|
9528
|
+
lines: [["hh", "the same CLI, on a machine where home-hosted is installed"]]
|
|
9529
|
+
};
|
|
9530
|
+
var ENVIRONMENT_SECTION = {
|
|
9531
|
+
heading: "Environment",
|
|
9532
|
+
lines: [
|
|
9533
|
+
["HHOSTED_HOME", "where config, secrets, logs, TLS and backups live"],
|
|
9534
|
+
["HHOSTED_PROJECT", "base for relative entry paths"],
|
|
9535
|
+
["HHOSTED_PASSWORD", "the password for a non-interactive set-password"],
|
|
9536
|
+
["HHOSTED_TOKEN", "the token for a non-interactive set-token"],
|
|
9537
|
+
["GITHUB_TOKEN", "a GitHub token for ui-switch (GH_TOKEN also works)"]
|
|
9538
|
+
]
|
|
9539
|
+
};
|
|
9540
|
+
/** The command list of the full reference, aligned as it always was. */
|
|
9541
|
+
function renderCommandList() {
|
|
9542
|
+
const width = Math.max(...Object.values(SYNOPSIS).map((synopsis) => synopsis.length));
|
|
9543
|
+
return Object.keys(SYNOPSIS).map((name) => {
|
|
9544
|
+
const synopsis = SYNOPSIS[name];
|
|
9545
|
+
return ` ${cyan(synopsis)}${" ".repeat(width + 1 - synopsis.length)} ${SUMMARIES[name]}`;
|
|
9546
|
+
}).join("\n");
|
|
9547
|
+
}
|
|
9548
|
+
function renderSection(section) {
|
|
9549
|
+
const lines = section.lines.map(([left, right]) => ` ${cyan(left)}${" ".repeat(Math.max(0, OPTION_WIDTH - left.length))} ${right}`).join("\n");
|
|
9550
|
+
return `${heading(section.heading)}\n${lines}`;
|
|
9551
|
+
}
|
|
9552
|
+
/** The trailer every command shares: the global flags, the alias and the environment. */
|
|
9553
|
+
var SHARED_TRAILER = [
|
|
9554
|
+
renderSection(EVERYWHERE_SECTION),
|
|
9555
|
+
"",
|
|
9556
|
+
renderSection(ALIAS_SECTION),
|
|
9557
|
+
"",
|
|
9558
|
+
renderSection(ENVIRONMENT_SECTION),
|
|
9559
|
+
""
|
|
9560
|
+
].join("\n");
|
|
9561
|
+
/** The full reference: every command, every option, the trailer. */
|
|
9562
|
+
var USAGE = [
|
|
9563
|
+
dim(HEADER),
|
|
9564
|
+
"",
|
|
9565
|
+
heading("Usage"),
|
|
9566
|
+
renderCommandList(),
|
|
9567
|
+
"",
|
|
9568
|
+
renderSection(UP_SECTION),
|
|
9569
|
+
"",
|
|
9570
|
+
renderSection(SET_PASSWORD_SECTION),
|
|
9571
|
+
"",
|
|
9572
|
+
renderSection(SET_TOKEN_SECTION),
|
|
9573
|
+
"",
|
|
9574
|
+
renderSection(MIGRATE_SECTION),
|
|
9575
|
+
"",
|
|
9576
|
+
renderSection(INIT_SECTION),
|
|
9577
|
+
"",
|
|
9578
|
+
renderSection(UI_SWITCH_SECTION),
|
|
9579
|
+
"",
|
|
9580
|
+
renderSection(STATUS_SECTION),
|
|
9581
|
+
"",
|
|
9582
|
+
SHARED_TRAILER
|
|
9583
|
+
].join("\n");
|
|
9584
|
+
var UP_COMMANDS = /* @__PURE__ */ new Set(["up", "restart"]);
|
|
9585
|
+
var SECTIONS = {
|
|
9586
|
+
"status": STATUS_SECTION,
|
|
9587
|
+
"set-password": SET_PASSWORD_SECTION,
|
|
9588
|
+
"set-token": SET_TOKEN_SECTION,
|
|
9589
|
+
"migrate": MIGRATE_SECTION,
|
|
9590
|
+
"init": INIT_SECTION,
|
|
9591
|
+
"ui-switch": UI_SWITCH_SECTION
|
|
9592
|
+
};
|
|
9593
|
+
/**
|
|
9594
|
+
* `home-hosted <command> --help`: the usage line, that command's own options,
|
|
9595
|
+
* and the shared trailer. A command with no options says so instead of printing
|
|
9596
|
+
* an empty heading.
|
|
9597
|
+
*/
|
|
9598
|
+
function commandHelp(command) {
|
|
9599
|
+
const synopsis = SYNOPSIS[command];
|
|
9600
|
+
if (synopsis === void 0) return USAGE;
|
|
9601
|
+
const section = UP_COMMANDS.has(command) ? UP_SECTION : SECTIONS[command];
|
|
9602
|
+
const options = section === void 0 ? dim("no options") : renderSection(section);
|
|
9603
|
+
return [
|
|
9604
|
+
dim(HEADER),
|
|
9605
|
+
"",
|
|
9606
|
+
cyan(synopsis),
|
|
9607
|
+
"",
|
|
9608
|
+
options,
|
|
9609
|
+
"",
|
|
9610
|
+
SHARED_TRAILER
|
|
9611
|
+
].join("\n");
|
|
9612
|
+
}
|
|
9195
9613
|
function manifestVersion() {
|
|
9196
9614
|
try {
|
|
9197
9615
|
return JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "0.0.0";
|
|
@@ -9233,7 +9651,7 @@ async function main() {
|
|
|
9233
9651
|
applyDirFlags(dirFlags);
|
|
9234
9652
|
const invocation = resolveInvocation(dirFlags.rest, commandNames);
|
|
9235
9653
|
if (invocation.kind === "help") {
|
|
9236
|
-
process.stdout.write(USAGE);
|
|
9654
|
+
process.stdout.write(invocation.command === void 0 ? USAGE : commandHelp(invocation.command));
|
|
9237
9655
|
return;
|
|
9238
9656
|
}
|
|
9239
9657
|
if (invocation.kind === "version") {
|
|
@@ -9258,6 +9676,6 @@ main().catch((error) => {
|
|
|
9258
9676
|
fail(error instanceof Error ? error.message : String(error));
|
|
9259
9677
|
});
|
|
9260
9678
|
//#endregion
|
|
9261
|
-
export {};
|
|
9679
|
+
export { commandHelp };
|
|
9262
9680
|
|
|
9263
9681
|
//# sourceMappingURL=cli.js.map
|