home-hosted 0.7.17 → 0.7.18

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
@@ -1,9 +1,9 @@
1
- import fs from "node:fs";
2
1
  import process from "node:process";
3
2
  import { fileURLToPath } from "node:url";
4
3
  import { defineCommand, runCommand } from "citty";
5
4
  import path from "node:path";
6
5
  import readline from "node:readline";
6
+ import fs, { createReadStream } from "node:fs";
7
7
  import { exec, execFile, execFileSync, spawn, spawnSync } from "node:child_process";
8
8
  import { Buffer as Buffer$1 } from "node:buffer";
9
9
  import os from "node:os";
@@ -14,6 +14,8 @@ import { isDevelopment } from "std-env";
14
14
  import { DetailedError } from "@namesmt/utils";
15
15
  import { type } from "arktype";
16
16
  import crypto, { X509Certificate, createPrivateKey, createPublicKey, randomBytes } from "node:crypto";
17
+ import { Readable, Writable } from "node:stream";
18
+ import { bodyLimit } from "hono/body-limit";
17
19
  import { streamSSE } from "hono/streaming";
18
20
  import { autoRetry } from "@grammyjs/auto-retry";
19
21
  import { Bot } from "grammy";
@@ -24,7 +26,6 @@ import { Hono } from "hono";
24
26
  import { HTTPException } from "hono/http-exception";
25
27
  import { Scalar } from "@scalar/hono-api-reference";
26
28
  import https from "node:https";
27
- import { Readable, Writable } from "node:stream";
28
29
  import { finished } from "node:stream/promises";
29
30
  import { BlobReader, ZipReader, ZipWriter, configure } from "@zip.js/zip.js";
30
31
  import { serve } from "srvx";
@@ -48,6 +49,44 @@ var __exportAll = (all, no_symbols) => {
48
49
  return target;
49
50
  };
50
51
  //#endregion
52
+ //#region src/cli/nearest.ts
53
+ /**
54
+ * The nearest word to a typo, or null when nothing is close.
55
+ *
56
+ * Two callers need it — a mistyped command and a mistyped option — so it takes its candidate list as
57
+ * a parameter rather than reaching for one. One implementation, so both suggest by the same rule.
58
+ *
59
+ * Levenshtein distance, capped: only a genuinely near miss is worth naming, because suggesting an
60
+ * unrelated word is worse than suggesting none.
61
+ */
62
+ function nearestWord(input, candidates) {
63
+ if (candidates.length === 0) return null;
64
+ const distance = (a, b) => {
65
+ let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
66
+ for (let i = 1; i <= a.length; i += 1) {
67
+ const current = [i];
68
+ for (let j = 1; j <= b.length; j += 1) {
69
+ const substitution = previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1);
70
+ current[j] = Math.min(current[j - 1] + 1, previous[j] + 1, substitution);
71
+ }
72
+ previous = current;
73
+ }
74
+ return previous[b.length];
75
+ };
76
+ const best = candidates.map((name) => ({
77
+ name,
78
+ d: distance(input.toLowerCase(), name.toLowerCase())
79
+ })).sort((left, right) => left.d - right.d || left.name.localeCompare(right.name))[0];
80
+ if (best === void 0) return null;
81
+ const proportional = Math.floor(Math.min(input.length, best.name.length) / 2);
82
+ const allowed = Math.min(proportional, ABSOLUTE_BOUND);
83
+ return best.d <= allowed ? best.name : null;
84
+ }
85
+ var ABSOLUTE_BOUND;
86
+ var init_nearest = __esmMin((() => {
87
+ ABSOLUTE_BOUND = 2;
88
+ }));
89
+ //#endregion
51
90
  //#region src/cli/args.ts
52
91
  /** `--home`/`--project` are handled for every command, so they are peeled off first. */
53
92
  function extractDirFlags(argv) {
@@ -130,15 +169,33 @@ function aliasesOf(def) {
130
169
  if (def === void 0 || !("alias" in def) || def.alias === void 0) return [];
131
170
  return Array.isArray(def.alias) ? def.alias : [def.alias];
132
171
  }
172
+ /**
173
+ * Every spelling that reaches one declared option: the key, its kebab form, each alias, and the
174
+ * `no-` negation citty gives a boolean. `findArg` accepts on this set and the suggestion offers from
175
+ * it, so a suggestion can never name a spelling the parser would then refuse.
176
+ */
177
+ function namesOf$1(key, def) {
178
+ return [
179
+ key,
180
+ kebab(key),
181
+ ...aliasesOf(def)
182
+ ];
183
+ }
184
+ /** The spellings of every option this command declares, for a suggestion. */
185
+ function declaredOptionNames(argsDef) {
186
+ const names = [];
187
+ for (const [key, def] of Object.entries(argsDef)) {
188
+ if (def === void 0 || def.type === "positional") continue;
189
+ names.push(...namesOf$1(key, def));
190
+ if (def.type === "boolean") names.push(...namesOf$1(key, def).map((name) => `no-${name}`));
191
+ }
192
+ return names;
193
+ }
133
194
  function findArg(argsDef, name) {
134
195
  for (const [key, def] of Object.entries(argsDef)) {
135
196
  if (def === void 0) continue;
136
197
  if (def.type === "positional") continue;
137
- if ((/* @__PURE__ */ new Set([
138
- key,
139
- kebab(key),
140
- ...aliasesOf(def)
141
- ])).has(name)) return def;
198
+ if (namesOf$1(key, def).includes(name)) return def;
142
199
  if (def.type === "boolean" && (name === `no-${key}` || name === `no-${kebab(key)}`)) return def;
143
200
  }
144
201
  }
@@ -166,7 +223,10 @@ function rejectUnknownFlags(argv, argsDef) {
166
223
  const equals = body.indexOf("=");
167
224
  const name = equals === -1 ? body : body.slice(0, equals);
168
225
  const def = name.length === 0 ? void 0 : findArg(argsDef, name);
169
- if (def === void 0) return `Unknown option '${token}'`;
226
+ if (def === void 0) {
227
+ const suggested = nearestWord(name, declaredOptionNames(argsDef));
228
+ return `Unknown option '${token}'${suggested === null ? "" : ` — did you mean \`--${suggested}\`?`}`;
229
+ }
170
230
  if (def.type === "string" && equals === -1 && argv[index + 1] === void 0) return `Option '${token}' needs a value`;
171
231
  if (def.type === "string" && equals === -1) index += 1;
172
232
  }
@@ -188,6 +248,7 @@ function buildDaemonArgv(flags) {
188
248
  }
189
249
  var HELP_TOKENS, VERSION_TOKENS, HELP_FLAGS, VERSION_FLAGS, CAMEL;
190
250
  var init_args = __esmMin((() => {
251
+ init_nearest();
191
252
  HELP_TOKENS = /* @__PURE__ */ new Set([
192
253
  "help",
193
254
  "--help",
@@ -306,6 +367,54 @@ var init_runtime = __esmMin((() => {
306
367
  NANNY_COMMAND = "__nanny";
307
368
  }));
308
369
  //#endregion
370
+ //#region src/helpers/version.ts
371
+ var version_exports = /* @__PURE__ */ __exportAll({
372
+ appVersion: () => appVersion,
373
+ versionMismatchNote: () => versionMismatchNote
374
+ });
375
+ /**
376
+ * The running release, read from the package manifest.
377
+ *
378
+ * The caller may be `src/**` under tsx or the built `dist/cli.js`, so the manifest
379
+ * is found by walking up rather than by a fixed relative path, and the name is checked:
380
+ * a nested manifest must not be trusted as this package's.
381
+ *
382
+ * `src/cli.ts` reaches this through a **relative** import, not `#src/*`. That file is the
383
+ * bootstrap and may only pull in modules that load node builtins statically — a `#src`
384
+ * import there would pull the daemon graph in before citty is even asked anything.
385
+ */
386
+ function appVersion() {
387
+ if (cached !== null) return cached;
388
+ let dir = path.dirname(fileURLToPath(import.meta.url));
389
+ for (let depth = 0; depth < 4; depth++) {
390
+ try {
391
+ const manifest = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
392
+ if (manifest.name === "home-hosted" && typeof manifest.version === "string") {
393
+ cached = manifest.version;
394
+ return cached;
395
+ }
396
+ } catch {}
397
+ const parent = path.dirname(dir);
398
+ if (parent === dir) break;
399
+ dir = parent;
400
+ }
401
+ cached = "0.0.0";
402
+ return cached;
403
+ }
404
+ /**
405
+ * The one line to add when this CLI and the panel it is talking to are not the same
406
+ * release — which is what a linked checkout, a global install and a project pin
407
+ * produce. `null` when they agree.
408
+ */
409
+ function versionMismatchNote(panelVersion) {
410
+ const cli = appVersion();
411
+ return cli === panelVersion ? null : `note: this CLI is ${cli}, the panel is ${panelVersion}`;
412
+ }
413
+ var cached;
414
+ var init_version = __esmMin((() => {
415
+ cached = null;
416
+ }));
417
+ //#endregion
309
418
  //#region src/helpers/daemon-log.ts
310
419
  var daemon_log_exports = /* @__PURE__ */ __exportAll({
311
420
  followLog: () => followLog,
@@ -493,50 +602,6 @@ var init_daemon_log = __esmMin((() => {
493
602
  LOG_ROTATE_BYTES = 5242880;
494
603
  }));
495
604
  //#endregion
496
- //#region src/helpers/version.ts
497
- var version_exports = /* @__PURE__ */ __exportAll({
498
- appVersion: () => appVersion,
499
- versionMismatchNote: () => versionMismatchNote
500
- });
501
- /**
502
- * The running release, read from the package manifest.
503
- *
504
- * The caller may be `src/**` under tsx or the built `dist/cli.js`, so the manifest
505
- * is found by walking up rather than by a fixed relative path. `src/cli.ts` keeps
506
- * its own read because it may only import node builtins statically.
507
- */
508
- function appVersion() {
509
- if (cached !== null) return cached;
510
- let dir = path.dirname(fileURLToPath(import.meta.url));
511
- for (let depth = 0; depth < 4; depth++) {
512
- try {
513
- const manifest = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
514
- if (manifest.name === "home-hosted" && typeof manifest.version === "string") {
515
- cached = manifest.version;
516
- return cached;
517
- }
518
- } catch {}
519
- const parent = path.dirname(dir);
520
- if (parent === dir) break;
521
- dir = parent;
522
- }
523
- cached = "0.0.0";
524
- return cached;
525
- }
526
- /**
527
- * The one line to add when this CLI and the panel it is talking to are not the same
528
- * release — which is what a linked checkout, a global install and a project pin
529
- * produce. `null` when they agree.
530
- */
531
- function versionMismatchNote(panelVersion) {
532
- const cli = appVersion();
533
- return cli === panelVersion ? null : `note: this CLI is ${cli}, the panel is ${panelVersion}`;
534
- }
535
- var cached;
536
- var init_version = __esmMin((() => {
537
- cached = null;
538
- }));
539
- //#endregion
540
605
  //#region src/helpers/factory.ts
541
606
  var appFactory;
542
607
  var init_factory = __esmMin((() => {
@@ -631,7 +696,7 @@ function parseBind(value) {
631
696
  const parsed = bindSchema(value);
632
697
  return parsed instanceof type.errors ? null : parsed;
633
698
  }
634
- var bindSchema, workspaceIdSchema, workspaceSchema, workspaceCreateSchema, workspaceRenameSchema, portSchema, onPortConflictSchema, onPortConflictDefaultSchema, restartSchema, httpCheckSchema, resourcesSchema, healthSchema, stopSchema, bootstrapSchema, bootstrapOrNullSchema, logBufferLinesSchema, serverSchema, authSchema, telegramSchema, notificationsSchema, logsSchema, tlsSchema, hostSchema, ddnsRecordTypeSchema, ddnsAccountSchema, ddnsDomainSchema, ddnsIpv4Schema, ddnsIpv6Schema, ddnsConfigSchema, ddnsRecordStateSchema, ddnsRecordViewSchema, ddnsStatusSchema, ddnsProviderFieldSchema, ddnsProviderInfoSchema, ddnsViewSchema, ddnsCredentialsSchema, backupsSchema, controlSchema, defaultsSchema, restartPatchSchema, httpCheckPatchSchema, resourcesPatchSchema, healthPatchSchema, stopPatchSchema, authPatchSchema, telegramPatchSchema, notificationsPatchSchema, hostPatchSchema, backupsPatchSchema, logsPatchSchema, tlsPatchSchema, controlPatchSchema, defaultsPatchSchema, editableFields, serverPatchSchema, serverCreateSchema, nannySpecSchema, nannyExitSchema, nannyStateSchema, settingsPatchSchema, workspaceSettingsPatchSchema, authStatusSchema, tlsStatusSchema, proxyEngineSchema, proxyTargetSchema, proxyTlsModeSchema, proxyRouteSchema, proxyCertificateSchema, proxyCertificateStateSchema, proxyDns01Schema, proxyConfigSchema, proxyEngineSourceSchema, proxyEngineStatusSchema, proxyEngineInfoSchema, proxyRouteStatusSchema, proxyRouteViewSchema, proxyRunStateSchema, proxyStatusSchema, proxyCertificateViewSchema, proxyDnsAccountViewSchema, proxyViewSchema, proxyRoutePatchSchema, proxyPatchSchema, proxyCertificateUploadSchema, proxyEngineInstallSchema, telegramStatusSchema, notificationViewSchema, sessionViewSchema, loginSchema, passwordValueSchema, passwordSchema, serverStatusSchema, healthStateSchema, portStateSchema, logStreamSchema, logLineSchema, historyEventSchema, serverHistorySchema, processResourcesSchema, hostDiskSchema, hostViewSchema, backupFileSchema, backupEntrySchema, backupsViewSchema, restoreItemSchema, restorePlanSchema, backupCreateSchema, restoreRequestSchema, serverViewSchema, controlViewSchema, uiMetaSchema, uiStatusSchema, workspaceViewSchema, appStateSchema, logQuerySchema, freePortResultSchema, logHistoryQuerySchema, logFileInfoSchema, logServerViewSchema, logServersViewSchema, notificationActionSchema, telegramTokenSchema, apiErrorSchema, tlsUploadSchema, settingsViewSchema, settingsSavedSchema, workspaceSettingsViewSchema, workspaceSettingsSavedSchema;
699
+ var bindSchema, workspaceIdSchema, workspaceSchema, workspaceCreateSchema, workspaceRenameSchema, portSchema, onPortConflictSchema, onPortConflictDefaultSchema, restartSchema, httpCheckSchema, resourcesSchema, healthSchema, stopSchema, bootstrapSchema, bootstrapOrNullSchema, labelOrNullSchema, logBufferLinesSchema, serverSchema, authSchema, telegramSchema, notificationsSchema, logsSchema, tlsSchema, hostSchema, ddnsRecordTypeSchema, ddnsAccountSchema, ddnsDomainSchema, ddnsIpv4Schema, ddnsIpv6Schema, ddnsConfigSchema, ddnsRecordStateSchema, ddnsRecordViewSchema, ddnsStatusSchema, ddnsProviderFieldSchema, ddnsProviderInfoSchema, ddnsViewSchema, ddnsCredentialsSchema, backupsSchema, controlSchema, defaultsSchema, restartPatchSchema, httpCheckPatchSchema, resourcesPatchSchema, healthPatchSchema, stopPatchSchema, authPatchSchema, telegramPatchSchema, notificationsPatchSchema, hostPatchSchema, backupsPatchSchema, logsPatchSchema, tlsPatchSchema, controlPatchSchema, defaultsPatchSchema, editableFields, serverPatchSchema, serverCreateSchema, nannySpecSchema, nannyExitSchema, nannyStateSchema, settingsPatchSchema, workspaceSettingsPatchSchema, authStatusSchema, tlsStatusSchema, proxyEngineSchema, proxyTargetSchema, proxyTlsModeSchema, proxyRouteSchema, proxyCertificateSchema, proxyCertificateStateSchema, proxyDns01Schema, proxyConfigSchema, proxyEngineSourceSchema, proxyEngineStatusSchema, proxyEngineInfoSchema, proxyRouteStatusSchema, proxyRouteViewSchema, proxyRunStateSchema, proxyStatusSchema, proxyCertificateViewSchema, proxyDnsAccountViewSchema, proxyViewSchema, proxyRoutePatchSchema, proxyPatchSchema, proxyCertificateUploadSchema, proxyEngineInstallSchema, telegramStatusSchema, telegramChatsSchema, notificationViewSchema, sessionViewSchema, loginSchema, passwordValueSchema, passwordSchema, serverStatusSchema, healthStateSchema, portStateSchema, logStreamSchema, logLineSchema, historyEventSchema, serverHistorySchema, processResourcesSchema, hostDiskSchema, hostViewSchema, backupFileSchema, backupEntrySchema, backupsViewSchema, restoreItemSchema, restorePlanSchema, backupCreateSchema, restoreRequestSchema, serverViewSchema, healthResponseSchema, controlViewSchema, uiMetaSchema, uiStatusSchema, workspaceViewSchema, appStateSchema, logQuerySchema, freePortResultSchema, logHistoryQuerySchema, logFileInfoSchema, logServerViewSchema, logServersViewSchema, notificationActionSchema, telegramTokenSchema, apiErrorSchema, tlsUploadSchema, settingsViewSchema, settingsSavedSchema, workspaceSettingsViewSchema, workspaceSettingsSavedSchema;
635
700
  var init_contracts = __esmMin((() => {
636
701
  bindSchema = type("\"local\" | \"lan\" | /^\\d{1,3}(?:\\.\\d{1,3}){3}$/");
637
702
  workspaceIdSchema = type("/^[a-z0-9][a-z0-9_-]*$/");
@@ -699,10 +764,11 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
699
764
  runOnce: "boolean = true"
700
765
  }).onUndeclaredKey("reject");
701
766
  bootstrapOrNullSchema = bootstrapSchema.or(type("null"));
767
+ labelOrNullSchema = type("string | null");
702
768
  logBufferLinesSchema = type("50 <= number.integer <= 100000");
703
769
  serverSchema = type({
704
770
  id: "/^[a-z0-9][a-z0-9_-]*$/",
705
- label: "string?",
771
+ label: labelOrNullSchema.optional(),
706
772
  enabled: "boolean = true",
707
773
  autostart: "boolean = false",
708
774
  /**
@@ -728,7 +794,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
728
794
  health: healthSchema.default(() => ({})),
729
795
  stop: stopSchema.default(() => ({})),
730
796
  logBufferLines: logBufferLinesSchema.default(() => 500),
731
- /** Ids this server needs running first (and healthy); stopped in reverse order. */
797
+ /** Ids this server needs running first (and healthy); `stop-all` stops dependents before it. */
732
798
  dependsOn: type("string[]").default(() => []),
733
799
  /** Optional KEY=value file loaded at spawn; its values override `env`. */
734
800
  envFile: "string = \"\"",
@@ -743,11 +809,12 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
743
809
  }).onUndeclaredKey("reject");
744
810
  authSchema = type({
745
811
  enabled: "boolean = true",
812
+ /** Idle timeout: a session dies this long after its **last** request, not after login. */
746
813
  sessionTtlMs: "number >= 60000 = 604800000",
747
814
  /** `auto` adds `Secure` when the request arrived over https (proxy-aware). */
748
815
  cookieSecure: "\"auto\" | \"always\" | \"never\" = \"auto\"",
749
816
  /** Trust `x-forwarded-*` from a reverse proxy; also drives the client IP. */
750
- trustProxy: "boolean = false",
817
+ trustProxy: "boolean | \"loopback\" = false",
751
818
  maxLoginAttempts: "number.integer >= 1 = 5",
752
819
  lockoutMs: "number >= 1000 = 60000"
753
820
  }).onUndeclaredKey("reject");
@@ -953,7 +1020,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
953
1020
  enabled: "boolean?",
954
1021
  sessionTtlMs: "number >= 60000?",
955
1022
  cookieSecure: "\"auto\" | \"always\" | \"never\"?",
956
- trustProxy: "boolean?",
1023
+ trustProxy: "boolean | \"loopback\"?",
957
1024
  maxLoginAttempts: "number.integer >= 1?",
958
1025
  lockoutMs: "number >= 1000?"
959
1026
  }).onUndeclaredKey("reject");
@@ -992,7 +1059,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
992
1059
  }).onUndeclaredKey("reject");
993
1060
  tlsPatchSchema = type({ enabled: "boolean?" }).onUndeclaredKey("reject");
994
1061
  controlPatchSchema = type({
995
- label: "1 <= string <= 60?",
1062
+ label: "1 <= string <= 60 | null?",
996
1063
  port: "1 <= number.integer <= 65535?",
997
1064
  host: bindSchema.optional(),
998
1065
  openBrowser: "boolean?",
@@ -1096,7 +1163,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
1096
1163
  blockedReason: "string | null",
1097
1164
  sessionTtlMs: "number",
1098
1165
  cookieSecure: "string",
1099
- trustProxy: "boolean",
1166
+ trustProxy: "boolean | \"loopback\"",
1100
1167
  maxLoginAttempts: "number",
1101
1168
  lockoutMs: "number"
1102
1169
  });
@@ -1301,6 +1368,10 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
1301
1368
  lastResult: "string | null",
1302
1369
  lastResultAt: "number | null"
1303
1370
  });
1371
+ telegramChatsSchema = type({ chats: type({
1372
+ id: "string | number",
1373
+ title: "string"
1374
+ }).array() });
1304
1375
  notificationViewSchema = type({ telegram: telegramStatusSchema });
1305
1376
  sessionViewSchema = type({
1306
1377
  authenticated: "boolean",
@@ -1477,6 +1548,17 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
1477
1548
  "responseMs": "number | null",
1478
1549
  "resources": processResourcesSchema.or(type("null"))
1479
1550
  });
1551
+ healthResponseSchema = type({
1552
+ "status": "\"ok\" | \"degraded\"",
1553
+ "uptimeMs": "number",
1554
+ "servers?": type({
1555
+ total: "number",
1556
+ running: "number",
1557
+ crashed: "number",
1558
+ unhealthy: "number"
1559
+ }),
1560
+ "hostAlerts?": "string[]"
1561
+ });
1480
1562
  controlViewSchema = type({
1481
1563
  /** The configured panel name, for the shell to render. */
1482
1564
  label: "string",
@@ -1574,7 +1656,12 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
1574
1656
  });
1575
1657
  logHistoryQuerySchema = type({
1576
1658
  tail: "string?",
1577
- /** Case-insensitive substring filter over the tail window. */
1659
+ /**
1660
+ * Case-insensitive substring filter. Both this and `stream` are filters, so the route reads a
1661
+ * wider window than `tail` and applies them to that: filtering only the display tail would
1662
+ * report an older match as "no results", and "the last N stderr lines" as whatever happened
1663
+ * to fall in a window sized for all streams. `searched` reports how many lines were read.
1664
+ */
1578
1665
  search: "string?",
1579
1666
  stream: "\"stdout\" | \"stderr\" | \"system\"?"
1580
1667
  });
@@ -1607,10 +1694,11 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
1607
1694
  }).onUndeclaredKey("reject");
1608
1695
  telegramTokenSchema = type({ botToken: "string >= 1" }).onUndeclaredKey("reject");
1609
1696
  apiErrorSchema = type({
1610
- message: "string",
1697
+ "message": "string",
1611
1698
  /** Stable, machine-readable; `AUTH_REQUIRED` also drives the login redirect. */
1612
- code: "string",
1613
- detail: "unknown"
1699
+ "code": "string",
1700
+ /** Structured context when the failure has any — a field list, the routes holding a certificate. */
1701
+ "detail?": "unknown"
1614
1702
  }).onUndeclaredKey("reject");
1615
1703
  tlsUploadSchema = type({
1616
1704
  certificate: "string >= 1",
@@ -1663,6 +1751,10 @@ var init_openapi$1 = __esmMin((() => {
1663
1751
  404: {
1664
1752
  description: "Unknown id",
1665
1753
  content: jsonBody(apiErrorSchema)
1754
+ },
1755
+ 413: {
1756
+ description: "The body is larger than this route accepts",
1757
+ content: jsonBody(apiErrorSchema)
1666
1758
  }
1667
1759
  };
1668
1760
  }));
@@ -1678,6 +1770,7 @@ function validate(target, schema) {
1678
1770
  return validator(target, schema, (result) => {
1679
1771
  if (result.success === false) throw new DetailedError("validation failed", {
1680
1772
  statusCode: 400,
1773
+ code: "INVALID_INPUT",
1681
1774
  detail: normalizeIssues(result.error)
1682
1775
  });
1683
1776
  });
@@ -1712,6 +1805,28 @@ function writeFileAtomic(file, content, options = {}) {
1712
1805
  }
1713
1806
  var init_atomic = __esmMin((() => {}));
1714
1807
  //#endregion
1808
+ //#region src/shared/shape.ts
1809
+ /**
1810
+ * A plain object, and nothing else: not `null`, and not an array.
1811
+ *
1812
+ * Every untyped boundary here narrows to this shape, and it lived as **nine** definitions across
1813
+ * `src/` and the UIs: seven strict (one an inline ternary rather than a named function, which is why
1814
+ * a search for the name found only some of them) and two — both UIs' `lib/api.ts` — that accepted an
1815
+ * array.
1816
+ *
1817
+ * The `!Array.isArray` half is the one that matters: `typeof [] === 'object'` is true, so without
1818
+ * it an array narrows to a record and reading a property off it silently yields `undefined`
1819
+ * instead of failing — and iterating it with `Object.entries` yields the *indices*.
1820
+ *
1821
+ * The two loose copies happened to behave the same on the payloads they see, but only by accident
1822
+ * of which properties they read. They use this one now, so the next reader does not have to work
1823
+ * that out.
1824
+ */
1825
+ function isRecord(value) {
1826
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1827
+ }
1828
+ var init_shape = __esmMin((() => {}));
1829
+ //#endregion
1715
1830
  //#region src/config/secrets.ts
1716
1831
  function deriveKey(password, salt, cost, keylen) {
1717
1832
  return crypto.scryptSync(password.normalize("NFKC"), salt, keylen, { ...cost });
@@ -1760,32 +1875,29 @@ function apiTokenRecord(token, now = Date.now()) {
1760
1875
  updatedAt: now
1761
1876
  };
1762
1877
  }
1763
- function isRecord$4(value) {
1764
- return typeof value === "object" && value !== null && !Array.isArray(value);
1765
- }
1766
1878
  /** A field map: strings only, empty values dropped; anything else reads as absent. */
1767
1879
  function readDdnsValues(value) {
1768
- if (!isRecord$4(value)) return {};
1880
+ if (!isRecord(value)) return {};
1769
1881
  const out = {};
1770
1882
  for (const [key, field] of Object.entries(value)) if (typeof field === "string" && field.length > 0) out[key] = field;
1771
1883
  return out;
1772
1884
  }
1773
1885
  function isSealedDdns(value) {
1774
- return isRecord$4(value) && value.algo === DDNS_SEALED_ALGO && typeof value.iv === "string" && typeof value.tag === "string" && typeof value.data === "string";
1886
+ return isRecord(value) && value.algo === DDNS_SEALED_ALGO && typeof value.iv === "string" && typeof value.tag === "string" && typeof value.data === "string";
1775
1887
  }
1776
1888
  function isPlainDdns(value) {
1777
1889
  return "plain" in value;
1778
1890
  }
1779
1891
  /** Every shape this file has held: sealed, `{ provider, values }`, or a bare field map. */
1780
1892
  function readStoredDdns(value) {
1781
- if (!isRecord$4(value)) return {};
1893
+ if (!isRecord(value)) return {};
1782
1894
  const out = {};
1783
1895
  for (const [accountId, entry] of Object.entries(value)) {
1784
1896
  if (isSealedDdns(entry)) {
1785
1897
  out[accountId] = entry;
1786
1898
  continue;
1787
1899
  }
1788
- if (!isRecord$4(entry)) continue;
1900
+ if (!isRecord(entry)) continue;
1789
1901
  const values = readDdnsValues("values" in entry ? entry.values : entry);
1790
1902
  if (Object.keys(values).length === 0) continue;
1791
1903
  out[accountId] = {
@@ -1797,8 +1909,8 @@ function readStoredDdns(value) {
1797
1909
  return out;
1798
1910
  }
1799
1911
  function readDdnsKdf(value) {
1800
- if (!isRecord$4(value) || value.algo !== "scrypt" || typeof value.salt !== "string") return null;
1801
- const cost = isRecord$4(value.cost) ? value.cost : {};
1912
+ if (!isRecord(value) || value.algo !== "scrypt" || typeof value.salt !== "string") return null;
1913
+ const cost = isRecord(value.cost) ? value.cost : {};
1802
1914
  const N = Number(cost.N);
1803
1915
  const r = Number(cost.r);
1804
1916
  const p = Number(cost.p);
@@ -1858,6 +1970,7 @@ var COST, KEYLEN, SALT_BYTES, DDNS_KEYLEN, DDNS_IV_BYTES, DDNS_AAD_PREFIX, DDNS_
1858
1970
  var init_secrets = __esmMin((() => {
1859
1971
  init_atomic();
1860
1972
  init_logger();
1973
+ init_shape();
1861
1974
  COST = {
1862
1975
  N: 16384,
1863
1976
  r: 8,
@@ -1878,6 +1991,7 @@ var init_secrets = __esmMin((() => {
1878
1991
  cacheKey = "";
1879
1992
  ddnsKeyCache = null;
1880
1993
  warnedAccounts = /* @__PURE__ */ new Set();
1994
+ warnedDefaultSecret = false;
1881
1995
  ddnsSecret;
1882
1996
  constructor(file, ddnsSecret, scope = "all") {
1883
1997
  this.file = file;
@@ -1885,6 +1999,16 @@ var init_secrets = __esmMin((() => {
1885
1999
  const candidate = (ddnsSecret ?? process.env.HHOSTED_DDNS_SECRET ?? "").trim();
1886
2000
  this.ddnsSecret = candidate.length > 0 ? candidate : "hh";
1887
2001
  }
2002
+ /**
2003
+ * Whether the DDNS credentials are sealed under the built-in key.
2004
+ *
2005
+ * Sealing is what stops a leaked *file* — a backup, a committed state directory — from handing over
2006
+ * the DNS credentials. Under the default the key is the literal `hh`, so anyone holding the file can
2007
+ * open it. The docs say so; this is the same fact for code that wants to say so at runtime.
2008
+ */
2009
+ get usingDefaultDdnsSecret() {
2010
+ return this.ddnsSecret === "hh";
2011
+ }
1888
2012
  get path() {
1889
2013
  return this.file;
1890
2014
  }
@@ -2108,6 +2232,10 @@ var init_secrets = __esmMin((() => {
2108
2232
  sealStored(contents) {
2109
2233
  const plain = Object.entries(contents.ddns).filter((entry) => isPlainDdns(entry[1]));
2110
2234
  if (plain.length === 0) return contents;
2235
+ if (this.usingDefaultDdnsSecret && !this.warnedDefaultSecret) {
2236
+ this.warnedDefaultSecret = true;
2237
+ logger.warn(`ddns: credentials are sealed under the built-in key — set HHOSTED_DDNS_SECRET to protect them if this state directory is ever copied or committed (docs/DDNS.md)`);
2238
+ }
2111
2239
  const kdf = contents.ddnsKdf ?? newDdnsKdf();
2112
2240
  const key = this.ddnsKey(kdf);
2113
2241
  const ddns = { ...contents.ddns };
@@ -2300,7 +2428,6 @@ var init_auth$1 = __esmMin((() => {
2300
2428
  const token = crypto.randomBytes(32).toString("base64url");
2301
2429
  this.sessions.set(token, {
2302
2430
  token,
2303
- createdAt: now,
2304
2431
  expiresAt: now + config.sessionTtlMs,
2305
2432
  lastSeenAt: now,
2306
2433
  ip
@@ -2499,7 +2626,7 @@ function checkExposure(control, passwordSet, usingDefaultPassword = false) {
2499
2626
  */
2500
2627
  function checkProxyExposure(proxy, authEnabled, passwordSet, usingDefaultPassword = false) {
2501
2628
  if (!proxy.enabled) return null;
2502
- const panelRoutes = proxy.routes.filter((route) => route.enabled && route.target === "panel" && route.tls !== "off");
2629
+ const panelRoutes = proxy.routes.filter((route) => route.enabled && route.target === "panel");
2503
2630
  if (panelRoutes.length === 0) return null;
2504
2631
  const hosts = panelRoutes.map((route) => route.host).join(", ");
2505
2632
  if (!authEnabled && !passwordSet) return `the reverse proxy would serve the control panel at ${hosts} but authentication is disabled and no password is set`;
@@ -2508,6 +2635,23 @@ function checkProxyExposure(proxy, authEnabled, passwordSet, usingDefaultPasswor
2508
2635
  if (usingDefaultPassword) return `the reverse proxy would serve the control panel at ${hosts} but it still uses the default password — change it first`;
2509
2636
  return null;
2510
2637
  }
2638
+ /**
2639
+ * A warning for the combination that makes the login lockout advisory.
2640
+ *
2641
+ * `trustProxy: true` tells srvx to trust `x-forwarded-*` from **any** peer, and it resolves the client
2642
+ * address from the leftmost entry of `x-forwarded-for` — a value the caller controls. Login failures are
2643
+ * keyed on that address, so a client that rotates the header never accumulates them: measured, 30 wrong
2644
+ * passwords from 30 forged addresses produced **zero** 429s, against 27 from a fixed address.
2645
+ *
2646
+ * This is a warning rather than a refusal because `true` has legitimate uses — a proxy on another host —
2647
+ * and, once the header is trusted, the real peer is not reachable through srvx (`#remoteAddress` is
2648
+ * private), so the panel cannot re-key the lockout on it. Prefer `host: local` with the proxy on this
2649
+ * machine, or leave `trustProxy` off and reach the panel directly.
2650
+ */
2651
+ function proxyTrustWarning(control) {
2652
+ if (control.auth.trustProxy !== true || !isExposed(control.host)) return null;
2653
+ return `auth.trustProxy is on while the panel is bound to ${control.host}: the client address comes from the caller's own x-forwarded-for header, so the login lockout (maxLoginAttempts/lockoutMs) counts per forged address and can be bypassed. Keep the bind on local with the proxy on this machine, or put an authenticating gateway in front`;
2654
+ }
2511
2655
  var init_exposure = __esmMin((() => {
2512
2656
  init_bind();
2513
2657
  }));
@@ -2671,10 +2815,8 @@ var paths_exports = /* @__PURE__ */ __exportAll({
2671
2815
  DEFAULT_WORKSPACE_ID: () => DEFAULT_WORKSPACE_ID,
2672
2816
  daemonLogPath: () => daemonLogPath,
2673
2817
  dataRoot: () => dataRoot,
2674
- defaultBackupsDirName: () => defaultBackupsDirName,
2675
2818
  globalSecretsPath: () => globalSecretsPath,
2676
2819
  globalSettingsPath: () => globalSettingsPath,
2677
- globalSettingsSchemaPath: () => globalSettingsSchemaPath,
2678
2820
  hasLegacyLayout: () => hasLegacyLayout,
2679
2821
  hhDir: () => hhDir,
2680
2822
  legacy: () => legacy,
@@ -2701,12 +2843,9 @@ var paths_exports = /* @__PURE__ */ __exportAll({
2701
2843
  workspaceLogsDir: () => workspaceLogsDir,
2702
2844
  workspaceSecretsPath: () => workspaceSecretsPath,
2703
2845
  workspaceServersPath: () => workspaceServersPath,
2704
- workspaceServersSchemaPath: () => workspaceServersSchemaPath,
2705
2846
  workspaceSettingsPath: () => workspaceSettingsPath,
2706
- workspaceSettingsSchemaPath: () => workspaceSettingsSchemaPath,
2707
2847
  workspaceStateDir: () => workspaceStateDir,
2708
- workspacesPath: () => workspacesPath,
2709
- workspacesSchemaPath: () => workspacesSchemaPath
2848
+ workspacesPath: () => workspacesPath
2710
2849
  });
2711
2850
  /**
2712
2851
  * Where home-hosted keeps everything it owns. `HHOSTED_HOME` overrides it —
@@ -2741,16 +2880,10 @@ function workspaceDir(id) {
2741
2880
  function workspaceSettingsPath(id) {
2742
2881
  return path.join(workspaceDir(id), "settings.json");
2743
2882
  }
2744
- function workspaceSettingsSchemaPath(id) {
2745
- return path.join(workspaceDir(id), "settings.schema.json");
2746
- }
2747
2883
  /** The servers a workspace supervises. */
2748
2884
  function workspaceServersPath(id) {
2749
2885
  return path.join(workspaceDir(id), "servers.config.json");
2750
2886
  }
2751
- function workspaceServersSchemaPath(id) {
2752
- return path.join(workspaceDir(id), "servers.config.schema.json");
2753
- }
2754
2887
  /** Workspace secrets: Telegram bot token and DDNS credentials. Mode 0600. */
2755
2888
  function workspaceSecretsPath(id) {
2756
2889
  return path.join(workspaceDir(id), ".secrets.json");
@@ -2791,19 +2924,16 @@ function resolveUserPath(target, base = projectDir) {
2791
2924
  else if (value.startsWith("~/") || value.startsWith("~\\")) value = path.join(os.homedir(), value.slice(2));
2792
2925
  return path.isAbsolute(value) ? value : path.resolve(base, value);
2793
2926
  }
2794
- var dataRoot, projectDir, hhDir, DEFAULT_WORKSPACE_ID, globalSettingsPath, globalSettingsSchemaPath, globalSecretsPath, workspacesPath, workspacesSchemaPath, tlsDir, defaultBackupsDirName, daemonLogPath, runtimePath, uiDir, proxyDir, proxyBinDir, proxyEngineDir, proxyConfigPath, proxyPreviousConfigPath, proxyStateDir, proxyAdminPath, proxyTlsDir, proxyChallengeAuthPath, legacy;
2927
+ var dataRoot, projectDir, hhDir, DEFAULT_WORKSPACE_ID, globalSettingsPath, globalSecretsPath, workspacesPath, tlsDir, daemonLogPath, runtimePath, uiDir, proxyDir, proxyBinDir, proxyEngineDir, proxyConfigPath, proxyPreviousConfigPath, proxyStateDir, proxyAdminPath, proxyTlsDir, proxyChallengeAuthPath, legacy;
2795
2928
  var init_paths = __esmMin((() => {
2796
2929
  dataRoot = resolveDataRoot();
2797
2930
  projectDir = resolveProjectDir();
2798
2931
  hhDir = path.join(dataRoot, ".hh");
2799
2932
  DEFAULT_WORKSPACE_ID = "default";
2800
2933
  globalSettingsPath = path.join(hhDir, "settings.json");
2801
- globalSettingsSchemaPath = path.join(hhDir, "settings.schema.json");
2802
2934
  globalSecretsPath = path.join(hhDir, ".control-secrets.json");
2803
2935
  workspacesPath = path.join(hhDir, "workspaces.json");
2804
- workspacesSchemaPath = path.join(hhDir, "workspaces.schema.json");
2805
2936
  tlsDir = path.join(hhDir, ".tls");
2806
- defaultBackupsDirName = ".backups";
2807
2937
  daemonLogPath = path.join(hhDir, ".logs", "home-hosted.log");
2808
2938
  runtimePath = path.join(hhDir, "run.json");
2809
2939
  uiDir = path.join(hhDir, ".ui");
@@ -2936,14 +3066,14 @@ function createBackupsRoute(deps) {
2936
3066
  200: { description: "the archive (application/zip)" },
2937
3067
  404: ERROR_RESPONSES[404]
2938
3068
  }
2939
- }), validate("param", nameParam), (c) => {
3069
+ }), validate("param", nameParam), async (c) => {
2940
3070
  const file = deps.backups.resolve(c.req.valid("param").name);
2941
3071
  if (file === null) throw new DetailedError("unknown backup", {
2942
3072
  statusCode: 404,
2943
3073
  code: "UNKNOWN_BACKUP"
2944
3074
  });
2945
- const stats = fs.statSync(file);
2946
- return c.body(fs.readFileSync(file), 200, {
3075
+ const stats = await fs.promises.stat(file);
3076
+ return c.body(Readable.toWeb(createReadStream(file)), 200, {
2947
3077
  "Content-Type": "application/zip",
2948
3078
  "Content-Length": String(stats.size),
2949
3079
  "Content-Disposition": `attachment; filename="${path.basename(file)}"`
@@ -2964,92 +3094,115 @@ function createBackupsRoute(deps) {
2964
3094
  ok: true,
2965
3095
  files: deps.backups.list()
2966
3096
  });
2967
- }).post("/backups/restore", describeRoute({
2968
- tags: ["backups"],
2969
- summary: "Plan or apply a restore from a stored or uploaded archive",
2970
- responses: {
2971
- 200: {
2972
- description: "The plan",
2973
- content: jsonBody(restorePlanSchema)
2974
- },
2975
- 400: ERROR_RESPONSES[400],
2976
- 404: ERROR_RESPONSES[404]
2977
- }
2978
- }), async (c) => {
2979
- const confirm = c.req.query("confirm") === "true";
2980
- const contentType = c.req.header("content-type") ?? "";
2981
- let archive = null;
2982
- let uploadedTo = null;
2983
- let request;
2984
- try {
2985
- if (contentType.includes("multipart/form-data")) {
2986
- const declared = Number.parseInt(c.req.header("content-length") ?? "0", 10);
2987
- if (Number.isFinite(declared) && declared > MAX_UPLOAD_BYTES) throw new DetailedError(`the upload is larger than ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)}MB`, {
2988
- statusCode: 413,
2989
- code: "UPLOAD_TOO_LARGE"
2990
- });
2991
- const body = await c.req.parseBody();
2992
- const file = body.file;
2993
- if (!(file instanceof File)) throw new DetailedError("expected a `file` field with the archive", {
2994
- statusCode: 400,
2995
- code: "MISSING_FILE"
2996
- });
2997
- if (file.size > MAX_UPLOAD_BYTES) throw new DetailedError(`the upload is larger than ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)}MB`, {
3097
+ }).post(
3098
+ "/backups/restore",
3099
+ describeRoute({
3100
+ tags: ["backups"],
3101
+ summary: "Plan or apply a restore from a stored or uploaded archive",
3102
+ responses: {
3103
+ 200: {
3104
+ description: "The plan",
3105
+ content: jsonBody(restorePlanSchema)
3106
+ },
3107
+ 400: ERROR_RESPONSES[400],
3108
+ 404: ERROR_RESPONSES[404],
3109
+ 413: ERROR_RESPONSES[413]
3110
+ }
3111
+ }),
3112
+ /**
3113
+ * Bound the body **while it arrives**, not after.
3114
+ *
3115
+ * The handler's own checks read `Content-Length` first — a header a chunked request simply does
3116
+ * not send — and then compare `file.size` after `parseBody()` has already buffered the whole
3117
+ * body into memory. So an upload with no length is buffered in full and *then* refused: the
3118
+ * ceiling was a memory limit rather than a guard. This middleware counts chunks and aborts at the
3119
+ * cap, which is the half the header check cannot do.
3120
+ */
3121
+ bodyLimit({
3122
+ maxSize: MAX_UPLOAD_BYTES,
3123
+ onError: () => {
3124
+ throw new DetailedError(`the upload is larger than ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)}MB`, {
2998
3125
  statusCode: 413,
2999
3126
  code: "UPLOAD_TOO_LARGE"
3000
3127
  });
3001
- const uploads = path.join(deps.backups.directory, "uploads");
3002
- fs.mkdirSync(uploads, { recursive: true });
3003
- uploadedTo = path.join(uploads, `upload-${crypto.randomUUID()}.zip`);
3004
- fs.writeFileSync(uploadedTo, Buffer$1.from(await file.arrayBuffer()), { mode: 384 });
3005
- archive = uploadedTo;
3006
- const rawInclude = typeof body.include === "string" && body.include.length > 0 ? body.include : null;
3007
- let include;
3008
- if (rawInclude !== null) try {
3009
- include = JSON.parse(rawInclude);
3010
- } catch {
3011
- throw new DetailedError("`include` must be a JSON array of item ids", {
3128
+ }
3129
+ }),
3130
+ async (c) => {
3131
+ const confirm = c.req.query("confirm") === "true";
3132
+ const contentType = c.req.header("content-type") ?? "";
3133
+ let archive = null;
3134
+ let uploadedTo = null;
3135
+ let request;
3136
+ try {
3137
+ if (contentType.includes("multipart/form-data")) {
3138
+ const declared = Number.parseInt(c.req.header("content-length") ?? "0", 10);
3139
+ if (Number.isFinite(declared) && declared > MAX_UPLOAD_BYTES) throw new DetailedError(`the upload is larger than ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)}MB`, {
3140
+ statusCode: 413,
3141
+ code: "UPLOAD_TOO_LARGE"
3142
+ });
3143
+ const body = await c.req.parseBody();
3144
+ const file = body.file;
3145
+ if (!(file instanceof File)) throw new DetailedError("expected a `file` field with the archive", {
3012
3146
  statusCode: 400,
3013
- code: "INVALID_INCLUDE"
3147
+ code: "MISSING_FILE"
3148
+ });
3149
+ if (file.size > MAX_UPLOAD_BYTES) throw new DetailedError(`the upload is larger than ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)}MB`, {
3150
+ statusCode: 413,
3151
+ code: "UPLOAD_TOO_LARGE"
3152
+ });
3153
+ const uploads = path.join(deps.backups.directory, "uploads");
3154
+ fs.mkdirSync(uploads, { recursive: true });
3155
+ uploadedTo = path.join(uploads, `upload-${crypto.randomUUID()}.zip`);
3156
+ fs.writeFileSync(uploadedTo, Buffer$1.from(await file.arrayBuffer()), { mode: 384 });
3157
+ archive = uploadedTo;
3158
+ const rawInclude = typeof body.include === "string" && body.include.length > 0 ? body.include : null;
3159
+ let include;
3160
+ if (rawInclude !== null) try {
3161
+ include = JSON.parse(rawInclude);
3162
+ } catch {
3163
+ throw new DetailedError("`include` must be a JSON array of item ids", {
3164
+ statusCode: 400,
3165
+ code: "INVALID_INCLUDE"
3166
+ });
3167
+ }
3168
+ request = parseOrThrow(restoreRequestSchema, {
3169
+ ...typeof body.password === "string" ? { password: body.password } : {},
3170
+ ...rawInclude === null ? {} : { include }
3171
+ }, "body");
3172
+ } else {
3173
+ request = parseOrThrow(restoreRequestSchema, await c.req.json().catch(() => ({})), "body");
3174
+ if (request.name === void 0) throw new DetailedError("expected a backup name or a file upload", {
3175
+ statusCode: 400,
3176
+ code: "MISSING_ARCHIVE"
3177
+ });
3178
+ archive = deps.backups.resolve(request.name);
3179
+ if (archive === null) throw new DetailedError("unknown backup", {
3180
+ statusCode: 404,
3181
+ code: "UNKNOWN_BACKUP"
3014
3182
  });
3015
3183
  }
3016
- request = parseOrThrow(restoreRequestSchema, {
3017
- ...typeof body.password === "string" ? { password: body.password } : {},
3018
- ...rawInclude === null ? {} : { include }
3019
- }, "body");
3020
- } else {
3021
- request = parseOrThrow(restoreRequestSchema, await c.req.json().catch(() => ({})), "body");
3022
- if (request.name === void 0) throw new DetailedError("expected a backup name or a file upload", {
3023
- statusCode: 400,
3024
- code: "MISSING_ARCHIVE"
3184
+ const plan = await deps.backups.restore(archive, {
3185
+ confirm,
3186
+ password: request.password,
3187
+ include: request.include
3025
3188
  });
3026
- archive = deps.backups.resolve(request.name);
3027
- if (archive === null) throw new DetailedError("unknown backup", {
3028
- statusCode: 404,
3029
- code: "UNKNOWN_BACKUP"
3189
+ if (plan.needsPassword) return c.json(plan);
3190
+ if (plan.error !== void 0) throw new DetailedError(plan.error, {
3191
+ statusCode: 400,
3192
+ code: "RESTORE_FAILED",
3193
+ detail: {
3194
+ items: plan.items,
3195
+ applied: plan.applied,
3196
+ skipped: plan.skipped
3197
+ }
3030
3198
  });
3199
+ if (confirm) logger.info(`restored from ${path.basename(archive)}: ${plan.applied.join(", ")}`);
3200
+ return c.json(plan);
3201
+ } finally {
3202
+ if (uploadedTo !== null) fs.rmSync(uploadedTo, { force: true });
3031
3203
  }
3032
- const plan = await deps.backups.restore(archive, {
3033
- confirm,
3034
- password: request.password,
3035
- include: request.include
3036
- });
3037
- if (plan.needsPassword) return c.json(plan);
3038
- if (plan.error !== void 0) throw new DetailedError(plan.error, {
3039
- statusCode: 400,
3040
- code: "RESTORE_FAILED",
3041
- detail: {
3042
- items: plan.items,
3043
- applied: plan.applied,
3044
- skipped: plan.skipped
3045
- }
3046
- });
3047
- if (confirm) logger.info(`restored from ${path.basename(archive)}: ${plan.applied.join(", ")}`);
3048
- return c.json(plan);
3049
- } finally {
3050
- if (uploadedTo !== null) fs.rmSync(uploadedTo, { force: true });
3051
3204
  }
3052
- });
3205
+ );
3053
3206
  }
3054
3207
  var MAX_UPLOAD_BYTES, nameParam;
3055
3208
  var init_backups$1 = __esmMin((() => {
@@ -3065,12 +3218,37 @@ var init_backups$1 = __esmMin((() => {
3065
3218
  }));
3066
3219
  //#endregion
3067
3220
  //#region src/helpers/action-result.ts
3221
+ /** The one shape a missing entry takes, so the marker and the message cannot drift apart. */
3222
+ function missingServer(id) {
3223
+ return {
3224
+ ok: false,
3225
+ error: `unknown server "${id}"`,
3226
+ [UNKNOWN_SERVER]: true
3227
+ };
3228
+ }
3229
+ /**
3230
+ * The same failure where the caller throws instead of answering — the route side of `missingServer()`.
3231
+ *
3232
+ * `api/logs.ts` and `api/servers/$.routes.ts` each carried a byte-identical private copy of this, with
3233
+ * six call sites between them. Two copies of a wire contract (the message, the 404, the `UNKNOWN_SERVER`
3234
+ * code) is the drift that already happened once for `request`: a change landing in one UI and not the
3235
+ * other. Defining it beside the message keeps one owner for the wording.
3236
+ */
3237
+ function unknownServerError(id) {
3238
+ return new DetailedError(`unknown server "${id}"`, {
3239
+ statusCode: 404,
3240
+ code: "UNKNOWN_SERVER"
3241
+ });
3242
+ }
3068
3243
  /** Unknown ids are 404; a server that exists but cannot act is a 409. */
3069
3244
  function statusForAction(result) {
3070
3245
  if (result.ok) return 200;
3071
- return result.error?.startsWith("unknown server") ? 404 : 409;
3246
+ return result[UNKNOWN_SERVER] === true ? 404 : 409;
3072
3247
  }
3073
- var init_action_result = __esmMin((() => {}));
3248
+ var UNKNOWN_SERVER;
3249
+ var init_action_result = __esmMin((() => {
3250
+ UNKNOWN_SERVER = Symbol("hh.unknownServer");
3251
+ }));
3074
3252
  //#endregion
3075
3253
  //#region src/helpers/deferred.ts
3076
3254
  /**
@@ -3088,10 +3266,28 @@ function afterResponse(task, onError) {
3088
3266
  }
3089
3267
  var init_deferred = __esmMin((() => {}));
3090
3268
  //#endregion
3091
- //#region src/config/schema.ts
3092
- function isRecord$3(value) {
3093
- return typeof value === "object" && value !== null && !Array.isArray(value);
3269
+ //#region src/helpers/secret-compare.ts
3270
+ /**
3271
+ * Compares two secrets without leaking their contents through timing.
3272
+ *
3273
+ * `a !== b` short-circuits at the first differing byte, so an attacker who can measure response time
3274
+ * recovers a secret one byte at a time. That matters wherever the secret is the *only* thing standing
3275
+ * between a caller and a privileged action — the `/_hh` control channel, which is deliberately outside
3276
+ * `/api` and guarded by nothing else.
3277
+ *
3278
+ * Length is compared first because `timingSafeEqual` **throws** on differing lengths. That leaks only
3279
+ * the length, which is not secret here (the token has a fixed shape and a `hh_` prefix); rejecting it
3280
+ * is what keeps a truncated header from becoming a 500.
3281
+ */
3282
+ function secretEqual(actual, expected) {
3283
+ const left = Buffer$1.from(actual, "utf8");
3284
+ const right = Buffer$1.from(expected, "utf8");
3285
+ if (left.length !== right.length || left.length === 0) return false;
3286
+ return crypto.timingSafeEqual(left, right);
3094
3287
  }
3288
+ var init_secret_compare = __esmMin((() => {}));
3289
+ //#endregion
3290
+ //#region src/config/schema.ts
3095
3291
  /**
3096
3292
  * Resolves one entry against the workspace's server defaults. A group (`restart`,
3097
3293
  * `health`, `health.http`, `stop`) merges key by key, so an entry that decides one
@@ -3103,13 +3299,14 @@ function mergeDefaults(defaults, entry) {
3103
3299
  for (const [key, value] of Object.entries(defaults)) {
3104
3300
  const current = merged[key];
3105
3301
  if (current === void 0) merged[key] = value;
3106
- else if (isRecord$3(value) && isRecord$3(current)) merged[key] = mergeDefaults(value, current);
3302
+ else if (isRecord(value) && isRecord(current)) merged[key] = mergeDefaults(value, current);
3107
3303
  }
3108
3304
  return merged;
3109
3305
  }
3110
3306
  var metaSchema, globalSettingsSchema, workspaceSettingsSchema, serversFileSchema, GLOBAL_SETTINGS_KEYS, WORKSPACE_SETTINGS_KEYS, SERVERS_FILE_KEYS;
3111
3307
  var init_schema = __esmMin((() => {
3112
3308
  init_contracts();
3309
+ init_shape();
3113
3310
  metaSchema = type({
3114
3311
  writtenBy: "string = \"\"",
3115
3312
  schema: "number.integer >= 1 = 1"
@@ -3185,6 +3382,11 @@ var init_workspaces = __esmMin((() => {
3185
3382
  }).onUndeclaredKey("reject");
3186
3383
  WorkspaceError = class extends Error {
3187
3384
  name = "WorkspaceError";
3385
+ code;
3386
+ constructor(message, code) {
3387
+ super(message);
3388
+ this.code = code;
3389
+ }
3188
3390
  };
3189
3391
  seed = () => [{
3190
3392
  id: DEFAULT_WORKSPACE_ID,
@@ -3255,14 +3457,14 @@ var init_workspaces = __esmMin((() => {
3255
3457
  label: label.length > 0 ? label : id
3256
3458
  });
3257
3459
  if (validated instanceof type.errors) throw new WorkspaceError(validated.summary);
3258
- if (this.has(id)) throw new WorkspaceError(`workspace "${id}" already exists`);
3460
+ if (this.has(id)) throw new WorkspaceError(`workspace "${id}" already exists`, "WORKSPACE_EXISTS");
3259
3461
  const next = [...this.list, validated];
3260
3462
  this.commit(next);
3261
3463
  return validated;
3262
3464
  }
3263
3465
  rename(id, label) {
3264
3466
  const index = this.list.findIndex((entry) => entry.id === id);
3265
- if (index < 0) throw new WorkspaceError(`unknown workspace "${id}"`);
3467
+ if (index < 0) throw new WorkspaceError(`unknown workspace "${id}"`, "UNKNOWN_WORKSPACE");
3266
3468
  const validated = workspaceSchema({
3267
3469
  id,
3268
3470
  label: label.trim()
@@ -3275,7 +3477,7 @@ var init_workspaces = __esmMin((() => {
3275
3477
  }
3276
3478
  remove(id) {
3277
3479
  const entry = this.get(id);
3278
- if (entry === void 0) throw new WorkspaceError(`unknown workspace "${id}"`);
3480
+ if (entry === void 0) throw new WorkspaceError(`unknown workspace "${id}"`, "UNKNOWN_WORKSPACE");
3279
3481
  if (this.list.length <= 1) throw new WorkspaceError("cannot remove the only workspace");
3280
3482
  this.commit(this.list.filter((candidate) => candidate.id !== id));
3281
3483
  return entry;
@@ -3336,7 +3538,7 @@ function createControlRoute(deps) {
3336
3538
  /** The one place the local channel's credentials are read. */
3337
3539
  function assertLocalCall(c, action) {
3338
3540
  const token = c.req.header("x-home-hosted-token");
3339
- if (token === void 0 || token !== deps.runtimeToken) throw new DetailedError("invalid token", {
3541
+ if (token === void 0 || !secretEqual(token, deps.runtimeToken)) throw new DetailedError("invalid token", {
3340
3542
  statusCode: 403,
3341
3543
  code: "INVALID_TOKEN"
3342
3544
  });
@@ -3399,6 +3601,7 @@ var init_control = __esmMin((() => {
3399
3601
  init_deferred();
3400
3602
  init_factory();
3401
3603
  init_logger();
3604
+ init_secret_compare();
3402
3605
  init_validator();
3403
3606
  init_workspace();
3404
3607
  init_loopback();
@@ -3408,11 +3611,13 @@ var init_control = __esmMin((() => {
3408
3611
  /** What would have to run to bring `from` up to `to`. */
3409
3612
  function planConfigMigrations(from, options = {}) {
3410
3613
  const to = options.to ?? 1;
3614
+ const steps = (options.migrations ?? configMigrations).filter((migration) => migration.to > from && migration.to <= to).sort((a, b) => a.to - b.to);
3411
3615
  return {
3412
3616
  from,
3413
3617
  to,
3414
- steps: (options.migrations ?? configMigrations).filter((migration) => migration.to > from && migration.to <= to).sort((a, b) => a.to - b.to),
3415
- tooNew: from > to
3618
+ steps,
3619
+ tooNew: from > to,
3620
+ reached: steps.reduce((at, step) => step.to, from)
3416
3621
  };
3417
3622
  }
3418
3623
  /** Applies the plan in order; the caller owns writing the result. */
@@ -3431,18 +3636,15 @@ var init_migrations = __esmMin((() => {
3431
3636
  }));
3432
3637
  //#endregion
3433
3638
  //#region src/config/parse.ts
3434
- function isRecord$2(value) {
3435
- return typeof value === "object" && value !== null && !Array.isArray(value);
3436
- }
3437
3639
  function deleteAtPath(root, path) {
3438
3640
  let node = root;
3439
3641
  for (const key of path.slice(0, -1)) {
3440
- node = Array.isArray(node) ? node[Number(key)] : isRecord$2(node) ? node[key] : void 0;
3642
+ node = Array.isArray(node) ? node[Number(key)] : isRecord(node) ? node[key] : void 0;
3441
3643
  if (node === void 0) return;
3442
3644
  }
3443
3645
  const last = path[path.length - 1];
3444
3646
  if (last === void 0) return;
3445
- if (isRecord$2(node)) delete node[String(last)];
3647
+ if (isRecord(node)) delete node[String(last)];
3446
3648
  else if (Array.isArray(node) && typeof last === "number") node.splice(last, 1);
3447
3649
  }
3448
3650
  /**
@@ -3467,7 +3669,7 @@ function parseTolerant(value, schema, prefix, unknownKeys) {
3467
3669
  value: null,
3468
3670
  error: parsed.summary
3469
3671
  };
3470
- if (!isRecord$2(candidate)) return {
3672
+ if (!isRecord(candidate)) return {
3471
3673
  value: null,
3472
3674
  error: parsed.summary
3473
3675
  };
@@ -3497,11 +3699,11 @@ function parseGrouped(raw, label, definition, options) {
3497
3699
  schemaVersion: 1,
3498
3700
  writtenBy: null
3499
3701
  };
3500
- if (!isRecord$2(raw)) {
3702
+ if (!isRecord(raw)) {
3501
3703
  errors.push(`the ${label} must contain a JSON object`);
3502
3704
  return result;
3503
3705
  }
3504
- const meta = isRecord$2(raw.meta) ? raw.meta : null;
3706
+ const meta = isRecord(raw.meta) ? raw.meta : null;
3505
3707
  const schemaVersion = typeof meta?.schema === "number" ? meta.schema : 1;
3506
3708
  const writtenBy = typeof meta?.writtenBy === "string" && meta.writtenBy.length > 0 ? meta.writtenBy : null;
3507
3709
  result.schemaVersion = schemaVersion;
@@ -3583,11 +3785,11 @@ function parseServersFile(raw, defaults, options = {}) {
3583
3785
  schemaVersion: 1,
3584
3786
  writtenBy: null
3585
3787
  };
3586
- if (!isRecord$2(raw)) {
3788
+ if (!isRecord(raw)) {
3587
3789
  errors.push("the servers config must contain a JSON object");
3588
3790
  return result;
3589
3791
  }
3590
- const meta = isRecord$2(raw.meta) ? raw.meta : null;
3792
+ const meta = isRecord(raw.meta) ? raw.meta : null;
3591
3793
  const schemaVersion = typeof meta?.schema === "number" ? meta.schema : 1;
3592
3794
  const writtenBy = typeof meta?.writtenBy === "string" && meta.writtenBy.length > 0 ? meta.writtenBy : null;
3593
3795
  result.schemaVersion = schemaVersion;
@@ -3605,9 +3807,9 @@ function parseServersFile(raw, defaults, options = {}) {
3605
3807
  const seen = /* @__PURE__ */ new Set();
3606
3808
  (Array.isArray(raw.servers) ? raw.servers : []).forEach((entry, index) => {
3607
3809
  const label = `servers[${index}]`;
3608
- const parsed = parseTolerant(isRecord$2(entry) ? mergeDefaults(defaults, entry) : entry, serverSchema, label, unknownKeys);
3810
+ const parsed = parseTolerant(isRecord(entry) ? mergeDefaults(defaults, entry) : entry, serverSchema, label, unknownKeys);
3609
3811
  if (parsed.error !== null) {
3610
- const id = isRecord$2(entry) ? entry.id : void 0;
3812
+ const id = isRecord(entry) ? entry.id : void 0;
3611
3813
  errors.push(`${label}${typeof id === "string" ? ` ("${id}")` : ""}: ${parsed.error}`);
3612
3814
  return;
3613
3815
  }
@@ -3617,20 +3819,31 @@ function parseServersFile(raw, defaults, options = {}) {
3617
3819
  return;
3618
3820
  }
3619
3821
  seen.add(server.id);
3822
+ const { label: entryLabel, ...rest } = server;
3620
3823
  result.servers.push({
3621
- ...server,
3622
- port: server.port ?? null
3824
+ ...rest,
3825
+ port: server.port ?? null,
3826
+ ...entryLabel === null || entryLabel === void 0 ? {} : { label: entryLabel }
3623
3827
  });
3624
3828
  });
3625
- warnings.push(...validateDependencies(result.servers));
3829
+ warnings.push(...validateCrossField(result.servers));
3626
3830
  return result;
3627
3831
  }
3628
- /** Dangling dependencies and cycles are reported, not fatal: supervision still runs. */
3629
- function validateDependencies(servers) {
3832
+ /**
3833
+ * Config that validates field-by-field but is wrong as a whole.
3834
+ *
3835
+ * Warned rather than refused, because supervision still runs and a hard failure would take a working
3836
+ * panel down over a setting one field away from correct.
3837
+ */
3838
+ function validateCrossField(servers) {
3630
3839
  const ids = new Set(servers.map((server) => server.id));
3631
3840
  const errors = [];
3632
- for (const server of servers) for (const dependency of server.dependsOn) if (dependency === server.id) errors.push(`"${server.id}" depends on itself`);
3633
- else if (!ids.has(dependency)) errors.push(`"${server.id}" depends on unknown server "${dependency}"`);
3841
+ for (const server of servers) {
3842
+ const { health } = server;
3843
+ if (health.mode === "http" && health.http.method === "HEAD" && health.http.expectBody.length > 0) errors.push(`"${server.id}" requires a response body with method HEAD, which has none — the body check is ignored`);
3844
+ for (const dependency of server.dependsOn) if (dependency === server.id) errors.push(`"${server.id}" depends on itself`);
3845
+ else if (!ids.has(dependency)) errors.push(`"${server.id}" depends on unknown server "${dependency}"`);
3846
+ }
3634
3847
  const visiting = /* @__PURE__ */ new Set();
3635
3848
  const settled = /* @__PURE__ */ new Set();
3636
3849
  const byId = new Map(servers.map((server) => [server.id, server]));
@@ -3665,20 +3878,22 @@ var init_parse = __esmMin((() => {
3665
3878
  init_migrations();
3666
3879
  init_schema();
3667
3880
  init_version();
3881
+ init_shape();
3668
3882
  UNDECLARED = "must be removed";
3669
3883
  }));
3670
3884
  //#endregion
3671
3885
  //#region src/config/patch.ts
3672
- function isRecord$1(value) {
3673
- return typeof value === "object" && value !== null && !Array.isArray(value);
3674
- }
3675
3886
  function applyPatch(target, patch, mergeKeys) {
3676
3887
  for (const [key, value] of Object.entries(patch)) {
3677
3888
  if (value === void 0) continue;
3678
- if (mergeKeys.has(key) && isRecord$1(value) && isRecord$1(target[key])) {
3889
+ if (mergeKeys.has(key) && isRecord(value) && isRecord(target[key])) {
3679
3890
  target[key] = mergeGroup(target[key], value);
3680
3891
  continue;
3681
3892
  }
3893
+ if (value === null) {
3894
+ delete target[key];
3895
+ continue;
3896
+ }
3682
3897
  target[key] = value;
3683
3898
  }
3684
3899
  }
@@ -3696,7 +3911,7 @@ function mergeGroup(target, patch) {
3696
3911
  delete merged[key];
3697
3912
  continue;
3698
3913
  }
3699
- if (isRecord$1(value) && isRecord$1(merged[key])) {
3914
+ if (isRecord(value) && isRecord(merged[key])) {
3700
3915
  merged[key] = mergeGroup(merged[key], value);
3701
3916
  continue;
3702
3917
  }
@@ -3706,6 +3921,7 @@ function mergeGroup(target, patch) {
3706
3921
  }
3707
3922
  var SERVER_MERGE_KEYS, CONTROL_MERGE_KEYS, NOTIFICATION_MERGE_KEYS, DDNS_MERGE_KEYS, PROXY_MERGE_KEYS, EMPTY_MERGE_KEYS;
3708
3923
  var init_patch = __esmMin((() => {
3924
+ init_shape();
3709
3925
  SERVER_MERGE_KEYS = /* @__PURE__ */ new Set([
3710
3926
  "restart",
3711
3927
  "health",
@@ -3756,6 +3972,16 @@ var init_settings$1 = __esmMin((() => {
3756
3972
  init_paths();
3757
3973
  ConfigError = class extends Error {
3758
3974
  name = "ConfigError";
3975
+ /**
3976
+ * The *kind* of config problem, when a caller has to branch on it rather than just report it.
3977
+ * `UNKNOWN_SERVER` is the one case today: the servers route answers 404 for a missing id and 400
3978
+ * for an invalid patch, and it decided that by matching the message text.
3979
+ */
3980
+ code;
3981
+ constructor(message, code) {
3982
+ super(message);
3983
+ this.code = code;
3984
+ }
3759
3985
  };
3760
3986
  GlobalSettingsStore = class {
3761
3987
  file;
@@ -4054,12 +4280,22 @@ async function call$1(path, headers, context, init = {}) {
4054
4280
  * Walks the host's parents through `GET /zones?name=`, which is how the
4055
4281
  * reference image resolves a zone without a pasted zone id; the id is cached in
4056
4282
  * the panel's state file, so it usually costs nothing.
4283
+ *
4284
+ * A cached id is **confirmed before use**, the same way `findRecord` treats its own. It was returned
4285
+ * outright, so a stale id — the zone deleted, the API token swapped to another account, a restored
4286
+ * `ddns.json` — made every later call address a zone the token cannot see, and the walk that would have
4287
+ * found the right one never ran: the update failed permanently, with the provider's error for a zone
4288
+ * that is not even the host's. One extra request per run, and only when a cache exists.
4057
4289
  */
4058
4290
  async function resolveZone(host, headers, context, cached) {
4059
- if (cached !== void 0) return {
4060
- id: cached,
4061
- name: host
4062
- };
4291
+ if (cached !== void 0) {
4292
+ const confirmed = await call$1(`/zones/${cached}`, headers, context);
4293
+ if (confirmed.error === null && confirmed.data !== null) return {
4294
+ id: cached,
4295
+ name: host
4296
+ };
4297
+ if (confirmed.status === 0) throw new Error(confirmed.error ?? "could not reach Cloudflare");
4298
+ }
4063
4299
  const labels = host.split(".");
4064
4300
  for (let index = 0; index <= labels.length - 2; index++) {
4065
4301
  const candidate = labels.slice(index).join(".");
@@ -5381,6 +5617,8 @@ function validateDdnsConfig(config) {
5381
5617
  errors.push(`"${domain.host}" points at unknown account "${domain.account}"`);
5382
5618
  continue;
5383
5619
  }
5620
+ const zone = domain.zone?.trim().toLowerCase();
5621
+ if (zone !== void 0 && zone.length > 0 && domain.host.toLowerCase() !== zone && !domain.host.toLowerCase().endsWith(`.${zone}`)) errors.push(`"${domain.host}" has zone "${domain.zone}", which it is not inside — a zone is the registered domain the record lives in`);
5384
5622
  const provider = ddnsProvider(account.provider);
5385
5623
  if (provider !== null) for (const recordType of domain.types) {
5386
5624
  if (!provider.families.includes(recordType)) errors.push(`${account.label || provider.label} cannot manage ${recordType} records ("${domain.host}")`);
@@ -5661,7 +5899,7 @@ var init_store = __esmMin((() => {
5661
5899
  }
5662
5900
  updateServer(id, patch) {
5663
5901
  const index = (this.rawServers.servers ?? []).findIndex((entry) => entry.id === id);
5664
- if (index < 0) throw new ConfigError(`unknown server "${id}"`);
5902
+ if (index < 0) throw new ConfigError(`unknown server "${id}"`, "UNKNOWN_SERVER");
5665
5903
  const draft = structuredClone(this.rawServers);
5666
5904
  const entry = draft.servers[index];
5667
5905
  applyPatch(entry, patch, SERVER_MERGE_KEYS);
@@ -5723,7 +5961,7 @@ var init_store = __esmMin((() => {
5723
5961
  const list = draft.servers ?? [];
5724
5962
  const before = list.length;
5725
5963
  draft.servers = list.filter((entry) => entry.id !== id);
5726
- if (draft.servers.length === before) throw new ConfigError(`unknown server "${id}"`);
5964
+ if (draft.servers.length === before) throw new ConfigError(`unknown server "${id}"`, "UNKNOWN_SERVER");
5727
5965
  this.commitServers(draft);
5728
5966
  }
5729
5967
  /**
@@ -5945,20 +6183,28 @@ var init_ddns$1 = __esmMin((() => {
5945
6183
  accountParam = type({ id: "/^[a-z0-9][a-z0-9_-]*$/" });
5946
6184
  }));
5947
6185
  //#endregion
5948
- //#region src/services/events.ts
6186
+ //#region src/shared/server-key.ts
5949
6187
  /**
5950
- * Fan-out for SSE subscribers, optionally scoped to one server.
6188
+ * The SSE bucket key: one server inside one workspace.
6189
+ *
6190
+ * A server id is only unique within its workspace, so anything keyed by id alone mixes frames between
6191
+ * same-named servers — and this key was **three separate definitions**: `services/events.ts` (which
6192
+ * dispatches) and both UIs' composables (which subscribe). Byte-identical today, but a change to the
6193
+ * format on either side would route messages to the wrong bucket or to none, silently — the panel
6194
+ * would simply stop updating, with nothing to error on.
5951
6195
  *
5952
- * The scope is `workspaceId/serverId`, never the bare id: server ids are only
5953
- * unique *within* a workspace, so keying on the id alone would deliver one
5954
- * workspace's log lines to a subscriber watching another workspace's server of
5955
- * the same name.
6196
+ * It lives in `src/shared` rather than a UI because this module imports no Vue and is reachable from
6197
+ * the server and both UIs alike.
5956
6198
  */
5957
6199
  function serverKey(workspaceId, serverId) {
5958
6200
  return `${workspaceId}/${serverId}`;
5959
6201
  }
6202
+ var init_server_key = __esmMin((() => {}));
6203
+ //#endregion
6204
+ //#region src/services/events.ts
5960
6205
  var ALL, EventHub;
5961
6206
  var init_events$1 = __esmMin((() => {
6207
+ init_server_key();
5962
6208
  ALL = "*";
5963
6209
  EventHub = class {
5964
6210
  listeners = /* @__PURE__ */ new Map();
@@ -5976,11 +6222,6 @@ var init_events$1 = __esmMin((() => {
5976
6222
  this.dispatch(ALL, message);
5977
6223
  if (message.serverId && message.workspaceId) this.dispatch(serverKey(message.workspaceId, message.serverId), message);
5978
6224
  }
5979
- get subscriberCount() {
5980
- let total = 0;
5981
- for (const bucket of this.listeners.values()) total += bucket.size;
5982
- return total;
5983
- }
5984
6225
  dispatch(key, message) {
5985
6226
  const bucket = this.listeners.get(key);
5986
6227
  if (!bucket) return;
@@ -6084,17 +6325,7 @@ function createHealthRoute(deps) {
6084
6325
  responses: {
6085
6326
  200: {
6086
6327
  description: "Serving",
6087
- content: jsonBody(type({
6088
- "status": "\"ok\" | \"degraded\"",
6089
- "uptimeMs": "number",
6090
- "servers?": type({
6091
- total: "number",
6092
- running: "number",
6093
- crashed: "number",
6094
- unhealthy: "number"
6095
- }),
6096
- "hostAlerts?": "string[]"
6097
- }))
6328
+ content: jsonBody(healthResponseSchema)
6098
6329
  },
6099
6330
  503: { description: "An autostart server has crashed" }
6100
6331
  }
@@ -6122,15 +6353,10 @@ var init_health = __esmMin((() => {
6122
6353
  init_factory();
6123
6354
  init_openapi$1();
6124
6355
  init_auth();
6356
+ init_contracts();
6125
6357
  }));
6126
6358
  //#endregion
6127
6359
  //#region src/api/logs.ts
6128
- function unknownServer$1(id) {
6129
- return new DetailedError(`unknown server "${id}"`, {
6130
- statusCode: 404,
6131
- code: "UNKNOWN_SERVER"
6132
- });
6133
- }
6134
6360
  function createLogsRoute(deps) {
6135
6361
  return appFactory.createApp().get("/logs", describeRoute({
6136
6362
  tags: ["logs"],
@@ -6157,15 +6383,24 @@ function createLogsRoute(deps) {
6157
6383
  }), validate("query", historyQuery), validate("param", idParam$2), (c) => {
6158
6384
  const runtime = requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace);
6159
6385
  const { id } = c.req.valid("param");
6160
- if (!runtime.store.getServer(id)) throw unknownServer$1(id);
6386
+ if (!runtime.store.getServer(id)) throw unknownServerError(id);
6161
6387
  const query = c.req.valid("query");
6162
6388
  const requested = query.tail === void 0 ? DEFAULT_TAIL : Number.parseInt(query.tail, 10);
6163
6389
  const tail = Number.isNaN(requested) ? DEFAULT_TAIL : Math.min(Math.max(requested, MIN_TAIL), MAX_TAIL);
6164
6390
  const info = runtime.logFiles.info(id);
6165
6391
  const search = query.search?.trim() ?? "";
6166
- const window = search.length > 0 ? Math.max(tail, MAX_TAIL) : tail;
6392
+ const stream = query.stream ?? "";
6393
+ /**
6394
+ * A filter that keeps only *some* of the lines has to read a wider window than the
6395
+ * display tail, or "the last 500 stderr lines" silently means "the stderr lines among
6396
+ * the last 500 overall" — 50 of them, in a log where every tenth line is stderr, and
6397
+ * 500 exist. `search` already widened for exactly this reason; `stream` is the same
6398
+ * shape of filter and did not, so it was short whenever the two streams interleave.
6399
+ */
6400
+ const window = search.length > 0 || stream.length > 0 ? Math.max(tail, MAX_TAIL) : tail;
6167
6401
  let lines = runtime.logFiles.readTail(id, window);
6168
- if (query.stream !== void 0 && query.stream.length > 0) lines = lines.filter((line) => line.stream === query.stream);
6402
+ if (stream.length > 0) lines = lines.filter((line) => line.stream === stream);
6403
+ const windowLines = lines.length;
6169
6404
  if (search.length > 0) {
6170
6405
  const needle = search.toLowerCase();
6171
6406
  lines = lines.filter((line) => line.text.toLowerCase().includes(needle));
@@ -6175,7 +6410,7 @@ function createLogsRoute(deps) {
6175
6410
  enabled: info.enabled,
6176
6411
  sizeBytes: info.sizeBytes,
6177
6412
  files: info.files.map((file) => file.name),
6178
- searched: search.length > 0 ? window : null,
6413
+ searched: search.length > 0 ? windowLines : null,
6179
6414
  lines: lines.slice(-tail)
6180
6415
  });
6181
6416
  }).get("/logs/:id/download", describeRoute({
@@ -6188,7 +6423,7 @@ function createLogsRoute(deps) {
6188
6423
  }), validate("query", downloadQuery), validate("param", idParam$2), (c) => {
6189
6424
  const runtime = requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace);
6190
6425
  const { id } = c.req.valid("param");
6191
- if (!runtime.store.getServer(id)) throw unknownServer$1(id);
6426
+ if (!runtime.store.getServer(id)) throw unknownServerError(id);
6192
6427
  const requested = c.req.valid("query").file ?? `${id}.log`;
6193
6428
  if (!runtime.logFiles.info(id).files.map((file) => file.name).includes(requested)) throw new DetailedError("unknown log file", {
6194
6429
  statusCode: 404,
@@ -6211,13 +6446,14 @@ function createLogsRoute(deps) {
6211
6446
  }), validate("query", workspaceQuerySchema), validate("param", idParam$2), (c) => {
6212
6447
  const runtime = requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace);
6213
6448
  const { id } = c.req.valid("param");
6214
- if (!runtime.store.getServer(id)) throw unknownServer$1(id);
6449
+ if (!runtime.store.getServer(id)) throw unknownServerError(id);
6215
6450
  runtime.logFiles.clear(id);
6216
6451
  return c.json({ ok: true });
6217
6452
  });
6218
6453
  }
6219
6454
  var MIN_TAIL, MAX_TAIL, DEFAULT_TAIL, idParam$2, downloadQuery, historyQuery;
6220
6455
  var init_logs$1 = __esmMin((() => {
6456
+ init_action_result();
6221
6457
  init_factory();
6222
6458
  init_openapi$1();
6223
6459
  init_validator();
@@ -6445,10 +6681,7 @@ function createNotificationsRoute(deps) {
6445
6681
  responses: {
6446
6682
  200: {
6447
6683
  description: "Chats",
6448
- content: jsonBody(type({ chats: type({
6449
- id: "string | number",
6450
- title: "string"
6451
- }).array() }))
6684
+ content: jsonBody(telegramChatsSchema)
6452
6685
  },
6453
6686
  400: ERROR_RESPONSES[400]
6454
6687
  }
@@ -6836,19 +7069,35 @@ function sameArg(a, b) {
6836
7069
  const normalize = (value) => comparable(value).replace(/["\\]/g, "");
6837
7070
  return normalize(a) === normalize(b);
6838
7071
  }
6839
- /** The same file spelled differently (`node`, `node.exe`, a relative path) compares equal. */
7072
+ /** Whether a value names a file with no directory part, under either platform's separator. */
7073
+ function isBare(value) {
7074
+ return !value.includes("/") && !value.includes("\\");
7075
+ }
7076
+ /**
7077
+ * The same file spelled differently compares equal — but only while one of the two spellings is a
7078
+ * bare name, which is the case the fallback exists for: the config says `node` and the process table
7079
+ * reports `/usr/bin/node`, or the reverse on Windows.
7080
+ *
7081
+ * When **both** spellings name a directory, they are two different files. Comparing basenames
7082
+ * unconditionally made `/tmp/evil/server.js` match `/srv/web/server.js`, and a false match here is
7083
+ * not cosmetic: the panel would treat a process it never spawned as its own entry, so `follow` adopts
7084
+ * a stranger and `reclaim`/`kill` may remove one.
7085
+ */
6840
7086
  function sameWord(a, b) {
6841
7087
  const left = comparable(a);
6842
7088
  const right = comparable(b);
6843
- if (left === right || path.basename(left) === path.basename(right)) return true;
6844
- return path.extname(b) === "" && path.basename(left, path.extname(left)) === right;
7089
+ if (left === right) return true;
7090
+ if (!isBare(left) && !isBare(right)) return false;
7091
+ return path.basename(left) === path.basename(right) || path.extname(b) === "" && path.basename(left, path.extname(left)) === right || path.extname(a) === "" && path.basename(right, path.extname(right)) === left;
6845
7092
  }
6846
7093
  /**
6847
7094
  * True when the argv a process is running is the entry's own: the image must match where
6848
7095
  * `spawn` would have looked it up — the image Path, or the first word of the command line
6849
7096
  * — and the words after it must open with the entry's args, compared literally. So
6850
7097
  * `spawn --port 4000` also covers `spawn -p 4000 --extra`, which is what a self-restarting
6851
- * wrapper does, while `/tmp/evil/server.js` never covers `/srv/web/server.js`.
7098
+ * wrapper does, while `/tmp/evil/server.js` never covers `/srv/web/server.js` — two paths that name
7099
+ * a directory are different files, and the basename fallback applies only while one spelling is bare
7100
+ * (see `sameWord`).
6852
7101
  *
6853
7102
  * `words` must already be the process's own argv. A command-line *string* is only correct
6854
7103
  * on Windows, where the OS hands one out; `/proc/<pid>/cmdline` quotes are literal bytes
@@ -7036,13 +7285,20 @@ async function killPortHolders(port, exclude) {
7036
7285
  } catch {}
7037
7286
  return pids;
7038
7287
  }
7039
- /** Signal 0 asks the OS whether the pid still exists, without touching it. */
7040
- function isProcessAlive$1(pid) {
7288
+ /**
7289
+ * Signal 0 asks the OS whether the pid still exists, without touching it.
7290
+ *
7291
+ * `EPERM` means the process is there but is **not ours to signal** — a port holder owned by another
7292
+ * user is exactly that. Returning `false` for it made `nannyIsAlive` report a live nanny as gone, so
7293
+ * `resumePersistent` consumed its state file and started a second copy of the entry while the first ran
7294
+ * on untracked. `helpers/daemon.ts` already had this right; the two predicates now agree.
7295
+ */
7296
+ function isProcessAlive(pid) {
7041
7297
  try {
7042
7298
  process.kill(pid, 0);
7043
7299
  return true;
7044
- } catch {
7045
- return false;
7300
+ } catch (error) {
7301
+ return error.code === "EPERM";
7046
7302
  }
7047
7303
  }
7048
7304
  function signalPid$1(pid, name) {
@@ -7060,13 +7316,13 @@ function delay$2(ms) {
7060
7316
  */
7061
7317
  async function terminatePids(pids, options = {}) {
7062
7318
  const graceMs = options.graceMs ?? 3e3;
7063
- const aliveBefore = [...new Set(pids)].filter(isProcessAlive$1);
7319
+ const aliveBefore = [...new Set(pids)].filter(isProcessAlive);
7064
7320
  for (const pid of aliveBefore) signalPid$1(pid, "SIGTERM");
7065
7321
  const deadline = Date.now() + graceMs;
7066
- let alive = aliveBefore.filter(isProcessAlive$1);
7322
+ let alive = aliveBefore.filter(isProcessAlive);
7067
7323
  while (alive.length > 0 && Date.now() < deadline) {
7068
7324
  await delay$2(100);
7069
- alive = alive.filter(isProcessAlive$1);
7325
+ alive = alive.filter(isProcessAlive);
7070
7326
  }
7071
7327
  const stopped = aliveBefore.filter((pid) => !alive.includes(pid));
7072
7328
  for (const pid of alive) signalPid$1(pid, "SIGKILL");
@@ -7117,6 +7373,14 @@ async function listPortHolders(port) {
7117
7373
  return [];
7118
7374
  }
7119
7375
  }
7376
+ /**
7377
+ * Pids from `lsof -ti` (one per line) or `fuser` (space-separated, on one line, with a leading
7378
+ * space) — both shapes arrive here, which is why the split is on `\s+` rather than `\n`.
7379
+ *
7380
+ * Exported for its test: this feeds `killPortHolders`, which sends SIGKILL, so a parser that
7381
+ * picked up the wrong number would kill an unrelated process. The filter is not decorative —
7382
+ * `Number.parseInt('')` is NaN for the empty leading cell, and a pid of 0 or our own is dropped.
7383
+ */
7120
7384
  function parsePids(stdout) {
7121
7385
  return [...new Set(stdout.split(/\s+/).map((entry) => Number.parseInt(entry, 10)).filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid))];
7122
7386
  }
@@ -7219,9 +7483,34 @@ function writeNannyState(file, state) {
7219
7483
  function clearNannyState(file) {
7220
7484
  fs.rmSync(file, { force: true });
7221
7485
  }
7222
- /** The same wording the supervisor uses for a child's exit, so history reads alike. */
7486
+ /**
7487
+ * The same wording the supervisor uses for a child's exit, so history reads alike — plus a hint
7488
+ * when the code has a conventional meaning, because `code 137` alone tells an operator nothing and
7489
+ * that is the one they most want explained (a container limit or the kernel OOM killer).
7490
+ *
7491
+ * The hint is worded as a possibility, never a claim: 128+N is a shell convention, and a program
7492
+ * is free to exit 137 for its own reasons — a JVM reports OOM that way itself. 126 and 127 are
7493
+ * firmer, since the shell sets those when it cannot run the command at all.
7494
+ */
7223
7495
  function describeNannyExit(exit) {
7224
- return exit.signal !== null ? `signal ${exit.signal}` : `code ${exit.code}`;
7496
+ if (exit.signal !== null) return `signal ${exit.signal}`;
7497
+ const hint = exitCodeHint(exit.code);
7498
+ return hint === null ? `code ${exit.code}` : `code ${exit.code} (${hint})`;
7499
+ }
7500
+ /** What a conventional exit code usually means, or null when there is nothing to add. */
7501
+ function exitCodeHint(code) {
7502
+ if (code === null || code === 0) return null;
7503
+ if (code === 126) return "the command is not executable";
7504
+ if (code === 127) return "the command was not found";
7505
+ if (code <= 128 || code > 192) return null;
7506
+ const signal = signalName(code - 128);
7507
+ if (signal === null) return null;
7508
+ return code === 137 ? `${signal} — which the kernel sends when memory runs out` : `${signal}, if the shell reported the kill`;
7509
+ }
7510
+ /** `SIGKILL` for 9, or null when the number names nothing. */
7511
+ function signalName(number) {
7512
+ for (const [name, value] of Object.entries(os.constants.signals)) if (value === number) return name;
7513
+ return null;
7225
7514
  }
7226
7515
  /**
7227
7516
  * Is the nanny this state describes still ours? A live pid proves nothing on its
@@ -7230,7 +7519,7 @@ function describeNannyExit(exit) {
7230
7519
  * everywhere else, Windows included.
7231
7520
  */
7232
7521
  async function nannyIsAlive(state, id) {
7233
- if (state.nannyPid <= 0 || !isProcessAlive$1(state.nannyPid)) return false;
7522
+ if (state.nannyPid <= 0 || !isProcessAlive(state.nannyPid)) return false;
7234
7523
  if (Date.now() - state.heartbeatAt <= 3e4) return true;
7235
7524
  if (await processCarriesServerId(state.nannyPid, id)) return true;
7236
7525
  const words = await processArgv(state.nannyPid);
@@ -7460,8 +7749,8 @@ function matchFor(route) {
7460
7749
  const match = { host: [route.host] };
7461
7750
  const prefix = route.path.trim();
7462
7751
  if (prefix.length > 0) {
7463
- const base = prefix.startsWith("/") ? prefix : `/${prefix}`;
7464
- match.path = base === "/" ? ["/*"] : [base, `${base.replace(/\/$/, "")}/*`];
7752
+ const base = (prefix.startsWith("/") ? prefix : `/${prefix}`).replace(/\/+$/, "");
7753
+ match.path = base.length === 0 ? ["/*"] : [base, `${base}/*`];
7465
7754
  }
7466
7755
  return match;
7467
7756
  }
@@ -7590,6 +7879,24 @@ var init_proxy_config = __esmMin((() => {
7590
7879
  }));
7591
7880
  //#endregion
7592
7881
  //#region src/services/tls.ts
7882
+ /** Whether the certificate's validity window covers now. Both ends, from one place. */
7883
+ function validityError(x509, now = Date.now()) {
7884
+ if (new Date(x509.validFrom).getTime() > now) return `the certificate is not valid until ${x509.validFrom}`;
7885
+ if (new Date(x509.validTo).getTime() < now) return `the certificate expired on ${x509.validTo}`;
7886
+ return null;
7887
+ }
7888
+ /** Whether the private key is the one that certificate was issued for. */
7889
+ function publicKeysMatch(certificate, privateKey) {
7890
+ const fromKey = createPublicKey(createPrivateKey(privateKey)).export({
7891
+ type: "spki",
7892
+ format: "der"
7893
+ });
7894
+ const fromCert = new X509Certificate(certificate).publicKey.export({
7895
+ type: "spki",
7896
+ format: "der"
7897
+ });
7898
+ return Buffer$1.from(fromKey).equals(Buffer$1.from(fromCert));
7899
+ }
7593
7900
  /** Checks the certificate parses, is time-valid, and matches the private key. */
7594
7901
  function validatePair(certificate, privateKey) {
7595
7902
  let x509;
@@ -7602,16 +7909,7 @@ function validatePair(certificate, privateKey) {
7602
7909
  };
7603
7910
  }
7604
7911
  try {
7605
- const key = createPrivateKey(privateKey);
7606
- const fromKey = createPublicKey(key).export({
7607
- type: "spki",
7608
- format: "der"
7609
- });
7610
- const fromCert = x509.publicKey.export({
7611
- type: "spki",
7612
- format: "der"
7613
- });
7614
- if (!Buffer$1.from(fromKey).equals(Buffer$1.from(fromCert))) return {
7912
+ if (!publicKeysMatch(certificate, privateKey)) return {
7615
7913
  ok: false,
7616
7914
  error: "the private key does not match the certificate"
7617
7915
  };
@@ -7621,15 +7919,17 @@ function validatePair(certificate, privateKey) {
7621
7919
  error: `private key is not a valid PEM: ${error instanceof Error ? error.message : String(error)}`
7622
7920
  };
7623
7921
  }
7624
- if (new Date(x509.validTo).getTime() < Date.now()) return {
7922
+ const invalid = validityError(x509);
7923
+ if (invalid !== null) return {
7625
7924
  ok: false,
7626
- error: `the certificate expired on ${x509.validTo}`
7925
+ error: invalid
7627
7926
  };
7628
7927
  return { ok: true };
7629
7928
  }
7630
7929
  var TlsStore;
7631
7930
  var init_tls$1 = __esmMin((() => {
7632
7931
  init_atomic();
7932
+ init_logger();
7633
7933
  TlsStore = class {
7634
7934
  dir;
7635
7935
  name;
@@ -7690,6 +7990,25 @@ var init_tls$1 = __esmMin((() => {
7690
7990
  this.cachedMtime = "";
7691
7991
  return { ok: true };
7692
7992
  }
7993
+ /**
7994
+ * The pair to serve, or `null` when there is nothing servable.
7995
+ *
7996
+ * `load()` returns whatever is on disk, and `serve({ tls })` **throws synchronously** on a pair that
7997
+ * does not match (`ERR_OSSL_X509_KEY_VALUES_MISMATCH`) — before `listen()`'s `ready()`/`error` race
7998
+ * can catch it. A mismatched pair therefore fails the reboot: hand-copied files, a restore, or a
7999
+ * crash between `save()`'s two writes all reach it, and the panel reports an OpenSSL code instead of
8000
+ * booting on http or naming the problem. Validated here, where the answer is still recoverable.
8001
+ */
8002
+ servable() {
8003
+ const pair = this.load();
8004
+ if (pair === null) return null;
8005
+ const validation = validatePair(pair.cert, pair.key);
8006
+ if (!validation.ok) {
8007
+ logger.warn(`the stored TLS pair will not be served: ${validation.error ?? "it is not usable"}`);
8008
+ return null;
8009
+ }
8010
+ return pair;
8011
+ }
7693
8012
  clear() {
7694
8013
  for (const file of [this.certPath, this.keyPath]) try {
7695
8014
  fs.rmSync(file, { force: true });
@@ -7722,6 +8041,7 @@ var init_tls$1 = __esmMin((() => {
7722
8041
  const x509 = new X509Certificate(pair.cert);
7723
8042
  const validTo = new Date(x509.validTo);
7724
8043
  const daysRemaining = Math.floor((validTo.getTime() - Date.now()) / 864e5);
8044
+ const keyMatches = publicKeysMatch(pair.cert, pair.key);
7725
8045
  return {
7726
8046
  ...base,
7727
8047
  subject: x509.subject.replace(/\n/g, ", "),
@@ -7730,8 +8050,8 @@ var init_tls$1 = __esmMin((() => {
7730
8050
  validTo: validTo.toISOString(),
7731
8051
  daysRemaining,
7732
8052
  fingerprint: x509.fingerprint256,
7733
- keyMatches: validatePair(pair.cert, pair.key).ok,
7734
- error: daysRemaining < 0 ? "the certificate has expired" : null
8053
+ keyMatches,
8054
+ error: validityError(x509) ?? (keyMatches ? null : "the private key does not match the certificate")
7735
8055
  };
7736
8056
  } catch (error) {
7737
8057
  return {
@@ -7743,6 +8063,30 @@ var init_tls$1 = __esmMin((() => {
7743
8063
  };
7744
8064
  }));
7745
8065
  //#endregion
8066
+ //#region src/shared/proxy-form.ts
8067
+ /** A name an ACME CA could issue for; anything else gets the engine's own CA. */
8068
+ function isPublicHost(host) {
8069
+ const name = host.trim().toLowerCase();
8070
+ if (name.length === 0 || name === "localhost" || !name.includes(".")) return false;
8071
+ if (LOCAL_TLDS.some((tld) => name.endsWith(tld))) return false;
8072
+ return !/^\d{1,3}(?:\.\d{1,3}){3,}$/.test(name);
8073
+ }
8074
+ var LOCAL_TLDS;
8075
+ var init_proxy_form = __esmMin((() => {
8076
+ init_contracts();
8077
+ LOCAL_TLDS = [
8078
+ ".localhost",
8079
+ ".local",
8080
+ ".internal",
8081
+ ".home.arpa",
8082
+ ".lan",
8083
+ ".home",
8084
+ ".test",
8085
+ ".invalid",
8086
+ ".example"
8087
+ ];
8088
+ }));
8089
+ //#endregion
7746
8090
  //#region src/services/proxy.ts
7747
8091
  /** The DNS names a certificate covers, from its SANs (and its subject as a fallback). */
7748
8092
  function namesOf(x509) {
@@ -7784,7 +8128,9 @@ function retryWindowMinutes(lifetimeMs) {
7784
8128
  function parseUpstream(value) {
7785
8129
  const trimmed = value.trim();
7786
8130
  if (trimmed.length === 0) return null;
7787
- const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
8131
+ const explicit = /^([a-z][a-z0-9+.-]*):\/\//i.exec(trimmed);
8132
+ if (explicit !== null && !/^https?$/i.test(explicit[1])) return null;
8133
+ const withScheme = explicit === null ? `http://${trimmed}` : trimmed;
7788
8134
  try {
7789
8135
  const url = new URL(withScheme);
7790
8136
  const port = url.port.length > 0 ? url.port : url.protocol === "https:" ? "443" : "80";
@@ -7850,12 +8196,6 @@ function validateProxyConfig(config, dnsAccount) {
7850
8196
  }
7851
8197
  return errors;
7852
8198
  }
7853
- function isPublicHost(host) {
7854
- const name = host.toLowerCase();
7855
- if (name === "localhost" || !name.includes(".")) return false;
7856
- if (LOCAL_TLDS.some((tld) => name.endsWith(tld))) return false;
7857
- return !/^\d{1,3}(?:\.\d{1,3}){3,}$/.test(name);
7858
- }
7859
8199
  /** Caddy answers with `{"error": "..."}`; that sentence is the useful part. */
7860
8200
  function engineMessage(body) {
7861
8201
  try {
@@ -7865,7 +8205,7 @@ function engineMessage(body) {
7865
8205
  return body.trim().length > 0 ? body.trim().slice(0, 400) : null;
7866
8206
  }
7867
8207
  }
7868
- var PROXY_ID, ADMIN_TIMEOUT_MS, READY_TIMEOUT_MS, READY_POLL_MS, DOWNLOAD_TIMEOUT_MS$1, ENGINE_COMMAND_TIMEOUT_MS, DNS01_MODULE, engineRecordSchema, challengeAuthSchema, appliedRecordSchema, adminRecordSchema, ProxyService, LOCAL_TLDS;
8208
+ var PROXY_ID, ADMIN_TIMEOUT_MS, READY_TIMEOUT_MS, READY_POLL_MS, DOWNLOAD_TIMEOUT_MS$1, ENGINE_COMMAND_TIMEOUT_MS, DNS01_MODULE, engineRecordSchema, challengeAuthSchema, appliedRecordSchema, adminRecordSchema, ProxyService;
7869
8209
  var init_proxy$1 = __esmMin((() => {
7870
8210
  init_atomic();
7871
8211
  init_logger();
@@ -7878,6 +8218,7 @@ var init_proxy$1 = __esmMin((() => {
7878
8218
  init_proxy$2();
7879
8219
  init_proxy_config();
7880
8220
  init_tls$1();
8221
+ init_proxy_form();
7881
8222
  PROXY_ID = "proxy";
7882
8223
  ADMIN_TIMEOUT_MS = 5e3;
7883
8224
  READY_TIMEOUT_MS = 2e4;
@@ -7914,6 +8255,8 @@ var init_proxy$1 = __esmMin((() => {
7914
8255
  nanny = null;
7915
8256
  admin = null;
7916
8257
  lastError = null;
8258
+ /** The parsed failure map, keyed on the log's identity — see `obtainFailures`. */
8259
+ failureCache = null;
7917
8260
  /** The installed binary's modules, read once per process; an install clears it. */
7918
8261
  moduleCache = null;
7919
8262
  /** A legacy `engine.json` is reported once, not on every state frame. */
@@ -8342,15 +8685,41 @@ var init_proxy$1 = __esmMin((() => {
8342
8685
  /**
8343
8686
  * The last thing the engine said about obtaining a certificate, per name. Caddy
8344
8687
  * has no admin endpoint that lists failures, so its own log is the source.
8688
+ *
8689
+ * Memoised on the file's identity and mtime, because this is called **per route** from
8690
+ * `routeViews()` — which `view()` runs for every state frame and `sync()` runs every tick.
8691
+ * Each call read the whole log (bounded at 2 MB by rotation), split every line and kept the
8692
+ * last 400: ~1.3 ms each, so ~13 ms of blocking synchronous I/O per second at ten routes,
8693
+ * for a file that only changes when the engine tries to obtain a certificate.
8694
+ *
8695
+ * The key is `ino` + `mtimeMs` + size, not mtime alone: rotation replaces the file, and a
8696
+ * replacement can land in the same millisecond as the previous read.
8345
8697
  */
8346
8698
  obtainFailures() {
8347
- const failures = /* @__PURE__ */ new Map();
8699
+ const file = path.join(this.options.logDir, `${PROXY_ID}.log`);
8700
+ let stamp;
8701
+ try {
8702
+ const stats = fs.statSync(file);
8703
+ stamp = `${stats.ino}:${stats.mtimeMs}:${stats.size}`;
8704
+ } catch {
8705
+ this.failureCache = null;
8706
+ return /* @__PURE__ */ new Map();
8707
+ }
8708
+ if (this.failureCache !== null && this.failureCache.stamp === stamp) return this.failureCache.failures;
8348
8709
  let text;
8349
8710
  try {
8350
- text = fs.readFileSync(path.join(this.options.logDir, `${PROXY_ID}.log`), "utf8");
8711
+ text = fs.readFileSync(file, "utf8");
8351
8712
  } catch {
8352
- return failures;
8713
+ return /* @__PURE__ */ new Map();
8353
8714
  }
8715
+ const failures = this.parseFailures(text, /* @__PURE__ */ new Map());
8716
+ this.failureCache = {
8717
+ stamp,
8718
+ failures
8719
+ };
8720
+ return failures;
8721
+ }
8722
+ parseFailures(text, failures) {
8354
8723
  for (const line of text.trimEnd().split("\n").slice(-400)) {
8355
8724
  let inner;
8356
8725
  try {
@@ -8412,12 +8781,12 @@ var init_proxy$1 = __esmMin((() => {
8412
8781
  engineRunning() {
8413
8782
  const state = readNannyState(nannyStatePath(this.options.stateDir, PROXY_ID));
8414
8783
  if (state === null || state.serverId !== PROXY_ID) return false;
8415
- return [state.nannyPid, state.childPid].some((pid) => pid !== null && pid > 0 && isProcessAlive$1(pid));
8784
+ return [state.nannyPid, state.childPid].some((pid) => pid !== null && pid > 0 && isProcessAlive(pid));
8416
8785
  }
8417
8786
  live() {
8418
8787
  const state = readNannyState(nannyStatePath(this.options.stateDir, PROXY_ID));
8419
8788
  if (state === null || state.serverId !== PROXY_ID) return false;
8420
- return state.nannyPid > 0 && isProcessAlive$1(state.nannyPid) && Date.now() - state.heartbeatAt <= 3e4;
8789
+ return state.nannyPid > 0 && isProcessAlive(state.nannyPid) && Date.now() - state.heartbeatAt <= 3e4;
8421
8790
  }
8422
8791
  status() {
8423
8792
  const config = this.config;
@@ -8443,7 +8812,7 @@ var init_proxy$1 = __esmMin((() => {
8443
8812
  lastError: "the proxy engine is not installed"
8444
8813
  };
8445
8814
  const state = readNannyState(nannyStatePath(this.options.stateDir, PROXY_ID));
8446
- if (state === null || state.serverId !== PROXY_ID || state.nannyPid <= 0 || !isProcessAlive$1(state.nannyPid)) {
8815
+ if (state === null || state.serverId !== PROXY_ID || state.nannyPid <= 0 || !isProcessAlive(state.nannyPid)) {
8447
8816
  const lastExit = state?.lastExit;
8448
8817
  const detail = lastExit === void 0 ? null : `the engine exited with ${lastExit.signal === null ? `code ${lastExit.code}` : `signal ${lastExit.signal}`}`;
8449
8818
  const lastError = this.lastError ?? detail;
@@ -9243,7 +9612,7 @@ var init_proxy$1 = __esmMin((() => {
9243
9612
  if (state !== null && state.serverId === PROXY_ID) {
9244
9613
  const pids = [state.nannyPid, state.childPid].filter((pid) => pid !== null && pid > 0);
9245
9614
  for (const pid of pids) {
9246
- if (!isProcessAlive$1(pid)) continue;
9615
+ if (!isProcessAlive(pid)) continue;
9247
9616
  try {
9248
9617
  process.kill(pid, "SIGTERM");
9249
9618
  } catch (error) {
@@ -9251,8 +9620,8 @@ var init_proxy$1 = __esmMin((() => {
9251
9620
  }
9252
9621
  }
9253
9622
  const deadline = Date.now() + (this.options.stopGraceMs ?? 1e4);
9254
- while (Date.now() < deadline && pids.some((pid) => isProcessAlive$1(pid))) await new Promise((resolve) => setTimeout(resolve, 100));
9255
- for (const pid of pids.filter(isProcessAlive$1)) try {
9623
+ while (Date.now() < deadline && pids.some((pid) => isProcessAlive(pid))) await new Promise((resolve) => setTimeout(resolve, 100));
9624
+ for (const pid of pids.filter(isProcessAlive)) try {
9256
9625
  process.kill(pid, "SIGKILL");
9257
9626
  } catch {}
9258
9627
  fs.rmSync(statePath, { force: true });
@@ -9410,17 +9779,6 @@ var init_proxy$1 = __esmMin((() => {
9410
9779
  }
9411
9780
  }
9412
9781
  };
9413
- LOCAL_TLDS = [
9414
- ".localhost",
9415
- ".local",
9416
- ".internal",
9417
- ".home.arpa",
9418
- ".lan",
9419
- ".home",
9420
- ".test",
9421
- ".invalid",
9422
- ".example"
9423
- ];
9424
9782
  }));
9425
9783
  //#endregion
9426
9784
  //#region src/api/proxy.ts
@@ -9631,12 +9989,6 @@ var init_proxy = __esmMin((() => {
9631
9989
  }));
9632
9990
  //#endregion
9633
9991
  //#region src/api/servers/$.routes.ts
9634
- function unknownServer(id) {
9635
- return new DetailedError(`unknown server "${id}"`, {
9636
- statusCode: 404,
9637
- code: "UNKNOWN_SERVER"
9638
- });
9639
- }
9640
9992
  function createServersRoute(deps) {
9641
9993
  return appFactory.createApp().get("/", describeRoute({
9642
9994
  tags: ["servers"],
@@ -9705,7 +10057,7 @@ function createServersRoute(deps) {
9705
10057
  const runtime = requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace);
9706
10058
  const { id } = c.req.valid("param");
9707
10059
  const server = runtime.supervisor.views().find((entry) => entry.id === id);
9708
- if (!server) throw unknownServer(id);
10060
+ if (!server) throw unknownServerError(id);
9709
10061
  return c.json({ server });
9710
10062
  }).get("/:id/logs", describeRoute({
9711
10063
  tags: ["servers"],
@@ -9717,7 +10069,7 @@ function createServersRoute(deps) {
9717
10069
  }), validate("query", bufferedLogsQuery), validate("param", idParam$1), (c) => {
9718
10070
  const runtime = requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace);
9719
10071
  const { id } = c.req.valid("param");
9720
- if (!runtime.store.getServer(id)) throw unknownServer(id);
10072
+ if (!runtime.store.getServer(id)) throw unknownServerError(id);
9721
10073
  const { limit } = c.req.valid("query");
9722
10074
  const parsed = limit === void 0 ? NaN : Number.parseInt(limit, 10);
9723
10075
  const bounded = Number.isNaN(parsed) ? void 0 : Math.min(Math.max(parsed, 1), 1e5);
@@ -9732,7 +10084,7 @@ function createServersRoute(deps) {
9732
10084
  }), validate("query", workspaceQuerySchema), validate("param", idParam$1), (c) => {
9733
10085
  const runtime = requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace);
9734
10086
  const { id } = c.req.valid("param");
9735
- if (!runtime.store.getServer(id)) throw unknownServer(id);
10087
+ if (!runtime.store.getServer(id)) throw unknownServerError(id);
9736
10088
  return streamSSE(c, async (stream) => {
9737
10089
  let closed = false;
9738
10090
  let pending = 0;
@@ -9858,7 +10210,7 @@ function createServersRoute(deps) {
9858
10210
  return c.json({ server: runtime.store.updateServer(c.req.valid("param").id, c.req.valid("json")) });
9859
10211
  } catch (error) {
9860
10212
  if (error instanceof ConfigError) {
9861
- const status = error.message.startsWith("unknown server") ? 404 : 400;
10213
+ const status = error.code === "UNKNOWN_SERVER" ? 404 : 400;
9862
10214
  throw new DetailedError(error.message, {
9863
10215
  statusCode: status,
9864
10216
  code: status === 404 ? "UNKNOWN_SERVER" : "INVALID_SERVER"
@@ -9884,7 +10236,7 @@ function createServersRoute(deps) {
9884
10236
  runtime.store.removeServer(id);
9885
10237
  return c.json({ ok: true });
9886
10238
  } catch (error) {
9887
- if (error instanceof ConfigError) throw unknownServer(id);
10239
+ if (error instanceof ConfigError) throw unknownServerError(id);
9888
10240
  throw error;
9889
10241
  }
9890
10242
  });
@@ -10019,47 +10371,66 @@ function createSettingsRoute(deps) {
10019
10371
  rebinding: endpointChanged || proxyChanged || tlsChanged,
10020
10372
  targetUrl
10021
10373
  });
10022
- }).post("/settings/ui", describeRoute({
10023
- tags: ["panel"],
10024
- summary: "Replace the panel UI with an uploaded static build",
10025
- responses: {
10026
- 200: { description: "Installed" },
10027
- 400: ERROR_RESPONSES[400],
10028
- 413: { description: "Too large" }
10029
- }
10030
- }), async (c) => {
10031
- const declared = Number.parseInt(c.req.header("content-length") ?? "0", 10);
10032
- if (Number.isFinite(declared) && declared > MAX_UI_UPLOAD_BYTES) throw new DetailedError(`the upload is larger than ${Math.round(MAX_UI_UPLOAD_BYTES / 1024 / 1024)}MB`, {
10033
- statusCode: 413,
10034
- code: "UPLOAD_TOO_LARGE"
10035
- });
10036
- const file = (await c.req.parseBody()).file;
10037
- if (!(file instanceof File)) throw new DetailedError("expected a `file` field with the UI archive", {
10038
- statusCode: 400,
10039
- code: "MISSING_FILE"
10040
- });
10041
- if (file.size > MAX_UI_UPLOAD_BYTES) throw new DetailedError(`the upload is larger than ${Math.round(MAX_UI_UPLOAD_BYTES / 1024 / 1024)}MB`, {
10042
- statusCode: 413,
10043
- code: "UPLOAD_TOO_LARGE"
10044
- });
10045
- const staging = path.join(path.dirname(deps.ui.directory), `.ui-upload-${Date.now()}.zip`);
10046
- try {
10047
- await fs.promises.writeFile(staging, Buffer$1.from(await file.arrayBuffer()));
10048
- const result = await deps.ui.install(staging, sanitizeName(file.name));
10049
- if (!result.ok) throw new DetailedError(result.error, {
10374
+ }).post(
10375
+ "/settings/ui",
10376
+ describeRoute({
10377
+ tags: ["panel"],
10378
+ summary: "Replace the panel UI with an uploaded static build",
10379
+ responses: {
10380
+ 200: { description: "Installed" },
10381
+ 400: ERROR_RESPONSES[400],
10382
+ 413: ERROR_RESPONSES[413]
10383
+ }
10384
+ }),
10385
+ /**
10386
+ * Bound the body **while it arrives**, not after. The checks below read `Content-Length`, which a
10387
+ * chunked request does not send, and compare `file.size` only after `parseBody()` has buffered
10388
+ * everything — so an upload with no declared length was read into memory in full and then refused.
10389
+ * See the same reasoning in `api/backups.ts`.
10390
+ */
10391
+ bodyLimit({
10392
+ maxSize: MAX_UI_UPLOAD_BYTES,
10393
+ onError: () => {
10394
+ throw new DetailedError(`the upload is larger than ${Math.round(MAX_UI_UPLOAD_BYTES / 1024 / 1024)}MB`, {
10395
+ statusCode: 413,
10396
+ code: "UPLOAD_TOO_LARGE"
10397
+ });
10398
+ }
10399
+ }),
10400
+ async (c) => {
10401
+ const declared = Number.parseInt(c.req.header("content-length") ?? "0", 10);
10402
+ if (Number.isFinite(declared) && declared > MAX_UI_UPLOAD_BYTES) throw new DetailedError(`the upload is larger than ${Math.round(MAX_UI_UPLOAD_BYTES / 1024 / 1024)}MB`, {
10403
+ statusCode: 413,
10404
+ code: "UPLOAD_TOO_LARGE"
10405
+ });
10406
+ const file = (await c.req.parseBody()).file;
10407
+ if (!(file instanceof File)) throw new DetailedError("expected a `file` field with the UI archive", {
10050
10408
  statusCode: 400,
10051
- code: "INVALID_UI"
10409
+ code: "MISSING_FILE"
10052
10410
  });
10053
- logger.info(`UI replaced with ${result.meta.name} (${result.meta.files} files)`);
10054
- return c.json({
10055
- ok: true,
10056
- meta: result.meta,
10057
- ui: deps.ui.status()
10411
+ if (file.size > MAX_UI_UPLOAD_BYTES) throw new DetailedError(`the upload is larger than ${Math.round(MAX_UI_UPLOAD_BYTES / 1024 / 1024)}MB`, {
10412
+ statusCode: 413,
10413
+ code: "UPLOAD_TOO_LARGE"
10058
10414
  });
10059
- } finally {
10060
- fs.rmSync(staging, { force: true });
10415
+ const staging = path.join(path.dirname(deps.ui.directory), `.ui-upload-${Date.now()}.zip`);
10416
+ try {
10417
+ await fs.promises.writeFile(staging, Buffer$1.from(await file.arrayBuffer()));
10418
+ const result = await deps.ui.install(staging, sanitizeName(file.name));
10419
+ if (!result.ok) throw new DetailedError(result.error, {
10420
+ statusCode: 400,
10421
+ code: "INVALID_UI"
10422
+ });
10423
+ logger.info(`UI replaced with ${result.meta.name} (${result.meta.files} files)`);
10424
+ return c.json({
10425
+ ok: true,
10426
+ meta: result.meta,
10427
+ ui: deps.ui.status()
10428
+ });
10429
+ } finally {
10430
+ fs.rmSync(staging, { force: true });
10431
+ }
10061
10432
  }
10062
- }).delete("/settings/ui", describeRoute({
10433
+ ).delete("/settings/ui", describeRoute({
10063
10434
  tags: ["panel"],
10064
10435
  summary: "Go back to the stock UI",
10065
10436
  responses: { 200: { description: "Reverted" } }
@@ -10263,7 +10634,9 @@ function createTlsRoute(deps) {
10263
10634
  }), (c) => {
10264
10635
  deps.tls.clear();
10265
10636
  if (deps.panel.settings.control.tls.enabled) afterResponse(async () => {
10266
- await deps.controlServer.restart();
10637
+ const result = await deps.controlServer.restart();
10638
+ if (!result.ok) logger.error(`could not reload TLS after removing the pair: ${result.error ?? "unknown error"}`);
10639
+ else logger.info(`control panel reloaded — ${deps.panel.settings.control.tls.enabled && deps.tls.servable() !== null ? "https" : "http"}`);
10267
10640
  }, (error) => logger.error("tls reload failed", error));
10268
10641
  return c.json(review());
10269
10642
  });
@@ -10282,8 +10655,8 @@ var init_tls = __esmMin((() => {
10282
10655
  /** A registry failure is an API failure: unknown ids are 404, conflicts 409, the rest 400. */
10283
10656
  function workspaceFailure(error) {
10284
10657
  if (error instanceof WorkspaceError) {
10285
- const unknown = error.message.startsWith("unknown workspace");
10286
- const conflict = error.message.endsWith("already exists");
10658
+ const unknown = error.code === "UNKNOWN_WORKSPACE";
10659
+ const conflict = error.code === "WORKSPACE_EXISTS";
10287
10660
  throw new DetailedError(error.message, {
10288
10661
  statusCode: unknown ? 404 : conflict ? 409 : 400,
10289
10662
  code: unknown ? "UNKNOWN_WORKSPACE" : conflict ? "WORKSPACE_EXISTS" : "INVALID_WORKSPACE"
@@ -10426,18 +10799,11 @@ var init_error = __esmMin((() => {
10426
10799
  * UI author has to be able to read it before they can log in.
10427
10800
  */
10428
10801
  /** The version the package was built with, so the spec never drifts from it. */
10429
- function packageVersion$1() {
10430
- try {
10431
- return JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "0.0.0";
10432
- } catch {
10433
- return "0.0.0";
10434
- }
10435
- }
10436
10802
  function setupOpenAPI(app) {
10437
10803
  return appFactory.createApp().get(`${PREFIX}/spec.json`, openAPIRouteHandler(app, { documentation: {
10438
10804
  info: {
10439
10805
  title: "home-hosted",
10440
- version: packageVersion$1(),
10806
+ version: appVersion(),
10441
10807
  description: "Control plane for the processes you host at home: servers, logs, vitals, backups and settings."
10442
10808
  },
10443
10809
  tags: [
@@ -10478,6 +10844,7 @@ function setupOpenAPI(app) {
10478
10844
  var PREFIX;
10479
10845
  var init_openapi = __esmMin((() => {
10480
10846
  init_factory();
10847
+ init_version();
10481
10848
  PREFIX = "/openapi";
10482
10849
  }));
10483
10850
  //#endregion
@@ -10530,7 +10897,7 @@ var layout_exports = /* @__PURE__ */ __exportAll({
10530
10897
  function readJson(file) {
10531
10898
  try {
10532
10899
  const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
10533
- return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : null;
10900
+ return isRecord(parsed) ? parsed : null;
10534
10901
  } catch {
10535
10902
  return null;
10536
10903
  }
@@ -10736,6 +11103,7 @@ var EMPTY, KNOWN_TOP_LEVEL;
10736
11103
  var init_layout = __esmMin((() => {
10737
11104
  init_logger();
10738
11105
  init_paths();
11106
+ init_shape();
10739
11107
  EMPTY = {
10740
11108
  migrated: false,
10741
11109
  moved: [],
@@ -10784,15 +11152,6 @@ function clearRuntime() {
10784
11152
  function newToken() {
10785
11153
  return randomBytes(32).toString("base64url");
10786
11154
  }
10787
- /** Signal 0 only probes the pid; `EPERM` still means the process is there. */
10788
- function isProcessAlive(pid) {
10789
- try {
10790
- process.kill(pid, 0);
10791
- return true;
10792
- } catch (error) {
10793
- return error.code === "EPERM";
10794
- }
10795
- }
10796
11155
  /** A degraded panel is still answering: a 503 must not read as "not running". */
10797
11156
  async function probeRuntime(runtime, timeoutMs = 2500) {
10798
11157
  const answer = await localCall(runtime, "/healthz", "GET", void 0, timeoutMs);
@@ -10872,6 +11231,7 @@ var runtimeSchema;
10872
11231
  var init_daemon = __esmMin((() => {
10873
11232
  init_atomic();
10874
11233
  init_paths();
11234
+ init_port();
10875
11235
  runtimeSchema = type({
10876
11236
  version: "string",
10877
11237
  pid: "number.integer >= 1",
@@ -10932,15 +11292,9 @@ function failure(status, message) {
10932
11292
  headers: { "content-type": "application/json" }
10933
11293
  });
10934
11294
  }
10935
- /** Constant-time, and length-safe: `timingSafeEqual` throws on differing lengths. */
10936
- function matches(actual, expected) {
10937
- const left = Buffer$1.from(actual, "utf8");
10938
- const right = Buffer$1.from(expected, "utf8");
10939
- if (left.length !== right.length) return false;
10940
- return crypto.timingSafeEqual(left, right);
10941
- }
10942
11295
  var proxyMessageSchema, AcmeChallengeService;
10943
11296
  var init_acme_challenge = __esmMin((() => {
11297
+ init_secret_compare();
10944
11298
  init_ddns$2();
10945
11299
  init_types();
10946
11300
  proxyMessageSchema = type({
@@ -11005,7 +11359,9 @@ var init_acme_challenge = __esmMin((() => {
11005
11359
  if (separator < 0) return false;
11006
11360
  const user = decoded.slice(0, separator);
11007
11361
  const password = decoded.slice(separator + 1);
11008
- return matches(user, auth.username) && matches(password, auth.password);
11362
+ const userOk = secretEqual(user, auth.username);
11363
+ const passwordOk = secretEqual(password, auth.password);
11364
+ return userOk && passwordOk;
11009
11365
  } catch {
11010
11366
  return false;
11011
11367
  }
@@ -11305,13 +11661,19 @@ async function probeHttp(host, port, options) {
11305
11661
  ms,
11306
11662
  detail: `status ${response.status} is >= ${options.expectStatusBelow}`
11307
11663
  };
11308
- if (options.expectBody.length > 0 && options.method !== "HEAD") {
11664
+ const bodySkipped = options.expectBody.length > 0 && options.method === "HEAD";
11665
+ if (options.expectBody.length > 0 && !bodySkipped) {
11309
11666
  if (!(await response.text()).includes(options.expectBody)) return {
11310
11667
  healthy: false,
11311
11668
  ms,
11312
11669
  detail: `body does not contain ${JSON.stringify(options.expectBody)}`
11313
11670
  };
11314
11671
  }
11672
+ if (bodySkipped) return {
11673
+ healthy: true,
11674
+ ms,
11675
+ detail: `HTTP ${response.status} (a HEAD carries no body, so the body check was skipped)`
11676
+ };
11315
11677
  return {
11316
11678
  healthy: true,
11317
11679
  ms,
@@ -11507,7 +11869,16 @@ function orderByDependencies(servers) {
11507
11869
  for (const server of servers) visit(server);
11508
11870
  return ordered;
11509
11871
  }
11510
- /** The transitive dependencies of a server, nearest first. */
11872
+ /**
11873
+ * The transitive dependencies of a server, **deepest first** — the order they have to be started in, so
11874
+ * every dependency is up before something that depends on it.
11875
+ *
11876
+ * A dependency is pushed *after* its own dependencies are walked, which is what makes that hold. Pushing
11877
+ * it before (the shape this used to have) yielded a list that was not topological: for
11878
+ * `a -> [b, c], b -> [x], c -> [b]` it returned `[b, x, c]`, where `b` precedes `x` even though `b`
11879
+ * depends on `x` — so `startDependencies` started `b` without `x` running and awaited a readiness that
11880
+ * could not arrive.
11881
+ */
11511
11882
  function dependenciesOf(server, servers) {
11512
11883
  const byId = new Map(servers.map((entry) => [entry.id, entry]));
11513
11884
  const found = [];
@@ -11518,13 +11889,17 @@ function dependenciesOf(server, servers) {
11518
11889
  seen.add(dependency);
11519
11890
  const target = byId.get(dependency);
11520
11891
  if (!target) continue;
11521
- found.push(target);
11522
11892
  walk(target);
11893
+ found.push(target);
11523
11894
  }
11524
11895
  };
11525
11896
  walk(server);
11526
11897
  return found;
11527
11898
  }
11899
+ /** Dependents that must stop before this server does. */
11900
+ function dependentsOf(server, servers) {
11901
+ return servers.filter((entry) => entry.id !== server.id && dependenciesOf(entry, servers).some((d) => d.id === server.id));
11902
+ }
11528
11903
  var init_dependencies = __esmMin((() => {}));
11529
11904
  //#endregion
11530
11905
  //#region src/services/log-buffer.ts
@@ -11598,10 +11973,11 @@ function parseLogLine(raw) {
11598
11973
  return null;
11599
11974
  }
11600
11975
  }
11601
- var MAX_BYTES_PER_READ, MAX_TAIL_BYTES, MAX_CARRY_CHARS, LOG_STREAMS, LogTailer;
11976
+ var MAX_BYTES_PER_READ, BACKFILL_INITIAL_BYTES, BACKFILL_MAX_BYTES, MAX_CARRY_CHARS, LOG_STREAMS, LogTailer;
11602
11977
  var init_log_tail = __esmMin((() => {
11603
11978
  MAX_BYTES_PER_READ = 262144;
11604
- MAX_TAIL_BYTES = 262144;
11979
+ BACKFILL_INITIAL_BYTES = 262144;
11980
+ BACKFILL_MAX_BYTES = 8388608;
11605
11981
  MAX_CARRY_CHARS = 1048576;
11606
11982
  LOG_STREAMS = /* @__PURE__ */ new Set([
11607
11983
  "stdout",
@@ -11647,7 +12023,6 @@ var init_log_tail = __esmMin((() => {
11647
12023
  this.offset = size;
11648
12024
  this.identity = stats.ino === 0 ? stats.birthtimeMs : stats.ino;
11649
12025
  if (size === 0 || count <= 0) return [];
11650
- const start = Math.max(0, size - MAX_TAIL_BYTES);
11651
12026
  let handle;
11652
12027
  try {
11653
12028
  handle = fs.openSync(this.file, "r");
@@ -11655,15 +12030,22 @@ var init_log_tail = __esmMin((() => {
11655
12030
  return [];
11656
12031
  }
11657
12032
  try {
11658
- const buffer = Buffer$1.alloc(size - start);
11659
- const bytes = fs.readSync(handle, buffer, 0, buffer.length, start);
11660
- const raw = buffer.subarray(0, bytes).toString("utf8").split("\n");
11661
- let partialStart = false;
11662
- if (start > 0) {
11663
- const previous = Buffer$1.alloc(1);
11664
- partialStart = fs.readSync(handle, previous, 0, 1, start - 1) === 1 && previous[0] !== 10;
12033
+ let window = Math.min(size, BACKFILL_INITIAL_BYTES);
12034
+ for (;;) {
12035
+ const start = size - window;
12036
+ const buffer = Buffer$1.alloc(window);
12037
+ const bytes = fs.readSync(handle, buffer, 0, buffer.length, start);
12038
+ const raw = buffer.subarray(0, bytes).toString("utf8").split("\n");
12039
+ let partialStart = false;
12040
+ if (start > 0) {
12041
+ const previous = Buffer$1.alloc(1);
12042
+ partialStart = fs.readSync(handle, previous, 0, 1, start - 1) === 1 && previous[0] !== 10;
12043
+ }
12044
+ const lines = (partialStart ? raw.slice(1) : raw).filter((line) => line.trim().length > 0);
12045
+ const grew = window < size && window < BACKFILL_MAX_BYTES;
12046
+ if (lines.length >= count || !grew) return lines.slice(-count).map(parseLogLine).filter((line) => line !== null);
12047
+ window = Math.min(size, window * 4);
11665
12048
  }
11666
- return (partialStart ? raw.slice(1) : raw).filter((line) => line.trim().length > 0).slice(-count).map(parseLogLine).filter((line) => line !== null);
11667
12049
  } catch {
11668
12050
  return [];
11669
12051
  } finally {
@@ -11707,14 +12089,17 @@ var init_log_tail = __esmMin((() => {
11707
12089
  return [];
11708
12090
  }
11709
12091
  try {
11710
- const buffer = Buffer$1.alloc(Math.min(MAX_BYTES_PER_READ, stats.size - this.offset));
11711
- while (this.offset < stats.size) {
11712
- const length = Math.min(buffer.length, stats.size - this.offset);
12092
+ const budget = Math.min(MAX_BYTES_PER_READ, stats.size - this.offset);
12093
+ const buffer = Buffer$1.alloc(budget);
12094
+ let consumed = 0;
12095
+ while (consumed < budget && this.offset < stats.size) {
12096
+ const length = Math.min(buffer.length, budget - consumed, stats.size - this.offset);
11713
12097
  const bytes = fs.readSync(handle, buffer, 0, length, this.offset);
11714
12098
  if (bytes <= 0) break;
11715
12099
  this.offset += bytes;
12100
+ consumed += bytes;
11716
12101
  this.consume(buffer.subarray(0, bytes).toString("utf8"), lines);
11717
- if (bytes < buffer.length) break;
12102
+ if (bytes < length) break;
11718
12103
  }
11719
12104
  } catch {} finally {
11720
12105
  fs.closeSync(handle);
@@ -11853,6 +12238,7 @@ function serverTemplateVars(config) {
11853
12238
  }
11854
12239
  var HISTORY_WINDOW_MS, HISTORY_CACHE_MS, TICK_INTERVAL_MS, PORT_STATE_INTERVAL_MS, PORT_RELEASE_RECHECK_MS, RESOURCE_SAMPLE_INTERVAL_MS, PERSISTENT_BACKFILL_LINES, ECHOED_LINE_LIMIT, Supervisor;
11855
12240
  var init_supervisor = __esmMin((() => {
12241
+ init_action_result();
11856
12242
  init_backoff();
11857
12243
  init_bind();
11858
12244
  init_env_file();
@@ -11947,10 +12333,7 @@ var init_supervisor = __esmMin((() => {
11947
12333
  }
11948
12334
  async start(id, options = {}) {
11949
12335
  const entry = this.entries.get(id);
11950
- if (!entry) return {
11951
- ok: false,
11952
- error: `unknown server "${id}"`
11953
- };
12336
+ if (!entry) return missingServer(id);
11954
12337
  if (!entry.config.enabled) return {
11955
12338
  ok: false,
11956
12339
  error: `server "${id}" is disabled`
@@ -12000,10 +12383,7 @@ var init_supervisor = __esmMin((() => {
12000
12383
  }
12001
12384
  async stop(id) {
12002
12385
  const entry = this.entries.get(id);
12003
- if (!entry) return {
12004
- ok: false,
12005
- error: `unknown server "${id}"`
12006
- };
12386
+ if (!entry) return missingServer(id);
12007
12387
  return this.stopEntry(entry);
12008
12388
  }
12009
12389
  async restart(id) {
@@ -12030,7 +12410,7 @@ var init_supervisor = __esmMin((() => {
12030
12410
  };
12031
12411
  if (!entry) return {
12032
12412
  ...empty,
12033
- error: `unknown server "${id}"`
12413
+ ...missingServer(id)
12034
12414
  };
12035
12415
  const port = entry.config.port;
12036
12416
  if (port === null) return {
@@ -12209,6 +12589,11 @@ var init_supervisor = __esmMin((() => {
12209
12589
  this.publishServer(entry);
12210
12590
  return { ok: true };
12211
12591
  }
12592
+ const orphaned = dependentsOf(entry.config, this.store.servers).filter((dependent) => {
12593
+ const state = this.entries.get(dependent.id);
12594
+ return state !== void 0 && this.isActive(state);
12595
+ });
12596
+ if (orphaned.length > 0) this.log(entry, "system", `still running and depending on this: ${orphaned.map((dependent) => dependent.id).join(", ")}`);
12212
12597
  entry.status = "stopping";
12213
12598
  this.publishServer(entry);
12214
12599
  const nannyState = entry.config.persistent ? readNannyState(nannyStatePath(this.options.nannyDir, entry.config.id)) : null;
@@ -12222,7 +12607,7 @@ var init_supervisor = __esmMin((() => {
12222
12607
  const leftover = await killPortHolders(port, await this.supervisedPids());
12223
12608
  if (leftover.length > 0) this.log(entry, "system", `port ${port} was still held by pid ${leftover.join(", ")} — killed`);
12224
12609
  }
12225
- const survivor = entry.config.persistent && entry.pid !== null && isProcessAlive$1(entry.pid) ? entry.pid : null;
12610
+ const survivor = entry.config.persistent && entry.pid !== null && isProcessAlive(entry.pid) ? entry.pid : null;
12226
12611
  if (entry.config.persistent) {
12227
12612
  if (survivor === null) {
12228
12613
  clearNannyState(nannyStatePath(this.options.nannyDir, entry.config.id));
@@ -12284,6 +12669,8 @@ var init_supervisor = __esmMin((() => {
12284
12669
  if (!config) {
12285
12670
  this.entries.delete(id);
12286
12671
  this.relay.unfollow(id);
12672
+ this.options.logFiles.clear(id);
12673
+ this.options.history.forget(id);
12287
12674
  this.stopEntry(entry).catch((error) => {
12288
12675
  logger.error(`could not stop the removed server ${id}`, error);
12289
12676
  });
@@ -12561,6 +12948,11 @@ var init_supervisor = __esmMin((() => {
12561
12948
  HHOSTED_CONTROL_PORT: String(this.options.control.port)
12562
12949
  };
12563
12950
  for (const key of Object.keys(dataEnvs)) env[key] = path.resolve(cwd, env[key]);
12951
+ for (const key of Object.keys(env)) {
12952
+ if (!key.includes("\0")) continue;
12953
+ delete env[key];
12954
+ this.log(entry, "system", "ignored an environment key containing a null byte");
12955
+ }
12564
12956
  return {
12565
12957
  command,
12566
12958
  args: expandEnvList(resolveTemplates(entry.config.args, vars), expansionVars),
@@ -12856,7 +13248,12 @@ var init_supervisor = __esmMin((() => {
12856
13248
  entry.exitCode = code;
12857
13249
  entry.exitSignal = signal;
12858
13250
  const neverStarted = nannyExit !== null ? nannyExit.code === null && nannyExit.signal === null : code === null && signal === null && entry.lastError !== null;
12859
- const detail = neverStarted ? entry.lastError ?? "the entry could not start" : nannyExit !== null ? describeNannyExit(nannyExit) : signal !== null ? `signal ${signal}` : `code ${code}`;
13251
+ const detail = neverStarted ? entry.lastError ?? "the entry could not start" : nannyExit !== null ? describeNannyExit(nannyExit) : describeNannyExit({
13252
+ code,
13253
+ signal,
13254
+ runtimeMs: 0,
13255
+ at: Date.now()
13256
+ });
12860
13257
  const ranForMs = nannyExit?.runtimeMs ?? (entry.startedAt === null ? 0 : Date.now() - entry.startedAt);
12861
13258
  this.options.history.record(entry.config.id, {
12862
13259
  type: "exit",
@@ -12913,7 +13310,7 @@ var init_supervisor = __esmMin((() => {
12913
13310
  await this.sampleResources(now);
12914
13311
  await Promise.allSettled([...this.entries.values()].map((entry) => this.probeEntry(entry, now)));
12915
13312
  for (const entry of this.entries.values()) {
12916
- if (entry.adopted && entry.pid !== null && !isProcessAlive$1(entry.pid)) {
13313
+ if (entry.adopted && entry.pid !== null && !isProcessAlive(entry.pid)) {
12917
13314
  this.handleAdoptedExit(entry);
12918
13315
  continue;
12919
13316
  }
@@ -13287,9 +13684,6 @@ function readText(file) {
13287
13684
  return null;
13288
13685
  }
13289
13686
  }
13290
- function isRecord(value) {
13291
- return typeof value === "object" && value !== null && !Array.isArray(value);
13292
- }
13293
13687
  /** The label of one item, for `applied`; items are built before actions run. */
13294
13688
  function labelOf(plan, id) {
13295
13689
  return plan.items.find((item) => item.id === id)?.label ?? id;
@@ -13325,6 +13719,7 @@ var init_backups = __esmMin((() => {
13325
13719
  init_archive();
13326
13720
  init_supervisor();
13327
13721
  init_generated();
13722
+ init_shape();
13328
13723
  MANIFEST = "manifest.json";
13329
13724
  ALLOWED_ROOTS = /* @__PURE__ */ new Set([
13330
13725
  "global",
@@ -14211,13 +14606,27 @@ async function swapUsedPercent(now = Date.now()) {
14211
14606
  };
14212
14607
  return percent;
14213
14608
  }
14609
+ /**
14610
+ * `sysctl -n vm.swapusage` as a percentage. Pure, so the arithmetic is testable with the readings
14611
+ * the platform can actually produce rather than only the ones a runner happens to have.
14612
+ */
14613
+ function swapPercentFromSysctl(stdout) {
14614
+ const total = /total\s*=\s*([\d.]+)M/.exec(stdout)?.[1];
14615
+ const used = /used\s*=\s*([\d.]+)M/.exec(stdout)?.[1];
14616
+ const totalMb = Number.parseFloat(total ?? "0");
14617
+ return usedPercent(Math.max(0, totalMb - Number.parseFloat(used ?? "0")), totalMb);
14618
+ }
14619
+ /** One `Win32_PageFileUsage` CSV row as a percentage; see `swapPercentFromSysctl` for the clamp. */
14620
+ function swapPercentFromPageFile(stdout) {
14621
+ const cells = (stdout.split(/\r?\n/).slice(1).find((entry) => entry.trim().length > 0) ?? "").split(",").map((entry) => entry.replace(/"/g, "").trim());
14622
+ const totalMb = Number.parseFloat(cells[0] ?? "0");
14623
+ const usedMb = Number.parseFloat(cells[1] ?? "0");
14624
+ return usedPercent(Math.max(0, totalMb - usedMb), totalMb);
14625
+ }
14214
14626
  async function readSwapUsedPercent() {
14215
14627
  if (process.platform === "darwin") try {
14216
14628
  const { stdout } = await execFileAsync("sysctl", ["-n", "vm.swapusage"], { timeout: 3e3 });
14217
- const total = /total\s*=\s*([\d.]+)M/.exec(stdout)?.[1];
14218
- const used = /used\s*=\s*([\d.]+)M/.exec(stdout)?.[1];
14219
- const totalMb = Number.parseFloat(total ?? "0");
14220
- return totalMb > 0 ? Number.parseFloat(used ?? "0") / totalMb * 100 : 0;
14629
+ return swapPercentFromSysctl(stdout);
14221
14630
  } catch {
14222
14631
  return 0;
14223
14632
  }
@@ -14228,10 +14637,7 @@ async function readSwapUsedPercent() {
14228
14637
  "-Command",
14229
14638
  "Get-CimInstance Win32_PageFileUsage | Select-Object AllocatedBaseSize,CurrentUsage | ConvertTo-Csv -NoTypeInformation"
14230
14639
  ], { timeout: 5e3 });
14231
- const cells = (stdout.split(/\r?\n/).slice(1).find((entry) => entry.trim().length > 0) ?? "").split(",").map((entry) => entry.replace(/"/g, "").trim());
14232
- const totalMb = Number.parseFloat(cells[0] ?? "0");
14233
- const usedMb = Number.parseFloat(cells[1] ?? "0");
14234
- return totalMb > 0 ? usedMb / totalMb * 100 : 0;
14640
+ return swapPercentFromPageFile(stdout);
14235
14641
  } catch {
14236
14642
  return 0;
14237
14643
  }
@@ -14251,23 +14657,33 @@ async function readSwapUsedPercent() {
14251
14657
  * `vm_stat` and count what macOS itself counts as available.
14252
14658
  * - Windows: `os.freemem()` is the free physical page count, which is the ordinary reading.
14253
14659
  */
14660
+ /**
14661
+ * How much of `total` is not `free`, as a percentage clamped into `[0, 100]`; 0 when `total` is
14662
+ * meaningless.
14663
+ *
14664
+ * `MemAvailable` is a kernel *estimate* and can exceed `MemTotal`, and `SwapFree` can exceed
14665
+ * `SwapTotal` — both make the raw ratio negative. That matters more than a strange display: the
14666
+ * alert is `used >= threshold`, so a negative reading silently **suppresses** the memory alert
14667
+ * entirely. The fallback branch already clamped for exactly this reason; the `/proc` branch did not,
14668
+ * so the two disagreed on the same quantity.
14669
+ */
14670
+ function usedPercent(free, total) {
14671
+ if (!(total > 0)) return 0;
14672
+ return Math.max(0, Math.min(100, (total - free) / total * 100));
14673
+ }
14254
14674
  function memoryInfo() {
14255
14675
  try {
14256
14676
  const info = fs.readFileSync("/proc/meminfo", "utf8");
14257
14677
  const read = (key) => Number.parseInt(new RegExp(`^${key}:\\s+(\\d+)`, "m").exec(info)?.[1] ?? "0", 10);
14258
- const total = read("MemTotal");
14259
- const available = read("MemAvailable");
14260
- const swapTotal = read("SwapTotal");
14261
- const swapFree = read("SwapFree");
14262
14678
  return {
14263
- memoryUsedPercent: total > 0 ? (total - available) / total * 100 : 0,
14264
- swapUsedPercent: swapTotal > 0 ? (swapTotal - swapFree) / swapTotal * 100 : 0
14679
+ memoryUsedPercent: usedPercent(read("MemAvailable"), read("MemTotal")),
14680
+ swapUsedPercent: usedPercent(read("SwapFree"), read("SwapTotal"))
14265
14681
  };
14266
14682
  } catch {
14267
14683
  const total = os.totalmem();
14268
14684
  const available = availableMemoryBytes(process.platform, os.freemem());
14269
14685
  return {
14270
- memoryUsedPercent: total > 0 && available !== null ? Math.max(0, Math.min(100, (total - available) / total * 100)) : 0,
14686
+ memoryUsedPercent: available === null ? 0 : usedPercent(available, total),
14271
14687
  swapUsedPercent: 0
14272
14688
  };
14273
14689
  }
@@ -14374,7 +14790,7 @@ async function diskUsage(target) {
14374
14790
  path: target,
14375
14791
  totalBytes,
14376
14792
  freeBytes,
14377
- usedPercent: totalBytes > 0 ? (totalBytes - freeBytes) / totalBytes * 100 : 0
14793
+ usedPercent: usedPercent(freeBytes, totalBytes)
14378
14794
  };
14379
14795
  } catch {
14380
14796
  return null;
@@ -14509,9 +14925,50 @@ function isPublicAddress(ip, family) {
14509
14925
  const [a, b] = parts;
14510
14926
  return !(a === 0 || a === 10 || a === 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 100 && b >= 64 && b <= 127);
14511
14927
  }
14512
- const lower = ip.toLowerCase();
14513
- if (lower === "::1" || lower === "::") return false;
14514
- return !(lower.startsWith("fc") || lower.startsWith("fd") || /^fe[89ab]/.test(lower));
14928
+ const words = ipv6Words(ip);
14929
+ if (words === null) return false;
14930
+ const [w0, w1] = words;
14931
+ if (words.every((word) => word === 0) || w0 === 0 && w1 === 0 && words.slice(2, 7).every((word) => word === 0) && words[7] === 1) return false;
14932
+ if (w0 === 0 && w1 === 0 && words[2] === 0 && words[3] === 0 && (words[4] === 65535 || words[4] === 0)) return false;
14933
+ if ((w0 & 65280) === 65280) return false;
14934
+ if ((w0 & 65024) === 64512 || (w0 & 65472) === 65152) return false;
14935
+ if (w0 === 8193 && w1 === 3512) return false;
14936
+ if (w0 === 16383 && (w1 & 61440) === 0) return false;
14937
+ if (w0 === 8194 || w0 === 8193 && w1 <= 511) return false;
14938
+ if (w0 === 100 && w1 === 65435) return false;
14939
+ return true;
14940
+ }
14941
+ /**
14942
+ * An IPv6 address as eight 16-bit words, or null when it cannot be parsed.
14943
+ *
14944
+ * The prefix checks above compare bits, and the same address has many spellings (`::ffff:c000:201`
14945
+ * and `::ffff:192.168.1.5` are one address), so a textual `startsWith` is not enough — that is how
14946
+ * the mapped block slipped through.
14947
+ */
14948
+ function ipv6Words(ip) {
14949
+ let text = ip.toLowerCase();
14950
+ const dotted = /(\d{1,3}(?:\.\d{1,3}){3})$/.exec(text);
14951
+ if (dotted !== null) {
14952
+ const parts = dotted[1].split(".").map((part) => Number.parseInt(part, 10));
14953
+ if (parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null;
14954
+ const hi = ((parts[0] << 8 | parts[1]) >>> 0).toString(16);
14955
+ const lo = ((parts[2] << 8 | parts[3]) >>> 0).toString(16);
14956
+ text = `${text.slice(0, dotted.index)}${hi}:${lo}`;
14957
+ }
14958
+ const halves = text.split("::");
14959
+ if (halves.length > 2) return null;
14960
+ const head = halves[0] === "" ? [] : halves[0].split(":");
14961
+ const tail = halves.length === 1 || halves[1] === "" ? [] : halves[1].split(":");
14962
+ const zeros = [];
14963
+ if (halves.length > 1) for (let index = 0; index < 8 - head.length - tail.length; index += 1) zeros.push("0");
14964
+ const words = halves.length === 1 ? head : [
14965
+ ...head,
14966
+ ...zeros,
14967
+ ...tail
14968
+ ];
14969
+ if (words.length !== 8) return null;
14970
+ const numbers = words.map((word) => /^[0-9a-f]{1,4}$/.test(word) ? Number.parseInt(word, 16) : NaN);
14971
+ return numbers.some((number) => Number.isNaN(number)) ? null : numbers;
14515
14972
  }
14516
14973
  /**
14517
14974
  * Reads the address out of whatever the endpoint answered: a bare address, a
@@ -14913,6 +15370,22 @@ var init_history = __esmMin((() => {
14913
15370
  this.load();
14914
15371
  return [...this.events];
14915
15372
  }
15373
+ /**
15374
+ * Drops every event for a server that no longer exists.
15375
+ *
15376
+ * An id is only unique *while* it exists. Nothing pruned on removal, so re-adding an id — a
15377
+ * rename-back, a restore, a config edit — inherited the old entry's crash time and event list, and
15378
+ * the card showed "Last crash" for a server that had never been started. Bounded (5000 events) so it
15379
+ * was never a leak, but wrong in the same way the log files were.
15380
+ */
15381
+ forget(serverId) {
15382
+ this.load();
15383
+ const kept = this.events.filter((event) => event.serverId !== serverId);
15384
+ if (kept.length === this.events.length) return;
15385
+ this.events = kept;
15386
+ this.version += 1;
15387
+ this.scheduleSave();
15388
+ }
14916
15389
  /** `runningSince` adds the in-flight up-interval so a long-running server shows its real ratio. */
14917
15390
  summarize(serverId, windowMs, now = Date.now(), runningSince = null) {
14918
15391
  this.load();
@@ -14920,7 +15393,11 @@ var init_history = __esmMin((() => {
14920
15393
  const mine = this.events.filter((event) => event.serverId === serverId);
14921
15394
  const recent = mine.filter((event) => event.ts >= since);
14922
15395
  let upMs = 0;
14923
- for (const event of recent) if (event.runtimeMs !== void 0) upMs += Math.min(event.runtimeMs, windowMs);
15396
+ for (const event of recent) {
15397
+ if (event.runtimeMs === void 0) continue;
15398
+ const startedAt = event.ts - event.runtimeMs;
15399
+ upMs += event.ts - Math.max(startedAt, since);
15400
+ }
14924
15401
  if (runningSince !== null) upMs += Math.max(0, now - Math.max(runningSince, since));
14925
15402
  const lastCrash = [...mine].reverse().find((event) => event.type === "crash");
14926
15403
  const lastExit = [...mine].reverse().find((event) => event.type === "exit" || event.type === "crash");
@@ -14933,7 +15410,7 @@ var init_history = __esmMin((() => {
14933
15410
  lastCrashAt: lastCrash?.ts ?? null,
14934
15411
  lastExitAt: lastExit?.ts ?? null,
14935
15412
  lastRuntimeMs: lastExit?.runtimeMs ?? null,
14936
- events: mine.slice(-8)
15413
+ events: recent.slice(-8)
14937
15414
  };
14938
15415
  }
14939
15416
  dispose() {
@@ -15512,10 +15989,15 @@ var init_panel = __esmMin((() => {
15512
15989
  const runtime = this.runtimes.get(id);
15513
15990
  if (runtime === void 0) throw new WorkspaceError(`unknown workspace "${id}"`);
15514
15991
  if (this.runtimes.size <= 1) throw new WorkspaceError("cannot remove the only workspace");
15515
- for (const server of runtime.store.servers) try {
15516
- await runtime.supervisor.stop(server.id);
15517
- } catch (error) {
15518
- logger.warn(`could not stop ${server.id} while removing workspace "${id}"`, error);
15992
+ for (const server of runtime.store.servers) {
15993
+ const stopped = await runtime.supervisor.stop(server.id).catch((error) => {
15994
+ logger.warn(`could not stop ${server.id} while removing workspace "${id}"`, error);
15995
+ return {
15996
+ ok: true,
15997
+ error: void 0
15998
+ };
15999
+ });
16000
+ if (!stopped.ok && stopped.error?.includes("survived the stop")) throw new WorkspaceError(`cannot remove "${id}": ${stopped.error} — stop it first, or the workspace would be deleted while that process is still running and unreachable from here`);
15519
16001
  }
15520
16002
  await runtime.supervisor.dispose();
15521
16003
  this.disposeRuntime(runtime);
@@ -16474,7 +16956,7 @@ async function runControlPlane(options) {
16474
16956
  return app.fetch(request);
16475
16957
  },
16476
16958
  trustProxy: () => settings.control.auth.trustProxy,
16477
- tls: () => settings.control.tls.enabled ? tls.load() : null
16959
+ tls: () => settings.control.tls.enabled ? tls.servable() : null
16478
16960
  }, {
16479
16961
  host: intended.host,
16480
16962
  port: intended.port,
@@ -16493,12 +16975,18 @@ async function runControlPlane(options) {
16493
16975
  logger.info("bind the panel back to `local`, or set a password with `home-hosted set-password` and enable auth in Global Settings");
16494
16976
  process.exit(1);
16495
16977
  }
16978
+ const trustWarning = proxyTrustWarning({
16979
+ ...configured,
16980
+ host: intended.host
16981
+ });
16982
+ if (trustWarning !== null) logger.warn(trustWarning);
16496
16983
  if (options.printConfig) {
16497
16984
  printEffectiveConfig(settings, registry, options);
16498
16985
  return;
16499
16986
  }
16500
16987
  if (!await isPortFree(intended.port)) {
16501
- logger.error(`control port ${intended.port} is already in use — is another home-hosted running?`);
16988
+ const holders = await listPortHolders(intended.port);
16989
+ logger.error(`control port ${intended.port} is already in use${holders.length > 0 ? ` by pid ${holders.join(", ")}` : ""} — another home-hosted, or something else on this port`);
16502
16990
  process.exit(1);
16503
16991
  }
16504
16992
  if (intended.host !== configured.host || intended.port !== configured.port) settings.updateControl({
@@ -16750,7 +17238,8 @@ var up_exports = /* @__PURE__ */ __exportAll({
16750
17238
  function toUpFlags(args) {
16751
17239
  let port;
16752
17240
  if (args.port !== void 0) {
16753
- port = Number.parseInt(args.port, 10);
17241
+ const raw = args.port.trim();
17242
+ port = /^\d+$/.test(raw) ? Number.parseInt(raw, 10) : NaN;
16754
17243
  if (!Number.isInteger(port) || port <= 0 || port > 65535) fail(`invalid port: ${args.port}`);
16755
17244
  }
16756
17245
  return {
@@ -16764,8 +17253,8 @@ function toUpFlags(args) {
16764
17253
  };
16765
17254
  }
16766
17255
  async function runUp(flags, entry) {
16767
- const { runControlPlane } = await Promise.resolve().then(() => (init_src(), src_exports));
16768
17256
  if (flags.foreground || flags.printConfig) {
17257
+ const { runControlPlane } = await Promise.resolve().then(() => (init_src(), src_exports));
16769
17258
  await runControlPlane({
16770
17259
  configPath: flags.config,
16771
17260
  port: flags.port,
@@ -16837,10 +17326,7 @@ async function waitForStartup(child, timeoutMs = 2e4) {
16837
17326
  }
16838
17327
  function upCommand(entry) {
16839
17328
  return defineCommand({
16840
- meta: {
16841
- name: "up",
16842
- description: "start the panel in the background (detached)"
16843
- },
17329
+ meta: { name: "up" },
16844
17330
  args: upArgs,
16845
17331
  run: async ({ args }) => {
16846
17332
  await runUp(toUpFlags(args), entry);
@@ -16990,10 +17476,7 @@ var init_down = __esmMin((() => {
16990
17476
  init_io();
16991
17477
  init_version();
16992
17478
  downCommand = defineCommand({
16993
- meta: {
16994
- name: "down",
16995
- description: "stop it, and everything it supervises"
16996
- },
17479
+ meta: { name: "down" },
16997
17480
  run: async () => {
16998
17481
  await runDown();
16999
17482
  }
@@ -17002,7 +17485,6 @@ var init_down = __esmMin((() => {
17002
17485
  //#endregion
17003
17486
  //#region src/cli/server.ts
17004
17487
  var server_exports = /* @__PURE__ */ __exportAll({
17005
- restartServerCommand: () => restartServerCommand,
17006
17488
  runServerAction: () => runServerAction,
17007
17489
  startCommand: () => startCommand,
17008
17490
  stopCommand: () => stopCommand
@@ -17029,10 +17511,7 @@ async function runServerAction(action, id, workspace) {
17029
17511
  }
17030
17512
  function serverCommand(action) {
17031
17513
  return defineCommand({
17032
- meta: {
17033
- name: action,
17034
- description: DESCRIPTION[action]
17035
- },
17514
+ meta: { name: action },
17036
17515
  args: {
17037
17516
  id: {
17038
17517
  type: "positional",
@@ -17052,17 +17531,11 @@ function serverCommand(action) {
17052
17531
  }
17053
17532
  });
17054
17533
  }
17055
- var DESCRIPTION, startCommand, stopCommand, restartServerCommand;
17534
+ var startCommand, stopCommand;
17056
17535
  var init_server = __esmMin((() => {
17057
17536
  init_io();
17058
- DESCRIPTION = {
17059
- start: "start one server (the panel keeps running)",
17060
- stop: "stop one server (the panel keeps running)",
17061
- restart: "restart one server (the panel keeps running)"
17062
- };
17063
17537
  startCommand = serverCommand("start");
17064
17538
  stopCommand = serverCommand("stop");
17065
- restartServerCommand = serverCommand("restart");
17066
17539
  }));
17067
17540
  //#endregion
17068
17541
  //#region src/cli/restart.ts
@@ -17077,10 +17550,7 @@ var restart_exports = /* @__PURE__ */ __exportAll({ restartCommand: () => restar
17077
17550
  */
17078
17551
  function restartCommand(entry) {
17079
17552
  return defineCommand({
17080
- meta: {
17081
- name: "restart",
17082
- description: "down then up — or one server, given an id"
17083
- },
17553
+ meta: { name: "restart" },
17084
17554
  args: {
17085
17555
  ...upArgs,
17086
17556
  id: {
@@ -17122,11 +17592,16 @@ var status_exports = /* @__PURE__ */ __exportAll({
17122
17592
  async function runStatus(json) {
17123
17593
  const { isProcessAlive, probeRuntime, readRuntime } = await Promise.resolve().then(() => (init_daemon(), daemon_exports));
17124
17594
  const { UiService } = await Promise.resolve().then(() => (init_ui(), ui_exports));
17125
- const { hhDir: dataRoot } = await Promise.resolve().then(() => (init_paths(), paths_exports));
17595
+ const { hhDir: dataRoot, workspaceDir, DEFAULT_WORKSPACE_ID, workspacesPath } = await Promise.resolve().then(() => (init_paths(), paths_exports));
17126
17596
  const runtime = readRuntime();
17127
17597
  if (runtime === null) {
17128
- if (json) process.stdout.write(`${JSON.stringify({ running: false }, null, 2)}\n`);
17129
- else process.stdout.write("home-hosted is not running\n");
17598
+ const initialised = fs.existsSync(workspacesPath);
17599
+ if (json) process.stdout.write(`${JSON.stringify({
17600
+ running: false,
17601
+ initialised
17602
+ }, null, 2)}\n`);
17603
+ else if (initialised) process.stdout.write("home-hosted is not running — start it with `home-hosted up`\n");
17604
+ else process.stdout.write("home-hosted is not running — nothing is set up here yet; `home-hosted init` creates a project, `home-hosted up` starts one here\n");
17130
17605
  process.exitCode = 1;
17131
17606
  return;
17132
17607
  }
@@ -17135,22 +17610,29 @@ async function runStatus(json) {
17135
17610
  reachable: false,
17136
17611
  degraded: false
17137
17612
  };
17613
+ const ui = new UiService({ dataRoot });
17614
+ const uiMeta = ui.status().meta;
17615
+ const uiLabel = ui.custom ? uiMeta?.name ?? "installed" : "stock";
17138
17616
  if (json) {
17139
17617
  const { token: _token, ...safe } = runtime;
17140
17618
  process.stdout.write(`${JSON.stringify({
17141
17619
  running,
17142
17620
  answering: probe.reachable,
17143
17621
  degraded: probe.degraded,
17144
- ...safe
17622
+ ...safe,
17623
+ logsDir: path.join(workspaceDir(DEFAULT_WORKSPACE_ID), ".logs"),
17624
+ ui: {
17625
+ custom: ui.custom,
17626
+ name: uiLabel,
17627
+ version: uiMeta?.version ?? null
17628
+ }
17145
17629
  }, null, 2)}\n`);
17146
17630
  if (!running) process.exitCode = 1;
17147
17631
  return;
17148
17632
  }
17149
17633
  const uptime = formatDuration(Date.now() - runtime.startedAt);
17150
- const state = !running ? paint("31", "stale (the process is gone)") : probe.degraded ? paint("33", "running — a server needs attention") : probe.reachable ? green("running") : paint("33", "running, but not answering");
17151
- const ui = new UiService({ dataRoot });
17152
17634
  const rows = [
17153
- ["status", state],
17635
+ ["status", !running ? paint("31", "stale (the process is gone)") : probe.degraded ? paint("33", "running — a server needs attention") : probe.reachable ? green("running") : paint("33", "running, but not answering")],
17154
17636
  ["pid", running ? `${runtime.pid} · up ${uptime}` : String(runtime.pid)],
17155
17637
  ["url", `${runtime.url} ${dim(`(${runtime.protocol})`)}`],
17156
17638
  ["version", runtime.version],
@@ -17158,10 +17640,15 @@ async function runStatus(json) {
17158
17640
  ["state", runtime.dataRoot],
17159
17641
  ["internal", runtime.configPath],
17160
17642
  ["log", runtime.logFile],
17161
- ["ui", ui.custom ? `custom — ${ui.status().meta?.name ?? "installed"} (revert with \`home-hosted ui-revert\`)` : "stock"]
17643
+ ["serverlogs", (() => {
17644
+ const dir = path.join(workspaceDir(DEFAULT_WORKSPACE_ID), ".logs");
17645
+ return fs.existsSync(dir) ? `${dir} (one file per server: <id>.log)` : dir;
17646
+ })()],
17647
+ ["ui", ui.custom ? `custom — ${uiLabel} (revert with \`home-hosted ui-revert\`)` : "stock"]
17162
17648
  ];
17163
17649
  process.stdout.write(`${bold(`home-hosted ${runtime.version}`)}\n`);
17164
- for (const [label, value] of rows) process.stdout.write(` ${dim(label.padEnd(8))} ${value}\n`);
17650
+ const width = Math.max(...rows.map(([label]) => label.length));
17651
+ for (const [label, value] of rows) process.stdout.write(` ${dim(label.padEnd(width))} ${value}\n`);
17165
17652
  if (!running) process.exitCode = 1;
17166
17653
  }
17167
17654
  function formatDuration(ms) {
@@ -17181,10 +17668,7 @@ var init_status = __esmMin((() => {
17181
17668
  description: "print machine-readable JSON"
17182
17669
  } };
17183
17670
  statusCommand = defineCommand({
17184
- meta: {
17185
- name: "status",
17186
- description: "is it running, where, and how to reach it"
17187
- },
17671
+ meta: { name: "status" },
17188
17672
  args: statusArgs,
17189
17673
  run: async ({ args }) => {
17190
17674
  await runStatus(args.json === true);
@@ -17198,12 +17682,39 @@ var logs_exports = /* @__PURE__ */ __exportAll({
17198
17682
  logsCommand: () => logsCommand,
17199
17683
  runLogs: () => runLogs
17200
17684
  });
17201
- /** `all` or a negative count means the whole log; a bad number is not worth a hard failure. */
17685
+ /**
17686
+ * Whether a path exists but cannot be read — the case `readLog` cannot distinguish from absence,
17687
+ * because it folds every failure into "nothing there".
17688
+ *
17689
+ * A *missing* log is normal (a panel that has not started), so only a real permission or IO
17690
+ * refusal is reported. `R_OK` is the question being asked; existence alone would call an
17691
+ * unreadable file readable.
17692
+ */
17693
+ function isUnreadable(file) {
17694
+ try {
17695
+ fs.accessSync(file, fs.constants.R_OK);
17696
+ return false;
17697
+ } catch (error) {
17698
+ return error.code !== "ENOENT";
17699
+ }
17700
+ }
17701
+ /**
17702
+ * `all` or a negative count means the whole log; an unreadable count is a typo, not a reason to
17703
+ * fail, so it falls back to the default.
17704
+ *
17705
+ * What it does **not** do is accept a *partly* numeric argument, which is how
17706
+ * `Number.parseInt` behaves and why this is not just a `parseInt`. `--lines 1e3` meant 1 line
17707
+ * (not 1000), and `--lines 0x10` parsed as 0 — which is the "whole log" sentinel, so a bounded
17708
+ * request silently became an unbounded one. Neither is a typo falling back to a default; both are
17709
+ * a wrong answer the caller cannot see. The whole trimmed argument has to be a decimal integer.
17710
+ */
17202
17711
  function parseLines(raw) {
17203
17712
  if (raw === void 0) return DEFAULT_LINES;
17204
- if (raw.trim().toLowerCase() === "all") return 0;
17205
- const parsed = Number.parseInt(raw, 10);
17206
- if (!Number.isFinite(parsed)) return DEFAULT_LINES;
17713
+ const trimmed = raw.trim();
17714
+ if (trimmed.toLowerCase() === "all") return 0;
17715
+ if (!/^[+-]?\d+$/.test(trimmed)) return DEFAULT_LINES;
17716
+ const parsed = Number.parseInt(trimmed, 10);
17717
+ if (!Number.isSafeInteger(parsed)) return DEFAULT_LINES;
17207
17718
  return parsed < 0 ? 0 : parsed;
17208
17719
  }
17209
17720
  async function runLogs(input) {
@@ -17212,10 +17723,37 @@ async function runLogs(input) {
17212
17723
  const lines = parseLines(input.lines);
17213
17724
  if (input.follow === true) {
17214
17725
  const { followLog } = await Promise.resolve().then(() => (init_daemon_log(), daemon_log_exports));
17215
- await followLog(daemonLogPath, lines === 0 ? DEFAULT_LINES : lines, (chunk) => process.stdout.write(chunk));
17726
+ const count = lines === 0 ? DEFAULT_LINES : lines;
17727
+ if (input.json !== true) {
17728
+ await followLog(daemonLogPath, count, (chunk) => process.stdout.write(chunk));
17729
+ return;
17730
+ }
17731
+ const emit = (line) => {
17732
+ process.stdout.write(`${JSON.stringify({
17733
+ path: daemonLogPath,
17734
+ line
17735
+ })}\n`);
17736
+ };
17737
+ let pending = "";
17738
+ await followLog(daemonLogPath, count, (chunk) => {
17739
+ pending += chunk;
17740
+ const parts = pending.split("\n");
17741
+ pending = parts.pop() ?? "";
17742
+ for (const part of parts) emit(part);
17743
+ });
17744
+ if (pending.length > 0) emit(pending);
17216
17745
  return;
17217
17746
  }
17218
17747
  const output = readLog(daemonLogPath, lines);
17748
+ if (output.length === 0 && isUnreadable(daemonLogPath)) {
17749
+ if (input.json === true) process.stdout.write(`${JSON.stringify({
17750
+ path: daemonLogPath,
17751
+ error: "unreadable"
17752
+ }, null, 2)}\n`);
17753
+ else process.stdout.write(`${paint("31", "error")} cannot read ${daemonLogPath} — check its permissions\n`);
17754
+ process.exitCode = 1;
17755
+ return;
17756
+ }
17219
17757
  if (input.json === true) {
17220
17758
  process.stdout.write(`${JSON.stringify({
17221
17759
  path: daemonLogPath,
@@ -17249,10 +17787,7 @@ var init_logs = __esmMin((() => {
17249
17787
  };
17250
17788
  DEFAULT_LINES = 50;
17251
17789
  logsCommand = defineCommand({
17252
- meta: {
17253
- name: "logs",
17254
- description: "show the panel's own console output"
17255
- },
17790
+ meta: { name: "logs" },
17256
17791
  args: logsArgs,
17257
17792
  async run({ args }) {
17258
17793
  try {
@@ -17305,10 +17840,7 @@ var init_set_password = __esmMin((() => {
17305
17840
  description: "remove the password, which disables authentication"
17306
17841
  } };
17307
17842
  setPasswordCommand = defineCommand({
17308
- meta: {
17309
- name: "set-password",
17310
- description: "set the panel password without the API"
17311
- },
17843
+ meta: { name: "set-password" },
17312
17844
  args: setPasswordArgs,
17313
17845
  run: async ({ args }) => {
17314
17846
  await runSetPassword(args.clear === true);
@@ -17387,10 +17919,7 @@ var init_set_token = __esmMin((() => {
17387
17919
  }
17388
17920
  };
17389
17921
  setTokenCommand = defineCommand({
17390
- meta: {
17391
- name: "set-token",
17392
- description: "set the API token that scripts and agents use"
17393
- },
17922
+ meta: { name: "set-token" },
17394
17923
  args: setTokenArgs,
17395
17924
  run: async ({ args }) => {
17396
17925
  await runSetToken(args.generate === true, args.clear === true);
@@ -17458,6 +17987,8 @@ async function runMigrate(config, dryRun, yes) {
17458
17987
  if (plan.tooNew) fail(`${target.file} was written by home-hosted ${meta.writtenBy ?? "a newer release"} (config schema ${from});\n this release understands schema ${plan.to}. Install that version, or edit the file yourself.`);
17459
17988
  const problem = target.validate(raw);
17460
17989
  if (problem !== null) fail(`${target.file} is not a config this release can read: ${problem}`);
17990
+ if (plan.reached !== plan.to) fail(`${target.file} is schema ${from} and this release can only lift it to schema ${plan.reached} — the migration chain in configMigrations has a gap to schema ${plan.to}.
17991
+ That is a bug in this release: report it rather than editing the file.`);
17461
17992
  if (plan.steps.length === 0) {
17462
17993
  const stamped = stampConfig(raw);
17463
17994
  if (JSON.stringify(stamped) !== JSON.stringify(raw)) stampOnly += 1;
@@ -17517,10 +18048,7 @@ var init_migrate = __esmMin((() => {
17517
18048
  }
17518
18049
  };
17519
18050
  migrateCommand = defineCommand({
17520
- meta: {
17521
- name: "migrate",
17522
- description: "bring the state up to this release's layout and schema"
17523
- },
18051
+ meta: { name: "migrate" },
17524
18052
  args: migrateArgs,
17525
18053
  run: async ({ args }) => {
17526
18054
  await runMigrate(args.config, args.dryRun === true, args.yes === true);
@@ -17722,10 +18250,7 @@ var init_init = __esmMin((() => {
17722
18250
  }
17723
18251
  };
17724
18252
  initCommand = defineCommand({
17725
- meta: {
17726
- name: "init",
17727
- description: "scaffold a project that keeps its state in the repo"
17728
- },
18253
+ meta: { name: "init" },
17729
18254
  args: initArgs,
17730
18255
  run: async ({ args }) => {
17731
18256
  await runInit({
@@ -17897,10 +18422,7 @@ var init_ui_switch = __esmMin((() => {
17897
18422
  init_ui_release();
17898
18423
  init_ui_release();
17899
18424
  uiSwitchCommand = defineCommand({
17900
- meta: {
17901
- name: "ui-switch",
17902
- description: "install a UI from a release asset, a zip file or a URL"
17903
- },
18425
+ meta: { name: "ui-switch" },
17904
18426
  run: async ({ rawArgs }) => {
17905
18427
  await uiSwitch(rawArgs, {
17906
18428
  write: (text) => process.stdout.write(text),
@@ -18161,10 +18683,7 @@ var init_ui_update = __esmMin((() => {
18161
18683
  init_io();
18162
18684
  init_ui_release();
18163
18685
  uiUpdateCommand = defineCommand({
18164
- meta: {
18165
- name: "ui-update",
18166
- description: "update the installed UI to match this panel, or pick a release"
18167
- },
18686
+ meta: { name: "ui-update" },
18168
18687
  run: async ({ rawArgs }) => {
18169
18688
  await uiUpdate(rawArgs, {
18170
18689
  write: (text) => process.stdout.write(text),
@@ -18196,10 +18715,7 @@ var init_ui_revert = __esmMin((() => {
18196
18715
  init_paths();
18197
18716
  init_ui();
18198
18717
  uiRevertCommand = defineCommand({
18199
- meta: {
18200
- name: "ui-revert",
18201
- description: "go back to the stock control panel UI"
18202
- },
18718
+ meta: { name: "ui-revert" },
18203
18719
  run: async () => {
18204
18720
  await runUiRevert();
18205
18721
  }
@@ -18300,7 +18816,7 @@ async function runNanny(spec, statePath) {
18300
18816
  at: Date.now(),
18301
18817
  runtimeMs
18302
18818
  };
18303
- emit("system", note ?? (signal !== null ? `persistent: exited with signal ${signal}` : `persistent: exited with code ${code}`));
18819
+ emit("system", note ?? `persistent: exited with ${describeNannyExit(lastExit)}`);
18304
18820
  persistState();
18305
18821
  logFiles.dispose();
18306
18822
  exitCode = code ?? (signal === null ? 0 : 1);
@@ -18357,7 +18873,9 @@ var init_nanny = __esmMin((() => {
18357
18873
  //#region src/cli.ts
18358
18874
  init_args();
18359
18875
  init_io();
18876
+ init_nearest();
18360
18877
  init_runtime();
18878
+ init_version();
18361
18879
  /**
18362
18880
  * The command line, and nothing else. Two things happen before citty is asked
18363
18881
  * anything: `--home`/`--project` are peeled off and applied (because
@@ -18436,7 +18954,7 @@ var LOGS_SECTION = {
18436
18954
  lines: [
18437
18955
  ["--lines <n>", "how many lines to show (default 50, `all` for everything)"],
18438
18956
  ["--follow", "keep printing as the panel writes (like tail -f)"],
18439
- ["--json", "print machine-readable JSON"]
18957
+ ["--json", "print machine-readable JSON (one object per line with --follow)"]
18440
18958
  ]
18441
18959
  };
18442
18960
  var SERVER_SECTION = {
@@ -18520,6 +19038,14 @@ var ENVIRONMENT_SECTION = {
18520
19038
  ["GITHUB_TOKEN", "a GitHub token for ui-switch (GH_TOKEN also works)"]
18521
19039
  ]
18522
19040
  };
19041
+ /**
19042
+ * The command name a typo most likely meant, or null when nothing is close. The candidates are
19043
+ * `SYNOPSIS`, the same source the reference renders, so a suggestion cannot name a command the CLI
19044
+ * does not have.
19045
+ */
19046
+ function suggestCommand(input) {
19047
+ return nearestWord(input, Object.keys(SYNOPSIS));
19048
+ }
18523
19049
  /** The command list of the full reference, aligned as it always was. */
18524
19050
  function renderCommandList() {
18525
19051
  const width = Math.max(...Object.values(SYNOPSIS).map((synopsis) => synopsis.length));
@@ -18600,15 +19126,8 @@ function commandHelp(command) {
18600
19126
  SHARED_TRAILER
18601
19127
  ].join("\n");
18602
19128
  }
18603
- function manifestVersion() {
18604
- try {
18605
- return JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "0.0.0";
18606
- } catch {
18607
- return "0.0.0";
18608
- }
18609
- }
18610
19129
  function version() {
18611
- process.stdout.write(`${manifestVersion()}\n`);
19130
+ process.stdout.write(`${appVersion()}\n`);
18612
19131
  }
18613
19132
  /**
18614
19133
  * citty owns dispatch and argument parsing. Each entry is a lazy import, so a
@@ -18633,7 +19152,7 @@ var COMMANDS = {
18633
19152
  var rootCommand = defineCommand({
18634
19153
  meta: {
18635
19154
  name: "home-hosted",
18636
- version: manifestVersion(),
19155
+ version: appVersion(),
18637
19156
  description: "a control panel for the processes on your home server"
18638
19157
  },
18639
19158
  subCommands: COMMANDS
@@ -18662,7 +19181,10 @@ async function main() {
18662
19181
  return;
18663
19182
  }
18664
19183
  if (invocation.kind === "unknown") {
18665
- process.stderr.write(`unknown command: ${invocation.command}\n\n${USAGE}`);
19184
+ const suggestion = suggestCommand(invocation.command);
19185
+ process.stderr.write(`unknown command: ${invocation.command}\n`);
19186
+ if (suggestion !== null) process.stderr.write(`${dim(`did you mean \`${suggestion}\`?`)}\n`);
19187
+ process.stderr.write(`\n${USAGE}`);
18666
19188
  process.exit(1);
18667
19189
  }
18668
19190
  const { ensureLayout } = await Promise.resolve().then(() => (init_layout(), layout_exports));
@@ -18681,6 +19203,6 @@ main().catch((error) => {
18681
19203
  fail(error instanceof Error ? error.message : String(error));
18682
19204
  });
18683
19205
  //#endregion
18684
- export { commandHelp };
19206
+ export { commandHelp, suggestCommand };
18685
19207
 
18686
19208
  //# sourceMappingURL=cli.js.map