home-hosted 0.7.4 → 0.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -12,8 +12,9 @@ level: `settings.json` (listener, auth, TLS policy, host vitals, backups, revers
12
12
  `.backups/`, `.ui/`, `.logs/` (the panel console), `.proxy/` (the reverse-proxy engine, its generated
13
13
  configuration and its nanny state), and `run.json` — the live daemon's pid/url/token, 0600. Every workspace owns
14
14
  `.hh/<id>/`: `settings.json` (server defaults, log retention, notifications, DDNS), `servers.config.json`,
15
- `.secrets.json` (0600: Telegram bot token and DDNS credentials sealed with AES-256-GCM under
16
- `HHOSTED_DDNS_SECRET`), `.logs/`, and `.state/` (a persistent entry's nanny state plus its 0600 spawn
15
+ `.secrets.json` (0600: Telegram bot token, plus DDNS credentials sealed with AES-256-GCM under
16
+ `HHOSTED_DDNS_SECRET` — only DDNS is sealed, because it is the one thing replayed to a third party),
17
+ `.logs/`, and `.state/` (a persistent entry's nanny state plus its 0600 spawn
17
18
  spec until the nanny reads it, plus `ddns.json`). A pre-`.hh` instance is relocated automatically by
18
19
  `ensureLayout()` (`src/config/layout.ts`). The package ships **no servers**: never commit a config, a
19
20
  seed entry, or a path that names one.
@@ -165,9 +166,10 @@ a minor without one.
165
166
  - A single on/off setting is a `ToggleSwitch`; `CheckField` is only for picking items out of a set
166
167
  (the restore plan). A checkbox in a `FieldGroup` grid reads as misaligned next to the inputs.
167
168
  - A create body carries only what differs from what the entry would inherit — the schema's defaults
168
- with `Workspace Settings → Server defaults` on top (`inheritBaseline` + `diffFields` in both
169
- UIs). A value written into `servers.config.json` stops following those defaults, so anything the
170
- person did not decide stays out; the editor applies the same rule to an edit.
169
+ with `Workspace Settings → Server defaults` on top (each UI's own `inheritBaseline` plus the shared
170
+ `diffFields` in `src/shared/patch-diff.ts`). A value written into `servers.config.json` stops
171
+ following those defaults, so anything the person did not decide stays out; the editor applies the
172
+ same rule to an edit.
171
173
  - ArkType at every runtime boundary: routes use `validate('json'|'query'|'param', schema)` then
172
174
  `c.req.valid(...)`; ad-hoc payloads use `parseOrThrow`. Schemas reject undeclared keys.
173
175
  - Every failure is a `DetailedError` (`@namesmt/utils`), mapped by `src/helpers/error.ts` into one
package/README.md CHANGED
@@ -232,6 +232,7 @@ default workspace.
232
232
  | `GET`/`PATCH /api/settings/workspace` | server defaults, logs, notifications | ✅ |
233
233
  | `GET`/`PATCH /api/settings` | panel-wide: listener, authentication, TLS, host vitals, backups | ❌ |
234
234
  | `GET`/`POST /api/backups`, restore | archives | ❌ |
235
+ | `GET`/`PATCH /api/proxy`, `POST /api/proxy/{engine,start,stop,apply,revert}`, `PUT`/`DELETE /api/proxy/certificates/:id` | the reverse proxy: its engine, its route table, and its uploaded certificates | ❌ |
235
236
  | `GET /healthz` | no session needed — the one an external monitor wants (its per-server detail needs a credential) | ❌ |
236
237
  | `GET /api/metrics` | Prometheus text (needs a token or session, like every `/api` route) | ❌ |
237
238
 
@@ -346,7 +347,7 @@ restarts itself), and how hand-edits are validated: [SERVERS.md](./docs/SERVERS.
346
347
  | `home-hosted set-password` | set the panel password without opening a browser |
347
348
  | `home-hosted set-token` | set the API token scripts and agents use (`--generate`, `--clear`) |
348
349
  | `home-hosted migrate` | relocate a pre-workspaces state directory and stamp every config for this release (`--dry-run`, `--yes`) |
349
- | `home-hosted init` | scaffold a self-contained project (`HHOSTED_HOME` sets to the repo) |
350
+ | `home-hosted init` | scaffold a self-contained project, whose scripts keep state in the repo (`--home ./state`) |
350
351
  | `home-hosted ui-switch` | install a UI from a release asset, a zip file or a URL (interactive) |
351
352
  | `home-hosted ui-update` | bring an installed UI up to date automatically (official UI) or pick a release (`--old`, `--check`) |
352
353
  | `home-hosted ui-revert` | go back to the stock panel UI after uploading your own |
@@ -578,7 +579,9 @@ or `4480`/`4443` unprivileged with your router forwarding to them
578
579
 
579
580
  Yes. Process trees are sampled from Win32_Process, termination uses `taskkill /T`, and the shipped
580
581
  examples avoid POSIX-only commands. CPU temperature and swap are best-effort where the OS does not
581
- expose them to an unprivileged process, and adopting a self-restarted process is Linux/macOS only.
582
+ expose them to an unprivileged process. Adopting a self-restarted process works on Windows too, off
583
+ its argv instead of the environment marker; a launcher whose real process is a `.cmd` shim is the one
584
+ case it cannot see through, and `kill` is the answer there.
582
585
 
583
586
  </details>
584
587
 
package/dist/cli.js CHANGED
@@ -5340,6 +5340,10 @@ var init_store = __esmMin((() => {
5340
5340
  this.listeners.add(listener);
5341
5341
  return () => this.listeners.delete(listener);
5342
5342
  }
5343
+ /** How many listeners are attached; a disposed owner must not add to it. */
5344
+ get listenerCount() {
5345
+ return this.listeners.size;
5346
+ }
5343
5347
  getServer(id) {
5344
5348
  return this.servers.find((server) => server.id === id);
5345
5349
  }
@@ -7546,9 +7550,9 @@ function parseUpstream(value) {
7546
7550
  const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
7547
7551
  try {
7548
7552
  const url = new URL(withScheme);
7549
- if (url.port.length === 0) return null;
7553
+ const port = url.port.length > 0 ? url.port : url.protocol === "https:" ? "443" : "80";
7550
7554
  return {
7551
- dial: `${url.hostname}:${url.port}`,
7555
+ dial: `${url.hostname}:${port}`,
7552
7556
  tls: url.protocol === "https:"
7553
7557
  };
7554
7558
  } catch {
@@ -7594,7 +7598,7 @@ function validateProxyConfig(config, dnsAccount) {
7594
7598
  for (const route of config.routes) {
7595
7599
  if (ids.has(route.id)) errors.push(`route id "${route.id}" is used twice`);
7596
7600
  ids.add(route.id);
7597
- const key = `${route.host.toLowerCase()}${route.path}`;
7601
+ const key = `${route.host.toLowerCase()}${route.path.trim().replace(/\/+$/, "")}`;
7598
7602
  if (seen.has(key)) errors.push(`"${route.host}" is routed twice`);
7599
7603
  seen.add(key);
7600
7604
  if (route.target === "server" && (route.workspace.length === 0 || route.server.length === 0)) errors.push(`route "${route.id}" needs a workspace and a server`);
@@ -7613,7 +7617,7 @@ function isPublicHost(host) {
7613
7617
  const name = host.toLowerCase();
7614
7618
  if (name === "localhost" || !name.includes(".")) return false;
7615
7619
  if (LOCAL_TLDS.some((tld) => name.endsWith(tld))) return false;
7616
- return !/^\d{1,3}(?:\.\d{1,3}){3}$/.test(name);
7620
+ return !/^\d{1,3}(?:\.\d{1,3}){3,}$/.test(name);
7617
7621
  }
7618
7622
  /** Caddy answers with `{"error": "..."}`; that sentence is the useful part. */
7619
7623
  function engineMessage(body) {
@@ -7668,7 +7672,7 @@ var init_proxy$1 = __esmMin((() => {
7668
7672
  port: "number = 0",
7669
7673
  origin: "string = \"\""
7670
7674
  }).onUndeclaredKey("reject");
7671
- ProxyService = class {
7675
+ ProxyService = class ProxyService {
7672
7676
  options;
7673
7677
  nanny = null;
7674
7678
  admin = null;
@@ -7677,6 +7681,8 @@ var init_proxy$1 = __esmMin((() => {
7677
7681
  moduleCache = null;
7678
7682
  /** A legacy `engine.json` is reported once, not on every state frame. */
7679
7683
  legacyChecksumWarned = false;
7684
+ /** Why the engine's nanny could not be spawned, cleared on the next start. */
7685
+ nannyError = null;
7680
7686
  certCache = null;
7681
7687
  /** The certificate state the last apply was built from, so a change re-applies once. */
7682
7688
  certificateSignature = null;
@@ -8518,11 +8524,15 @@ var init_proxy$1 = __esmMin((() => {
8518
8524
  subjects: routes.filter((route) => route.tls === "auto" && isPublicHost(route.host)).map((route) => route.host)
8519
8525
  };
8520
8526
  }
8527
+ /** The route's path as an engine prefix: trailing slashes are not a different route. */
8528
+ static pathKey(path) {
8529
+ return path.trim().replace(/\/+$/, "");
8530
+ }
8521
8531
  /** Every route, resolved far enough to render. */
8522
8532
  resolveRoutes(exclude) {
8523
8533
  const seen = /* @__PURE__ */ new Map();
8524
8534
  return this.config.routes.filter((route) => exclude?.has(route.id) !== true).map((route) => {
8525
- const key = `${route.host.toLowerCase()}${route.path}`;
8535
+ const key = `${route.host.toLowerCase()}${ProxyService.pathKey(route.path)}`;
8526
8536
  const clash = seen.get(key);
8527
8537
  if (clash !== void 0) return {
8528
8538
  route,
@@ -8802,11 +8812,16 @@ var init_proxy$1 = __esmMin((() => {
8802
8812
  let settled = false;
8803
8813
  let timer;
8804
8814
  let killTimer;
8815
+ /** The escalation is cancelled by a real exit, never by `finish`. */
8816
+ const stopEscalation = () => {
8817
+ if (killTimer === void 0) return;
8818
+ clearTimeout(killTimer);
8819
+ killTimer = void 0;
8820
+ };
8805
8821
  const finish = (value) => {
8806
8822
  if (settled) return;
8807
8823
  settled = true;
8808
8824
  if (timer !== void 0) clearTimeout(timer);
8809
- if (killTimer !== void 0) clearTimeout(killTimer);
8810
8825
  resolve(value);
8811
8826
  };
8812
8827
  timer = setTimeout(() => {
@@ -8818,8 +8833,14 @@ var init_proxy$1 = __esmMin((() => {
8818
8833
  child.stdout?.on("data", (chunk) => {
8819
8834
  output += chunk.toString("utf8");
8820
8835
  });
8821
- child.on("error", () => finish(null));
8822
- child.on("exit", () => finish(output));
8836
+ child.on("error", () => {
8837
+ stopEscalation();
8838
+ finish(null);
8839
+ });
8840
+ child.on("exit", () => {
8841
+ stopEscalation();
8842
+ finish(output);
8843
+ });
8823
8844
  });
8824
8845
  }
8825
8846
  /** Runs `version` once and reports what the binary actually is. */
@@ -8881,7 +8902,7 @@ var init_proxy$1 = __esmMin((() => {
8881
8902
  return;
8882
8903
  }
8883
8904
  await this.assertPortsFree(config.httpPort, config.httpsPort);
8884
- const admin = await this.chooseAdmin();
8905
+ const admin = await (this.options.chooseAdmin ?? this.chooseAdmin.bind(this))();
8885
8906
  this.admin = admin;
8886
8907
  const invalid = this.resolveRoutes().find((view) => view.status === "error");
8887
8908
  if (invalid !== void 0) throw new DetailedError(invalid.message ?? "a route cannot be built", {
@@ -8937,7 +8958,7 @@ var init_proxy$1 = __esmMin((() => {
8937
8958
  killPortHolders: false
8938
8959
  }
8939
8960
  });
8940
- this.nanny = spawn(process.execPath, nannyArgv(nannyEntryPoint(), PROXY_ID, specPath, statePath), {
8961
+ const nanny = spawn(process.execPath, nannyArgv(nannyEntryPoint(), PROXY_ID, specPath, statePath), {
8941
8962
  cwd: this.options.engineDir,
8942
8963
  env: {
8943
8964
  ...process.env,
@@ -8953,16 +8974,26 @@ var init_proxy$1 = __esmMin((() => {
8953
8974
  detached: true,
8954
8975
  windowsHide: true
8955
8976
  });
8956
- this.nanny.on("exit", () => {
8977
+ this.nannyError = null;
8978
+ this.nanny = nanny;
8979
+ const nannyGone = () => {
8980
+ if (this.nanny !== nanny) return;
8957
8981
  this.nanny = null;
8958
8982
  this.admin = null;
8959
8983
  fs.rmSync(this.options.adminPath, { force: true });
8960
8984
  this.options.onStateChange();
8985
+ };
8986
+ nanny.on("error", (error) => {
8987
+ this.nannyError = `the proxy engine's nanny could not be started: ${error.message}`;
8988
+ logger.error(`proxy: ${this.nannyError}`);
8989
+ nannyGone();
8961
8990
  });
8991
+ nanny.on("exit", nannyGone);
8962
8992
  if (!await this.waitReady()) {
8963
- this.lastError = `the engine did not answer on its admin endpoint within ${Math.round(READY_TIMEOUT_MS / 1e3)}s: ${this.logTail()}`;
8993
+ const failure = this.nannyError ?? `the engine did not answer on its admin endpoint within ${Math.round(READY_TIMEOUT_MS / 1e3)}s: ${this.logTail()}`;
8964
8994
  await this.stop().catch(() => void 0);
8965
- throw new DetailedError(this.lastError, {
8995
+ this.lastError = failure;
8996
+ throw new DetailedError(failure, {
8966
8997
  statusCode: 502,
8967
8998
  code: "ENGINE_NOT_READY"
8968
8999
  });
@@ -8990,11 +9021,10 @@ var init_proxy$1 = __esmMin((() => {
8990
9021
  logger.warn(`proxy: could not signal pid ${pid}`, error);
8991
9022
  }
8992
9023
  }
8993
- const deadline = Date.now() + 1e4;
9024
+ const deadline = Date.now() + (this.options.stopGraceMs ?? 1e4);
8994
9025
  while (Date.now() < deadline && pids.some((pid) => isProcessAlive$1(pid))) await new Promise((resolve) => setTimeout(resolve, 100));
8995
- const survivor = pids.find((pid) => isProcessAlive$1(pid));
8996
- if (survivor !== void 0) try {
8997
- process.kill(survivor, "SIGKILL");
9026
+ for (const pid of pids.filter(isProcessAlive$1)) try {
9027
+ process.kill(pid, "SIGKILL");
8998
9028
  } catch {}
8999
9029
  fs.rmSync(statePath, { force: true });
9000
9030
  }
@@ -9087,6 +9117,17 @@ var init_proxy$1 = __esmMin((() => {
9087
9117
  }
9088
9118
  if (admin.kind === "tcp") headers.origin = admin.origin;
9089
9119
  return new Promise((resolve, reject) => {
9120
+ let settled = false;
9121
+ const succeed = (value) => {
9122
+ if (settled) return;
9123
+ settled = true;
9124
+ resolve(value);
9125
+ };
9126
+ const fail = (error) => {
9127
+ if (settled) return;
9128
+ settled = true;
9129
+ reject(error instanceof Error ? error : new Error(String(error)));
9130
+ };
9090
9131
  const request = http.request({
9091
9132
  ...admin.kind === "unix" ? { socketPath: admin.path } : {
9092
9133
  host: admin.host,
@@ -9099,20 +9140,24 @@ var init_proxy$1 = __esmMin((() => {
9099
9140
  }, (response) => {
9100
9141
  const chunks = [];
9101
9142
  response.on("data", (chunk) => chunks.push(chunk));
9102
- response.on("end", () => resolve({
9143
+ response.on("end", () => succeed({
9103
9144
  status: response.statusCode ?? 0,
9104
9145
  body: Buffer$1.concat(chunks).toString("utf8")
9105
9146
  }));
9147
+ response.on("error", fail);
9148
+ response.on("aborted", () => fail(/* @__PURE__ */ new Error("the engine closed the connection before it answered")));
9106
9149
  });
9107
9150
  request.on("timeout", () => request.destroy(/* @__PURE__ */ new Error("the engine did not answer in time")));
9108
- request.on("error", reject);
9151
+ request.on("error", fail);
9109
9152
  if (payload !== null) request.write(payload);
9110
9153
  request.end();
9111
9154
  });
9112
9155
  }
9113
9156
  async waitReady() {
9157
+ if (this.options.waitReady !== void 0) return this.options.waitReady();
9114
9158
  const deadline = Date.now() + READY_TIMEOUT_MS;
9115
9159
  while (Date.now() < deadline) {
9160
+ if (this.nanny === null) return false;
9116
9161
  try {
9117
9162
  if ((await this.request("GET", "/config/")).status < 400) return true;
9118
9163
  } catch {}
@@ -10255,6 +10300,26 @@ function readJson(file) {
10255
10300
  return null;
10256
10301
  }
10257
10302
  }
10303
+ /**
10304
+ * A legacy file, with "absent" told apart from "present but unreadable".
10305
+ *
10306
+ * `readJson` collapses both into `null`, and a split that is skipped reads exactly like
10307
+ * one with nothing to do — so a corrupt legacy config used to be abandoned silently
10308
+ * while `workspaces.json` was still created, after which `hasLegacyLayout()` is false
10309
+ * and no later run ever looks at that file again. The definitions would sit unread
10310
+ * beside a panel supervising nothing.
10311
+ */
10312
+ function readLegacy(file) {
10313
+ if (!fs.existsSync(file)) return {
10314
+ parsed: null,
10315
+ unreadable: false
10316
+ };
10317
+ const parsed = readJson(file);
10318
+ return {
10319
+ parsed,
10320
+ unreadable: parsed === null
10321
+ };
10322
+ }
10258
10323
  function writeJson(file, value, mode) {
10259
10324
  fs.mkdirSync(path.dirname(file), { recursive: true });
10260
10325
  fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, mode === void 0 ? {} : { mode });
@@ -10352,10 +10417,11 @@ function migrateLayout() {
10352
10417
  const moved = [];
10353
10418
  const warnings = [];
10354
10419
  fs.mkdirSync(hhDir, { recursive: true });
10355
- const legacyConfig = readJson(legacy.configPath);
10420
+ const legacyConfig = readLegacy(legacy.configPath);
10356
10421
  let configSplit = false;
10357
- if (legacyConfig !== null) {
10358
- const split = splitConfig(legacyConfig);
10422
+ if (legacyConfig.unreadable) warnings.push(`${legacy.configPath} could not be read — it was NOT migrated and is still there; fix or remove it, then move the file in by hand`);
10423
+ if (legacyConfig.parsed !== null) {
10424
+ const split = splitConfig(legacyConfig.parsed);
10359
10425
  const destinations = [
10360
10426
  path.join(hhDir, "settings.json"),
10361
10427
  path.join(hhDir, "default", "settings.json"),
@@ -10372,10 +10438,11 @@ function migrateLayout() {
10372
10438
  configSplit = true;
10373
10439
  }
10374
10440
  }
10375
- const legacySecrets = readJson(legacy.secretsPath);
10441
+ const legacySecrets = readLegacy(legacy.secretsPath);
10376
10442
  let secretsSplit = false;
10377
- if (legacySecrets !== null) {
10378
- const split = splitSecrets(legacySecrets);
10443
+ if (legacySecrets.unreadable) warnings.push(`${legacy.secretsPath} could not be read — it was NOT migrated and is still there; a panel without it starts on the default password`);
10444
+ if (legacySecrets.parsed !== null) {
10445
+ const split = splitSecrets(legacySecrets.parsed);
10379
10446
  const destinations = [path.join(hhDir, ".control-secrets.json"), path.join(hhDir, "default", ".secrets.json")];
10380
10447
  const taken = destinations.filter((file) => fs.existsSync(file));
10381
10448
  if (taken.length > 0) warnings.push(`${legacy.secretsPath} could not be split — ${taken.map((file) => path.relative(hhDir, file)).join(", ")} already exist(s); left it in place`);
@@ -10555,6 +10622,8 @@ function localCall(runtime, path, method, token, timeoutMs) {
10555
10622
  status: response.statusCode ?? 0,
10556
10623
  body
10557
10624
  }));
10625
+ response.once("aborted", () => resolve(null));
10626
+ response.once("error", () => resolve(null));
10558
10627
  });
10559
10628
  request.once("error", () => resolve(null));
10560
10629
  request.once("timeout", () => {
@@ -10869,10 +10938,23 @@ async function open(file, password) {
10869
10938
  const blob = await fs.openAsBlob(file, { type: "application/zip" });
10870
10939
  return password === void 0 ? new ZipReader(new BlobReader(blob)) : new ZipReader(new BlobReader(blob), { password });
10871
10940
  }
10872
- /** Sorted, deterministic walk with symlinks resolved and directory loops broken. */
10941
+ /**
10942
+ * Sorted, deterministic walk with symlinks resolved and directory loops broken.
10943
+ *
10944
+ * Every read is best-effort: a directory that vanishes mid-walk (a rotated log, a
10945
+ * build directory being cleaned, a container being torn down) must leave it out of
10946
+ * the archive rather than reject `createZip` and turn a backup into a failure.
10947
+ */
10873
10948
  function walk(root) {
10874
10949
  const files = [];
10875
10950
  const seen = /* @__PURE__ */ new Set();
10951
+ const readDir = (absolute) => {
10952
+ try {
10953
+ return fs.readdirSync(absolute).sort();
10954
+ } catch {
10955
+ return [];
10956
+ }
10957
+ };
10876
10958
  const visit = (absolute, name) => {
10877
10959
  let stats;
10878
10960
  try {
@@ -10881,7 +10963,12 @@ function walk(root) {
10881
10963
  return;
10882
10964
  }
10883
10965
  if (stats.isDirectory()) {
10884
- const real = fs.realpathSync(absolute);
10966
+ let real;
10967
+ try {
10968
+ real = fs.realpathSync(absolute);
10969
+ } catch {
10970
+ return;
10971
+ }
10885
10972
  if (seen.has(real)) return;
10886
10973
  seen.add(real);
10887
10974
  files.push({
@@ -10890,7 +10977,7 @@ function walk(root) {
10890
10977
  directory: true,
10891
10978
  size: 0
10892
10979
  });
10893
- for (const child of fs.readdirSync(absolute).sort()) visit(path.join(absolute, child), `${name}/${child}`);
10980
+ for (const child of readDir(absolute)) visit(path.join(absolute, child), `${name}/${child}`);
10894
10981
  return;
10895
10982
  }
10896
10983
  if (stats.isFile()) files.push({
@@ -10900,7 +10987,7 @@ function walk(root) {
10900
10987
  size: stats.size
10901
10988
  });
10902
10989
  };
10903
- for (const child of fs.readdirSync(root).sort()) visit(path.join(root, child), child);
10990
+ for (const child of readDir(root)) visit(path.join(root, child), child);
10904
10991
  return files;
10905
10992
  }
10906
10993
  var ZIPS;
@@ -11335,8 +11422,13 @@ var init_log_tail = __esmMin((() => {
11335
11422
  try {
11336
11423
  const buffer = Buffer$1.alloc(size - start);
11337
11424
  const bytes = fs.readSync(handle, buffer, 0, buffer.length, start);
11338
- const raw = buffer.subarray(0, bytes).toString("utf8").split("\n").filter((line) => line.trim().length > 0);
11339
- return (start > 0 ? raw.slice(1) : raw).slice(-count).map(parseLogLine).filter((line) => line !== null);
11425
+ const raw = buffer.subarray(0, bytes).toString("utf8").split("\n");
11426
+ let partialStart = false;
11427
+ if (start > 0) {
11428
+ const previous = Buffer$1.alloc(1);
11429
+ partialStart = fs.readSync(handle, previous, 0, 1, start - 1) === 1 && previous[0] !== 10;
11430
+ }
11431
+ return (partialStart ? raw.slice(1) : raw).filter((line) => line.trim().length > 0).slice(-count).map(parseLogLine).filter((line) => line !== null);
11340
11432
  } catch {
11341
11433
  return [];
11342
11434
  } finally {
@@ -11562,6 +11654,8 @@ var init_supervisor = __esmMin((() => {
11562
11654
  relay = new LogRelay({ onLines: (serverId, lines) => this.ingestExternalLines(serverId, lines) });
11563
11655
  entries = /* @__PURE__ */ new Map();
11564
11656
  tickTimer;
11657
+ /** Detaches this supervisor from the store's listener set in `dispose()`. */
11658
+ storeUnsubscribe;
11565
11659
  disposed = false;
11566
11660
  constructor(store, hub, options) {
11567
11661
  this.store = store;
@@ -11570,7 +11664,7 @@ var init_supervisor = __esmMin((() => {
11570
11664
  const swept = sweepNannySpecs(this.options.nannyDir);
11571
11665
  if (swept > 0) logger.warn(`removed ${swept} unread nanny spec file(s) from ${this.options.nannyDir}`);
11572
11666
  this.sync();
11573
- this.store.onChange(() => this.sync());
11667
+ this.storeUnsubscribe = this.store.onChange(() => this.sync());
11574
11668
  this.tickTimer = setInterval(() => {
11575
11669
  this.tick().catch((error) => logger.error("the supervisor tick failed", error));
11576
11670
  }, TICK_INTERVAL_MS);
@@ -11807,6 +11901,7 @@ var init_supervisor = __esmMin((() => {
11807
11901
  async dispose() {
11808
11902
  this.disposed = true;
11809
11903
  clearInterval(this.tickTimer);
11904
+ this.storeUnsubscribe();
11810
11905
  for (const entry of this.entries.values()) this.clearRetry(entry);
11811
11906
  await Promise.all([...this.entries.values()].filter((entry) => !entry.config.persistent).map((entry) => this.stopEntry(entry)));
11812
11907
  this.relay.dispose();
@@ -11870,6 +11965,9 @@ var init_supervisor = __esmMin((() => {
11870
11965
  this.clearRetry(entry);
11871
11966
  entry.nextRetryAt = null;
11872
11967
  entry.stopping = true;
11968
+ if (entry.config.persistent && entry.child === null && entry.pid === null) await this.resumePersistent(entry).catch((error) => {
11969
+ logger.error(`could not reattach the persistent server ${entry.config.id} to stop it`, error);
11970
+ });
11873
11971
  if (entry.child === null && entry.pid === null) {
11874
11972
  entry.stopping = false;
11875
11973
  entry.status = "stopped";
@@ -12154,19 +12252,26 @@ var init_supervisor = __esmMin((() => {
12154
12252
  const splitter = new LineSplitter((_stream, text) => {
12155
12253
  if (text.trim().length > 0) this.log(entry, "system", `[bootstrap] ${text}`);
12156
12254
  });
12157
- const child = spawn(resolveCommand(spec.command, cwd, projectDir), args, {
12158
- cwd,
12159
- env: {
12160
- ...process.env,
12161
- ...resolveRecord(spec.env, vars)
12162
- },
12163
- stdio: [
12164
- "ignore",
12165
- "pipe",
12166
- "pipe"
12167
- ],
12168
- windowsHide: true
12169
- });
12255
+ let child;
12256
+ try {
12257
+ child = spawn(resolveCommand(spec.command, cwd, projectDir), args, {
12258
+ cwd,
12259
+ env: {
12260
+ ...process.env,
12261
+ ...resolveRecord(spec.env, vars)
12262
+ },
12263
+ stdio: [
12264
+ "ignore",
12265
+ "pipe",
12266
+ "pipe"
12267
+ ],
12268
+ windowsHide: true
12269
+ });
12270
+ } catch (error) {
12271
+ this.log(entry, "system", `bootstrap could not start: ${error.message} — continuing anyway`);
12272
+ entry.bootstrapDone = true;
12273
+ return;
12274
+ }
12170
12275
  child.stdout?.on("data", (chunk) => splitter.push("stdout", chunk));
12171
12276
  child.stderr?.on("data", (chunk) => splitter.push("stderr", chunk));
12172
12277
  const code = await new Promise((resolve) => {
@@ -12832,6 +12937,29 @@ function isInside(parent, child) {
12832
12937
  const relative = path.relative(parent, child);
12833
12938
  return !path.isAbsolute(relative) && (relative.length === 0 || !relative.startsWith(".."));
12834
12939
  }
12940
+ /** Resolves symlinks on the deepest existing ancestor, keeping the rest verbatim. */
12941
+ function resolveExisting(target) {
12942
+ let prefix = target;
12943
+ const rest = [];
12944
+ for (;;) try {
12945
+ return path.join(fs.realpathSync(prefix), ...rest);
12946
+ } catch {
12947
+ const parent = path.dirname(prefix);
12948
+ if (parent === prefix) return target;
12949
+ rest.unshift(path.basename(prefix));
12950
+ prefix = parent;
12951
+ }
12952
+ }
12953
+ /**
12954
+ * `isInside` after resolving symlinks, so a link inside a declared path cannot stand
12955
+ * for somewhere else. `cpSync` follows links already present in the tree it writes
12956
+ * into* — a link to an uploads or cache directory is routine — which would let an
12957
+ * archive write outside the path the config declared, and on capture let a file from
12958
+ * outside it into the archive. Both sides check with this.
12959
+ */
12960
+ function isInsideResolved(parent, child) {
12961
+ return isInside(resolveExisting(path.resolve(parent)), resolveExisting(path.resolve(child)));
12962
+ }
12835
12963
  /**
12836
12964
  * Every path a backup should capture: the global list, then each server's
12837
12965
  * `backupPaths` and the values of its `dataEnvs`. A path already covered by a
@@ -13408,7 +13536,8 @@ var init_backups = __esmMin((() => {
13408
13536
  note: target.path === archivedPath ? null : `restored from ${archivedPath}`
13409
13537
  }, () => fs.cpSync(from, target.path, {
13410
13538
  recursive: true,
13411
- force: true
13539
+ force: true,
13540
+ filter: (_source, destination) => isInsideResolved(target.path, destination)
13412
13541
  }), aliases);
13413
13542
  }
13414
13543
  if (parsedGlobal !== null && actions.has("global:settings") && settingsText !== null) {
@@ -16982,6 +17111,7 @@ state/.hh/*
16982
17111
  !state/.hh/settings.json
16983
17112
  !state/.hh/workspaces.json
16984
17113
  state/.hh/*/*
17114
+ !state/.hh/*/
16985
17115
  !state/.hh/*/settings.json
16986
17116
  !state/.hh/*/servers.config.json
16987
17117
  data/
@@ -17229,7 +17359,7 @@ async function uiSwitch(argv, io) {
17229
17359
  io.write("cancelled — nothing was installed\n");
17230
17360
  return;
17231
17361
  }
17232
- await installFromUrl(assetDownloadUrl(chosen), fallbackUiName(chosen.name ?? ""), context);
17362
+ await installFromUrl(assetDownloadUrl(chosen), fallbackUiName(chosen.name ?? ""), context, release.tag);
17233
17363
  }
17234
17364
  async function chooseAsset(assets, values, context) {
17235
17365
  const { io } = context;
@@ -17269,7 +17399,13 @@ async function installFromFile(value, context) {
17269
17399
  }
17270
17400
  await installFromUrl(source.url, fallbackUiName(new URL(source.url).pathname), context);
17271
17401
  }
17272
- async function installFromUrl(url, fallbackName, context) {
17402
+ /**
17403
+ * `installedTag` is the release the asset came from, never the one inside the archive:
17404
+ * a UI zip is built before its release is cut, so it carries the previous tag at best,
17405
+ * and recording that makes the panel re-install the same UI on every boot. A local file
17406
+ * or a bare URL names no release, so it has none to record.
17407
+ */
17408
+ async function installFromUrl(url, fallbackName, context, installedTag) {
17273
17409
  const headers = {
17274
17410
  "accept": "application/octet-stream",
17275
17411
  "user-agent": `home-hosted/${context.version}`
@@ -17277,7 +17413,7 @@ async function installFromUrl(url, fallbackName, context) {
17277
17413
  if (context.token !== null && context.token.length > 0 && isGithubHost(url)) headers.authorization = `Bearer ${context.token}`;
17278
17414
  const download = await downloadToTemp(url, headers, context);
17279
17415
  try {
17280
- await installArchive(download.file, fallbackName, context);
17416
+ await installArchive(download.file, fallbackName, context, installedTag);
17281
17417
  } finally {
17282
17418
  fs.rmSync(download.dir, {
17283
17419
  recursive: true,
@@ -17285,11 +17421,11 @@ async function installFromUrl(url, fallbackName, context) {
17285
17421
  });
17286
17422
  }
17287
17423
  }
17288
- async function installArchive(archivePath, fallbackName, context) {
17424
+ async function installArchive(archivePath, fallbackName, context, installedTag) {
17289
17425
  const { UiService } = await Promise.resolve().then(() => (init_ui(), ui_exports));
17290
17426
  const { hhDir: dataRoot } = await Promise.resolve().then(() => (init_paths(), paths_exports));
17291
17427
  const ui = new UiService({ dataRoot });
17292
- const result = await ui.install(archivePath, fallbackName);
17428
+ const result = await ui.install(archivePath, fallbackName, installedTag);
17293
17429
  if (!result.ok) throw new Error(`nothing was installed: ${result.error}`);
17294
17430
  const { meta } = result;
17295
17431
  const label = meta.version === null ? meta.name : `${meta.name} ${meta.version}`;
@@ -17550,7 +17686,7 @@ async function installTag(repo, wantedAsset, tag, context) {
17550
17686
  try {
17551
17687
  const { UiService } = await Promise.resolve().then(() => (init_ui(), ui_exports));
17552
17688
  const { hhDir: dataRoot } = await Promise.resolve().then(() => (init_paths(), paths_exports));
17553
- const result = await new UiService({ dataRoot }).install(download.file, assetName);
17689
+ const result = await new UiService({ dataRoot }).install(download.file, assetName, release.tag);
17554
17690
  if (!result.ok) throw new Error(`nothing was installed: ${result.error}`);
17555
17691
  const { meta } = result;
17556
17692
  const { io } = context;