home-hosted 0.5.0 → 0.6.1
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 +38 -11
- package/README.md +70 -43
- package/dist/cli.js +1396 -470
- package/dist/cli.js.map +1 -1
- package/docs/SERVERS.md +46 -4
- package/docs/UI_CREATION.md +35 -4
- package/package.json +3 -2
- package/uis/stock/dist/assets/{index-C7Rc5zP8.js → index-sj5eYUeD.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(),
|
|
@@ -888,10 +897,20 @@ password: passwordValueSchema.optional() }).onUndeclaredKey("reject");
|
|
|
888
897
|
detail: "unknown"
|
|
889
898
|
}).onUndeclaredKey("reject");
|
|
890
899
|
uiMetaSchema = type({
|
|
891
|
-
name: "string",
|
|
892
|
-
version: "string | null",
|
|
893
|
-
|
|
894
|
-
|
|
900
|
+
"name?": "string",
|
|
901
|
+
"version?": "string | null",
|
|
902
|
+
/** Set by the panel, not by the author. */
|
|
903
|
+
"uploadedAt?": "number",
|
|
904
|
+
/** Counted by the panel, not declared. */
|
|
905
|
+
"files?": "number.integer >= 1",
|
|
906
|
+
/** `owner/name` of the UI's own repository, for `ui-update`. */
|
|
907
|
+
"repo?": "string",
|
|
908
|
+
/** The release tag this build came from, e.g. `v0.6.0`. */
|
|
909
|
+
"tag?": "string",
|
|
910
|
+
/** The release asset name, e.g. `home-hosted-ui-noc-console`. */
|
|
911
|
+
"asset?": "string",
|
|
912
|
+
/** When the UI was built, in unix epoch seconds. */
|
|
913
|
+
"unix?": "number.integer >= 0"
|
|
895
914
|
});
|
|
896
915
|
uiStatusSchema = type({
|
|
897
916
|
/** A user-supplied UI is being served instead of the stock one. */
|
|
@@ -1206,7 +1225,7 @@ var SESSION_COOKIE, MAX_SESSIONS, MAX_LOCKOUT_MS, MAX_ATTEMPT_RECORDS, ATTEMPT_R
|
|
|
1206
1225
|
var init_auth$1 = __esmMin((() => {
|
|
1207
1226
|
init_secrets();
|
|
1208
1227
|
init_cookies();
|
|
1209
|
-
SESSION_COOKIE = "
|
|
1228
|
+
SESSION_COOKIE = "hh_session";
|
|
1210
1229
|
MAX_SESSIONS = 100;
|
|
1211
1230
|
MAX_LOCKOUT_MS = 9e5;
|
|
1212
1231
|
MAX_ATTEMPT_RECORDS = 1e4;
|
|
@@ -1294,7 +1313,7 @@ var init_auth$1 = __esmMin((() => {
|
|
|
1294
1313
|
};
|
|
1295
1314
|
}
|
|
1296
1315
|
tokenFromCookie(cookieHeader) {
|
|
1297
|
-
return parseCookies(cookieHeader)["
|
|
1316
|
+
return parseCookies(cookieHeader)["hh_session"] ?? null;
|
|
1298
1317
|
}
|
|
1299
1318
|
/** Sliding expiry: an active panel stays logged in, an idle one does not. */
|
|
1300
1319
|
validate(token) {
|
|
@@ -2307,19 +2326,19 @@ function createMetricsRoute(deps) {
|
|
|
2307
2326
|
if (samples.length === 0) return;
|
|
2308
2327
|
lines.push(`# HELP ${name} ${help}`, `# TYPE ${name} gauge`, ...samples);
|
|
2309
2328
|
};
|
|
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("
|
|
2329
|
+
metric("hh_control_up", "Control plane is serving", ["hh_control_up 1"]);
|
|
2330
|
+
metric("hh_servers_total", "Configured servers", [`hh_servers_total ${state.servers.length}`]);
|
|
2331
|
+
metric("hh_server_up", "Server process is running", state.servers.map((server) => `hh_server_up{server="${server.id}"} ${server.status === "running" ? 1 : 0}`));
|
|
2332
|
+
metric("hh_server_restarts_total", "Restarts since the control plane started", state.servers.map((server) => `hh_server_restarts_total{server="${server.id}"} ${server.restarts}`));
|
|
2333
|
+
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}`));
|
|
2334
|
+
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)}`));
|
|
2335
|
+
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}`));
|
|
2336
|
+
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}`));
|
|
2337
|
+
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}`));
|
|
2338
|
+
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)}`));
|
|
2339
|
+
metric("hh_host_memory_used_percent", "Memory usage percent", [`hh_host_memory_used_percent ${state.host.memoryUsedPercent.toFixed(2)}`]);
|
|
2340
|
+
metric("hh_host_swap_used_percent", "Swap usage percent", [`hh_host_swap_used_percent ${state.host.swapUsedPercent.toFixed(2)}`]);
|
|
2341
|
+
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
2342
|
return c.text(`${lines.join("\n")}\n`, 200, { "Content-Type": "text/plain; version=0.0.4; charset=utf-8" });
|
|
2324
2343
|
});
|
|
2325
2344
|
}
|
|
@@ -4093,7 +4112,7 @@ function parseNetstatListeners(output, port) {
|
|
|
4093
4112
|
}
|
|
4094
4113
|
async function listPortHolders(port) {
|
|
4095
4114
|
if (process.platform === "win32") try {
|
|
4096
|
-
const { stdout } = await execFileAsync$
|
|
4115
|
+
const { stdout } = await execFileAsync$4("netstat", [
|
|
4097
4116
|
"-ano",
|
|
4098
4117
|
"-p",
|
|
4099
4118
|
"tcp"
|
|
@@ -4103,7 +4122,7 @@ async function listPortHolders(port) {
|
|
|
4103
4122
|
return [];
|
|
4104
4123
|
}
|
|
4105
4124
|
try {
|
|
4106
|
-
const { stdout } = await execFileAsync$
|
|
4125
|
+
const { stdout } = await execFileAsync$4("lsof", [
|
|
4107
4126
|
"-ti",
|
|
4108
4127
|
`tcp:${port}`,
|
|
4109
4128
|
"-sTCP:LISTEN"
|
|
@@ -4111,7 +4130,7 @@ async function listPortHolders(port) {
|
|
|
4111
4130
|
return parsePids(stdout);
|
|
4112
4131
|
} catch {}
|
|
4113
4132
|
try {
|
|
4114
|
-
const { stdout } = await execFileAsync$
|
|
4133
|
+
const { stdout } = await execFileAsync$4("fuser", [`${port}/tcp`], { timeout: 3e3 });
|
|
4115
4134
|
return parsePids(stdout);
|
|
4116
4135
|
} catch {
|
|
4117
4136
|
return [];
|
|
@@ -4120,9 +4139,9 @@ async function listPortHolders(port) {
|
|
|
4120
4139
|
function parsePids(stdout) {
|
|
4121
4140
|
return [...new Set(stdout.split(/\s+/).map((entry) => Number.parseInt(entry, 10)).filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid))];
|
|
4122
4141
|
}
|
|
4123
|
-
var execFileAsync$
|
|
4142
|
+
var execFileAsync$4;
|
|
4124
4143
|
var init_port = __esmMin((() => {
|
|
4125
|
-
execFileAsync$
|
|
4144
|
+
execFileAsync$4 = promisify(execFile);
|
|
4126
4145
|
}));
|
|
4127
4146
|
//#endregion
|
|
4128
4147
|
//#region src/helpers/env-file.ts
|
|
@@ -4489,7 +4508,7 @@ function parseWindowsCsv(text) {
|
|
|
4489
4508
|
async function getClockTicks() {
|
|
4490
4509
|
if (clockTicks !== null) return clockTicks;
|
|
4491
4510
|
try {
|
|
4492
|
-
const { stdout } = await execFileAsync$
|
|
4511
|
+
const { stdout } = await execFileAsync$3("getconf", ["CLK_TCK"], { timeout: 2e3 });
|
|
4493
4512
|
const parsed = Number.parseInt(stdout.trim(), 10);
|
|
4494
4513
|
clockTicks = Number.isFinite(parsed) && parsed > 0 ? parsed : 100;
|
|
4495
4514
|
} catch {
|
|
@@ -4541,7 +4560,7 @@ async function readLinux() {
|
|
|
4541
4560
|
return rows;
|
|
4542
4561
|
}
|
|
4543
4562
|
async function readPosix() {
|
|
4544
|
-
const { stdout } = await execFileAsync$
|
|
4563
|
+
const { stdout } = await execFileAsync$3("ps", ["-Ao", "pid=,ppid=,rss=,%cpu="], {
|
|
4545
4564
|
timeout: 5e3,
|
|
4546
4565
|
maxBuffer: 16777216
|
|
4547
4566
|
});
|
|
@@ -4550,7 +4569,7 @@ async function readPosix() {
|
|
|
4550
4569
|
async function readWindows() {
|
|
4551
4570
|
const script = "Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,WorkingSetSize,KernelModeTime,UserModeTime | ConvertTo-Csv -NoTypeInformation";
|
|
4552
4571
|
try {
|
|
4553
|
-
const { stdout } = await execFileAsync$
|
|
4572
|
+
const { stdout } = await execFileAsync$3("powershell.exe", [
|
|
4554
4573
|
"-NoProfile",
|
|
4555
4574
|
"-NonInteractive",
|
|
4556
4575
|
"-Command",
|
|
@@ -4562,7 +4581,7 @@ async function readWindows() {
|
|
|
4562
4581
|
return parseWindowsCsv(stdout);
|
|
4563
4582
|
} catch {
|
|
4564
4583
|
try {
|
|
4565
|
-
const { stdout } = await execFileAsync$
|
|
4584
|
+
const { stdout } = await execFileAsync$3("wmic", [
|
|
4566
4585
|
"process",
|
|
4567
4586
|
"get",
|
|
4568
4587
|
"ProcessId,ParentProcessId,WorkingSetSize,KernelModeTime,UserModeTime",
|
|
@@ -4614,7 +4633,7 @@ async function processCarriesServerId(pid, serverId) {
|
|
|
4614
4633
|
return false;
|
|
4615
4634
|
}
|
|
4616
4635
|
if (process.platform === "darwin") try {
|
|
4617
|
-
const { stdout } = await execFileAsync$
|
|
4636
|
+
const { stdout } = await execFileAsync$3("ps", [
|
|
4618
4637
|
"-p",
|
|
4619
4638
|
String(pid),
|
|
4620
4639
|
"-E",
|
|
@@ -4628,9 +4647,9 @@ async function processCarriesServerId(pid, serverId) {
|
|
|
4628
4647
|
}
|
|
4629
4648
|
return false;
|
|
4630
4649
|
}
|
|
4631
|
-
var execFileAsync$
|
|
4650
|
+
var execFileAsync$3, clockTicks, ProcessSampler;
|
|
4632
4651
|
var init_proc = __esmMin((() => {
|
|
4633
|
-
execFileAsync$
|
|
4652
|
+
execFileAsync$3 = promisify(execFile);
|
|
4634
4653
|
clockTicks = null;
|
|
4635
4654
|
ProcessSampler = class {
|
|
4636
4655
|
previous = /* @__PURE__ */ new Map();
|
|
@@ -4703,6 +4722,224 @@ var init_proc = __esmMin((() => {
|
|
|
4703
4722
|
};
|
|
4704
4723
|
}));
|
|
4705
4724
|
//#endregion
|
|
4725
|
+
//#region src/providers/identity.ts
|
|
4726
|
+
/**
|
|
4727
|
+
* Splits a command line into words, undoing the quoting Windows put there. `CommandLine`
|
|
4728
|
+
* is the raw string from the spawn call, so `"C:\a b\x.cmd" /c` is one argv entry plus
|
|
4729
|
+
* two words, and a quoted argument that contains spaces has to come back as one word.
|
|
4730
|
+
*
|
|
4731
|
+
* Windows also *escapes* a quote inside an argument as `\"` (Node does this for any argv
|
|
4732
|
+
* containing a quote), so the escape is undone here — otherwise every argument with a
|
|
4733
|
+
* quote in it survives as a stray backslash and never matches what the panel spawned.
|
|
4734
|
+
*/
|
|
4735
|
+
function splitCommandLine(line) {
|
|
4736
|
+
const words = [];
|
|
4737
|
+
let current = "";
|
|
4738
|
+
let quoted = false;
|
|
4739
|
+
let started = false;
|
|
4740
|
+
for (const char of line.trim()) {
|
|
4741
|
+
if (char === "\"") {
|
|
4742
|
+
quoted = !quoted;
|
|
4743
|
+
started = true;
|
|
4744
|
+
continue;
|
|
4745
|
+
}
|
|
4746
|
+
if (!quoted && /\s/.test(char)) {
|
|
4747
|
+
if (started) words.push(current);
|
|
4748
|
+
current = "";
|
|
4749
|
+
started = false;
|
|
4750
|
+
continue;
|
|
4751
|
+
}
|
|
4752
|
+
current += char;
|
|
4753
|
+
started = true;
|
|
4754
|
+
}
|
|
4755
|
+
if (started) words.push(current);
|
|
4756
|
+
return words;
|
|
4757
|
+
}
|
|
4758
|
+
function comparable(target) {
|
|
4759
|
+
const value = target.replace(/^"(.*)"$/, "$1").replace(/\\(?=")/g, "");
|
|
4760
|
+
return process.platform === "win32" ? value.toLowerCase() : value;
|
|
4761
|
+
}
|
|
4762
|
+
/**
|
|
4763
|
+
* Literal comparison for an argument: the same text, modulo the case folding Windows needs
|
|
4764
|
+
* and the quoting a command line shuffles around.
|
|
4765
|
+
*
|
|
4766
|
+
* Deliberately *not* `sameWord`: folding an argument to its basename would let this
|
|
4767
|
+
* entry's `/srv/web/build/server.js` equal a stranger's `/tmp/evil/build/server.js`, and a
|
|
4768
|
+
* match here is what `reclaim` kills. Nor is it a plain string equality: reading an argv
|
|
4769
|
+
* back out of a Windows `CommandLine` cannot preserve quotes exactly — the OS escapes an
|
|
4770
|
+
* argument's own quote as `\"` and strips the structural ones — so quotes and backslashes
|
|
4771
|
+
* are dropped from both sides. That leaves the arguments' actual text, which is what the
|
|
4772
|
+
* match is about.
|
|
4773
|
+
*/
|
|
4774
|
+
function sameArg(a, b) {
|
|
4775
|
+
const normalize = (value) => comparable(value).replace(/["\\]/g, "");
|
|
4776
|
+
return normalize(a) === normalize(b);
|
|
4777
|
+
}
|
|
4778
|
+
/** The same file spelled differently (`node`, `node.exe`, a relative path) compares equal. */
|
|
4779
|
+
function sameWord(a, b) {
|
|
4780
|
+
const left = comparable(a);
|
|
4781
|
+
const right = comparable(b);
|
|
4782
|
+
if (left === right || path.basename(left) === path.basename(right)) return true;
|
|
4783
|
+
return path.extname(b) === "" && path.basename(left, path.extname(left)) === right;
|
|
4784
|
+
}
|
|
4785
|
+
/**
|
|
4786
|
+
* True when the argv a process is running is the entry's own: the image must match where
|
|
4787
|
+
* `spawn` would have looked it up — the image Path, or the first word of the command line
|
|
4788
|
+
* — and the words after it must open with the entry's args, compared literally. So
|
|
4789
|
+
* `spawn --port 4000` also covers `spawn -p 4000 --extra`, which is what a self-restarting
|
|
4790
|
+
* wrapper does, while `/tmp/evil/server.js` never covers `/srv/web/server.js`.
|
|
4791
|
+
*
|
|
4792
|
+
* `words` must already be the process's own argv. A command-line *string* is only correct
|
|
4793
|
+
* on Windows, where the OS hands one out; `/proc/<pid>/cmdline` quotes are literal bytes
|
|
4794
|
+
* of an argument, so re-joining that argv into one string corrupts it.
|
|
4795
|
+
*/
|
|
4796
|
+
function matchesSpawn(info, spawn) {
|
|
4797
|
+
const { words } = info;
|
|
4798
|
+
const first = words[0] ?? "";
|
|
4799
|
+
if (!(sameWord(first, spawn.command) || info.imagePath != null && info.imagePath !== "" && sameWord(info.imagePath, spawn.command))) return false;
|
|
4800
|
+
const offset = sameWord(first, spawn.command) ? 1 : 0;
|
|
4801
|
+
if (words.length < spawn.args.length + offset) return false;
|
|
4802
|
+
return spawn.args.every((arg, index) => sameArg(words[index + offset], arg));
|
|
4803
|
+
}
|
|
4804
|
+
/** The argv of a pid on the platforms whose process table can answer it; null otherwise. */
|
|
4805
|
+
async function processArgv(pid) {
|
|
4806
|
+
if (process.platform === "win32") return null;
|
|
4807
|
+
if (process.platform === "linux") try {
|
|
4808
|
+
const argv = (await fs.promises.readFile(`/proc/${pid}/cmdline`)).toString("utf8").split("\0").filter((part) => part.length > 0);
|
|
4809
|
+
return argv.length > 0 ? argv : null;
|
|
4810
|
+
} catch {
|
|
4811
|
+
return null;
|
|
4812
|
+
}
|
|
4813
|
+
try {
|
|
4814
|
+
const { stdout } = await execFileAsync$2("ps", [
|
|
4815
|
+
"-p",
|
|
4816
|
+
String(pid),
|
|
4817
|
+
"-ww",
|
|
4818
|
+
"-o",
|
|
4819
|
+
"command="
|
|
4820
|
+
], { timeout: 3e3 });
|
|
4821
|
+
const words = splitCommandLine(stdout.trim());
|
|
4822
|
+
return words.length > 0 ? words : null;
|
|
4823
|
+
} catch {
|
|
4824
|
+
return null;
|
|
4825
|
+
}
|
|
4826
|
+
}
|
|
4827
|
+
/**
|
|
4828
|
+
* The first two rows of `ConvertTo-Csv` output: the header and the first record. PowerShell
|
|
4829
|
+
* quotes and doubles its way around CSV, so `a,"b""c"` is three fields with the second
|
|
4830
|
+
* reading `b"c`.
|
|
4831
|
+
*/
|
|
4832
|
+
function parseCsvRows(output) {
|
|
4833
|
+
const rows = [];
|
|
4834
|
+
let row = [];
|
|
4835
|
+
let field = "";
|
|
4836
|
+
let quoted = false;
|
|
4837
|
+
for (let index = 0; index < output.length; index++) {
|
|
4838
|
+
const char = output[index];
|
|
4839
|
+
if (quoted) {
|
|
4840
|
+
if (char !== "\"") {
|
|
4841
|
+
field += char;
|
|
4842
|
+
continue;
|
|
4843
|
+
}
|
|
4844
|
+
if (output[index + 1] === "\"") {
|
|
4845
|
+
field += "\"";
|
|
4846
|
+
index++;
|
|
4847
|
+
continue;
|
|
4848
|
+
}
|
|
4849
|
+
quoted = false;
|
|
4850
|
+
continue;
|
|
4851
|
+
}
|
|
4852
|
+
if (char === "\"") {
|
|
4853
|
+
quoted = true;
|
|
4854
|
+
continue;
|
|
4855
|
+
}
|
|
4856
|
+
if (char === ",") {
|
|
4857
|
+
row.push(field);
|
|
4858
|
+
field = "";
|
|
4859
|
+
continue;
|
|
4860
|
+
}
|
|
4861
|
+
if (char === "\n") {
|
|
4862
|
+
row.push(field.replace(/\r$/, ""));
|
|
4863
|
+
rows.push(row);
|
|
4864
|
+
row = [];
|
|
4865
|
+
field = "";
|
|
4866
|
+
continue;
|
|
4867
|
+
}
|
|
4868
|
+
field += char;
|
|
4869
|
+
}
|
|
4870
|
+
if (field.length > 0 || row.length > 0) rows.push([...row, field.replace(/\r$/, "")]);
|
|
4871
|
+
return [rows[0] ?? null, rows[1] ?? null];
|
|
4872
|
+
}
|
|
4873
|
+
/**
|
|
4874
|
+
* `Win32_Process` for one pid, or null when it cannot be read.
|
|
4875
|
+
*
|
|
4876
|
+
* The whole round trip — launching PowerShell, loading the CIM provider, serializing —
|
|
4877
|
+
* costs seconds on a cold runner, so the timeout is generous and the result is converted
|
|
4878
|
+
* to CSV rather than JSON: CSV survives a value that contains a quote or a newline, which
|
|
4879
|
+
* an argv legitimately can.
|
|
4880
|
+
*/
|
|
4881
|
+
async function windowsProcessInfo(pid) {
|
|
4882
|
+
if (!windowsFilter.test(String(pid))) return null;
|
|
4883
|
+
try {
|
|
4884
|
+
const script = `Get-CimInstance Win32_Process -Filter "ProcessId=${pid}" | Select-Object CommandLine,ExecutablePath | ConvertTo-Csv -NoTypeInformation`;
|
|
4885
|
+
const { stdout } = await execFileAsync$2("powershell", [
|
|
4886
|
+
"-NoProfile",
|
|
4887
|
+
"-NonInteractive",
|
|
4888
|
+
"-Command",
|
|
4889
|
+
script
|
|
4890
|
+
], {
|
|
4891
|
+
timeout: 2e4,
|
|
4892
|
+
windowsHide: true
|
|
4893
|
+
});
|
|
4894
|
+
const [headers, values] = parseCsvRows(stdout);
|
|
4895
|
+
if (!headers || !values) return null;
|
|
4896
|
+
const commandLineIndex = headers.indexOf("CommandLine");
|
|
4897
|
+
const imageIndex = headers.indexOf("ExecutablePath");
|
|
4898
|
+
const commandLine = commandLineIndex >= 0 ? values[commandLineIndex] : void 0;
|
|
4899
|
+
if (commandLine === void 0 || commandLine.length === 0) return null;
|
|
4900
|
+
const imagePath = imageIndex >= 0 ? values[imageIndex] : void 0;
|
|
4901
|
+
return {
|
|
4902
|
+
commandLine,
|
|
4903
|
+
imagePath: imagePath && imagePath.length > 0 ? imagePath : null
|
|
4904
|
+
};
|
|
4905
|
+
} catch {
|
|
4906
|
+
return null;
|
|
4907
|
+
}
|
|
4908
|
+
}
|
|
4909
|
+
/**
|
|
4910
|
+
* Which of these pids look like this entry's own detached successor. The environment
|
|
4911
|
+
* marker is authoritative where the platform can read it; otherwise the answer rests on
|
|
4912
|
+
* the entry's own argv, which is the only signal a detached successor is obliged to keep
|
|
4913
|
+
* — and the only one Windows exposes at all.
|
|
4914
|
+
*/
|
|
4915
|
+
async function identifyHolders(serverId, spawn, pids) {
|
|
4916
|
+
const found = [];
|
|
4917
|
+
for (const pid of pids) {
|
|
4918
|
+
if (await processCarriesServerId(pid, serverId)) {
|
|
4919
|
+
found.push(pid);
|
|
4920
|
+
continue;
|
|
4921
|
+
}
|
|
4922
|
+
if (spawn.args.length === 0) continue;
|
|
4923
|
+
if (process.platform === "win32") {
|
|
4924
|
+
const info = await windowsProcessInfo(pid);
|
|
4925
|
+
if (info && matchesSpawn({
|
|
4926
|
+
words: splitCommandLine(info.commandLine),
|
|
4927
|
+
imagePath: info.imagePath
|
|
4928
|
+
}, spawn)) found.push(pid);
|
|
4929
|
+
continue;
|
|
4930
|
+
}
|
|
4931
|
+
const argv = await processArgv(pid);
|
|
4932
|
+
if (argv !== null && matchesSpawn({ words: argv }, spawn)) found.push(pid);
|
|
4933
|
+
}
|
|
4934
|
+
return found;
|
|
4935
|
+
}
|
|
4936
|
+
var execFileAsync$2, windowsFilter;
|
|
4937
|
+
var init_identity = __esmMin((() => {
|
|
4938
|
+
init_proc();
|
|
4939
|
+
execFileAsync$2 = promisify(execFile);
|
|
4940
|
+
windowsFilter = /^\d+$/;
|
|
4941
|
+
}));
|
|
4942
|
+
//#endregion
|
|
4706
4943
|
//#region src/providers/process.ts
|
|
4707
4944
|
/**
|
|
4708
4945
|
* Resolves a bare command through the entry's own directory and the project's
|
|
@@ -4966,6 +5203,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
4966
5203
|
init_paths();
|
|
4967
5204
|
init_template();
|
|
4968
5205
|
init_health_check();
|
|
5206
|
+
init_identity();
|
|
4969
5207
|
init_port();
|
|
4970
5208
|
init_proc();
|
|
4971
5209
|
init_process();
|
|
@@ -5110,11 +5348,8 @@ var init_supervisor = __esmMin((() => {
|
|
|
5110
5348
|
port,
|
|
5111
5349
|
error: `server "${id}" is busy — try again in a moment`
|
|
5112
5350
|
};
|
|
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 {
|
|
5351
|
+
const { ours, foreign } = await this.portHolders(port);
|
|
5352
|
+
if ([...ours, ...foreign].length === 0) return {
|
|
5118
5353
|
...empty,
|
|
5119
5354
|
port,
|
|
5120
5355
|
error: `nothing is listening on port ${port} any more`
|
|
@@ -5129,7 +5364,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
5129
5364
|
};
|
|
5130
5365
|
}
|
|
5131
5366
|
this.log(entry, "system", `freeing port ${port}: asking pid ${foreign.join(", ")} to stop`);
|
|
5132
|
-
const { stopped, forced } = await terminatePids(foreign);
|
|
5367
|
+
const { stopped, forced } = await terminatePids(foreign, { graceMs: entry.config.stop.graceMs });
|
|
5133
5368
|
if (forced.length > 0) this.log(entry, "system", `pid ${forced.join(", ")} ignored SIGTERM and was killed`);
|
|
5134
5369
|
const free = await this.waitForPortRelease(entry, port);
|
|
5135
5370
|
if (free) {
|
|
@@ -5154,6 +5389,20 @@ var init_supervisor = __esmMin((() => {
|
|
|
5154
5389
|
free
|
|
5155
5390
|
};
|
|
5156
5391
|
}
|
|
5392
|
+
/**
|
|
5393
|
+
* Splits the port's listeners into the processes this panel owns and everyone
|
|
5394
|
+
* else. Only the foreign half may ever be signalled — a port held by a sibling
|
|
5395
|
+
* is a config mistake, not a stray process. That also keeps a stale `(pid 1234)`
|
|
5396
|
+
* in an old banner from killing a recycled pid.
|
|
5397
|
+
*/
|
|
5398
|
+
async portHolders(port) {
|
|
5399
|
+
const supervised = this.supervisedPids();
|
|
5400
|
+
const holders = await listPortHolders(port);
|
|
5401
|
+
return {
|
|
5402
|
+
ours: holders.filter((pid) => supervised.has(pid)),
|
|
5403
|
+
foreign: holders.filter((pid) => !supervised.has(pid))
|
|
5404
|
+
};
|
|
5405
|
+
}
|
|
5157
5406
|
/** Pids of the child processes this panel owns, plus itself. */
|
|
5158
5407
|
supervisedPids() {
|
|
5159
5408
|
const pids = /* @__PURE__ */ new Set([process.pid]);
|
|
@@ -5320,7 +5569,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
5320
5569
|
this.publishState();
|
|
5321
5570
|
}
|
|
5322
5571
|
isActive(entry) {
|
|
5323
|
-
return entry.child !== null || entry.status === "backoff";
|
|
5572
|
+
return entry.child !== null || entry.pid !== null || entry.adopted || entry.status === "backoff";
|
|
5324
5573
|
}
|
|
5325
5574
|
/** Loopback first, then the configured address, so a custom bind is still probed. */
|
|
5326
5575
|
probeHosts(entry) {
|
|
@@ -5343,15 +5592,24 @@ var init_supervisor = __esmMin((() => {
|
|
|
5343
5592
|
return (await Promise.all(this.occupancyHosts(entry).map((host) => probePort(host, port, timeoutMs)))).some(Boolean);
|
|
5344
5593
|
}
|
|
5345
5594
|
/**
|
|
5346
|
-
* The port
|
|
5347
|
-
* itself leaves a
|
|
5348
|
-
* not a stranger to kill.
|
|
5349
|
-
*
|
|
5595
|
+
* The port holder that is this entry's own detached successor — a program that
|
|
5596
|
+
* restarted itself leaves a process behind, and that process is the *same server*,
|
|
5597
|
+
* not a stranger to kill.
|
|
5598
|
+
*
|
|
5599
|
+
* The environment marker is authoritative where the platform can read it (Linux,
|
|
5600
|
+
* macOS). Failing that — Windows has no per-process environment at all — the entry's
|
|
5601
|
+
* own argv answers, which a successor keeps unless it re-execs under a different
|
|
5602
|
+
* image. Ambiguity is not resolved by guessing: two holders that both look like this
|
|
5603
|
+
* entry means we do not know which one is ours, so we act on neither.
|
|
5350
5604
|
*/
|
|
5351
|
-
async
|
|
5352
|
-
const
|
|
5353
|
-
|
|
5354
|
-
|
|
5605
|
+
async ownPortHolder(entry, holders) {
|
|
5606
|
+
const spawn = this.resolveSpawn(entry);
|
|
5607
|
+
const candidates = await identifyHolders(entry.config.id, spawn, holders);
|
|
5608
|
+
if (candidates.length > 1) {
|
|
5609
|
+
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`);
|
|
5610
|
+
return null;
|
|
5611
|
+
}
|
|
5612
|
+
return candidates[0] ?? null;
|
|
5355
5613
|
}
|
|
5356
5614
|
async preflight(entry) {
|
|
5357
5615
|
const port = entry.config.port;
|
|
@@ -5367,16 +5625,49 @@ var init_supervisor = __esmMin((() => {
|
|
|
5367
5625
|
}
|
|
5368
5626
|
entry.portState = free ? "free" : "in-use";
|
|
5369
5627
|
if (free) return { kind: "free" };
|
|
5370
|
-
const
|
|
5371
|
-
const
|
|
5628
|
+
const { ours, foreign } = await this.portHolders(port);
|
|
5629
|
+
const holders = [...ours, ...foreign];
|
|
5630
|
+
const own = await this.ownPortHolder(entry, foreign);
|
|
5372
5631
|
const suffix = holders.length > 0 ? ` (pid ${holders.join(", ")})` : "";
|
|
5373
|
-
if (
|
|
5632
|
+
if (entry.config.onPortConflict === "kill") {
|
|
5633
|
+
if (ours.length > 0) {
|
|
5634
|
+
entry.status = "conflict";
|
|
5635
|
+
entry.lastError = `port ${port} is held by pid ${ours.join(", ")}, which this panel supervises — stop that server instead`;
|
|
5636
|
+
this.log(entry, "system", `${entry.lastError} — not starting (onPortConflict: kill)`);
|
|
5637
|
+
this.publishServer(entry);
|
|
5638
|
+
return {
|
|
5639
|
+
kind: "blocked",
|
|
5640
|
+
error: entry.lastError
|
|
5641
|
+
};
|
|
5642
|
+
}
|
|
5643
|
+
if (foreign.length === 0) {
|
|
5644
|
+
this.log(entry, "system", `port ${port} looks busy but no listener could be found — starting anyway`);
|
|
5645
|
+
return { kind: "free" };
|
|
5646
|
+
}
|
|
5647
|
+
this.log(entry, "system", `port ${port} is held by pid ${foreign.join(", ")} — onPortConflict: kill, stopping the holder`);
|
|
5648
|
+
const { forced } = await terminatePids(foreign, { graceMs: entry.config.stop.graceMs });
|
|
5649
|
+
if (forced.length > 0) this.log(entry, "system", `pid ${forced.join(", ")} ignored SIGTERM and was killed`);
|
|
5650
|
+
await delay(PORT_RELEASE_RECHECK_MS);
|
|
5651
|
+
if (await freeOnAll()) {
|
|
5652
|
+
entry.portState = "free";
|
|
5653
|
+
return { kind: "free" };
|
|
5654
|
+
}
|
|
5655
|
+
entry.status = "conflict";
|
|
5656
|
+
entry.lastError = `port ${port} is still in use after killing pid ${foreign.join(", ")}`;
|
|
5657
|
+
this.log(entry, "system", entry.lastError);
|
|
5658
|
+
this.publishServer(entry);
|
|
5659
|
+
return {
|
|
5660
|
+
kind: "blocked",
|
|
5661
|
+
error: entry.lastError
|
|
5662
|
+
};
|
|
5663
|
+
}
|
|
5664
|
+
if (own !== null && entry.config.onPortConflict === "follow") return {
|
|
5374
5665
|
kind: "adopt",
|
|
5375
|
-
pid: own
|
|
5666
|
+
pid: own
|
|
5376
5667
|
};
|
|
5377
|
-
if (own
|
|
5378
|
-
this.log(entry, "system", `port ${port} is held by pid ${own
|
|
5379
|
-
const { forced } = await terminatePids(own);
|
|
5668
|
+
if (own !== null && entry.config.onPortConflict === "reclaim") {
|
|
5669
|
+
this.log(entry, "system", `port ${port} is held by pid ${own}, a detached restart of this entry — replacing it with a supervised process`);
|
|
5670
|
+
const { forced } = await terminatePids([own], { graceMs: entry.config.stop.graceMs });
|
|
5380
5671
|
if (forced.length > 0) this.log(entry, "system", `pid ${forced.join(", ")} ignored SIGTERM and was killed`);
|
|
5381
5672
|
await delay(PORT_RELEASE_RECHECK_MS);
|
|
5382
5673
|
if (await freeOnAll()) {
|
|
@@ -5384,7 +5675,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
5384
5675
|
return { kind: "free" };
|
|
5385
5676
|
}
|
|
5386
5677
|
entry.status = "conflict";
|
|
5387
|
-
entry.lastError = `port ${port} is still in use after replacing pid ${own
|
|
5678
|
+
entry.lastError = `port ${port} is still in use after replacing pid ${own}`;
|
|
5388
5679
|
this.log(entry, "system", entry.lastError);
|
|
5389
5680
|
this.publishServer(entry);
|
|
5390
5681
|
return {
|
|
@@ -5392,7 +5683,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
5392
5683
|
error: entry.lastError
|
|
5393
5684
|
};
|
|
5394
5685
|
}
|
|
5395
|
-
const hint = own
|
|
5686
|
+
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
5687
|
if (entry.config.onPortConflict !== "warn") {
|
|
5397
5688
|
entry.status = "conflict";
|
|
5398
5689
|
entry.lastError = `port ${port} is already in use${suffix}${hint}`;
|
|
@@ -5487,7 +5778,13 @@ var init_supervisor = __esmMin((() => {
|
|
|
5487
5778
|
if (code === 0) this.log(entry, "system", "bootstrap finished");
|
|
5488
5779
|
else if (code !== null) this.log(entry, "system", `bootstrap exited with code ${code} — continuing anyway`);
|
|
5489
5780
|
}
|
|
5490
|
-
|
|
5781
|
+
/**
|
|
5782
|
+
* Everything `spawn` needs for an entry: the resolved image, the expanded argv, the
|
|
5783
|
+
* cwd and the environment. The argv is also what the preflight recognizes the entry's
|
|
5784
|
+
* own detached successor by, so spawn and identification must never resolve it twice
|
|
5785
|
+
* with two different rules.
|
|
5786
|
+
*/
|
|
5787
|
+
resolveSpawn(entry) {
|
|
5491
5788
|
const vars = this.buildVars(entry);
|
|
5492
5789
|
const cwd = resolveCwd(entry.config.cwd);
|
|
5493
5790
|
const command = resolveCommand(entry.config.command, cwd, projectDir);
|
|
@@ -5503,15 +5800,26 @@ var init_supervisor = __esmMin((() => {
|
|
|
5503
5800
|
...process.env,
|
|
5504
5801
|
...fileEnv
|
|
5505
5802
|
};
|
|
5506
|
-
const
|
|
5803
|
+
const dataEnvs = resolveRecord(entry.config.dataEnvs, vars);
|
|
5507
5804
|
const env = {
|
|
5508
5805
|
...expandEnvRecord(resolveRecord(entry.config.env, vars), expansionVars),
|
|
5509
5806
|
...fileEnv,
|
|
5510
|
-
...expandEnvRecord(
|
|
5807
|
+
...expandEnvRecord(dataEnvs, expansionVars),
|
|
5511
5808
|
HHOSTED_SERVER_ID: entry.config.id,
|
|
5512
5809
|
HHOSTED_CONTROL_PORT: String(this.options.control.port)
|
|
5513
5810
|
};
|
|
5514
|
-
|
|
5811
|
+
for (const key of Object.keys(dataEnvs)) env[key] = path.resolve(cwd, env[key]);
|
|
5812
|
+
return {
|
|
5813
|
+
command,
|
|
5814
|
+
args: expandEnvList(resolveTemplates(entry.config.args, vars), expansionVars),
|
|
5815
|
+
env,
|
|
5816
|
+
cwd,
|
|
5817
|
+
loggedArgs: resolveTemplates(entry.config.args, vars)
|
|
5818
|
+
};
|
|
5819
|
+
}
|
|
5820
|
+
spawnEntry(entry) {
|
|
5821
|
+
const { command, args, env, cwd, loggedArgs } = this.resolveSpawn(entry);
|
|
5822
|
+
this.log(entry, "system", `start: ${command} ${loggedArgs.join(" ")}`);
|
|
5515
5823
|
let child;
|
|
5516
5824
|
try {
|
|
5517
5825
|
child = spawnManaged({
|
|
@@ -7515,7 +7823,11 @@ var init_ui = __esmMin((() => {
|
|
|
7515
7823
|
MAX_NAME = 120;
|
|
7516
7824
|
manifestSchema = type({
|
|
7517
7825
|
"name?": "string",
|
|
7518
|
-
"version?": "string"
|
|
7826
|
+
"version?": "string",
|
|
7827
|
+
"repo?": "string",
|
|
7828
|
+
"tag?": "string",
|
|
7829
|
+
"asset?": "string",
|
|
7830
|
+
"unix?": "number.integer >= 0"
|
|
7519
7831
|
});
|
|
7520
7832
|
UiService = class {
|
|
7521
7833
|
options;
|
|
@@ -7555,14 +7867,14 @@ var init_ui = __esmMin((() => {
|
|
|
7555
7867
|
* swapped in, so a failed upload leaves the previous UI (or the stock one)
|
|
7556
7868
|
* serving.
|
|
7557
7869
|
*/
|
|
7558
|
-
async install(archivePath, fallbackName = "custom-ui") {
|
|
7870
|
+
async install(archivePath, fallbackName = "custom-ui", installedTag) {
|
|
7559
7871
|
if (!isZipArchive(archivePath)) return {
|
|
7560
7872
|
ok: false,
|
|
7561
7873
|
error: "the upload is not a zip archive"
|
|
7562
7874
|
};
|
|
7563
7875
|
const staging = path.join(this.options.dataRoot, `.ui-staging-${Date.now()}`);
|
|
7564
7876
|
try {
|
|
7565
|
-
return await this.stage(archivePath, staging, fallbackName);
|
|
7877
|
+
return await this.stage(archivePath, staging, fallbackName, installedTag);
|
|
7566
7878
|
} catch (error) {
|
|
7567
7879
|
return {
|
|
7568
7880
|
ok: false,
|
|
@@ -7584,7 +7896,7 @@ var init_ui = __esmMin((() => {
|
|
|
7584
7896
|
});
|
|
7585
7897
|
return existed;
|
|
7586
7898
|
}
|
|
7587
|
-
async stage(archivePath, staging, fallbackName) {
|
|
7899
|
+
async stage(archivePath, staging, fallbackName, installedTag) {
|
|
7588
7900
|
let entries;
|
|
7589
7901
|
try {
|
|
7590
7902
|
entries = await listZip(archivePath);
|
|
@@ -7623,33 +7935,43 @@ var init_ui = __esmMin((() => {
|
|
|
7623
7935
|
name: manifest?.name ?? fallbackName,
|
|
7624
7936
|
version: manifest?.version ?? null,
|
|
7625
7937
|
uploadedAt: Date.now(),
|
|
7626
|
-
files: countFiles(root)
|
|
7938
|
+
files: countFiles(root),
|
|
7939
|
+
...manifest?.repo === void 0 ? {} : { repo: manifest.repo },
|
|
7940
|
+
...installedTag !== void 0 ? { tag: installedTag } : manifest?.tag === void 0 ? {} : { tag: manifest.tag },
|
|
7941
|
+
...manifest?.asset === void 0 ? {} : { asset: manifest.asset },
|
|
7942
|
+
...manifest?.unix === void 0 ? {} : { unix: manifest.unix }
|
|
7627
7943
|
};
|
|
7628
|
-
const previous = `${this.directory}.previous`;
|
|
7629
|
-
fs.rmSync(previous, {
|
|
7630
|
-
recursive: true,
|
|
7631
|
-
force: true
|
|
7632
|
-
});
|
|
7944
|
+
const previous = `${this.directory}.previous-${process.pid}-${Date.now()}`;
|
|
7633
7945
|
if (fs.existsSync(this.directory)) fs.renameSync(this.directory, previous);
|
|
7634
7946
|
try {
|
|
7635
7947
|
fs.renameSync(root, this.directory);
|
|
7636
7948
|
writeFileAtomic(path.join(this.directory, META), `${JSON.stringify(meta, null, 2)}\n`);
|
|
7637
7949
|
} catch (error) {
|
|
7638
|
-
|
|
7639
|
-
|
|
7640
|
-
|
|
7641
|
-
|
|
7642
|
-
|
|
7950
|
+
try {
|
|
7951
|
+
fs.rmSync(this.directory, {
|
|
7952
|
+
recursive: true,
|
|
7953
|
+
force: true
|
|
7954
|
+
});
|
|
7955
|
+
if (fs.existsSync(previous)) fs.renameSync(previous, this.directory);
|
|
7956
|
+
fs.rmSync(previous, {
|
|
7957
|
+
recursive: true,
|
|
7958
|
+
force: true
|
|
7959
|
+
});
|
|
7960
|
+
} catch {
|
|
7961
|
+
return {
|
|
7962
|
+
ok: false,
|
|
7963
|
+
error: `${error instanceof Error ? error.message : String(error)} — the previous UI is kept at ${previous}`
|
|
7964
|
+
};
|
|
7965
|
+
}
|
|
7643
7966
|
return {
|
|
7644
7967
|
ok: false,
|
|
7645
7968
|
error: error instanceof Error ? error.message : String(error)
|
|
7646
7969
|
};
|
|
7647
|
-
} finally {
|
|
7648
|
-
fs.rmSync(previous, {
|
|
7649
|
-
recursive: true,
|
|
7650
|
-
force: true
|
|
7651
|
-
});
|
|
7652
7970
|
}
|
|
7971
|
+
fs.rmSync(previous, {
|
|
7972
|
+
recursive: true,
|
|
7973
|
+
force: true
|
|
7974
|
+
});
|
|
7653
7975
|
return {
|
|
7654
7976
|
ok: true,
|
|
7655
7977
|
meta
|
|
@@ -7658,95 +7980,488 @@ var init_ui = __esmMin((() => {
|
|
|
7658
7980
|
};
|
|
7659
7981
|
}));
|
|
7660
7982
|
//#endregion
|
|
7661
|
-
//#region src/
|
|
7662
|
-
|
|
7663
|
-
|
|
7664
|
-
|
|
7665
|
-
|
|
7666
|
-
|
|
7667
|
-
|
|
7668
|
-
|
|
7669
|
-
}
|
|
7670
|
-
return "0.0.0";
|
|
7671
|
-
}
|
|
7983
|
+
//#region src/providers/ui-release.ts
|
|
7984
|
+
/** `owner/name`, the only slug GitHub releases are addressed by. */
|
|
7985
|
+
function parseRepoSlug(value) {
|
|
7986
|
+
const match = /^([\w.-]+)\/([\w.-]+)$/.exec(value.trim());
|
|
7987
|
+
if (match === null) return null;
|
|
7988
|
+
return {
|
|
7989
|
+
owner: match[1],
|
|
7990
|
+
name: match[2]
|
|
7991
|
+
};
|
|
7672
7992
|
}
|
|
7673
|
-
|
|
7674
|
-
|
|
7675
|
-
|
|
7993
|
+
function repoSlug(repo) {
|
|
7994
|
+
return `${repo.owner}/${repo.name}`;
|
|
7995
|
+
}
|
|
7996
|
+
/** The repo the default tag rule is about; `DEFAULT_REPO` is its only definition. */
|
|
7997
|
+
function isOwnRepo(repo) {
|
|
7998
|
+
return repoSlug(repo).toLowerCase() === DEFAULT_REPO.toLowerCase();
|
|
7676
7999
|
}
|
|
7677
8000
|
/**
|
|
7678
|
-
*
|
|
7679
|
-
*
|
|
7680
|
-
*
|
|
8001
|
+
* Our own release tag matches this CLI's version, so a UI is paired with the panel
|
|
8002
|
+
* it was built for. Another repo has no such pairing, so its latest release is used.
|
|
8003
|
+
* `null` means "latest".
|
|
7681
8004
|
*/
|
|
7682
|
-
|
|
7683
|
-
|
|
7684
|
-
|
|
7685
|
-
|
|
7686
|
-
|
|
7687
|
-
}
|
|
7688
|
-
|
|
7689
|
-
|
|
7690
|
-
|
|
7691
|
-
|
|
7692
|
-
|
|
7693
|
-
|
|
7694
|
-
|
|
7695
|
-
|
|
7696
|
-
|
|
7697
|
-
|
|
7698
|
-
|
|
7699
|
-
|
|
7700
|
-
|
|
7701
|
-
|
|
7702
|
-
|
|
7703
|
-
const
|
|
7704
|
-
|
|
7705
|
-
|
|
7706
|
-
|
|
7707
|
-
|
|
7708
|
-
const
|
|
7709
|
-
|
|
7710
|
-
|
|
7711
|
-
|
|
7712
|
-
|
|
7713
|
-
|
|
7714
|
-
|
|
7715
|
-
|
|
7716
|
-
|
|
7717
|
-
|
|
7718
|
-
|
|
7719
|
-
|
|
7720
|
-
|
|
7721
|
-
|
|
7722
|
-
|
|
7723
|
-
|
|
7724
|
-
|
|
7725
|
-
|
|
7726
|
-
|
|
7727
|
-
|
|
7728
|
-
|
|
7729
|
-
|
|
7730
|
-
|
|
7731
|
-
|
|
7732
|
-
|
|
7733
|
-
|
|
7734
|
-
|
|
7735
|
-
|
|
7736
|
-
|
|
7737
|
-
|
|
7738
|
-
|
|
7739
|
-
};
|
|
7740
|
-
|
|
7741
|
-
|
|
7742
|
-
|
|
7743
|
-
}
|
|
7744
|
-
|
|
7745
|
-
|
|
7746
|
-
|
|
7747
|
-
|
|
7748
|
-
|
|
7749
|
-
|
|
8005
|
+
function defaultReleaseTag(repo, version) {
|
|
8006
|
+
return isOwnRepo(repo) ? `v${version}` : null;
|
|
8007
|
+
}
|
|
8008
|
+
function releaseApiUrl(repo, tag) {
|
|
8009
|
+
const base = `https://api.github.com/repos/${repo.owner}/${repo.name}/releases`;
|
|
8010
|
+
if (tag === null || tag.length === 0 || tag === "latest") return `${base}/latest`;
|
|
8011
|
+
return `${base}/tags/${encodeURIComponent(tag)}`;
|
|
8012
|
+
}
|
|
8013
|
+
/** Every published release, newest first, as GitHub orders them. */
|
|
8014
|
+
function releasesApiUrl(repo, perPage = 30) {
|
|
8015
|
+
return `https://api.github.com/repos/${repo.owner}/${repo.name}/releases?per_page=${perPage}`;
|
|
8016
|
+
}
|
|
8017
|
+
/** Generous and predictable: a UI bundle is an asset whose name ends in `.zip`. */
|
|
8018
|
+
function isUiAsset(name) {
|
|
8019
|
+
return /\.zip$/i.test(name.trim());
|
|
8020
|
+
}
|
|
8021
|
+
/**
|
|
8022
|
+
* Resolves a wanted asset: an exact name, a case-insensitive name, or a single
|
|
8023
|
+
* unambiguous substring (`--asset stock` for `home-hosted-ui-stock.zip`).
|
|
8024
|
+
*/
|
|
8025
|
+
function matchAsset(names, query) {
|
|
8026
|
+
const wanted = query.trim();
|
|
8027
|
+
if (wanted.length === 0) return {
|
|
8028
|
+
ok: false,
|
|
8029
|
+
error: "no asset name was given"
|
|
8030
|
+
};
|
|
8031
|
+
const exact = names.find((name) => name === wanted);
|
|
8032
|
+
if (exact !== void 0) return {
|
|
8033
|
+
ok: true,
|
|
8034
|
+
name: exact
|
|
8035
|
+
};
|
|
8036
|
+
const lower = wanted.toLowerCase();
|
|
8037
|
+
const insensitive = names.filter((name) => name.toLowerCase() === lower);
|
|
8038
|
+
if (insensitive.length === 1) return {
|
|
8039
|
+
ok: true,
|
|
8040
|
+
name: insensitive[0]
|
|
8041
|
+
};
|
|
8042
|
+
const partial = names.filter((name) => name.toLowerCase().includes(lower));
|
|
8043
|
+
if (partial.length === 0) return {
|
|
8044
|
+
ok: false,
|
|
8045
|
+
error: `no asset matches "${wanted}" (available: ${names.join(", ") || "none"})`
|
|
8046
|
+
};
|
|
8047
|
+
if (partial.length > 1) return {
|
|
8048
|
+
ok: false,
|
|
8049
|
+
error: `"${wanted}" matches more than one asset: ${partial.join(", ")} — use the full name`
|
|
8050
|
+
};
|
|
8051
|
+
return {
|
|
8052
|
+
ok: true,
|
|
8053
|
+
name: partial[0]
|
|
8054
|
+
};
|
|
8055
|
+
}
|
|
8056
|
+
/** `^https?://` means a URL; anything else is a filesystem path with `~` expanded. */
|
|
8057
|
+
function parseFileSource(value) {
|
|
8058
|
+
const trimmed = value.trim();
|
|
8059
|
+
if (/^https?:\/\//i.test(trimmed)) return {
|
|
8060
|
+
kind: "url",
|
|
8061
|
+
url: trimmed
|
|
8062
|
+
};
|
|
8063
|
+
return {
|
|
8064
|
+
kind: "path",
|
|
8065
|
+
path: expandHome(trimmed)
|
|
8066
|
+
};
|
|
8067
|
+
}
|
|
8068
|
+
function expandHome(value) {
|
|
8069
|
+
if (value === "~") return os.homedir();
|
|
8070
|
+
if (value.startsWith("~/") || value.startsWith("~\\")) return path.join(os.homedir(), value.slice(2));
|
|
8071
|
+
return value;
|
|
8072
|
+
}
|
|
8073
|
+
/**
|
|
8074
|
+
* A token is only ever sent to GitHub: `--file <url>` may point anywhere, and a
|
|
8075
|
+
* credential must not leak to a host the user did not vouch for.
|
|
8076
|
+
*/
|
|
8077
|
+
function isGithubHost(url) {
|
|
8078
|
+
try {
|
|
8079
|
+
const host = new URL(url).hostname.toLowerCase();
|
|
8080
|
+
return host === "github.com" || host.endsWith(".github.com") || host === "githubusercontent.com" || host.endsWith(".githubusercontent.com");
|
|
8081
|
+
} catch {
|
|
8082
|
+
return false;
|
|
8083
|
+
}
|
|
8084
|
+
}
|
|
8085
|
+
function timeoutSignal(ms) {
|
|
8086
|
+
return typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(ms) : void 0;
|
|
8087
|
+
}
|
|
8088
|
+
function isTimeout(error) {
|
|
8089
|
+
return error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
|
|
8090
|
+
}
|
|
8091
|
+
/** What `UiService` names the install when the archive carries no `ui.json`. */
|
|
8092
|
+
function fallbackUiName(source) {
|
|
8093
|
+
const stripped = path.basename(source).replace(/\.zip$/i, "").replace(/^home-hosted-ui-/i, "");
|
|
8094
|
+
return stripped.length > 0 ? stripped : "custom-ui";
|
|
8095
|
+
}
|
|
8096
|
+
/** One release's assets, or a message that says what was wrong with the answer. */
|
|
8097
|
+
async function fetchRelease(repo, tag, context) {
|
|
8098
|
+
const url = releaseApiUrl(repo, tag);
|
|
8099
|
+
let response;
|
|
8100
|
+
try {
|
|
8101
|
+
response = await fetch(url, {
|
|
8102
|
+
headers: apiHeaders(context),
|
|
8103
|
+
signal: timeoutSignal(REQUEST_TIMEOUT_MS)
|
|
8104
|
+
});
|
|
8105
|
+
} catch (error) {
|
|
8106
|
+
throw new Error(isTimeout(error) ? `GitHub did not answer within ${REQUEST_TIMEOUT_MS / 1e3}s` : `could not reach GitHub: ${describeError(error)}`);
|
|
8107
|
+
}
|
|
8108
|
+
if (!response.ok) throw new Error(describeReleaseFailure(response.status, repo, tag));
|
|
8109
|
+
const body = await response.json();
|
|
8110
|
+
return {
|
|
8111
|
+
tag: body.tag_name ?? tag ?? "latest",
|
|
8112
|
+
assets: Array.isArray(body.assets) ? body.assets : []
|
|
8113
|
+
};
|
|
8114
|
+
}
|
|
8115
|
+
/** Every published release, newest first. Drafts and unusable entries are dropped. */
|
|
8116
|
+
async function fetchReleases(repo, context, perPage = 30) {
|
|
8117
|
+
let response;
|
|
8118
|
+
try {
|
|
8119
|
+
response = await fetch(releasesApiUrl(repo, perPage), {
|
|
8120
|
+
headers: apiHeaders(context),
|
|
8121
|
+
signal: timeoutSignal(REQUEST_TIMEOUT_MS)
|
|
8122
|
+
});
|
|
8123
|
+
} catch (error) {
|
|
8124
|
+
throw new Error(isTimeout(error) ? `GitHub did not answer within ${REQUEST_TIMEOUT_MS / 1e3}s` : `could not reach GitHub: ${describeError(error)}`);
|
|
8125
|
+
}
|
|
8126
|
+
if (!response.ok) throw new Error(describeReleaseFailure(response.status, repo, null));
|
|
8127
|
+
const body = await response.json();
|
|
8128
|
+
if (!Array.isArray(body)) return [];
|
|
8129
|
+
return body.filter((release) => release.draft !== true && typeof release.tag_name === "string").map((release) => ({
|
|
8130
|
+
tag: release.tag_name,
|
|
8131
|
+
assets: Array.isArray(release.assets) ? release.assets : [],
|
|
8132
|
+
publishedAt: typeof release.published_at === "string" ? release.published_at : null
|
|
8133
|
+
}));
|
|
8134
|
+
}
|
|
8135
|
+
function assetDownloadUrl(asset) {
|
|
8136
|
+
const url = asset.url ?? asset.browser_download_url;
|
|
8137
|
+
if (url === void 0 || url.length === 0) throw new Error(`the release metadata for "${asset.name ?? "an asset"}" carries no download URL`);
|
|
8138
|
+
return url;
|
|
8139
|
+
}
|
|
8140
|
+
/** Streams to `os.tmpdir()` so a large body is never buffered in memory. */
|
|
8141
|
+
async function downloadToTemp(url, headers, context) {
|
|
8142
|
+
let response;
|
|
8143
|
+
try {
|
|
8144
|
+
response = await fetch(url, {
|
|
8145
|
+
headers,
|
|
8146
|
+
redirect: "follow",
|
|
8147
|
+
signal: timeoutSignal(DOWNLOAD_TIMEOUT_MS)
|
|
8148
|
+
});
|
|
8149
|
+
} catch (error) {
|
|
8150
|
+
throw new Error(isTimeout(error) ? `the download stalled for ${DOWNLOAD_TIMEOUT_MS / 1e3}s: ${url}` : `could not reach ${url}: ${describeError(error)}`);
|
|
8151
|
+
}
|
|
8152
|
+
if (!response.ok) throw new Error(describeDownloadFailure(response.status, url));
|
|
8153
|
+
const declared = Number(response.headers.get("content-length") ?? "0");
|
|
8154
|
+
if (!Number.isFinite(declared) || declared < 0) throw new Error(`the download from ${url} reported an unusable size`);
|
|
8155
|
+
if (declared > 536870912) throw new Error(tooLargeMessage(declared));
|
|
8156
|
+
if (response.body === null) throw new Error(`the download from ${url} had no body`);
|
|
8157
|
+
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "hh-ui-"));
|
|
8158
|
+
const file = path.join(dir, "ui.zip");
|
|
8159
|
+
const handle = await fs.promises.open(file, "w");
|
|
8160
|
+
let received = 0;
|
|
8161
|
+
try {
|
|
8162
|
+
for await (const chunk of response.body) {
|
|
8163
|
+
received += chunk.length;
|
|
8164
|
+
if (received > 536870912) throw new Error(tooLargeMessage(received));
|
|
8165
|
+
await handle.write(chunk);
|
|
8166
|
+
}
|
|
8167
|
+
} catch (error) {
|
|
8168
|
+
await handle.close();
|
|
8169
|
+
fs.rmSync(dir, {
|
|
8170
|
+
recursive: true,
|
|
8171
|
+
force: true
|
|
8172
|
+
});
|
|
8173
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
8174
|
+
}
|
|
8175
|
+
await handle.close();
|
|
8176
|
+
if (context.quiet !== true) context.io.write(`${context.io.style.dim(`downloaded ${formatBytes(received)}`)}\n`);
|
|
8177
|
+
return {
|
|
8178
|
+
dir,
|
|
8179
|
+
file
|
|
8180
|
+
};
|
|
8181
|
+
}
|
|
8182
|
+
function apiHeaders(context) {
|
|
8183
|
+
const headers = {
|
|
8184
|
+
"accept": "application/vnd.github+json",
|
|
8185
|
+
"user-agent": `home-hosted/${context.version}`,
|
|
8186
|
+
"x-github-api-version": "2022-11-28"
|
|
8187
|
+
};
|
|
8188
|
+
if (context.token !== null && context.token.length > 0) headers.authorization = `Bearer ${context.token}`;
|
|
8189
|
+
return headers;
|
|
8190
|
+
}
|
|
8191
|
+
function noAssetsMessage(where, skipped, tag) {
|
|
8192
|
+
const lines = [`no usable UI assets in ${where} (expected a .zip)`];
|
|
8193
|
+
lines.push(skipped.length > 0 ? ` skipped: ${skipped.join(", ")}` : " the release has no assets at all");
|
|
8194
|
+
if (tag !== null && tag !== "latest") lines.push(" installing a different version silently is worse than failing: try the newest release with --tag latest");
|
|
8195
|
+
return lines.join("\n");
|
|
8196
|
+
}
|
|
8197
|
+
function describeReleaseFailure(status, repo, tag) {
|
|
8198
|
+
const slug = repoSlug(repo);
|
|
8199
|
+
if (status === 404) {
|
|
8200
|
+
if (tag !== null && tag !== "latest") return `no release tagged "${tag}" in ${slug}\n list what exists with: home-hosted ui-switch --repo ${slug} --tag latest --list`;
|
|
8201
|
+
return `no repository or published release at ${slug}\n check the --repo slug (owner/name), and that the repository is public`;
|
|
8202
|
+
}
|
|
8203
|
+
if (status === 403 || status === 429) return `GitHub refused the request (HTTP ${status}) — the unauthenticated API rate limit is per address.\n set a token to raise it: --token <token>, or GITHUB_TOKEN / GH_TOKEN`;
|
|
8204
|
+
if (status === 401) return "GitHub rejected the token (HTTP 401) — check --token, GITHUB_TOKEN or GH_TOKEN";
|
|
8205
|
+
return `GitHub answered HTTP ${status} while reading the release of ${slug}`;
|
|
8206
|
+
}
|
|
8207
|
+
function describeDownloadFailure(status, url) {
|
|
8208
|
+
const github = isGithubHost(url);
|
|
8209
|
+
if (status === 404) return github ? `the asset is gone (HTTP 404) — ${url}\n the release may have been rebuilt since it was listed; run the command again` : `nothing is served at that URL (HTTP 404) — ${url}`;
|
|
8210
|
+
if (status === 403 || status === 429) return github ? `GitHub refused the download (HTTP ${status}) — a token raises the rate limit: --token <token>, or GITHUB_TOKEN / GH_TOKEN` : `the host refused the download (HTTP ${status}) — ${url}`;
|
|
8211
|
+
if (status === 401 && github) return "GitHub rejected the token on the download (HTTP 401) — check --token, GITHUB_TOKEN or GH_TOKEN";
|
|
8212
|
+
return `the download failed (HTTP ${status}) — ${url}`;
|
|
8213
|
+
}
|
|
8214
|
+
function tooLargeMessage(bytes) {
|
|
8215
|
+
return `the download is ${formatBytes(bytes)}, larger than the ${MAX_DOWNLOAD_BYTES / 1024 / 1024}MB a UI may be`;
|
|
8216
|
+
}
|
|
8217
|
+
function formatBytes(bytes) {
|
|
8218
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
8219
|
+
if (bytes < 1048576) return `${Math.round(bytes / 1024)} KB`;
|
|
8220
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
8221
|
+
}
|
|
8222
|
+
function describeError(error) {
|
|
8223
|
+
return error instanceof Error ? error.message : String(error);
|
|
8224
|
+
}
|
|
8225
|
+
/**
|
|
8226
|
+
* Tag ordering: `v1.2.3` beats `v1.2.2`, a prerelease loses to its release, and anything
|
|
8227
|
+
* that is not a version sorts below every version that is. Enough to answer "is this
|
|
8228
|
+
* release newer than the one the UI came from" without a semver dependency.
|
|
8229
|
+
*/
|
|
8230
|
+
function compareTags(a, b) {
|
|
8231
|
+
const left = parseTag(a);
|
|
8232
|
+
const right = parseTag(b);
|
|
8233
|
+
if (left === null && right === null) return a.localeCompare(b);
|
|
8234
|
+
if (left === null) return -1;
|
|
8235
|
+
if (right === null) return 1;
|
|
8236
|
+
for (let index = 0; index < 3; index++) {
|
|
8237
|
+
const difference = (left.parts[index] ?? 0) - (right.parts[index] ?? 0);
|
|
8238
|
+
if (difference !== 0) return difference;
|
|
8239
|
+
}
|
|
8240
|
+
if (left.prerelease.length === 0 && right.prerelease.length === 0) return 0;
|
|
8241
|
+
if (left.prerelease.length === 0) return 1;
|
|
8242
|
+
if (right.prerelease.length === 0) return -1;
|
|
8243
|
+
for (let index = 0; index < Math.max(left.prerelease.length, right.prerelease.length); index++) {
|
|
8244
|
+
const one = left.prerelease[index];
|
|
8245
|
+
const two = right.prerelease[index];
|
|
8246
|
+
if (one === void 0) return -1;
|
|
8247
|
+
if (two === void 0) return 1;
|
|
8248
|
+
if (one === two) continue;
|
|
8249
|
+
const numeric = /^\d+$/;
|
|
8250
|
+
if (numeric.test(one) && numeric.test(two)) return Number(one) - Number(two);
|
|
8251
|
+
if (numeric.test(one)) return -1;
|
|
8252
|
+
if (numeric.test(two)) return 1;
|
|
8253
|
+
return one.localeCompare(two);
|
|
8254
|
+
}
|
|
8255
|
+
return 0;
|
|
8256
|
+
}
|
|
8257
|
+
function parseTag(tag) {
|
|
8258
|
+
const match = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+.*)?$/.exec(tag.trim());
|
|
8259
|
+
if (match === null) return null;
|
|
8260
|
+
return {
|
|
8261
|
+
parts: [
|
|
8262
|
+
Number(match[1]),
|
|
8263
|
+
Number(match[2] ?? 0),
|
|
8264
|
+
Number(match[3] ?? 0)
|
|
8265
|
+
],
|
|
8266
|
+
prerelease: match[4] === void 0 ? [] : match[4].split(".")
|
|
8267
|
+
};
|
|
8268
|
+
}
|
|
8269
|
+
var DEFAULT_REPO, MAX_DOWNLOAD_BYTES, REQUEST_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS;
|
|
8270
|
+
var init_ui_release = __esmMin((() => {
|
|
8271
|
+
DEFAULT_REPO = "NamesMT/home-hosted";
|
|
8272
|
+
MAX_DOWNLOAD_BYTES = 536870912;
|
|
8273
|
+
REQUEST_TIMEOUT_MS = 3e4;
|
|
8274
|
+
DOWNLOAD_TIMEOUT_MS = 12e4;
|
|
8275
|
+
}));
|
|
8276
|
+
//#endregion
|
|
8277
|
+
//#region src/services/ui-update.ts
|
|
8278
|
+
/**
|
|
8279
|
+
* `ui.json` says which release this build came from. Anything else — an unofficial UI,
|
|
8280
|
+
* or one that never declared itself — is left for `home-hosted ui-update`.
|
|
8281
|
+
*/
|
|
8282
|
+
function officialTagFor(meta, runningVersion) {
|
|
8283
|
+
if (meta === null || typeof meta.repo !== "string") return null;
|
|
8284
|
+
const repo = parseRepoSlug(meta.repo);
|
|
8285
|
+
if (repo === null || !isOwnRepo(repo)) return null;
|
|
8286
|
+
return {
|
|
8287
|
+
tag: `v${runningVersion}`,
|
|
8288
|
+
repo: `${repo.owner}/${repo.name}`
|
|
8289
|
+
};
|
|
8290
|
+
}
|
|
8291
|
+
async function syncOfficialUi(ui, runningVersion = appVersion()) {
|
|
8292
|
+
if (!ui.custom) return { kind: "not-custom" };
|
|
8293
|
+
const meta = ui.readMeta();
|
|
8294
|
+
if (meta === null) return { kind: "no-identity" };
|
|
8295
|
+
const repo = meta.repo === void 0 ? null : parseRepoSlug(meta.repo);
|
|
8296
|
+
if (repo === null) return { kind: "no-identity" };
|
|
8297
|
+
if (!isOwnRepo(repo)) return { kind: "foreign" };
|
|
8298
|
+
const target = `v${runningVersion}`;
|
|
8299
|
+
if (meta.tag === target) return {
|
|
8300
|
+
kind: "current",
|
|
8301
|
+
tag: target
|
|
8302
|
+
};
|
|
8303
|
+
const context = {
|
|
8304
|
+
io: {
|
|
8305
|
+
write: () => {},
|
|
8306
|
+
style: {
|
|
8307
|
+
bold: (t) => t,
|
|
8308
|
+
dim: (t) => t,
|
|
8309
|
+
green: (t) => t
|
|
8310
|
+
}
|
|
8311
|
+
},
|
|
8312
|
+
version: runningVersion,
|
|
8313
|
+
token: process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? null,
|
|
8314
|
+
quiet: true
|
|
8315
|
+
};
|
|
8316
|
+
try {
|
|
8317
|
+
const release = await fetchRelease(repo, target, context);
|
|
8318
|
+
const names = release.assets.map((asset) => asset.name ?? "").filter(isUiAsset);
|
|
8319
|
+
if (names.length === 0) throw new Error(`no UI asset in ${DEFAULT_REPO}@${release.tag}`);
|
|
8320
|
+
const wanted = meta.asset ?? "";
|
|
8321
|
+
const matched = wanted.length > 0 ? matchAsset(names, wanted) : {
|
|
8322
|
+
ok: true,
|
|
8323
|
+
name: names[0]
|
|
8324
|
+
};
|
|
8325
|
+
if (!matched.ok) throw new Error(matched.error);
|
|
8326
|
+
const asset = release.assets.find((entry) => entry.name === matched.name);
|
|
8327
|
+
if (asset === void 0) throw new Error(`no asset named ${matched.name} in ${release.tag}`);
|
|
8328
|
+
const download = await downloadToTemp(assetDownloadUrl(asset), {
|
|
8329
|
+
"accept": "application/octet-stream",
|
|
8330
|
+
"user-agent": `home-hosted/${runningVersion}`
|
|
8331
|
+
}, context);
|
|
8332
|
+
try {
|
|
8333
|
+
const result = await ui.install(download.file, matched.name.replace(/\.zip$/i, ""), release.tag);
|
|
8334
|
+
if (!result.ok) throw new Error(result.error);
|
|
8335
|
+
} finally {
|
|
8336
|
+
(await import("node:fs")).rmSync(download.dir, {
|
|
8337
|
+
recursive: true,
|
|
8338
|
+
force: true
|
|
8339
|
+
});
|
|
8340
|
+
}
|
|
8341
|
+
return {
|
|
8342
|
+
kind: "updated",
|
|
8343
|
+
tag: release.tag
|
|
8344
|
+
};
|
|
8345
|
+
} catch (error) {
|
|
8346
|
+
return {
|
|
8347
|
+
kind: "failed",
|
|
8348
|
+
error: error instanceof Error ? error.message : String(error)
|
|
8349
|
+
};
|
|
8350
|
+
}
|
|
8351
|
+
}
|
|
8352
|
+
/**
|
|
8353
|
+
* The startup hook. Never awaited by the caller and never allowed to throw: the panel
|
|
8354
|
+
* serves the UI it already has while this runs, and the next request picks up the new
|
|
8355
|
+
* one, because `UiService.resolveDir()` is read per request.
|
|
8356
|
+
*/
|
|
8357
|
+
function autoUpdateOfficialUi(ui, runningVersion = appVersion()) {
|
|
8358
|
+
if (!ui.custom) return;
|
|
8359
|
+
const meta = ui.readMeta();
|
|
8360
|
+
const plan = officialTagFor(meta, runningVersion);
|
|
8361
|
+
if (plan === null || meta?.tag === plan.tag) return;
|
|
8362
|
+
logger.info(`ui: ${meta?.name ?? "custom UI"} ${meta?.version ?? ""} came from ${meta?.tag ?? "an unknown release"}; this panel is ${plan.tag} — updating`);
|
|
8363
|
+
syncOfficialUi(ui, runningVersion).then((result) => {
|
|
8364
|
+
if (result.kind === "updated") logger.info(`ui: updated to ${result.tag} — refresh the browser`);
|
|
8365
|
+
else if (result.kind === "failed") logger.warn(`ui: could not update to ${plan.tag}: ${result.error}`);
|
|
8366
|
+
}).catch((error) => {
|
|
8367
|
+
logger.warn(`ui: could not update: ${error instanceof Error ? error.message : String(error)}`);
|
|
8368
|
+
});
|
|
8369
|
+
}
|
|
8370
|
+
var init_ui_update$1 = __esmMin((() => {
|
|
8371
|
+
init_logger();
|
|
8372
|
+
init_version();
|
|
8373
|
+
init_ui_release();
|
|
8374
|
+
}));
|
|
8375
|
+
//#endregion
|
|
8376
|
+
//#region src/index.ts
|
|
8377
|
+
var src_exports = /* @__PURE__ */ __exportAll({
|
|
8378
|
+
packageRoot: () => packageRoot,
|
|
8379
|
+
runControlPlane: () => runControlPlane
|
|
8380
|
+
});
|
|
8381
|
+
function packageVersion() {
|
|
8382
|
+
try {
|
|
8383
|
+
return JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8")).version ?? "0.0.0";
|
|
8384
|
+
} catch {
|
|
8385
|
+
return "0.0.0";
|
|
8386
|
+
}
|
|
8387
|
+
}
|
|
8388
|
+
/** A probe target for a `lan` bind, which is not connectable as `0.0.0.0`. */
|
|
8389
|
+
function probeHostFor(bindHost) {
|
|
8390
|
+
return bindHost === "0.0.0.0" || bindHost === "::" ? "127.0.0.1" : bindHost;
|
|
8391
|
+
}
|
|
8392
|
+
/**
|
|
8393
|
+
* Runs the control plane in *this* process until it is stopped. `home-hosted up`
|
|
8394
|
+
* detaches a child that calls this; `--foreground` (systemd, docker) calls it
|
|
8395
|
+
* directly.
|
|
8396
|
+
*/
|
|
8397
|
+
async function runControlPlane(options) {
|
|
8398
|
+
const existing = readRuntime();
|
|
8399
|
+
if (existing !== null && existing.pid !== process.pid && isProcessAlive$1(existing.pid)) {
|
|
8400
|
+
logger.error(`already running (pid ${existing.pid}) at ${existing.url} — run \`home-hosted down\` first`);
|
|
8401
|
+
process.exit(1);
|
|
8402
|
+
}
|
|
8403
|
+
if (existing !== null) clearRuntime();
|
|
8404
|
+
const configPath = options.configPath ?? defaultConfigPath;
|
|
8405
|
+
const store = new ConfigStore(configPath, SEED_CONFIG);
|
|
8406
|
+
store.load();
|
|
8407
|
+
store.writeJsonSchema();
|
|
8408
|
+
if (store.configError !== null) {
|
|
8409
|
+
logger.error(`refusing to start: ${store.configError}`);
|
|
8410
|
+
logger.info(`fix ${store.path}, or install the release that wrote it`);
|
|
8411
|
+
process.exit(1);
|
|
8412
|
+
}
|
|
8413
|
+
if (store.pendingMigrations.length > 0) {
|
|
8414
|
+
logger.error(`refusing to start: ${store.path} needs ${store.pendingMigrations.length} migration(s) before ${appVersion()} can use it`);
|
|
8415
|
+
logger.info("run `home-hosted migrate` to see and apply them");
|
|
8416
|
+
process.exit(1);
|
|
8417
|
+
}
|
|
8418
|
+
const secrets = new SecretsStore(defaultSecretsPath);
|
|
8419
|
+
const auth = new AuthService(secrets, () => store.config.control.auth);
|
|
8420
|
+
const tls = new TlsStore(defaultTlsDir);
|
|
8421
|
+
const logFiles = new LogFiles(defaultLogsDir, () => store.config.logs);
|
|
8422
|
+
const history = new HistoryStore(defaultHistoryPath);
|
|
8423
|
+
const notifications = new NotificationService(secrets, () => store.config.notifications, () => store.config.logs);
|
|
8424
|
+
const hostMonitor = new HostMonitor(() => store.config.host, (target) => resolveUserPath(resolveTemplate(target, {
|
|
8425
|
+
projectDir,
|
|
8426
|
+
dataRoot,
|
|
8427
|
+
home: os.homedir()
|
|
8428
|
+
})), notifications);
|
|
8429
|
+
let onConfigRestored;
|
|
8430
|
+
const backups = new BackupService({
|
|
8431
|
+
dataRoot,
|
|
8432
|
+
getConfig: () => store.config.backups,
|
|
8433
|
+
getSources: () => ({
|
|
8434
|
+
configPath: store.path,
|
|
8435
|
+
secretsPath: secrets.path,
|
|
8436
|
+
tlsDir: tls.directory,
|
|
8437
|
+
paths: resolveBackupPaths(store.servers, store.config.backups.includePaths)
|
|
8438
|
+
}),
|
|
8439
|
+
onConfigRestored: () => onConfigRestored?.()
|
|
8440
|
+
});
|
|
8441
|
+
if (!auth.passwordSet) {
|
|
8442
|
+
auth.ensureDefaultPassword("hh");
|
|
8443
|
+
logger.warn(`no password was set — created the default "hh"; change it in Settings → Authentication`);
|
|
8444
|
+
}
|
|
8445
|
+
const configured = store.config.control;
|
|
8446
|
+
const intendedHost = options.host === void 0 ? configured.host : parseBind(options.host);
|
|
8447
|
+
if (intendedHost === null) {
|
|
8448
|
+
logger.error(`invalid control host: ${String(options.host)} (expected local, lan or an ipv4 address)`);
|
|
8449
|
+
process.exit(1);
|
|
8450
|
+
}
|
|
8451
|
+
const intended = {
|
|
8452
|
+
host: intendedHost,
|
|
8453
|
+
port: options.port ?? configured.port
|
|
8454
|
+
};
|
|
8455
|
+
if (!Number.isInteger(intended.port) || intended.port <= 0 || intended.port > 65535) {
|
|
8456
|
+
logger.error(`invalid control port: ${String(options.port)}`);
|
|
8457
|
+
process.exit(1);
|
|
8458
|
+
}
|
|
8459
|
+
if (options.printConfig) {
|
|
8460
|
+
process.stdout.write(`${JSON.stringify(store.config, null, 2)}\n`);
|
|
8461
|
+
return;
|
|
8462
|
+
}
|
|
8463
|
+
const exposure = checkExposure({
|
|
8464
|
+
...configured,
|
|
7750
8465
|
host: intended.host
|
|
7751
8466
|
}, auth.passwordSet, auth.usingDefaultPassword);
|
|
7752
8467
|
if (exposure.blockedReason !== null) {
|
|
@@ -7894,6 +8609,7 @@ async function runControlPlane(options) {
|
|
|
7894
8609
|
if (ui.custom) {
|
|
7895
8610
|
const meta = ui.status().meta;
|
|
7896
8611
|
logger.warn(`custom UI in use${meta === null ? "" : ` (${meta.name}${meta.version === null ? "" : ` ${meta.version}`})`} — if it breaks, run \`home-hosted ui-revert\``);
|
|
8612
|
+
autoUpdateOfficialUi(ui, runtime.version);
|
|
7897
8613
|
}
|
|
7898
8614
|
for (const warning of store.configWarnings) logger.warn(warning);
|
|
7899
8615
|
for (const entry of supervisor.views()) logger.info(` ${entry.id.padEnd(12)} ${entry.config.command} ${entry.config.args.join(" ")}`.trimEnd());
|
|
@@ -7950,6 +8666,7 @@ var init_src = __esmMin((() => {
|
|
|
7950
8666
|
init_supervisor();
|
|
7951
8667
|
init_tls();
|
|
7952
8668
|
init_ui();
|
|
8669
|
+
init_ui_update$1();
|
|
7953
8670
|
init_contracts();
|
|
7954
8671
|
packageRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
7955
8672
|
}));
|
|
@@ -8711,137 +9428,50 @@ var init_init = __esmMin((() => {
|
|
|
8711
9428
|
};
|
|
8712
9429
|
initCommand = defineCommand({
|
|
8713
9430
|
meta: {
|
|
8714
|
-
name: "init",
|
|
8715
|
-
description: "scaffold a project that keeps its state in the repo"
|
|
8716
|
-
},
|
|
8717
|
-
args: initArgs,
|
|
8718
|
-
run: async ({ args }) => {
|
|
8719
|
-
await runInit({
|
|
8720
|
-
dir: args.dir,
|
|
8721
|
-
name: args.name,
|
|
8722
|
-
pm: args.pm,
|
|
8723
|
-
noInstall: args.install === false,
|
|
8724
|
-
yes: args.yes === true
|
|
8725
|
-
});
|
|
8726
|
-
}
|
|
8727
|
-
});
|
|
8728
|
-
}));
|
|
8729
|
-
//#endregion
|
|
8730
|
-
//#region src/cli/ui-switch.ts
|
|
8731
|
-
var ui_switch_exports = /* @__PURE__ */ __exportAll({
|
|
8732
|
-
DEFAULT_REPO: () => DEFAULT_REPO,
|
|
8733
|
-
MAX_DOWNLOAD_BYTES: () => MAX_DOWNLOAD_BYTES,
|
|
8734
|
-
|
|
8735
|
-
|
|
8736
|
-
|
|
8737
|
-
|
|
8738
|
-
|
|
8739
|
-
|
|
8740
|
-
|
|
8741
|
-
|
|
8742
|
-
|
|
8743
|
-
|
|
8744
|
-
|
|
8745
|
-
|
|
8746
|
-
|
|
8747
|
-
|
|
8748
|
-
|
|
8749
|
-
|
|
8750
|
-
|
|
8751
|
-
|
|
8752
|
-
|
|
8753
|
-
|
|
8754
|
-
|
|
8755
|
-
|
|
8756
|
-
|
|
8757
|
-
|
|
8758
|
-
}
|
|
8759
|
-
/**
|
|
8760
|
-
* Our own release tag matches this CLI's version, so a UI is paired with the panel
|
|
8761
|
-
* it was built for. Another repo has no such pairing, so its latest release is used.
|
|
8762
|
-
* `null` means "latest".
|
|
8763
|
-
*/
|
|
8764
|
-
function defaultReleaseTag(repo, version) {
|
|
8765
|
-
return isOwnRepo(repo) ? `v${version}` : null;
|
|
8766
|
-
}
|
|
8767
|
-
function releaseApiUrl(repo, tag) {
|
|
8768
|
-
const base = `https://api.github.com/repos/${repo.owner}/${repo.name}/releases`;
|
|
8769
|
-
if (tag === null || tag.length === 0 || tag === "latest") return `${base}/latest`;
|
|
8770
|
-
return `${base}/tags/${encodeURIComponent(tag)}`;
|
|
8771
|
-
}
|
|
8772
|
-
/** Generous and predictable: a UI bundle is an asset whose name ends in `.zip`. */
|
|
8773
|
-
function isUiAsset(name) {
|
|
8774
|
-
return /\.zip$/i.test(name.trim());
|
|
8775
|
-
}
|
|
8776
|
-
/**
|
|
8777
|
-
* Resolves `--asset`: an exact name, a case-insensitive name, or a single
|
|
8778
|
-
* unambiguous substring (`--asset stock` for `home-hosted-ui-stock.zip`).
|
|
8779
|
-
*/
|
|
8780
|
-
function matchAsset(names, query) {
|
|
8781
|
-
const wanted = query.trim();
|
|
8782
|
-
if (wanted.length === 0) return {
|
|
8783
|
-
ok: false,
|
|
8784
|
-
error: "no asset name was given"
|
|
8785
|
-
};
|
|
8786
|
-
const exact = names.find((name) => name === wanted);
|
|
8787
|
-
if (exact !== void 0) return {
|
|
8788
|
-
ok: true,
|
|
8789
|
-
name: exact
|
|
8790
|
-
};
|
|
8791
|
-
const lower = wanted.toLowerCase();
|
|
8792
|
-
const insensitive = names.filter((name) => name.toLowerCase() === lower);
|
|
8793
|
-
if (insensitive.length === 1) return {
|
|
8794
|
-
ok: true,
|
|
8795
|
-
name: insensitive[0]
|
|
8796
|
-
};
|
|
8797
|
-
const partial = names.filter((name) => name.toLowerCase().includes(lower));
|
|
8798
|
-
if (partial.length === 0) return {
|
|
8799
|
-
ok: false,
|
|
8800
|
-
error: `no asset matches "${wanted}" (available: ${names.join(", ") || "none"})`
|
|
8801
|
-
};
|
|
8802
|
-
if (partial.length > 1) return {
|
|
8803
|
-
ok: false,
|
|
8804
|
-
error: `"${wanted}" matches more than one asset: ${partial.join(", ")} — use the full name`
|
|
8805
|
-
};
|
|
8806
|
-
return {
|
|
8807
|
-
ok: true,
|
|
8808
|
-
name: partial[0]
|
|
8809
|
-
};
|
|
8810
|
-
}
|
|
8811
|
-
/** `^https?://` means a URL; anything else is a filesystem path with `~` expanded. */
|
|
8812
|
-
function parseFileSource(value) {
|
|
8813
|
-
const trimmed = value.trim();
|
|
8814
|
-
if (/^https?:\/\//i.test(trimmed)) return {
|
|
8815
|
-
kind: "url",
|
|
8816
|
-
url: trimmed
|
|
8817
|
-
};
|
|
8818
|
-
return {
|
|
8819
|
-
kind: "path",
|
|
8820
|
-
path: expandHome(trimmed)
|
|
8821
|
-
};
|
|
8822
|
-
}
|
|
8823
|
-
function expandHome(value) {
|
|
8824
|
-
if (value === "~") return os.homedir();
|
|
8825
|
-
if (value.startsWith("~/") || value.startsWith("~\\")) return path.join(os.homedir(), value.slice(2));
|
|
8826
|
-
return value;
|
|
8827
|
-
}
|
|
8828
|
-
/**
|
|
8829
|
-
* A token is only ever sent to GitHub: `--file <url>` may point anywhere, and a
|
|
8830
|
-
* credential must not leak to a host the user did not vouch for.
|
|
8831
|
-
*/
|
|
8832
|
-
function isGithubHost(url) {
|
|
8833
|
-
try {
|
|
8834
|
-
const host = new URL(url).hostname.toLowerCase();
|
|
8835
|
-
return host === "github.com" || host.endsWith(".github.com") || host === "githubusercontent.com" || host.endsWith(".githubusercontent.com");
|
|
8836
|
-
} catch {
|
|
8837
|
-
return false;
|
|
8838
|
-
}
|
|
8839
|
-
}
|
|
8840
|
-
/** What `UiService` names the install when the archive carries no `ui.json`. */
|
|
8841
|
-
function fallbackUiName(source) {
|
|
8842
|
-
const stripped = path.basename(source).replace(/\.zip$/i, "").replace(/^home-hosted-ui-/i, "");
|
|
8843
|
-
return stripped.length > 0 ? stripped : "custom-ui";
|
|
8844
|
-
}
|
|
9431
|
+
name: "init",
|
|
9432
|
+
description: "scaffold a project that keeps its state in the repo"
|
|
9433
|
+
},
|
|
9434
|
+
args: initArgs,
|
|
9435
|
+
run: async ({ args }) => {
|
|
9436
|
+
await runInit({
|
|
9437
|
+
dir: args.dir,
|
|
9438
|
+
name: args.name,
|
|
9439
|
+
pm: args.pm,
|
|
9440
|
+
noInstall: args.install === false,
|
|
9441
|
+
yes: args.yes === true
|
|
9442
|
+
});
|
|
9443
|
+
}
|
|
9444
|
+
});
|
|
9445
|
+
}));
|
|
9446
|
+
//#endregion
|
|
9447
|
+
//#region src/cli/ui-switch.ts
|
|
9448
|
+
var ui_switch_exports = /* @__PURE__ */ __exportAll({
|
|
9449
|
+
DEFAULT_REPO: () => DEFAULT_REPO,
|
|
9450
|
+
MAX_DOWNLOAD_BYTES: () => MAX_DOWNLOAD_BYTES,
|
|
9451
|
+
apiHeaders: () => apiHeaders,
|
|
9452
|
+
assetDownloadUrl: () => assetDownloadUrl,
|
|
9453
|
+
compareTags: () => compareTags,
|
|
9454
|
+
defaultReleaseTag: () => defaultReleaseTag,
|
|
9455
|
+
describeError: () => describeError,
|
|
9456
|
+
describeReleaseFailure: () => describeReleaseFailure,
|
|
9457
|
+
downloadToTemp: () => downloadToTemp,
|
|
9458
|
+
fallbackUiName: () => fallbackUiName,
|
|
9459
|
+
fetchRelease: () => fetchRelease,
|
|
9460
|
+
fetchReleases: () => fetchReleases,
|
|
9461
|
+
formatBytes: () => formatBytes,
|
|
9462
|
+
isGithubHost: () => isGithubHost,
|
|
9463
|
+
isOwnRepo: () => isOwnRepo,
|
|
9464
|
+
isUiAsset: () => isUiAsset,
|
|
9465
|
+
matchAsset: () => matchAsset,
|
|
9466
|
+
noAssetsMessage: () => noAssetsMessage,
|
|
9467
|
+
parseFileSource: () => parseFileSource,
|
|
9468
|
+
parseRepoSlug: () => parseRepoSlug,
|
|
9469
|
+
releaseApiUrl: () => releaseApiUrl,
|
|
9470
|
+
releasesApiUrl: () => releasesApiUrl,
|
|
9471
|
+
repoSlug: () => repoSlug,
|
|
9472
|
+
uiSwitch: () => uiSwitch,
|
|
9473
|
+
uiSwitchCommand: () => uiSwitchCommand
|
|
9474
|
+
});
|
|
8845
9475
|
async function uiSwitch(argv, io) {
|
|
8846
9476
|
const { values } = parseArgs({
|
|
8847
9477
|
args: argv,
|
|
@@ -8893,21 +9523,6 @@ async function uiSwitch(argv, io) {
|
|
|
8893
9523
|
}
|
|
8894
9524
|
await installFromUrl(assetDownloadUrl(chosen), fallbackUiName(chosen.name ?? ""), context);
|
|
8895
9525
|
}
|
|
8896
|
-
async function fetchRelease(repo, tag, context) {
|
|
8897
|
-
const url = releaseApiUrl(repo, tag);
|
|
8898
|
-
let response;
|
|
8899
|
-
try {
|
|
8900
|
-
response = await fetch(url, { headers: apiHeaders(context) });
|
|
8901
|
-
} catch (error) {
|
|
8902
|
-
throw new Error(`could not reach GitHub: ${describeError(error)}`);
|
|
8903
|
-
}
|
|
8904
|
-
if (!response.ok) throw new Error(describeReleaseFailure(response.status, repo, tag));
|
|
8905
|
-
const body = await response.json();
|
|
8906
|
-
return {
|
|
8907
|
-
tag: body.tag_name ?? tag ?? "latest",
|
|
8908
|
-
assets: Array.isArray(body.assets) ? body.assets : []
|
|
8909
|
-
};
|
|
8910
|
-
}
|
|
8911
9526
|
async function chooseAsset(assets, values, context) {
|
|
8912
9527
|
const { io } = context;
|
|
8913
9528
|
if (values.asset !== void 0) {
|
|
@@ -8962,47 +9577,6 @@ async function installFromUrl(url, fallbackName, context) {
|
|
|
8962
9577
|
});
|
|
8963
9578
|
}
|
|
8964
9579
|
}
|
|
8965
|
-
/** Streams to `os.tmpdir()` so a large body is never buffered in memory. */
|
|
8966
|
-
async function downloadToTemp(url, headers, context) {
|
|
8967
|
-
let response;
|
|
8968
|
-
try {
|
|
8969
|
-
response = await fetch(url, {
|
|
8970
|
-
headers,
|
|
8971
|
-
redirect: "follow"
|
|
8972
|
-
});
|
|
8973
|
-
} catch (error) {
|
|
8974
|
-
throw new Error(`could not reach ${url}: ${describeError(error)}`);
|
|
8975
|
-
}
|
|
8976
|
-
if (!response.ok) throw new Error(describeDownloadFailure(response.status, url));
|
|
8977
|
-
const declared = Number(response.headers.get("content-length") ?? "0");
|
|
8978
|
-
if (!Number.isFinite(declared) || declared < 0) throw new Error(`the download from ${url} reported an unusable size`);
|
|
8979
|
-
if (declared > 536870912) throw new Error(tooLargeMessage(declared));
|
|
8980
|
-
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(), "hh2-ui-"));
|
|
8982
|
-
const file = path.join(dir, "ui.zip");
|
|
8983
|
-
const handle = await fs.promises.open(file, "w");
|
|
8984
|
-
let received = 0;
|
|
8985
|
-
try {
|
|
8986
|
-
for await (const chunk of response.body) {
|
|
8987
|
-
received += chunk.length;
|
|
8988
|
-
if (received > 536870912) throw new Error(tooLargeMessage(received));
|
|
8989
|
-
await handle.write(chunk);
|
|
8990
|
-
}
|
|
8991
|
-
} catch (error) {
|
|
8992
|
-
await handle.close();
|
|
8993
|
-
fs.rmSync(dir, {
|
|
8994
|
-
recursive: true,
|
|
8995
|
-
force: true
|
|
8996
|
-
});
|
|
8997
|
-
throw error instanceof Error ? error : new Error(String(error));
|
|
8998
|
-
}
|
|
8999
|
-
await handle.close();
|
|
9000
|
-
context.io.write(`${context.io.style.dim(`downloaded ${formatBytes(received)}`)}\n`);
|
|
9001
|
-
return {
|
|
9002
|
-
dir,
|
|
9003
|
-
file
|
|
9004
|
-
};
|
|
9005
|
-
}
|
|
9006
9580
|
async function installArchive(archivePath, fallbackName, context) {
|
|
9007
9581
|
const { UiService } = await Promise.resolve().then(() => (init_ui(), ui_exports));
|
|
9008
9582
|
const { dataRoot } = await Promise.resolve().then(() => (init_paths(), paths_exports));
|
|
@@ -9016,66 +9590,282 @@ async function installArchive(archivePath, fallbackName, context) {
|
|
|
9016
9590
|
context.io.write(` ${context.io.style.dim("files")} ${meta.files}\n`);
|
|
9017
9591
|
context.io.write(`refresh the browser to see it\n`);
|
|
9018
9592
|
}
|
|
9019
|
-
|
|
9020
|
-
|
|
9021
|
-
|
|
9022
|
-
|
|
9023
|
-
|
|
9024
|
-
|
|
9025
|
-
|
|
9026
|
-
|
|
9593
|
+
var uiSwitchCommand;
|
|
9594
|
+
var init_ui_switch = __esmMin((() => {
|
|
9595
|
+
init_io();
|
|
9596
|
+
init_ui_release();
|
|
9597
|
+
init_ui_release();
|
|
9598
|
+
uiSwitchCommand = defineCommand({
|
|
9599
|
+
meta: {
|
|
9600
|
+
name: "ui-switch",
|
|
9601
|
+
description: "install a UI from a release asset, a zip file or a URL"
|
|
9602
|
+
},
|
|
9603
|
+
run: async ({ rawArgs }) => {
|
|
9604
|
+
await uiSwitch(rawArgs, {
|
|
9605
|
+
write: (text) => process.stdout.write(text),
|
|
9606
|
+
prompt,
|
|
9607
|
+
style
|
|
9608
|
+
});
|
|
9609
|
+
}
|
|
9610
|
+
});
|
|
9611
|
+
}));
|
|
9612
|
+
//#endregion
|
|
9613
|
+
//#region src/cli/ui-update.ts
|
|
9614
|
+
var ui_update_exports = /* @__PURE__ */ __exportAll({
|
|
9615
|
+
impliedAsset: () => impliedAsset,
|
|
9616
|
+
planUiUpdate: () => planUiUpdate,
|
|
9617
|
+
uiUpdate: () => uiUpdate,
|
|
9618
|
+
uiUpdateCommand: () => uiUpdateCommand,
|
|
9619
|
+
usableCandidates: () => usableCandidates
|
|
9620
|
+
});
|
|
9621
|
+
/**
|
|
9622
|
+
* Which of a release list applies to what is installed. A candidate is only offered when
|
|
9623
|
+
* its assets carry the asset the person is actually using, so a UI is never swapped for a
|
|
9624
|
+
* different flavour by an update.
|
|
9625
|
+
*/
|
|
9626
|
+
function usableCandidates(releases, asset, order, currentTag) {
|
|
9627
|
+
const found = [];
|
|
9628
|
+
for (const release of releases) {
|
|
9629
|
+
const matched = matchAsset(release.assets.map((entry) => entry.name ?? "").filter(isUiAsset), asset);
|
|
9630
|
+
if (!matched.ok) continue;
|
|
9631
|
+
if (currentTag !== null) {
|
|
9632
|
+
const difference = compareTags(release.tag, currentTag);
|
|
9633
|
+
if (order === "newer" && difference <= 0) continue;
|
|
9634
|
+
if (order === "older" && difference >= 0) continue;
|
|
9635
|
+
}
|
|
9636
|
+
found.push({
|
|
9637
|
+
tag: release.tag,
|
|
9638
|
+
asset: matched.name
|
|
9639
|
+
});
|
|
9640
|
+
}
|
|
9641
|
+
return found;
|
|
9027
9642
|
}
|
|
9028
|
-
|
|
9029
|
-
|
|
9030
|
-
if (
|
|
9031
|
-
return
|
|
9643
|
+
/** What the asset the UI is using is likely called, when it never declared one. */
|
|
9644
|
+
function impliedAsset(identity) {
|
|
9645
|
+
if (identity.asset !== null && identity.asset.length > 0) return identity.asset;
|
|
9646
|
+
if (identity.name.length > 0) return `${identity.name}.zip`;
|
|
9647
|
+
return null;
|
|
9032
9648
|
}
|
|
9033
|
-
|
|
9034
|
-
|
|
9035
|
-
|
|
9036
|
-
|
|
9037
|
-
|
|
9649
|
+
/**
|
|
9650
|
+
* The decision, with no I/O in it: given what is installed and this panel's release,
|
|
9651
|
+
* what should `ui-update` do? `releases` is only consulted for someone else's UI.
|
|
9652
|
+
*/
|
|
9653
|
+
function planUiUpdate(installed, runningTag, releases = [], order = "newer") {
|
|
9654
|
+
if (installed === null) return { kind: "stock" };
|
|
9655
|
+
const repo = installed.repo === null ? null : parseRepoSlug(installed.repo);
|
|
9656
|
+
if (repo === null) return {
|
|
9657
|
+
kind: "unidentifiable",
|
|
9658
|
+
identity: installed
|
|
9659
|
+
};
|
|
9660
|
+
if (isOwnRepo(repo)) return {
|
|
9661
|
+
kind: "official",
|
|
9662
|
+
identity: installed,
|
|
9663
|
+
target: runningTag
|
|
9664
|
+
};
|
|
9665
|
+
const asset = impliedAsset(installed);
|
|
9666
|
+
if (asset === null) return {
|
|
9667
|
+
kind: "unidentifiable",
|
|
9668
|
+
identity: installed
|
|
9669
|
+
};
|
|
9670
|
+
return {
|
|
9671
|
+
kind: "choice",
|
|
9672
|
+
identity: installed,
|
|
9673
|
+
asset,
|
|
9674
|
+
candidates: usableCandidates(releases, asset, order, installed.tag)
|
|
9675
|
+
};
|
|
9038
9676
|
}
|
|
9039
|
-
|
|
9040
|
-
|
|
9041
|
-
|
|
9042
|
-
|
|
9043
|
-
|
|
9677
|
+
/** `home-hosted ui-update` — see the module comment for the three cases. */
|
|
9678
|
+
async function uiUpdate(argv, io) {
|
|
9679
|
+
const { values } = parseArgs({
|
|
9680
|
+
args: argv,
|
|
9681
|
+
options: {
|
|
9682
|
+
tag: { type: "string" },
|
|
9683
|
+
asset: { type: "string" },
|
|
9684
|
+
token: { type: "string" },
|
|
9685
|
+
yes: {
|
|
9686
|
+
type: "boolean",
|
|
9687
|
+
short: "y"
|
|
9688
|
+
},
|
|
9689
|
+
old: { type: "boolean" },
|
|
9690
|
+
check: { type: "boolean" },
|
|
9691
|
+
repo: { type: "string" }
|
|
9692
|
+
},
|
|
9693
|
+
allowPositionals: false
|
|
9694
|
+
});
|
|
9695
|
+
const { appVersion } = await Promise.resolve().then(() => (init_version(), version_exports));
|
|
9696
|
+
const { dataRoot } = await Promise.resolve().then(() => (init_paths(), paths_exports));
|
|
9697
|
+
const { UiService } = await Promise.resolve().then(() => (init_ui(), ui_exports));
|
|
9698
|
+
const context = {
|
|
9699
|
+
io: {
|
|
9700
|
+
write: io.write,
|
|
9701
|
+
style: io.style
|
|
9702
|
+
},
|
|
9703
|
+
ask: process.stdin.isTTY === true && values.yes !== true ? io.prompt : null,
|
|
9704
|
+
version: appVersion(),
|
|
9705
|
+
token: values.token ?? process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? null
|
|
9706
|
+
};
|
|
9707
|
+
const installed = identityOf(new UiService({ dataRoot }).status().meta);
|
|
9708
|
+
if (installed === null) {
|
|
9709
|
+
io.write(`${io.style.dim("the stock UI is in use — it ships with the panel and is always current")}\n`);
|
|
9710
|
+
return;
|
|
9044
9711
|
}
|
|
9045
|
-
if (
|
|
9046
|
-
|
|
9047
|
-
|
|
9712
|
+
if (values.repo !== void 0) {
|
|
9713
|
+
const repo = parseRepoSlug(values.repo);
|
|
9714
|
+
if (repo === null) throw new Error(`invalid --repo "${values.repo}" — expected an "owner/name" slug, e.g. ${DEFAULT_REPO}`);
|
|
9715
|
+
installed.repo = repoSlug(repo);
|
|
9716
|
+
}
|
|
9717
|
+
const order = values.old === true ? "older" : "newer";
|
|
9718
|
+
const repo = installed.repo === null ? null : parseRepoSlug(installed.repo);
|
|
9719
|
+
if (values.tag !== void 0 && repo === null) throw new Error(["--tag needs a repository to fetch from, and this UI does not declare one", ` pass it: home-hosted ui-update --repo ${DEFAULT_REPO} --tag ${values.tag}`].join("\n"));
|
|
9720
|
+
if (values.check === true && repo !== null) {
|
|
9721
|
+
if (isOwnRepo(repo)) {
|
|
9722
|
+
printCheck(io, installed, values.tag ?? `v${context.version}`);
|
|
9723
|
+
return;
|
|
9724
|
+
}
|
|
9725
|
+
const releases = await fetchReleases(repo, context);
|
|
9726
|
+
const asset = impliedAsset(installed);
|
|
9727
|
+
printCheck(io, installed, null, asset === null ? [] : usableCandidates(releases, asset, order, installed.tag));
|
|
9728
|
+
return;
|
|
9729
|
+
}
|
|
9730
|
+
if (values.tag !== void 0 && repo !== null) {
|
|
9731
|
+
await installTag(repo, values.asset ?? installed.asset ?? null, values.tag, context);
|
|
9732
|
+
return;
|
|
9733
|
+
}
|
|
9734
|
+
let releases = [];
|
|
9735
|
+
if (repo !== null && !isOwnRepo(repo)) releases = await fetchReleases(repo, context);
|
|
9736
|
+
const plan = planUiUpdate(installed, `v${context.version}`, releases, order);
|
|
9737
|
+
if (plan.kind === "unidentifiable") {
|
|
9738
|
+
io.write(`${io.style.bold("this UI does not say where it came from")} — nothing to follow automatically\n`);
|
|
9739
|
+
printIdentity(io, plan.identity);
|
|
9740
|
+
io.write(` ${io.style.dim("point it at a repo to make this work: home-hosted ui-update --repo owner/name")}\n`);
|
|
9741
|
+
return;
|
|
9742
|
+
}
|
|
9743
|
+
if (plan.kind === "official") {
|
|
9744
|
+
if (plan.identity.tag === plan.target) {
|
|
9745
|
+
io.write(`${io.style.green("already current")} — ${io.style.bold(plan.identity.name)} is at ${plan.target}\n`);
|
|
9746
|
+
return;
|
|
9747
|
+
}
|
|
9748
|
+
io.write(`${io.style.bold(`${plan.identity.name} ${plan.identity.version ?? ""}`.trim())} is on ${plan.identity.tag ?? "an unknown tag"}; this panel is ${plan.target} — updating\n`);
|
|
9749
|
+
await installTag(repo, plan.identity.asset ?? values.asset ?? null, plan.target, context);
|
|
9750
|
+
return;
|
|
9751
|
+
}
|
|
9752
|
+
if (plan.kind !== "choice") return;
|
|
9753
|
+
io.write(`${io.style.bold(plan.identity.name)} ${plan.identity.version ?? ""} — ${repoSlug(repo)}@${plan.identity.tag ?? "unknown tag"}\n`);
|
|
9754
|
+
io.write(` ${io.style.dim(`asset: ${plan.asset}`)}\n`);
|
|
9755
|
+
if (plan.candidates.length === 0) {
|
|
9756
|
+
const direction = order === "newer" ? "newer" : "older";
|
|
9757
|
+
io.write(`no ${direction} releases carry ${plan.asset}\n`);
|
|
9758
|
+
if (order === "newer") io.write(` ${io.style.dim("list older releases with: home-hosted ui-update --old")}\n`);
|
|
9759
|
+
return;
|
|
9760
|
+
}
|
|
9761
|
+
const chosen = await chooseCandidate(plan.candidates, context, order);
|
|
9762
|
+
if (chosen === null) {
|
|
9763
|
+
io.write("cancelled — nothing was installed\n");
|
|
9764
|
+
return;
|
|
9765
|
+
}
|
|
9766
|
+
await installTag(repo, chosen.asset, chosen.tag, context);
|
|
9048
9767
|
}
|
|
9049
|
-
function
|
|
9050
|
-
|
|
9051
|
-
|
|
9052
|
-
|
|
9053
|
-
|
|
9054
|
-
|
|
9768
|
+
function identityOf(meta) {
|
|
9769
|
+
if (meta === null) return null;
|
|
9770
|
+
return {
|
|
9771
|
+
name: meta.name ?? "custom-ui",
|
|
9772
|
+
version: meta.version ?? null,
|
|
9773
|
+
repo: meta.repo ?? null,
|
|
9774
|
+
tag: meta.tag ?? null,
|
|
9775
|
+
asset: meta.asset ?? null,
|
|
9776
|
+
unix: meta.unix ?? null
|
|
9777
|
+
};
|
|
9055
9778
|
}
|
|
9056
|
-
function
|
|
9057
|
-
|
|
9779
|
+
function printIdentity(io, identity) {
|
|
9780
|
+
io.write(` name ${identity.name}\n`);
|
|
9781
|
+
io.write(` version ${identity.version ?? "unspecified"}\n`);
|
|
9782
|
+
io.write(` repo ${identity.repo ?? "unspecified"}${identity.tag === null ? "" : ` @ ${identity.tag}`}\n`);
|
|
9783
|
+
if (identity.unix !== null) io.write(` built ${(/* @__PURE__ */ new Date(identity.unix * 1e3)).toISOString()}\n`);
|
|
9058
9784
|
}
|
|
9059
|
-
function
|
|
9060
|
-
if (
|
|
9061
|
-
|
|
9062
|
-
|
|
9785
|
+
function printCheck(io, identity, target, candidates = []) {
|
|
9786
|
+
if (target !== null) {
|
|
9787
|
+
if (identity.tag === target) {
|
|
9788
|
+
io.write(`${io.style.green("up to date")} — ${identity.version ?? identity.name} at ${target}\n`);
|
|
9789
|
+
return;
|
|
9790
|
+
}
|
|
9791
|
+
io.write(`update available: ${identity.tag ?? "unknown"} → ${target}\n`);
|
|
9792
|
+
return;
|
|
9793
|
+
}
|
|
9794
|
+
if (candidates.length === 0) {
|
|
9795
|
+
io.write(`up to date — no release carries ${identity.asset ?? identity.name}.zip\n`);
|
|
9796
|
+
return;
|
|
9797
|
+
}
|
|
9798
|
+
io.write(`${candidates.length} release(s) available:\n`);
|
|
9799
|
+
for (const candidate of candidates) io.write(` ${candidate.tag} ${io.style.dim(candidate.asset)}\n`);
|
|
9063
9800
|
}
|
|
9064
|
-
function
|
|
9065
|
-
|
|
9801
|
+
async function chooseCandidate(candidates, context, order) {
|
|
9802
|
+
const headline = order === "newer" ? "Newer releases" : "Older releases";
|
|
9803
|
+
const { io } = context;
|
|
9804
|
+
io.write(`${io.style.bold(headline)}\n`);
|
|
9805
|
+
for (const [index, candidate] of candidates.entries()) io.write(` ${index + 1}) ${candidate.tag}\n`);
|
|
9806
|
+
if (context.ask === null) {
|
|
9807
|
+
if (candidates.length === 1) return candidates[0];
|
|
9808
|
+
throw new Error([
|
|
9809
|
+
`${candidates.length} releases are available and this session cannot ask which one:`,
|
|
9810
|
+
...candidates.map((candidate) => ` ${candidate.tag}`),
|
|
9811
|
+
" run it in a terminal, or pass --tag <tag>"
|
|
9812
|
+
].join("\n"));
|
|
9813
|
+
}
|
|
9814
|
+
for (;;) {
|
|
9815
|
+
const answer = (await context.ask(`Select a release [1-${candidates.length}] (empty to cancel) `)).trim();
|
|
9816
|
+
if (answer.length === 0) return null;
|
|
9817
|
+
if (/^\d+$/.test(answer)) {
|
|
9818
|
+
const index = Number.parseInt(answer, 10);
|
|
9819
|
+
if (index >= 1 && index <= candidates.length) return candidates[index - 1];
|
|
9820
|
+
}
|
|
9821
|
+
const byTag = candidates.find((candidate) => candidate.tag === answer);
|
|
9822
|
+
if (byTag !== void 0) return byTag;
|
|
9823
|
+
io.write(` ${io.style.dim(`no such choice — enter 1-${candidates.length} or a tag`)}\n`);
|
|
9824
|
+
}
|
|
9066
9825
|
}
|
|
9067
|
-
|
|
9068
|
-
|
|
9826
|
+
/** Fetches one release's asset and installs it, reporting what landed. */
|
|
9827
|
+
async function installTag(repo, wantedAsset, tag, context) {
|
|
9828
|
+
const release = await fetchRelease(repo, tag, context);
|
|
9829
|
+
const names = release.assets.map((asset) => asset.name ?? "").filter(isUiAsset);
|
|
9830
|
+
let assetName;
|
|
9831
|
+
if (wantedAsset !== null && wantedAsset.length > 0) {
|
|
9832
|
+
const matched = matchAsset(names, wantedAsset);
|
|
9833
|
+
if (!matched.ok) throw new Error(`${matched.error}\n in ${repo.owner}/${repo.name}@${release.tag}`);
|
|
9834
|
+
assetName = matched.name;
|
|
9835
|
+
} else if (names.length === 1) assetName = names[0];
|
|
9836
|
+
else throw new Error(`${names.length} UI assets in ${repo.owner}/${repo.name}@${release.tag} — name one with --asset`);
|
|
9837
|
+
const download = await downloadToTemp(assetDownloadUrl(release.assets.find((entry) => entry.name === assetName)), {
|
|
9838
|
+
"accept": "application/octet-stream",
|
|
9839
|
+
"user-agent": `home-hosted/${context.version}`,
|
|
9840
|
+
...context.token !== null && context.token.length > 0 ? { authorization: `Bearer ${context.token}` } : {}
|
|
9841
|
+
}, context);
|
|
9842
|
+
try {
|
|
9843
|
+
const { UiService } = await Promise.resolve().then(() => (init_ui(), ui_exports));
|
|
9844
|
+
const { dataRoot } = await Promise.resolve().then(() => (init_paths(), paths_exports));
|
|
9845
|
+
const result = await new UiService({ dataRoot }).install(download.file, assetName);
|
|
9846
|
+
if (!result.ok) throw new Error(`nothing was installed: ${result.error}`);
|
|
9847
|
+
const { meta } = result;
|
|
9848
|
+
const { io } = context;
|
|
9849
|
+
io.write(`${io.style.green("UI updated")} — ${meta.name} ${meta.version ?? ""} at ${release.tag}\n`);
|
|
9850
|
+
io.write(` ${io.style.dim("refresh the browser to see it")}\n`);
|
|
9851
|
+
} finally {
|
|
9852
|
+
fs.rmSync(download.dir, {
|
|
9853
|
+
recursive: true,
|
|
9854
|
+
force: true
|
|
9855
|
+
});
|
|
9856
|
+
}
|
|
9857
|
+
}
|
|
9858
|
+
var uiUpdateCommand;
|
|
9859
|
+
var init_ui_update = __esmMin((() => {
|
|
9069
9860
|
init_io();
|
|
9070
|
-
|
|
9071
|
-
|
|
9072
|
-
uiSwitchCommand = defineCommand({
|
|
9861
|
+
init_ui_release();
|
|
9862
|
+
uiUpdateCommand = defineCommand({
|
|
9073
9863
|
meta: {
|
|
9074
|
-
name: "ui-
|
|
9075
|
-
description: "
|
|
9864
|
+
name: "ui-update",
|
|
9865
|
+
description: "update the installed UI to match this panel, or pick a release"
|
|
9076
9866
|
},
|
|
9077
9867
|
run: async ({ rawArgs }) => {
|
|
9078
|
-
await
|
|
9868
|
+
await uiUpdate(rawArgs, {
|
|
9079
9869
|
write: (text) => process.stdout.write(text),
|
|
9080
9870
|
prompt,
|
|
9081
9871
|
style
|
|
@@ -9132,66 +9922,201 @@ init_io();
|
|
|
9132
9922
|
* module) is a dynamic import behind `subCommands`.
|
|
9133
9923
|
*/
|
|
9134
9924
|
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
|
-
|
|
9925
|
+
/**
|
|
9926
|
+
* The curated prose, kept here because citty cannot generate it. One named
|
|
9927
|
+
* piece per command, so the full reference and a single command's help are
|
|
9928
|
+
* composed from the same lines and can never drift apart. Every section lays
|
|
9929
|
+
* its left column out at the same width, so the two views read alike.
|
|
9930
|
+
*/
|
|
9931
|
+
var HEADER = "home-hosted — a control panel for the processes on your home server";
|
|
9932
|
+
/** The one line the full reference lists for a command. */
|
|
9933
|
+
var SYNOPSIS = {
|
|
9934
|
+
"up": "home-hosted up [options]",
|
|
9935
|
+
"down": "home-hosted down",
|
|
9936
|
+
"restart": "home-hosted restart [options]",
|
|
9937
|
+
"status": "home-hosted status [--json]",
|
|
9938
|
+
"set-password": "home-hosted set-password",
|
|
9939
|
+
"set-token": "home-hosted set-token",
|
|
9940
|
+
"migrate": "home-hosted migrate",
|
|
9941
|
+
"init": "home-hosted init",
|
|
9942
|
+
"ui-switch": "home-hosted ui-switch",
|
|
9943
|
+
"ui-update": "home-hosted ui-update",
|
|
9944
|
+
"ui-revert": "home-hosted ui-revert"
|
|
9945
|
+
};
|
|
9946
|
+
var SUMMARIES = {
|
|
9947
|
+
"up": "start it in the background (detached)",
|
|
9948
|
+
"down": "stop it, and everything it supervises",
|
|
9949
|
+
"restart": "down, then up",
|
|
9950
|
+
"status": "is it running, where, and how to reach it",
|
|
9951
|
+
"set-password": "set the panel password without the API",
|
|
9952
|
+
"set-token": "set the API token that scripts and agents use",
|
|
9953
|
+
"migrate": "bring the config up to this release's schema",
|
|
9954
|
+
"init": "scaffold a project that keeps its state in the repo",
|
|
9955
|
+
"ui-switch": "install a UI from a release asset, a zip file or a URL",
|
|
9956
|
+
"ui-update": "bring the installed UI up to date, or pick a release",
|
|
9957
|
+
"ui-revert": "go back to the stock control panel UI"
|
|
9958
|
+
};
|
|
9959
|
+
/** Every section lays its left column out at this width, so the two views align. */
|
|
9960
|
+
var OPTION_WIDTH = 19;
|
|
9961
|
+
var UP_SECTION = {
|
|
9962
|
+
heading: "Options for up/restart",
|
|
9963
|
+
lines: [
|
|
9964
|
+
["-c, --config <file>", "servers config (default: <state>/servers.config.json)"],
|
|
9965
|
+
["-p, --port <port>", "control panel port (default: 3999)"],
|
|
9966
|
+
["--host <bind>", "local | lan | an ipv4 address (default: local)"],
|
|
9967
|
+
["--open", "open the panel in a browser once it is up"],
|
|
9968
|
+
["--no-autostart", "do not start the entries marked autostart"],
|
|
9969
|
+
["--foreground", "run in this process instead of detaching (systemd/docker)"],
|
|
9970
|
+
["--print-config", "print the effective config and exit"]
|
|
9971
|
+
]
|
|
9972
|
+
};
|
|
9973
|
+
var STATUS_SECTION = {
|
|
9974
|
+
heading: "Options for status",
|
|
9975
|
+
lines: [["--json", "print machine-readable JSON"]]
|
|
9976
|
+
};
|
|
9977
|
+
var SET_PASSWORD_SECTION = {
|
|
9978
|
+
heading: "Options for set-password",
|
|
9979
|
+
lines: [["--clear", "remove the password, which disables authentication"]]
|
|
9980
|
+
};
|
|
9981
|
+
var SET_TOKEN_SECTION = {
|
|
9982
|
+
heading: "Options for set-token",
|
|
9983
|
+
lines: [["--generate", "create a strong token and print it once"], ["--clear", "remove the token, so it stops working"]]
|
|
9984
|
+
};
|
|
9985
|
+
var MIGRATE_SECTION = {
|
|
9986
|
+
heading: "Options for migrate",
|
|
9987
|
+
lines: [["--dry-run", "print what would change, write nothing"], ["-y, --yes", "apply without asking (or set HHOSTED_MIGRATE=allow)"]]
|
|
9988
|
+
};
|
|
9989
|
+
var INIT_SECTION = {
|
|
9990
|
+
heading: "Options for init",
|
|
9991
|
+
lines: [
|
|
9992
|
+
["--dir <dir>", "where to scaffold (default: ./my-servers)"],
|
|
9993
|
+
["--name <name>", "package name (default: the directory name)"],
|
|
9994
|
+
["--pm <manager>", "pnpm | npm | yarn | bun (default: the first one installed)"],
|
|
9995
|
+
["--no-install", "write the files, install nothing"],
|
|
9996
|
+
["-y, --yes", "take every default, ask nothing"]
|
|
9997
|
+
]
|
|
9998
|
+
};
|
|
9999
|
+
var UI_SWITCH_SECTION = {
|
|
10000
|
+
heading: "Options for ui-switch",
|
|
10001
|
+
lines: [
|
|
10002
|
+
["--repo <owner/name>", "release repo (default: NamesMT/home-hosted)"],
|
|
10003
|
+
["--tag <tag>", "release tag (default: this release's tag, or latest for another repo)"],
|
|
10004
|
+
["--asset <name>", "asset to install (exact or unambiguous match)"],
|
|
10005
|
+
["--file <path|url>", "install a zip from a local path or an http(s) URL"],
|
|
10006
|
+
["--list", "list the usable assets and install nothing"],
|
|
10007
|
+
["--token <token>", "GitHub token (or GITHUB_TOKEN / GH_TOKEN)"],
|
|
10008
|
+
["-y, --yes", "take the only asset instead of asking"]
|
|
10009
|
+
]
|
|
10010
|
+
};
|
|
10011
|
+
var UI_UPDATE_SECTION = {
|
|
10012
|
+
heading: "Options for ui-update",
|
|
10013
|
+
lines: [
|
|
10014
|
+
["--check", "report whether an update is available and install nothing"],
|
|
10015
|
+
["--tag <tag>", "install that release instead of asking"],
|
|
10016
|
+
["--asset <name>", "asset to install (defaults to the one in use)"],
|
|
10017
|
+
["--old", "list older releases instead of newer ones"],
|
|
10018
|
+
["--repo <owner/name>", "for a UI that does not declare its own repo"],
|
|
10019
|
+
["--token <token>", "GitHub token (or GITHUB_TOKEN / GH_TOKEN)"],
|
|
10020
|
+
["-y, --yes", "take the only release instead of asking"]
|
|
10021
|
+
]
|
|
10022
|
+
};
|
|
10023
|
+
var EVERYWHERE_SECTION = {
|
|
10024
|
+
heading: "Everywhere",
|
|
10025
|
+
lines: [
|
|
10026
|
+
["--home <dir>", "state directory (default: $HHOSTED_HOME or ~/.home-hosted)"],
|
|
10027
|
+
["--project <dir>", "base for relative entry paths (default: the current directory)"],
|
|
10028
|
+
["-h, --help", "this text"],
|
|
10029
|
+
["-v, --version", "the version"]
|
|
10030
|
+
]
|
|
10031
|
+
};
|
|
10032
|
+
var ALIAS_SECTION = {
|
|
10033
|
+
heading: "Alias",
|
|
10034
|
+
lines: [["hh", "the same CLI, on a machine where home-hosted is installed"]]
|
|
10035
|
+
};
|
|
10036
|
+
var ENVIRONMENT_SECTION = {
|
|
10037
|
+
heading: "Environment",
|
|
10038
|
+
lines: [
|
|
10039
|
+
["HHOSTED_HOME", "where config, secrets, logs, TLS and backups live"],
|
|
10040
|
+
["HHOSTED_PROJECT", "base for relative entry paths"],
|
|
10041
|
+
["HHOSTED_PASSWORD", "the password for a non-interactive set-password"],
|
|
10042
|
+
["HHOSTED_TOKEN", "the token for a non-interactive set-token"],
|
|
10043
|
+
["GITHUB_TOKEN", "a GitHub token for ui-switch (GH_TOKEN also works)"]
|
|
10044
|
+
]
|
|
10045
|
+
};
|
|
10046
|
+
/** The command list of the full reference, aligned as it always was. */
|
|
10047
|
+
function renderCommandList() {
|
|
10048
|
+
const width = Math.max(...Object.values(SYNOPSIS).map((synopsis) => synopsis.length));
|
|
10049
|
+
return Object.keys(SYNOPSIS).map((name) => {
|
|
10050
|
+
const synopsis = SYNOPSIS[name];
|
|
10051
|
+
return ` ${cyan(synopsis)}${" ".repeat(width + 1 - synopsis.length)} ${SUMMARIES[name]}`;
|
|
10052
|
+
}).join("\n");
|
|
10053
|
+
}
|
|
10054
|
+
function renderSection(section) {
|
|
10055
|
+
const lines = section.lines.map(([left, right]) => ` ${cyan(left)}${" ".repeat(Math.max(0, OPTION_WIDTH - left.length))} ${right}`).join("\n");
|
|
10056
|
+
return `${heading(section.heading)}\n${lines}`;
|
|
10057
|
+
}
|
|
10058
|
+
/** The trailer every command shares: the global flags, the alias and the environment. */
|
|
10059
|
+
var SHARED_TRAILER = [
|
|
10060
|
+
renderSection(EVERYWHERE_SECTION),
|
|
10061
|
+
"",
|
|
10062
|
+
renderSection(ALIAS_SECTION),
|
|
10063
|
+
"",
|
|
10064
|
+
renderSection(ENVIRONMENT_SECTION),
|
|
10065
|
+
""
|
|
10066
|
+
].join("\n");
|
|
10067
|
+
/** The full reference: every command, every option, the trailer. */
|
|
10068
|
+
var USAGE = [
|
|
10069
|
+
dim(HEADER),
|
|
10070
|
+
"",
|
|
10071
|
+
heading("Usage"),
|
|
10072
|
+
renderCommandList(),
|
|
10073
|
+
"",
|
|
10074
|
+
renderSection(UP_SECTION),
|
|
10075
|
+
"",
|
|
10076
|
+
renderSection(SET_PASSWORD_SECTION),
|
|
10077
|
+
"",
|
|
10078
|
+
renderSection(SET_TOKEN_SECTION),
|
|
10079
|
+
"",
|
|
10080
|
+
renderSection(MIGRATE_SECTION),
|
|
10081
|
+
"",
|
|
10082
|
+
renderSection(INIT_SECTION),
|
|
10083
|
+
"",
|
|
10084
|
+
renderSection(UI_SWITCH_SECTION),
|
|
10085
|
+
"",
|
|
10086
|
+
renderSection(STATUS_SECTION),
|
|
10087
|
+
"",
|
|
10088
|
+
SHARED_TRAILER
|
|
10089
|
+
].join("\n");
|
|
10090
|
+
var UP_COMMANDS = /* @__PURE__ */ new Set(["up", "restart"]);
|
|
10091
|
+
var SECTIONS = {
|
|
10092
|
+
"status": STATUS_SECTION,
|
|
10093
|
+
"set-password": SET_PASSWORD_SECTION,
|
|
10094
|
+
"set-token": SET_TOKEN_SECTION,
|
|
10095
|
+
"migrate": MIGRATE_SECTION,
|
|
10096
|
+
"init": INIT_SECTION,
|
|
10097
|
+
"ui-switch": UI_SWITCH_SECTION,
|
|
10098
|
+
"ui-update": UI_UPDATE_SECTION
|
|
10099
|
+
};
|
|
10100
|
+
/**
|
|
10101
|
+
* `home-hosted <command> --help`: the usage line, that command's own options,
|
|
10102
|
+
* and the shared trailer. A command with no options says so instead of printing
|
|
10103
|
+
* an empty heading.
|
|
10104
|
+
*/
|
|
10105
|
+
function commandHelp(command) {
|
|
10106
|
+
const synopsis = SYNOPSIS[command];
|
|
10107
|
+
if (synopsis === void 0) return USAGE;
|
|
10108
|
+
const section = UP_COMMANDS.has(command) ? UP_SECTION : SECTIONS[command];
|
|
10109
|
+
const options = section === void 0 ? dim("no options") : renderSection(section);
|
|
10110
|
+
return [
|
|
10111
|
+
dim(HEADER),
|
|
10112
|
+
"",
|
|
10113
|
+
cyan(synopsis),
|
|
10114
|
+
"",
|
|
10115
|
+
options,
|
|
10116
|
+
"",
|
|
10117
|
+
SHARED_TRAILER
|
|
10118
|
+
].join("\n");
|
|
10119
|
+
}
|
|
9195
10120
|
function manifestVersion() {
|
|
9196
10121
|
try {
|
|
9197
10122
|
return JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "0.0.0";
|
|
@@ -9216,6 +10141,7 @@ var COMMANDS = {
|
|
|
9216
10141
|
"migrate": () => Promise.resolve().then(() => (init_migrate(), migrate_exports)).then((module) => module.migrateCommand),
|
|
9217
10142
|
"init": () => Promise.resolve().then(() => (init_init(), init_exports)).then((module) => module.initCommand),
|
|
9218
10143
|
"ui-switch": () => Promise.resolve().then(() => (init_ui_switch(), ui_switch_exports)).then((module) => module.uiSwitchCommand),
|
|
10144
|
+
"ui-update": () => Promise.resolve().then(() => (init_ui_update(), ui_update_exports)).then((module) => module.uiUpdateCommand),
|
|
9219
10145
|
"ui-revert": () => Promise.resolve().then(() => (init_ui_revert(), ui_revert_exports)).then((module) => module.uiRevertCommand)
|
|
9220
10146
|
};
|
|
9221
10147
|
var rootCommand = defineCommand({
|
|
@@ -9233,7 +10159,7 @@ async function main() {
|
|
|
9233
10159
|
applyDirFlags(dirFlags);
|
|
9234
10160
|
const invocation = resolveInvocation(dirFlags.rest, commandNames);
|
|
9235
10161
|
if (invocation.kind === "help") {
|
|
9236
|
-
process.stdout.write(USAGE);
|
|
10162
|
+
process.stdout.write(invocation.command === void 0 ? USAGE : commandHelp(invocation.command));
|
|
9237
10163
|
return;
|
|
9238
10164
|
}
|
|
9239
10165
|
if (invocation.kind === "version") {
|
|
@@ -9258,6 +10184,6 @@ main().catch((error) => {
|
|
|
9258
10184
|
fail(error instanceof Error ? error.message : String(error));
|
|
9259
10185
|
});
|
|
9260
10186
|
//#endregion
|
|
9261
|
-
export {};
|
|
10187
|
+
export { commandHelp };
|
|
9262
10188
|
|
|
9263
10189
|
//# sourceMappingURL=cli.js.map
|