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/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 first token that starts with `-` is the one-shot form
92
- * (`home-hosted -p 4000`), so `up` is prepended. Anything else has to name a
93
- * command, which keeps the old `unknown command:` text exact.
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 { kind: "help" };
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: "\"block\" | \"warn\" | \"follow\" | \"reclaim\" = \"block\"",
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: "\"block\" | \"warn\" | \"follow\" | \"reclaim\" = \"block\"",
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: "\"block\" | \"warn\" | \"follow\" | \"reclaim\"?",
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: "\"block\" | \"warn\" | \"follow\" | \"reclaim\"?",
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
- uploadedAt: "number",
894
- files: "number.integer >= 1"
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 = "hh2_session";
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)["hh2_session"] ?? null;
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("hh2_control_up", "Control plane is serving", ["hh2_control_up 1"]);
2311
- metric("hh2_servers_total", "Configured servers", [`hh2_servers_total ${state.servers.length}`]);
2312
- metric("hh2_server_up", "Server process is running", state.servers.map((server) => `hh2_server_up{server="${server.id}"} ${server.status === "running" ? 1 : 0}`));
2313
- metric("hh2_server_restarts_total", "Restarts since the control plane started", state.servers.map((server) => `hh2_server_restarts_total{server="${server.id}"} ${server.restarts}`));
2314
- metric("hh2_server_crashes_24h", "Crashes in the last 24 hours", state.servers.map((server) => `hh2_server_crashes_24h{server="${server.id}"} ${server.history.crashes}`));
2315
- metric("hh2_server_uptime_ratio_24h", "Share of the last 24 hours the server was up", state.servers.filter((server) => server.history.uptimeRatio !== null).map((server) => `hh2_server_uptime_ratio_24h{server="${server.id}"} ${server.history.uptimeRatio.toFixed(4)}`));
2316
- metric("hh2_server_response_ms", "Last health probe latency in milliseconds", state.servers.filter((server) => server.responseMs !== null).map((server) => `hh2_server_response_ms{server="${server.id}"} ${server.responseMs}`));
2317
- metric("hh2_server_rss_bytes", "RSS of the server process tree", state.servers.filter((server) => server.resources?.rssBytes != null).map((server) => `hh2_server_rss_bytes{server="${server.id}"} ${server.resources.rssBytes}`));
2318
- metric("hh2_server_cpu_percent", "CPU percent of the server process tree", state.servers.filter((server) => server.resources?.cpuPercent != null).map((server) => `hh2_server_cpu_percent{server="${server.id}"} ${server.resources.cpuPercent}`));
2319
- metric("hh2_host_disk_used_percent", "Disk usage percent per configured path", state.host.disks.map((disk) => `hh2_host_disk_used_percent{mount="${disk.path}"} ${disk.usedPercent.toFixed(2)}`));
2320
- metric("hh2_host_memory_used_percent", "Memory usage percent", [`hh2_host_memory_used_percent ${state.host.memoryUsedPercent.toFixed(2)}`]);
2321
- metric("hh2_host_swap_used_percent", "Swap usage percent", [`hh2_host_swap_used_percent ${state.host.swapUsedPercent.toFixed(2)}`]);
2322
- metric("hh2_host_load1_per_cpu", "1 minute load average per cpu", [`hh2_host_load1_per_cpu ${((state.host.loadAvg[0] ?? 0) / Math.max(1, state.host.cpus)).toFixed(3)}`]);
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$3("netstat", [
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$3("lsof", [
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$3("fuser", [`${port}/tcp`], { timeout: 3e3 });
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$3;
4142
+ var execFileAsync$4;
4124
4143
  var init_port = __esmMin((() => {
4125
- execFileAsync$3 = promisify(execFile);
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$2("getconf", ["CLK_TCK"], { timeout: 2e3 });
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$2("ps", ["-Ao", "pid=,ppid=,rss=,%cpu="], {
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$2("powershell.exe", [
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$2("wmic", [
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$2("ps", [
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$2, clockTicks, ProcessSampler;
4650
+ var execFileAsync$3, clockTicks, ProcessSampler;
4632
4651
  var init_proc = __esmMin((() => {
4633
- execFileAsync$2 = promisify(execFile);
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 supervised = this.supervisedPids();
5114
- const holders = await listPortHolders(port);
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 holders that carry this entry's own marker — a program that restarted
5347
- * itself leaves a detached process behind, and that process is the *same server*,
5348
- * not a stranger to kill. Read from the environment the supervisor gave the entry,
5349
- * which a successor inherits unless it scrubs it.
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 ownPortHolders(entry, holders) {
5352
- const own = [];
5353
- for (const pid of holders) if (await processCarriesServerId(pid, entry.config.id)) own.push(pid);
5354
- return own;
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 holders = await listPortHolders(port);
5371
- const own = await this.ownPortHolders(entry, holders);
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 (own.length > 0 && entry.config.onPortConflict === "follow") return {
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[0]
5666
+ pid: own
5376
5667
  };
5377
- if (own.length > 0 && entry.config.onPortConflict === "reclaim") {
5378
- this.log(entry, "system", `port ${port} is held by pid ${own.join(", ")}, a detached restart of this entry — replacing it with a supervised process`);
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.join(", ")}`;
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.length > 0 ? ` — pid ${own.join(", ")} is a detached restart of this entry: set onPortConflict to "follow" to adopt it, or "reclaim" to replace it with a supervised process` : "";
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
- spawnEntry(entry) {
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 args = expandEnvList(resolveTemplates(entry.config.args, vars), expansionVars);
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(resolveRecord(entry.config.dataEnvs, vars), expansionVars),
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
- this.log(entry, "system", `start: ${command} ${resolveTemplates(entry.config.args, vars).join(" ")}`);
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
- fs.rmSync(this.directory, {
7639
- recursive: true,
7640
- force: true
7641
- });
7642
- if (fs.existsSync(previous)) fs.renameSync(previous, this.directory);
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/index.ts
7662
- var src_exports = /* @__PURE__ */ __exportAll({
7663
- packageRoot: () => packageRoot,
7664
- runControlPlane: () => runControlPlane
7665
- });
7666
- function packageVersion() {
7667
- try {
7668
- return JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8")).version ?? "0.0.0";
7669
- } catch {
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
- /** A probe target for a `lan` bind, which is not connectable as `0.0.0.0`. */
7674
- function probeHostFor(bindHost) {
7675
- return bindHost === "0.0.0.0" || bindHost === "::" ? "127.0.0.1" : bindHost;
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
- * Runs the control plane in *this* process until it is stopped. `home-hosted up`
7679
- * detaches a child that calls this; `--foreground` (systemd, docker) calls it
7680
- * directly.
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
- async function runControlPlane(options) {
7683
- const existing = readRuntime();
7684
- if (existing !== null && existing.pid !== process.pid && isProcessAlive$1(existing.pid)) {
7685
- logger.error(`already running (pid ${existing.pid}) at ${existing.url} — run \`home-hosted down\` first`);
7686
- process.exit(1);
7687
- }
7688
- if (existing !== null) clearRuntime();
7689
- const configPath = options.configPath ?? defaultConfigPath;
7690
- const store = new ConfigStore(configPath, SEED_CONFIG);
7691
- store.load();
7692
- store.writeJsonSchema();
7693
- if (store.configError !== null) {
7694
- logger.error(`refusing to start: ${store.configError}`);
7695
- logger.info(`fix ${store.path}, or install the release that wrote it`);
7696
- process.exit(1);
7697
- }
7698
- if (store.pendingMigrations.length > 0) {
7699
- logger.error(`refusing to start: ${store.path} needs ${store.pendingMigrations.length} migration(s) before ${appVersion()} can use it`);
7700
- logger.info("run `home-hosted migrate` to see and apply them");
7701
- process.exit(1);
7702
- }
7703
- const secrets = new SecretsStore(defaultSecretsPath);
7704
- const auth = new AuthService(secrets, () => store.config.control.auth);
7705
- const tls = new TlsStore(defaultTlsDir);
7706
- const logFiles = new LogFiles(defaultLogsDir, () => store.config.logs);
7707
- const history = new HistoryStore(defaultHistoryPath);
7708
- const notifications = new NotificationService(secrets, () => store.config.notifications, () => store.config.logs);
7709
- const hostMonitor = new HostMonitor(() => store.config.host, (target) => resolveUserPath(resolveTemplate(target, {
7710
- projectDir,
7711
- dataRoot,
7712
- home: os.homedir()
7713
- })), notifications);
7714
- let onConfigRestored;
7715
- const backups = new BackupService({
7716
- dataRoot,
7717
- getConfig: () => store.config.backups,
7718
- getSources: () => ({
7719
- configPath: store.path,
7720
- secretsPath: secrets.path,
7721
- tlsDir: tls.directory,
7722
- paths: resolveBackupPaths(store.servers, store.config.backups.includePaths)
7723
- }),
7724
- onConfigRestored: () => onConfigRestored?.()
7725
- });
7726
- if (!auth.passwordSet) {
7727
- auth.ensureDefaultPassword("hh");
7728
- logger.warn(`no password was set — created the default "hh"; change it in Settings → Authentication`);
7729
- }
7730
- const configured = store.config.control;
7731
- const intendedHost = options.host === void 0 ? configured.host : parseBind(options.host);
7732
- if (intendedHost === null) {
7733
- logger.error(`invalid control host: ${String(options.host)} (expected local, lan or an ipv4 address)`);
7734
- process.exit(1);
7735
- }
7736
- const intended = {
7737
- host: intendedHost,
7738
- port: options.port ?? configured.port
7739
- };
7740
- if (!Number.isInteger(intended.port) || intended.port <= 0 || intended.port > 65535) {
7741
- logger.error(`invalid control port: ${String(options.port)}`);
7742
- process.exit(1);
7743
- }
7744
- if (options.printConfig) {
7745
- process.stdout.write(`${JSON.stringify(store.config, null, 2)}\n`);
7746
- return;
7747
- }
7748
- const exposure = checkExposure({
7749
- ...configured,
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
- defaultReleaseTag: () => defaultReleaseTag,
8735
- fallbackUiName: () => fallbackUiName,
8736
- isGithubHost: () => isGithubHost,
8737
- isOwnRepo: () => isOwnRepo,
8738
- isUiAsset: () => isUiAsset,
8739
- matchAsset: () => matchAsset,
8740
- parseFileSource: () => parseFileSource,
8741
- parseRepoSlug: () => parseRepoSlug,
8742
- releaseApiUrl: () => releaseApiUrl,
8743
- uiSwitch: () => uiSwitch,
8744
- uiSwitchCommand: () => uiSwitchCommand
8745
- });
8746
- /** `owner/name`, the only slug GitHub releases are addressed by. */
8747
- function parseRepoSlug(value) {
8748
- const match = /^([\w.-]+)\/([\w.-]+)$/.exec(value.trim());
8749
- if (match === null) return null;
8750
- return {
8751
- owner: match[1],
8752
- name: match[2]
8753
- };
8754
- }
8755
- /** The repo the default tag rule is about; `DEFAULT_REPO` is its only definition. */
8756
- function isOwnRepo(repo) {
8757
- return `${repo.owner}/${repo.name}`.toLowerCase() === DEFAULT_REPO.toLowerCase();
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
- function apiHeaders(context) {
9020
- const headers = {
9021
- "accept": "application/vnd.github+json",
9022
- "user-agent": `home-hosted/${context.version}`,
9023
- "x-github-api-version": "2022-11-28"
9024
- };
9025
- if (context.token !== null && context.token.length > 0) headers.authorization = `Bearer ${context.token}`;
9026
- return headers;
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
- function assetDownloadUrl(asset) {
9029
- const url = asset.url ?? asset.browser_download_url;
9030
- if (url === void 0 || url.length === 0) throw new Error(`the release metadata for "${asset.name ?? "an asset"}" carries no download URL`);
9031
- return url;
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
- function noAssetsMessage(where, skipped, tag) {
9034
- const lines = [`no usable UI assets in ${where} (expected a .zip)`];
9035
- lines.push(skipped.length > 0 ? ` skipped: ${skipped.join(", ")}` : " the release has no assets at all");
9036
- if (tag !== null && tag !== "latest") lines.push(" installing a different version silently is worse than failing: try the newest release with --tag latest");
9037
- return lines.join("\n");
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
- function describeReleaseFailure(status, repo, tag) {
9040
- const slug = `${repo.owner}/${repo.name}`;
9041
- if (status === 404) {
9042
- 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`;
9043
- return `no repository or published release at ${slug}\n check the --repo slug (owner/name), and that the repository is public`;
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 (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`;
9046
- if (status === 401) return "GitHub rejected the token (HTTP 401) — check --token, GITHUB_TOKEN or GH_TOKEN";
9047
- return `GitHub answered HTTP ${status} while reading the release of ${slug}`;
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 describeDownloadFailure(status, url) {
9050
- const github = isGithubHost(url);
9051
- 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}`;
9052
- 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}`;
9053
- if (status === 401 && github) return "GitHub rejected the token on the download (HTTP 401) — check --token, GITHUB_TOKEN or GH_TOKEN";
9054
- return `the download failed (HTTP ${status}) — ${url}`;
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 tooLargeMessage(bytes) {
9057
- return `the download is ${formatBytes(bytes)}, larger than the ${MAX_DOWNLOAD_BYTES / 1024 / 1024}MB a UI may be`;
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 formatBytes(bytes) {
9060
- if (bytes < 1024) return `${bytes} B`;
9061
- if (bytes < 1048576) return `${Math.round(bytes / 1024)} KB`;
9062
- return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
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 describeError(error) {
9065
- return error instanceof Error ? error.message : String(error);
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
- var DEFAULT_REPO, MAX_DOWNLOAD_BYTES, uiSwitchCommand;
9068
- var init_ui_switch = __esmMin((() => {
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
- DEFAULT_REPO = "NamesMT/home-hosted";
9071
- MAX_DOWNLOAD_BYTES = 536870912;
9072
- uiSwitchCommand = defineCommand({
9861
+ init_ui_release();
9862
+ uiUpdateCommand = defineCommand({
9073
9863
  meta: {
9074
- name: "ui-switch",
9075
- description: "install a UI from a release asset, a zip file or a URL"
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 uiSwitch(rawArgs, {
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
- var USAGE = `home-hosted — a control panel for the processes on your home server
9136
-
9137
- Usage
9138
- home-hosted up [options] start it in the background (detached)
9139
- home-hosted down stop it, and everything it supervises
9140
- home-hosted restart [options] down, then up
9141
- home-hosted status [--json] is it running, where, and how to reach it
9142
- home-hosted set-password set the panel password without the API
9143
- home-hosted set-token set the API token that scripts and agents use
9144
- home-hosted migrate bring the config up to this release's schema
9145
- home-hosted init scaffold a project that keeps its state in the repo
9146
- home-hosted ui-switch install a UI from a release asset, a zip file or a URL
9147
- home-hosted ui-revert go back to the stock control panel UI
9148
-
9149
- Options for up/restart
9150
- -c, --config <file> servers config (default: <state>/servers.config.json)
9151
- -p, --port <port> control panel port (default: 3999)
9152
- --host <bind> local | lan | an ipv4 address (default: local)
9153
- --open open the panel in a browser once it is up
9154
- --no-autostart do not start the entries marked autostart
9155
- --foreground run in this process instead of detaching (systemd/docker)
9156
- --print-config print the effective config and exit
9157
-
9158
- Options for set-token
9159
- --generate create a strong token and print it once
9160
- --clear remove the token, so it stops working
9161
-
9162
- Options for migrate
9163
- --dry-run print what would change, write nothing
9164
- -y, --yes apply without asking (or set HHOSTED_MIGRATE=allow)
9165
-
9166
- Options for init
9167
- --dir <dir> where to scaffold (default: ./my-servers)
9168
- --name <name> package name (default: the directory name)
9169
- --pm <manager> pnpm | npm | yarn | bun (default: the first one installed)
9170
- --no-install write the files, install nothing
9171
- -y, --yes take every default, ask nothing
9172
-
9173
- Options for ui-switch
9174
- --repo <owner/name> release repo (default: NamesMT/home-hosted)
9175
- --tag <tag> release tag (default: this release's tag, or latest for another repo)
9176
- --asset <name> asset to install (exact or unambiguous match)
9177
- --file <path|url> install a zip from a local path or an http(s) URL
9178
- --list list the usable assets and install nothing
9179
- --token <token> GitHub token (or GITHUB_TOKEN / GH_TOKEN)
9180
- -y, --yes take the only asset instead of asking
9181
-
9182
- Everywhere
9183
- --home <dir> state directory (default: $HHOSTED_HOME or ~/.home-hosted)
9184
- --project <dir> base for relative entry paths (default: the current directory)
9185
- -h, --help this text
9186
- -v, --version the version
9187
-
9188
- Environment
9189
- HHOSTED_HOME where config, secrets, logs, TLS and backups live
9190
- HHOSTED_PROJECT base for relative entry paths
9191
- HHOSTED_PASSWORD the password for a non-interactive set-password
9192
- HHOSTED_TOKEN the token for a non-interactive set-token
9193
- GITHUB_TOKEN a GitHub token for ui-switch (GH_TOKEN also works)
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