home-hosted 0.5.0 → 0.6.0

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