home-hosted 0.7.20 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -49,6 +49,19 @@ var __exportAll = (all, no_symbols) => {
49
49
  return target;
50
50
  };
51
51
  //#endregion
52
+ //#region src/shared/error-message.ts
53
+ /**
54
+ * One rendering for a thrown value, so a caught error reads the same everywhere.
55
+ *
56
+ * `catch` binds `unknown`, and the same ternary was written out in ~60 places across the
57
+ * panel and both UIs. Two idioms in one codebase is how they drift, and the UIs are
58
+ * separate bundles: a fix applied to one copy silently misses the others.
59
+ */
60
+ function errorMessage(caught) {
61
+ return caught instanceof Error ? caught.message : String(caught);
62
+ }
63
+ var init_error_message = __esmMin((() => {}));
64
+ //#endregion
52
65
  //#region src/cli/nearest.ts
53
66
  /**
54
67
  * The nearest word to a typo, or null when nothing is close.
@@ -1077,7 +1090,14 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
1077
1090
  logBufferLines: logBufferLinesSchema.optional()
1078
1091
  }).onUndeclaredKey("reject");
1079
1092
  editableFields = {
1080
- label: "string?",
1093
+ /**
1094
+ * `null` clears it, the same spelling `serverSchema` accepts and every display's `label ?? id`
1095
+ * fallback needs — an empty string satisfies neither. `serverPatchSchema` is what the editor
1096
+ * validates its payload against and what `PATCH /api/servers/:id` checks, so without this a
1097
+ * cleared Label was refused with "label must be a string (was null)" and could not be saved at all
1098
+ * (`1f58a5d` fixed `applyPatch` and the *control* patch schema, and cited that one in its message).
1099
+ */
1100
+ label: labelOrNullSchema.optional(),
1081
1101
  enabled: "boolean?",
1082
1102
  autostart: "boolean?",
1083
1103
  persistent: "boolean?",
@@ -1888,7 +1908,11 @@ function isSealedDdns(value) {
1888
1908
  function isPlainDdns(value) {
1889
1909
  return "plain" in value;
1890
1910
  }
1891
- /** Every shape this file has held: sealed, `{ provider, values }`, or a bare field map. */
1911
+ /**
1912
+ * Sealed on disk, or `{ provider, values }` waiting for the sealing write. A bare
1913
+ * field map — written before the provider was recorded — no longer reads, because
1914
+ * there is nothing to say which provider those fields belong to.
1915
+ */
1892
1916
  function readStoredDdns(value) {
1893
1917
  if (!isRecord(value)) return {};
1894
1918
  const out = {};
@@ -1897,12 +1921,12 @@ function readStoredDdns(value) {
1897
1921
  out[accountId] = entry;
1898
1922
  continue;
1899
1923
  }
1900
- if (!isRecord(entry)) continue;
1901
- const values = readDdnsValues("values" in entry ? entry.values : entry);
1924
+ if (!isRecord(entry) || typeof entry.provider !== "string" || entry.provider.length === 0) continue;
1925
+ const values = readDdnsValues(entry.values);
1902
1926
  if (Object.keys(values).length === 0) continue;
1903
1927
  out[accountId] = {
1904
1928
  plain: true,
1905
- provider: typeof entry.provider === "string" ? entry.provider : "",
1929
+ provider: entry.provider,
1906
1930
  values
1907
1931
  };
1908
1932
  }
@@ -1970,6 +1994,7 @@ var COST, KEYLEN, SALT_BYTES, DDNS_KEYLEN, DDNS_IV_BYTES, DDNS_AAD_PREFIX, DDNS_
1970
1994
  var init_secrets = __esmMin((() => {
1971
1995
  init_atomic();
1972
1996
  init_logger();
1997
+ init_error_message();
1973
1998
  init_shape();
1974
1999
  COST = {
1975
2000
  N: 16384,
@@ -1989,6 +2014,7 @@ var init_secrets = __esmMin((() => {
1989
2014
  scope;
1990
2015
  cache = null;
1991
2016
  cacheKey = "";
2017
+ error = null;
1992
2018
  ddnsKeyCache = null;
1993
2019
  warnedAccounts = /* @__PURE__ */ new Set();
1994
2020
  warnedDefaultSecret = false;
@@ -2113,7 +2139,7 @@ var init_secrets = __esmMin((() => {
2113
2139
  if (stored === void 0) return null;
2114
2140
  const record = this.openStored(accountId, stored);
2115
2141
  if (record === null) return null;
2116
- return record.provider.length > 0 && record.provider !== provider ? null : record;
2142
+ return record.provider === provider ? record : null;
2117
2143
  }
2118
2144
  /** An empty field is dropped, so clearing every field forgets the account. */
2119
2145
  setDdnsCredentials(accountId, provider, credentials) {
@@ -2184,9 +2210,22 @@ var init_secrets = __esmMin((() => {
2184
2210
  this.warnedAccounts.add(accountId);
2185
2211
  logger.warn(`ddns: cannot read the credentials for "${accountId}" — ${reason}`);
2186
2212
  }
2213
+ /**
2214
+ * A secrets file that exists but cannot be parsed, or null when it is fine (or absent).
2215
+ *
2216
+ * `read()` still returns the empty shape — a corrupt file must not authenticate anyone — but
2217
+ * "unreadable" and "absent" have to stay distinguishable: treated as absent, a truncated
2218
+ * `.control-secrets.json` reads as a fresh install, and `runControlPlane` then writes a
2219
+ * **default-password** file over it, destroying the password hash, the API token and every
2220
+ * Telegram and DDNS credential. Only the caller can refuse the boot, so this is its surface.
2221
+ */
2222
+ get configError() {
2223
+ this.load();
2224
+ return this.error;
2225
+ }
2187
2226
  read() {
2227
+ this.error = null;
2188
2228
  const empty = {
2189
- version: 3,
2190
2229
  password: null,
2191
2230
  apiToken: null,
2192
2231
  telegram: null,
@@ -2194,17 +2233,24 @@ var init_secrets = __esmMin((() => {
2194
2233
  ddnsKdf: null
2195
2234
  };
2196
2235
  if (!fs.existsSync(this.file)) return this.inScope(empty);
2236
+ let text;
2197
2237
  try {
2198
- const parsed = JSON.parse(fs.readFileSync(this.file, "utf8"));
2238
+ text = fs.readFileSync(this.file, "utf8");
2239
+ } catch (error) {
2240
+ this.error = `cannot read ${this.file}: ${errorMessage(error)}`;
2241
+ return this.inScope(empty);
2242
+ }
2243
+ try {
2244
+ const parsed = JSON.parse(text);
2199
2245
  return this.inScope({
2200
- version: 3,
2201
2246
  password: parsed?.password ?? null,
2202
2247
  apiToken: parsed?.apiToken?.hash ? parsed.apiToken : null,
2203
2248
  telegram: parsed?.telegram?.botToken ? { botToken: parsed.telegram.botToken } : null,
2204
2249
  ddns: readStoredDdns(parsed?.ddns),
2205
2250
  ddnsKdf: readDdnsKdf(parsed?.ddnsKdf)
2206
2251
  });
2207
- } catch {
2252
+ } catch (error) {
2253
+ this.error = `${this.file} is not readable JSON (${errorMessage(error)}) — it was left untouched`;
2208
2254
  return this.inScope(empty);
2209
2255
  }
2210
2256
  }
@@ -2224,6 +2270,7 @@ var init_secrets = __esmMin((() => {
2224
2270
  return contents;
2225
2271
  }
2226
2272
  save(contents) {
2273
+ if (this.configError !== null) throw new Error(`refusing to write ${this.file}: ${this.error}`);
2227
2274
  const sealed = this.sealStored(this.inScope(contents));
2228
2275
  writeFileAtomic(this.file, `${JSON.stringify(sealed, null, 2)}\n`, { mode: 384 });
2229
2276
  this.cache = sealed;
@@ -2912,7 +2959,11 @@ function workspaceIdsOnDisk() {
2912
2959
  return [];
2913
2960
  }
2914
2961
  }
2915
- /** True when a pre-workspace instance left state at the old paths. */
2962
+ /**
2963
+ * True when a pre-workspace instance left state at the old paths **and** never
2964
+ * crossed into `.hh`: `workspaces.json` means the layout is current, so a stray
2965
+ * root-level copy beside it is not an old instance.
2966
+ */
2916
2967
  function hasLegacyLayout() {
2917
2968
  if (fs.existsSync(workspacesPath)) return false;
2918
2969
  return fs.existsSync(legacy.configPath) || fs.existsSync(legacy.secretsPath);
@@ -2948,15 +2999,7 @@ var init_paths = __esmMin((() => {
2948
2999
  proxyChallengeAuthPath = path.join(proxyStateDir, "challenge.json");
2949
3000
  legacy = {
2950
3001
  configPath: path.join(dataRoot, "servers.config.json"),
2951
- configSchemaPath: path.join(dataRoot, "servers.config.schema.json"),
2952
- secretsPath: path.join(dataRoot, ".control-secrets.json"),
2953
- tlsDir: path.join(dataRoot, ".tls"),
2954
- logsDir: path.join(dataRoot, ".logs"),
2955
- historyPath: path.join(dataRoot, ".logs", "history.json"),
2956
- stateDir: path.join(dataRoot, ".state"),
2957
- backupsDir: path.join(dataRoot, ".backups"),
2958
- uiDir: path.join(dataRoot, ".ui"),
2959
- runtimePath: path.join(dataRoot, "run.json")
3002
+ secretsPath: path.join(dataRoot, ".control-secrets.json")
2960
3003
  };
2961
3004
  }));
2962
3005
  //#endregion
@@ -3617,7 +3660,7 @@ function planConfigMigrations(from, options = {}) {
3617
3660
  to,
3618
3661
  steps,
3619
3662
  tooNew: from > to,
3620
- reached: steps.reduce((at, step) => step.to, from)
3663
+ reached: steps.at(-1)?.to ?? from
3621
3664
  };
3622
3665
  }
3623
3666
  /** Applies the plan in order; the caller owns writing the result. */
@@ -3685,6 +3728,35 @@ function parseTolerant(value, schema, prefix, unknownKeys) {
3685
3728
  };
3686
3729
  }
3687
3730
  /**
3731
+ * Reads `meta`, then refuses a file this release cannot use.
3732
+ *
3733
+ * Both readers need this and the two messages have to stay in step: a file written by a
3734
+ * newer release, and one whose migration has not been applied, are different problems with
3735
+ * different fixes (`install that release` vs. `run migrate`). Written once here, so a change
3736
+ * to either wording cannot land on one path only.
3737
+ */
3738
+ function readStamp(raw, options) {
3739
+ const meta = isRecord(raw.meta) ? raw.meta : null;
3740
+ const schemaVersion = typeof meta?.schema === "number" ? meta.schema : 1;
3741
+ const writtenBy = typeof meta?.writtenBy === "string" && meta.writtenBy.length > 0 ? meta.writtenBy : null;
3742
+ if (schemaVersion > 1) return {
3743
+ schemaVersion,
3744
+ writtenBy,
3745
+ error: `written by home-hosted ${writtenBy ?? "a newer release"} (config schema ${schemaVersion}); this release understands schema 1`
3746
+ };
3747
+ const { steps } = planConfigMigrations(schemaVersion, options);
3748
+ if (steps.length > 0) return {
3749
+ schemaVersion,
3750
+ writtenBy,
3751
+ error: `config schema ${schemaVersion} needs ${steps.length} migration${steps.length === 1 ? "" : "s"} before this release can use it`
3752
+ };
3753
+ return {
3754
+ schemaVersion,
3755
+ writtenBy,
3756
+ error: null
3757
+ };
3758
+ }
3759
+ /**
3688
3760
  * Reads one settings file from any release: schema guard, unrecognized keys
3689
3761
  * reported and left on disk, blocking problems returned for the caller to refuse on.
3690
3762
  */
@@ -3703,18 +3775,11 @@ function parseGrouped(raw, label, definition, options) {
3703
3775
  errors.push(`the ${label} must contain a JSON object`);
3704
3776
  return result;
3705
3777
  }
3706
- const meta = isRecord(raw.meta) ? raw.meta : null;
3707
- const schemaVersion = typeof meta?.schema === "number" ? meta.schema : 1;
3708
- const writtenBy = typeof meta?.writtenBy === "string" && meta.writtenBy.length > 0 ? meta.writtenBy : null;
3709
- result.schemaVersion = schemaVersion;
3710
- result.writtenBy = writtenBy;
3711
- if (schemaVersion > 1) {
3712
- errors.push(`written by home-hosted ${writtenBy ?? "a newer release"} (config schema ${schemaVersion}); this release understands schema 1`);
3713
- return result;
3714
- }
3715
- const { steps } = planConfigMigrations(schemaVersion, options);
3716
- if (steps.length > 0) {
3717
- errors.push(`config schema ${schemaVersion} needs ${steps.length} migration${steps.length === 1 ? "" : "s"} before this release can use it`);
3778
+ const stamp = readStamp(raw, options);
3779
+ result.schemaVersion = stamp.schemaVersion;
3780
+ result.writtenBy = stamp.writtenBy;
3781
+ if (stamp.error !== null) {
3782
+ errors.push(stamp.error);
3718
3783
  return result;
3719
3784
  }
3720
3785
  for (const key of Object.keys(raw)) if (!definition.keys.includes(key)) unknownKeys.push(key);
@@ -3726,8 +3791,8 @@ function parseGrouped(raw, label, definition, options) {
3726
3791
  }
3727
3792
  if (errors.length > 0) return result;
3728
3793
  result.config = definition.build({
3729
- writtenBy: writtenBy ?? "",
3730
- schema: schemaVersion
3794
+ writtenBy: stamp.writtenBy ?? "",
3795
+ schema: stamp.schemaVersion
3731
3796
  }, groups);
3732
3797
  return result;
3733
3798
  }
@@ -3789,18 +3854,11 @@ function parseServersFile(raw, defaults, options = {}) {
3789
3854
  errors.push("the servers config must contain a JSON object");
3790
3855
  return result;
3791
3856
  }
3792
- const meta = isRecord(raw.meta) ? raw.meta : null;
3793
- const schemaVersion = typeof meta?.schema === "number" ? meta.schema : 1;
3794
- const writtenBy = typeof meta?.writtenBy === "string" && meta.writtenBy.length > 0 ? meta.writtenBy : null;
3795
- result.schemaVersion = schemaVersion;
3796
- result.writtenBy = writtenBy;
3797
- if (schemaVersion > 1) {
3798
- errors.push(`written by home-hosted ${writtenBy ?? "a newer release"} (config schema ${schemaVersion}); this release understands schema 1`);
3799
- return result;
3800
- }
3801
- const { steps } = planConfigMigrations(schemaVersion, options);
3802
- if (steps.length > 0) {
3803
- errors.push(`config schema ${schemaVersion} needs ${steps.length} migration${steps.length === 1 ? "" : "s"} before this release can use it`);
3857
+ const stamp = readStamp(raw, options);
3858
+ result.schemaVersion = stamp.schemaVersion;
3859
+ result.writtenBy = stamp.writtenBy;
3860
+ if (stamp.error !== null) {
3861
+ errors.push(stamp.error);
3804
3862
  return result;
3805
3863
  }
3806
3864
  for (const key of Object.keys(raw)) if (!SERVERS_FILE_KEYS.includes(key)) unknownKeys.push(key);
@@ -4195,6 +4253,18 @@ function failed(error) {
4195
4253
  error
4196
4254
  };
4197
4255
  }
4256
+ /**
4257
+ * The result of the write six providers end their `update()` with.
4258
+ *
4259
+ * They repeated the same two lines after their own write call, and they have to agree: a
4260
+ * non-ok response is a provider error with its body attached, and an ok one is a change.
4261
+ * The message names the record, because a settings page shows it as the account's last
4262
+ * result and "ok" would not say what actually moved.
4263
+ */
4264
+ async function wrote(response, record) {
4265
+ if (!response.ok) return failed(await errorText(response));
4266
+ return succeeded(`set ${record.host} ${record.type} to ${record.ip}`, true);
4267
+ }
4198
4268
  /** Missing required credential keys, as a sentence for the settings page. */
4199
4269
  function missingFields(credentials, keys) {
4200
4270
  const missing = keys.filter((key) => (credentials?.[key] ?? "").trim().length === 0);
@@ -4272,7 +4342,7 @@ async function call$1(path, headers, context, init = {}) {
4272
4342
  return {
4273
4343
  data: null,
4274
4344
  status: 0,
4275
- error: error instanceof Error ? error.message : String(error)
4345
+ error: errorMessage(error)
4276
4346
  };
4277
4347
  }
4278
4348
  }
@@ -4331,6 +4401,7 @@ async function findRecord(zoneId, record, headers, context, cached) {
4331
4401
  var API$5, cloudflareProvider;
4332
4402
  var init_cloudflare = __esmMin((() => {
4333
4403
  init_types();
4404
+ init_error_message();
4334
4405
  API$5 = "https://api.cloudflare.com/client/v4";
4335
4406
  cloudflareProvider = {
4336
4407
  id: "cloudflare",
@@ -4407,7 +4478,7 @@ var init_cloudflare = __esmMin((() => {
4407
4478
  }
4408
4479
  };
4409
4480
  } catch (error) {
4410
- return failed(error instanceof Error ? error.message : String(error));
4481
+ return failed(errorMessage(error));
4411
4482
  }
4412
4483
  },
4413
4484
  /**
@@ -4474,7 +4545,7 @@ var init_cloudflare = __esmMin((() => {
4474
4545
  } catch (error) {
4475
4546
  return {
4476
4547
  ok: false,
4477
- message: error instanceof Error ? error.message : String(error)
4548
+ message: errorMessage(error)
4478
4549
  };
4479
4550
  }
4480
4551
  }
@@ -4605,6 +4676,7 @@ var ENDPOINT$7, desecProvider;
4605
4676
  var init_desec = __esmMin((() => {
4606
4677
  init_dyndns2();
4607
4678
  init_types();
4679
+ init_error_message();
4608
4680
  ENDPOINT$7 = "https://update.dedyn.io/";
4609
4681
  desecProvider = {
4610
4682
  id: "desec",
@@ -4634,7 +4706,7 @@ var init_desec = __esmMin((() => {
4634
4706
  });
4635
4707
  return describeDyndnsResponse(response, await response.text(), "desec");
4636
4708
  } catch (error) {
4637
- return failed(error instanceof Error ? error.message : String(error));
4709
+ return failed(errorMessage(error));
4638
4710
  }
4639
4711
  }
4640
4712
  };
@@ -4702,6 +4774,7 @@ var API$4, digitaloceanProvider;
4702
4774
  var init_digitalocean = __esmMin((() => {
4703
4775
  init_types();
4704
4776
  init_zone();
4777
+ init_error_message();
4705
4778
  API$4 = "https://api.digitalocean.com/v2/domains";
4706
4779
  digitaloceanProvider = {
4707
4780
  id: "digitalocean",
@@ -4735,7 +4808,7 @@ var init_digitalocean = __esmMin((() => {
4735
4808
  if (!listed.ok) return failed(await errorText(listed));
4736
4809
  const existing = ((await listed.json()).domain_records ?? []).find((entry) => entry.name === name && entry.type === record.type);
4737
4810
  if (existing !== void 0 && existing.data === record.ip) return succeeded(`already ${record.ip}`, false);
4738
- const written = existing === void 0 ? await context.fetch(collection, {
4811
+ return await wrote(existing === void 0 ? await context.fetch(collection, {
4739
4812
  method: "POST",
4740
4813
  headers,
4741
4814
  body: JSON.stringify({
@@ -4755,11 +4828,9 @@ var init_digitalocean = __esmMin((() => {
4755
4828
  ttl
4756
4829
  }),
4757
4830
  signal: context.signal
4758
- });
4759
- if (!written.ok) return failed(await errorText(written));
4760
- return succeeded(`set ${record.host} ${record.type} to ${record.ip}`, true);
4831
+ }), record);
4761
4832
  } catch (error) {
4762
- return failed(error instanceof Error ? error.message : String(error));
4833
+ return failed(errorMessage(error));
4763
4834
  }
4764
4835
  }
4765
4836
  };
@@ -4774,6 +4845,7 @@ function subdomain(host) {
4774
4845
  var ENDPOINT$6, duckdnsProvider;
4775
4846
  var init_duckdns = __esmMin((() => {
4776
4847
  init_types();
4848
+ init_error_message();
4777
4849
  ENDPOINT$6 = "https://www.duckdns.org/update";
4778
4850
  duckdnsProvider = {
4779
4851
  id: "duckdns",
@@ -4802,7 +4874,7 @@ var init_duckdns = __esmMin((() => {
4802
4874
  if (body.toUpperCase() === "OK") return succeeded(`set ${record.host} ${record.type} to ${record.ip}`, true);
4803
4875
  return failed(`duckdns answered "${body.slice(0, 120) || `HTTP ${response.status}`}"`);
4804
4876
  } catch (error) {
4805
- return failed(error instanceof Error ? error.message : String(error));
4877
+ return failed(errorMessage(error));
4806
4878
  }
4807
4879
  }
4808
4880
  };
@@ -4813,6 +4885,7 @@ var ENDPOINT$5, dynuProvider;
4813
4885
  var init_dynu = __esmMin((() => {
4814
4886
  init_dyndns2();
4815
4887
  init_types();
4888
+ init_error_message();
4816
4889
  ENDPOINT$5 = "https://api.dynu.com/nic/update";
4817
4890
  dynuProvider = {
4818
4891
  id: "dynu",
@@ -4848,7 +4921,7 @@ var init_dynu = __esmMin((() => {
4848
4921
  });
4849
4922
  return describeDyndnsResponse(response, await response.text(), "dynu");
4850
4923
  } catch (error) {
4851
- return failed(error instanceof Error ? error.message : String(error));
4924
+ return failed(errorMessage(error));
4852
4925
  }
4853
4926
  }
4854
4927
  };
@@ -4867,6 +4940,7 @@ function updateToken(credentials) {
4867
4940
  var ENDPOINT$4, freednsProvider;
4868
4941
  var init_freedns = __esmMin((() => {
4869
4942
  init_types();
4943
+ init_error_message();
4870
4944
  ENDPOINT$4 = "https://freedns.afraid.org/dynamic/update.php";
4871
4945
  freednsProvider = {
4872
4946
  id: "freedns",
@@ -4893,7 +4967,7 @@ var init_freedns = __esmMin((() => {
4893
4967
  if (!response.ok || /^ERROR/i.test(body)) return failed(`freedns answered "${body.slice(0, 160) || `HTTP ${response.status}`}"`);
4894
4968
  return succeeded(`set ${record.host} ${record.type} to ${record.ip}`, true);
4895
4969
  } catch (error) {
4896
- return failed(error instanceof Error ? error.message : String(error));
4970
+ return failed(errorMessage(error));
4897
4971
  }
4898
4972
  }
4899
4973
  };
@@ -4912,6 +4986,7 @@ var API$3, gandiProvider;
4912
4986
  var init_gandi = __esmMin((() => {
4913
4987
  init_types();
4914
4988
  init_zone();
4989
+ init_error_message();
4915
4990
  API$3 = "https://api.gandi.net/v5/livedns/domains";
4916
4991
  gandiProvider = {
4917
4992
  id: "gandi",
@@ -4948,7 +5023,7 @@ var init_gandi = __esmMin((() => {
4948
5023
  const body = await current.json();
4949
5024
  if (body.rrset_values?.length === 1 && body.rrset_values[0] === record.ip) return succeeded(`already ${record.ip}`, false);
4950
5025
  }
4951
- const written = await context.fetch(url, {
5026
+ return await wrote(await context.fetch(url, {
4952
5027
  method: "PUT",
4953
5028
  headers,
4954
5029
  body: JSON.stringify({
@@ -4956,11 +5031,9 @@ var init_gandi = __esmMin((() => {
4956
5031
  rrset_ttl: ttl
4957
5032
  }),
4958
5033
  signal: context.signal
4959
- });
4960
- if (!written.ok) return failed(await errorText(written));
4961
- return succeeded(`set ${record.host} ${record.type} to ${record.ip}`, true);
5034
+ }), record);
4962
5035
  } catch (error) {
4963
- return failed(error instanceof Error ? error.message : String(error));
5036
+ return failed(errorMessage(error));
4964
5037
  }
4965
5038
  }
4966
5039
  };
@@ -4975,6 +5048,7 @@ var API$2, godaddyProvider;
4975
5048
  var init_godaddy = __esmMin((() => {
4976
5049
  init_types();
4977
5050
  init_zone();
5051
+ init_error_message();
4978
5052
  API$2 = "https://api.godaddy.com/v1/domains";
4979
5053
  godaddyProvider = {
4980
5054
  id: "godaddy",
@@ -5023,7 +5097,7 @@ var init_godaddy = __esmMin((() => {
5023
5097
  const body = await current.json();
5024
5098
  if (body.length === 1 && body[0]?.data === record.ip) return succeeded(`already ${record.ip}`, false);
5025
5099
  }
5026
- const written = await context.fetch(url, {
5100
+ return await wrote(await context.fetch(url, {
5027
5101
  method: "PUT",
5028
5102
  headers,
5029
5103
  body: JSON.stringify([{
@@ -5031,11 +5105,9 @@ var init_godaddy = __esmMin((() => {
5031
5105
  ttl
5032
5106
  }]),
5033
5107
  signal: context.signal
5034
- });
5035
- if (!written.ok) return failed(await errorText(written));
5036
- return succeeded(`set ${record.host} ${record.type} to ${record.ip}`, true);
5108
+ }), record);
5037
5109
  } catch (error) {
5038
- return failed(error instanceof Error ? error.message : String(error));
5110
+ return failed(errorMessage(error));
5039
5111
  }
5040
5112
  }
5041
5113
  };
@@ -5046,6 +5118,7 @@ var ENDPOINT$3, henetProvider;
5046
5118
  var init_henet = __esmMin((() => {
5047
5119
  init_dyndns2();
5048
5120
  init_types();
5121
+ init_error_message();
5049
5122
  ENDPOINT$3 = "https://dyn.dns.he.net/nic/update";
5050
5123
  henetProvider = {
5051
5124
  id: "henet",
@@ -5071,7 +5144,7 @@ var init_henet = __esmMin((() => {
5071
5144
  const response = await context.fetch(url, { signal: context.signal });
5072
5145
  return describeDyndnsResponse(response, await response.text(), "hurricane electric");
5073
5146
  } catch (error) {
5074
- return failed(error instanceof Error ? error.message : String(error));
5147
+ return failed(errorMessage(error));
5075
5148
  }
5076
5149
  }
5077
5150
  };
@@ -5085,6 +5158,7 @@ var ENDPOINT$2, namecheapProvider;
5085
5158
  var init_namecheap = __esmMin((() => {
5086
5159
  init_types();
5087
5160
  init_zone();
5161
+ init_error_message();
5088
5162
  ENDPOINT$2 = "https://dynamicdns.park-your-domain.com/update";
5089
5163
  namecheapProvider = {
5090
5164
  id: "namecheap",
@@ -5119,7 +5193,7 @@ var init_namecheap = __esmMin((() => {
5119
5193
  if (ip === null) return failed(`namecheap answered without an IP: ${body.replace(/\s+/g, " ").slice(0, 160)}`);
5120
5194
  return succeeded(`${host === "@" ? zone : `${host}.${zone}`} now ${ip}`, ip !== record.ip);
5121
5195
  } catch (error) {
5122
- return failed(error instanceof Error ? error.message : String(error));
5196
+ return failed(errorMessage(error));
5123
5197
  }
5124
5198
  }
5125
5199
  };
@@ -5211,7 +5285,7 @@ async function call(command, params, context) {
5211
5285
  body
5212
5286
  };
5213
5287
  } catch (error) {
5214
- return error instanceof Error ? error.message : String(error);
5288
+ return errorMessage(error);
5215
5289
  }
5216
5290
  }
5217
5291
  /** The registered domain split into the `SLD`/`TLD` pair the API asks for. */
@@ -5258,6 +5332,7 @@ var ENDPOINT$1, RECORD_LIMIT, CHALLENGE_TTL, XML_ENTITIES, namecheapApiProvider;
5258
5332
  var init_namecheap_api = __esmMin((() => {
5259
5333
  init_types();
5260
5334
  init_zone();
5335
+ init_error_message();
5261
5336
  ENDPOINT$1 = "https://api.namecheap.com/xml.response";
5262
5337
  RECORD_LIMIT = 150;
5263
5338
  CHALLENGE_TTL = 60;
@@ -5372,6 +5447,7 @@ var ENDPOINT, DEFAULT_AGENT, noipProvider;
5372
5447
  var init_noip = __esmMin((() => {
5373
5448
  init_dyndns2();
5374
5449
  init_types();
5450
+ init_error_message();
5375
5451
  ENDPOINT = "https://dynupdate.no-ip.com/nic/update";
5376
5452
  DEFAULT_AGENT = "home-hosted/1.0 (+https://github.com/NamesMT/home-hosted)";
5377
5453
  noipProvider = {
@@ -5420,7 +5496,7 @@ var init_noip = __esmMin((() => {
5420
5496
  });
5421
5497
  return describeDyndnsResponse(response, await response.text(), "no-ip");
5422
5498
  } catch (error) {
5423
- return failed(error instanceof Error ? error.message : String(error));
5499
+ return failed(errorMessage(error));
5424
5500
  }
5425
5501
  }
5426
5502
  };
@@ -5455,7 +5531,7 @@ async function post(path, context, body) {
5455
5531
  } catch (error) {
5456
5532
  return {
5457
5533
  data: null,
5458
- error: error instanceof Error ? error.message : String(error)
5534
+ error: errorMessage(error)
5459
5535
  };
5460
5536
  }
5461
5537
  }
@@ -5463,6 +5539,7 @@ var API$1, porkbunProvider;
5463
5539
  var init_porkbun = __esmMin((() => {
5464
5540
  init_types();
5465
5541
  init_zone();
5542
+ init_error_message();
5466
5543
  API$1 = "https://api.porkbun.com/api/json/v3";
5467
5544
  porkbunProvider = {
5468
5545
  id: "porkbun",
@@ -5511,6 +5588,7 @@ var API, PAGE_SIZE, spaceshipProvider;
5511
5588
  var init_spaceship = __esmMin((() => {
5512
5589
  init_types();
5513
5590
  init_zone();
5591
+ init_error_message();
5514
5592
  API = "https://spaceship.dev/api/v1/dns/records";
5515
5593
  PAGE_SIZE = 500;
5516
5594
  spaceshipProvider = {
@@ -5555,7 +5633,7 @@ var init_spaceship = __esmMin((() => {
5555
5633
  if (found !== void 0 || items.length < PAGE_SIZE) break;
5556
5634
  }
5557
5635
  if (found?.address === record.ip) return succeeded(`already ${record.ip}`, false);
5558
- const written = await context.fetch(`${API}/${encodeURIComponent(zone)}`, {
5636
+ return await wrote(await context.fetch(`${API}/${encodeURIComponent(zone)}`, {
5559
5637
  method: "PUT",
5560
5638
  headers,
5561
5639
  body: JSON.stringify({
@@ -5568,11 +5646,9 @@ var init_spaceship = __esmMin((() => {
5568
5646
  }]
5569
5647
  }),
5570
5648
  signal: context.signal
5571
- });
5572
- if (!written.ok) return failed(await errorText(written));
5573
- return succeeded(`set ${record.host} ${record.type} to ${record.ip}`, true);
5649
+ }), record);
5574
5650
  } catch (error) {
5575
- return failed(error instanceof Error ? error.message : String(error));
5651
+ return failed(errorMessage(error));
5576
5652
  }
5577
5653
  }
5578
5654
  };
@@ -6553,7 +6629,7 @@ function describeTelegramError(error) {
6553
6629
  const description = candidate.description ?? candidate.message;
6554
6630
  if (description) return `${description}${candidate.error_code === void 0 ? "" : ` (${candidate.error_code})`}${candidate.parameters?.retry_after === void 0 ? "" : `, retry in ${candidate.parameters.retry_after}s`}`;
6555
6631
  }
6556
- return error instanceof Error ? error.message : String(error);
6632
+ return errorMessage(error);
6557
6633
  }
6558
6634
  async function sendTelegramMessage(token, chatId, html) {
6559
6635
  try {
@@ -6621,6 +6697,7 @@ async function listTelegramChats(token) {
6621
6697
  }
6622
6698
  var bots;
6623
6699
  var init_telegram = __esmMin((() => {
6700
+ init_error_message();
6624
6701
  bots = /* @__PURE__ */ new Map();
6625
6702
  }));
6626
6703
  //#endregion
@@ -7779,15 +7856,15 @@ function acmeIssuers(acme, challenge = {}) {
7779
7856
  }, { module: "internal" }];
7780
7857
  }
7781
7858
  /**
7782
- * The page a visitor gets when the hostname they reached is not routed at all. It is
7783
- * the answer on both ports, so a request never lands on a redirect into a handshake
7784
- * that cannot finish.
7859
+ * The one page shell both visitor-facing pages use. They are the same document with
7860
+ * different words, and the stylesheet has to stay identical — a page the engine serves
7861
+ * on a port a person reaches by hand is a page that has to look like this panel.
7785
7862
  */
7786
- function notConfiguredPage() {
7863
+ function htmlPage(title, heading, body) {
7787
7864
  return `<!doctype html>
7788
7865
  <html lang="en">
7789
7866
  <head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
7790
- <title>No route for this hostname</title>
7867
+ <title>${title}</title>
7791
7868
  <style>
7792
7869
  :root { color-scheme: light dark }
7793
7870
  body { margin: 0; min-height: 100dvh; display: grid; place-items: center; font: 15px/1.6 system-ui, sans-serif }
@@ -7798,13 +7875,21 @@ function notConfiguredPage() {
7798
7875
  footer { color: #888; font-size: .85em }
7799
7876
  </style></head>
7800
7877
  <body><main>
7801
- <h1>This route is not configured for any target yet</h1>
7802
- <p>No route in <strong>home-hosted</strong>'s reverse proxy claims the hostname
7878
+ <h1>${heading}</h1>
7879
+ ${body}
7880
+ </main></body></html>`;
7881
+ }
7882
+ /**
7883
+ * The page a visitor gets when the hostname they reached is not routed at all. It is
7884
+ * the answer on both ports, so a request never lands on a redirect into a handshake
7885
+ * that cannot finish.
7886
+ */
7887
+ function notConfiguredPage() {
7888
+ return htmlPage("No route for this hostname", "This route is not configured for any target yet", ` <p>No route in <strong>home-hosted</strong>'s reverse proxy claims the hostname
7803
7889
  <code>{http.request.host}</code>, so there is nothing here to serve.</p>
7804
7890
  <p>Add a route for it under <strong>Others → Reverse Proxy</strong>, or check that you
7805
7891
  reached the hostname you meant to.</p>
7806
- <footer>home-hosted reverse proxy</footer>
7807
- </main></body></html>`;
7892
+ <footer>home-hosted reverse proxy</footer>`);
7808
7893
  }
7809
7894
  /** The catch-all: it answers last, so every configured route wins over it. */
7810
7895
  function notConfiguredRoute() {
@@ -7817,30 +7902,14 @@ function notConfiguredRoute() {
7817
7902
  }
7818
7903
  /** The page a visitor gets when a name has no certificate yet and HTTPS cannot work. */
7819
7904
  function noticePage(host, httpPort, httpsPort) {
7820
- return `<!doctype html>
7821
- <html lang="en">
7822
- <head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
7823
- <title>Certificate not ready — ${host}</title>
7824
- <style>
7825
- :root { color-scheme: light dark }
7826
- body { margin: 0; min-height: 100dvh; display: grid; place-items: center; font: 15px/1.6 system-ui, sans-serif }
7827
- main { max-width: 34rem; padding: 2rem }
7828
- h1 { font-size: 1.15rem; margin: 0 0 .75rem }
7829
- p { margin: 0 0 .75rem }
7830
- code { font-family: ui-monospace, monospace; font-size: .9em }
7831
- footer { color: #888; font-size: .85em }
7832
- </style></head>
7833
- <body><main>
7834
- <h1>No certificate yet for ${host}</h1>
7835
- <p>This hostname is routed, but the certificate authority has not issued a certificate
7905
+ return htmlPage(`Certificate not ready — ${host}`, `No certificate yet for ${host}`, ` <p>This hostname is routed, but the certificate authority has not issued a certificate
7836
7906
  for it yet, so HTTPS cannot complete a handshake. The engine keeps trying in the
7837
7907
  background and will serve the name as soon as it succeeds.</p>
7838
7908
  <p>The usual cause is that the CA cannot reach this machine: with the proxy on
7839
7909
  <code>${httpPort}</code>/<code>${httpsPort}</code>, forward <code>80 → ${httpPort}</code>
7840
7910
  and <code>443 → ${httpsPort}</code> on your router. Otherwise <code>http://${host}</code>
7841
7911
  will keep landing here.</p>
7842
- <footer>Details, and the engine's own reason, are in the panel: Others → Reverse Proxy.</footer>
7843
- </main></body></html>`;
7912
+ <footer>Details, and the engine's own reason, are in the panel: Others → Reverse Proxy.</footer>`);
7844
7913
  }
7845
7914
  /**
7846
7915
  * The same match, minus the ACME challenge path: the CA has to be able to read it on
@@ -8026,7 +8095,7 @@ function validatePair(certificate, privateKey) {
8026
8095
  } catch (error) {
8027
8096
  return {
8028
8097
  ok: false,
8029
- error: `certificate is not a valid PEM: ${error instanceof Error ? error.message : String(error)}`
8098
+ error: `certificate is not a valid PEM: ${errorMessage(error)}`
8030
8099
  };
8031
8100
  }
8032
8101
  try {
@@ -8037,7 +8106,7 @@ function validatePair(certificate, privateKey) {
8037
8106
  } catch (error) {
8038
8107
  return {
8039
8108
  ok: false,
8040
- error: `private key is not a valid PEM: ${error instanceof Error ? error.message : String(error)}`
8109
+ error: `private key is not a valid PEM: ${errorMessage(error)}`
8041
8110
  };
8042
8111
  }
8043
8112
  const invalid = validityError(x509);
@@ -8051,6 +8120,7 @@ var TlsStore;
8051
8120
  var init_tls$1 = __esmMin((() => {
8052
8121
  init_atomic();
8053
8122
  init_logger();
8123
+ init_error_message();
8054
8124
  TlsStore = class {
8055
8125
  dir;
8056
8126
  name;
@@ -8177,7 +8247,7 @@ var init_tls$1 = __esmMin((() => {
8177
8247
  } catch (error) {
8178
8248
  return {
8179
8249
  ...base,
8180
- error: `invalid certificate: ${error instanceof Error ? error.message : String(error)}`
8250
+ error: `invalid certificate: ${errorMessage(error)}`
8181
8251
  };
8182
8252
  }
8183
8253
  }
@@ -8336,9 +8406,11 @@ var init_proxy$1 = __esmMin((() => {
8336
8406
  init_types();
8337
8407
  init_nanny$2();
8338
8408
  init_port();
8409
+ init_proc();
8339
8410
  init_proxy$2();
8340
8411
  init_proxy_config();
8341
8412
  init_tls$1();
8413
+ init_error_message();
8342
8414
  init_proxy_form();
8343
8415
  PROXY_ID = "proxy";
8344
8416
  ADMIN_TIMEOUT_MS = 5e3;
@@ -8380,8 +8452,6 @@ var init_proxy$1 = __esmMin((() => {
8380
8452
  failureCache = null;
8381
8453
  /** The installed binary's modules, read once per process; an install clears it. */
8382
8454
  moduleCache = null;
8383
- /** A legacy `engine.json` is reported once, not on every state frame. */
8384
- legacyChecksumWarned = false;
8385
8455
  /** Why the engine's nanny could not be spawned, cleared on the next start. */
8386
8456
  nannyError = null;
8387
8457
  certCache = null;
@@ -8670,7 +8740,7 @@ var init_proxy$1 = __esmMin((() => {
8670
8740
  } catch (error) {
8671
8741
  return {
8672
8742
  ...base,
8673
- error: `unreadable certificate: ${error instanceof Error ? error.message : String(error)}`
8743
+ error: `unreadable certificate: ${errorMessage(error)}`
8674
8744
  };
8675
8745
  }
8676
8746
  });
@@ -8882,14 +8952,14 @@ var init_proxy$1 = __esmMin((() => {
8882
8952
  if (this.live()) {
8883
8953
  logger.info(`proxy: reattached to the running ${this.config.engine} engine`);
8884
8954
  await this.apply().catch((error) => {
8885
- logger.warn(`proxy: could not apply the current configuration to the reattached engine: ${error instanceof Error ? error.message : String(error)}`);
8955
+ logger.warn(`proxy: could not apply the current configuration to the reattached engine: ${errorMessage(error)}`);
8886
8956
  });
8887
8957
  return;
8888
8958
  }
8889
8959
  try {
8890
8960
  await this.start();
8891
8961
  } catch (error) {
8892
- this.lastError = error instanceof Error ? error.message : String(error);
8962
+ this.lastError = errorMessage(error);
8893
8963
  logger.error(`proxy: could not start: ${this.lastError}`);
8894
8964
  }
8895
8965
  }
@@ -9117,7 +9187,7 @@ var init_proxy$1 = __esmMin((() => {
9117
9187
  */
9118
9188
  async postConfiguration(rendered) {
9119
9189
  const result = await this.request("POST", "/load", rendered).catch((error) => {
9120
- this.lastError = `the engine did not accept the configuration: ${error instanceof Error ? error.message : String(error)}`;
9190
+ this.lastError = `the engine did not accept the configuration: ${errorMessage(error)}`;
9121
9191
  throw new DetailedError(this.lastError, {
9122
9192
  statusCode: 502,
9123
9193
  code: "ENGINE_UNREACHABLE"
@@ -9185,7 +9255,7 @@ var init_proxy$1 = __esmMin((() => {
9185
9255
  this.certificateSignature = signature;
9186
9256
  if (first) return;
9187
9257
  await this.apply().catch((error) => {
9188
- logger.warn(`proxy: could not re-apply after a certificate change: ${error instanceof Error ? error.message : String(error)}`);
9258
+ logger.warn(`proxy: could not re-apply after a certificate change: ${errorMessage(error)}`);
9189
9259
  });
9190
9260
  }
9191
9261
  /**
@@ -9392,15 +9462,7 @@ var init_proxy$1 = __esmMin((() => {
9392
9462
  try {
9393
9463
  const parsed = JSON.parse(fs.readFileSync(path.join(this.options.binDir, "engine.json"), "utf8"));
9394
9464
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
9395
- const record = parsed;
9396
- if ("sha256" in record) {
9397
- delete record.sha256;
9398
- if (!this.legacyChecksumWarned) {
9399
- this.legacyChecksumWarned = true;
9400
- logger.warn("proxy: dropped the recorded engine checksum; nothing verifies it");
9401
- }
9402
- }
9403
- const parsedRecord = engineRecordSchema(record);
9465
+ const parsedRecord = engineRecordSchema(parsed);
9404
9466
  return parsedRecord instanceof type.errors ? null : parsedRecord;
9405
9467
  } catch {
9406
9468
  return null;
@@ -9731,7 +9793,9 @@ var init_proxy$1 = __esmMin((() => {
9731
9793
  const statePath = nannyStatePath(this.options.stateDir, PROXY_ID);
9732
9794
  const state = readNannyState(statePath);
9733
9795
  if (state !== null && state.serverId === PROXY_ID) {
9734
- const pids = [state.nannyPid, state.childPid].filter((pid) => pid !== null && pid > 0);
9796
+ const nanny = await nannyIsAlive(state, PROXY_ID);
9797
+ const child = state.childPid !== null && state.childPid > 0 && await processCarriesServerId(state.childPid, PROXY_ID);
9798
+ const pids = [...nanny ? [state.nannyPid] : [], ...nanny || child ? [state.childPid] : []].filter((pid) => pid !== null && pid > 0);
9735
9799
  for (const pid of pids) {
9736
9800
  if (!isProcessAlive(pid)) continue;
9737
9801
  try {
@@ -9742,10 +9806,12 @@ var init_proxy$1 = __esmMin((() => {
9742
9806
  }
9743
9807
  const deadline = Date.now() + (this.options.stopGraceMs ?? 1e4);
9744
9808
  while (Date.now() < deadline && pids.some((pid) => isProcessAlive(pid))) await new Promise((resolve) => setTimeout(resolve, 100));
9745
- for (const pid of pids.filter(isProcessAlive)) try {
9809
+ const survivors = pids.filter(isProcessAlive);
9810
+ for (const pid of survivors) try {
9746
9811
  process.kill(pid, "SIGKILL");
9747
9812
  } catch {}
9748
- fs.rmSync(statePath, { force: true });
9813
+ if (survivors.some(isProcessAlive)) logger.warn(`proxy: pid ${survivors.join(", ")} ignored SIGKILL — keeping ${path.basename(statePath)} so a later boot can still stop it`);
9814
+ else fs.rmSync(statePath, { force: true });
9749
9815
  }
9750
9816
  this.nanny = null;
9751
9817
  this.admin = null;
@@ -9947,7 +10013,7 @@ function createProxyRoute(deps) {
9947
10013
  if (proxy().status().state === "running") await proxy().apply();
9948
10014
  else await proxy().start();
9949
10015
  } catch (error) {
9950
- proxy().recordError(error instanceof Error ? error.message : String(error));
10016
+ proxy().recordError(errorMessage(error));
9951
10017
  throw error;
9952
10018
  }
9953
10019
  };
@@ -10106,6 +10172,7 @@ var init_proxy = __esmMin((() => {
10106
10172
  init_exposure();
10107
10173
  init_proxy$1();
10108
10174
  init_contracts();
10175
+ init_error_message();
10109
10176
  certificateParam = type({ id: "/^[a-z0-9][a-z0-9_-]*$/" });
10110
10177
  }));
10111
10178
  //#endregion
@@ -11011,237 +11078,26 @@ var init_app = __esmMin((() => {
11011
11078
  }));
11012
11079
  //#endregion
11013
11080
  //#region src/config/layout.ts
11014
- var layout_exports = /* @__PURE__ */ __exportAll({
11015
- ensureLayout: () => ensureLayout,
11016
- migrateLayout: () => migrateLayout
11017
- });
11018
- function readJson(file) {
11019
- try {
11020
- const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
11021
- return isRecord(parsed) ? parsed : null;
11022
- } catch {
11023
- return null;
11024
- }
11025
- }
11081
+ var layout_exports = /* @__PURE__ */ __exportAll({ ensureLayout: () => ensureLayout });
11026
11082
  /**
11027
- * A legacy file, with "absent" told apart from "present but unreadable".
11083
+ * Refuses a state directory this release cannot use, before any command resolves a
11084
+ * path into it.
11028
11085
  *
11029
- * `readJson` collapses both into `null`, and a split that is skipped reads exactly like
11030
- * one with nothing to do — so a corrupt legacy config used to be abandoned silently
11031
- * while `workspaces.json` was still created, after which `hasLegacyLayout()` is false
11032
- * and no later run ever looks at that file again. The definitions would sit unread
11033
- * beside a panel supervising nothing.
11034
- */
11035
- function readLegacy(file) {
11036
- if (!fs.existsSync(file)) return {
11037
- parsed: null,
11038
- unreadable: false
11039
- };
11040
- const parsed = readJson(file);
11041
- return {
11042
- parsed,
11043
- unreadable: parsed === null
11044
- };
11045
- }
11046
- function writeJson(file, value, mode) {
11047
- fs.mkdirSync(path.dirname(file), { recursive: true });
11048
- fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, mode === void 0 ? {} : { mode });
11049
- }
11050
- /** `renameSync` across devices fails with EXDEV; fall back to a copy. */
11051
- function move(source, destination, warnings) {
11052
- if (!fs.existsSync(source)) return false;
11053
- if (fs.existsSync(destination)) {
11054
- warnings.push(`${destination} already exists — left ${source} alone`);
11055
- return false;
11056
- }
11057
- fs.mkdirSync(path.dirname(destination), { recursive: true });
11058
- try {
11059
- fs.renameSync(source, destination);
11060
- } catch {
11061
- fs.cpSync(source, destination, {
11062
- recursive: true,
11063
- force: true
11064
- });
11065
- fs.rmSync(source, {
11066
- recursive: true,
11067
- force: true
11068
- });
11069
- }
11070
- return true;
11071
- }
11072
- function metaOf(raw) {
11073
- const meta = raw?.meta;
11074
- if (typeof meta !== "object" || meta === null) return void 0;
11075
- return meta;
11076
- }
11077
- /** The old config split three ways: global settings, workspace settings, servers. */
11078
- function splitConfig(raw) {
11079
- const meta = metaOf(raw);
11080
- const unknown = {};
11081
- for (const [key, value] of Object.entries(raw)) if (!KNOWN_TOP_LEVEL.has(key)) unknown[key] = value;
11082
- return {
11083
- global: {
11084
- $schema: "./settings.schema.json",
11085
- ...meta === void 0 ? {} : { meta },
11086
- control: raw.control ?? {},
11087
- host: raw.host ?? {},
11088
- backups: raw.backups ?? {},
11089
- ...unknown
11090
- },
11091
- settings: {
11092
- $schema: "./settings.schema.json",
11093
- ...meta === void 0 ? {} : { meta },
11094
- defaults: raw.defaults ?? {},
11095
- logs: raw.logs ?? {},
11096
- notifications: raw.notifications ?? {},
11097
- ddns: raw.ddns ?? {}
11098
- },
11099
- servers: {
11100
- $schema: "./servers.config.schema.json",
11101
- ...meta === void 0 ? {} : { meta },
11102
- servers: Array.isArray(raw.servers) ? raw.servers : []
11103
- }
11104
- };
11105
- }
11106
- /** The old secrets file split global (password, token) vs. workspace (Telegram, DDNS). */
11107
- function splitSecrets(raw) {
11108
- return {
11109
- global: {
11110
- version: 3,
11111
- password: raw?.password ?? null,
11112
- apiToken: raw?.apiToken ?? null
11113
- },
11114
- workspace: {
11115
- version: 3,
11116
- telegram: raw?.telegram ?? null,
11117
- ddns: raw?.ddns ?? {},
11118
- ddnsKdf: raw?.ddnsKdf ?? null
11119
- }
11120
- };
11121
- }
11122
- /**
11123
- * Moves the panel's console log apart from the per-server logs: both used to
11124
- * live in `<root>/.logs`, and only the console log is global now.
11125
- */
11126
- function splitLogsDir(warnings) {
11127
- if (!fs.existsSync(legacy.logsDir)) return;
11128
- const workspaceLogs = path.join(hhDir, "default", ".logs");
11129
- for (const name of fs.readdirSync(legacy.logsDir)) {
11130
- const destination = name === "home-hosted.log" || name.startsWith("home-hosted.log.") ? path.join(hhDir, ".logs", name) : path.join(workspaceLogs, name);
11131
- move(path.join(legacy.logsDir, name), destination, warnings);
11132
- }
11133
- try {
11134
- if (fs.readdirSync(legacy.logsDir).length === 0) fs.rmdirSync(legacy.logsDir);
11135
- else warnings.push(`${legacy.logsDir} still holds files that could not be moved — left it in place`);
11136
- } catch {}
11137
- }
11138
- function migrateLayout() {
11139
- if (!hasLegacyLayout()) return EMPTY;
11140
- const moved = [];
11141
- const warnings = [];
11142
- fs.mkdirSync(hhDir, { recursive: true });
11143
- const legacyConfig = readLegacy(legacy.configPath);
11144
- let configSplit = false;
11145
- 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`);
11146
- if (legacyConfig.parsed !== null) {
11147
- const split = splitConfig(legacyConfig.parsed);
11148
- const destinations = [
11149
- path.join(hhDir, "settings.json"),
11150
- path.join(hhDir, "default", "settings.json"),
11151
- path.join(hhDir, "default", "servers.config.json")
11152
- ];
11153
- const taken = destinations.filter((file) => fs.existsSync(file));
11154
- if (taken.length > 0) warnings.push(`${legacy.configPath} could not be split — ${taken.map((file) => path.relative(hhDir, file)).join(", ")} already exist(s); left it in place`);
11155
- else {
11156
- writeJson(destinations[0], split.global);
11157
- fs.mkdirSync(path.join(hhDir, "default"), { recursive: true });
11158
- writeJson(destinations[1], split.settings);
11159
- writeJson(destinations[2], split.servers);
11160
- moved.push("servers.config.json → settings.json + default/settings.json + default/servers.config.json");
11161
- configSplit = true;
11162
- }
11163
- }
11164
- const legacySecrets = readLegacy(legacy.secretsPath);
11165
- let secretsSplit = false;
11166
- 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`);
11167
- if (legacySecrets.parsed !== null) {
11168
- const split = splitSecrets(legacySecrets.parsed);
11169
- const destinations = [path.join(hhDir, ".control-secrets.json"), path.join(hhDir, "default", ".secrets.json")];
11170
- const taken = destinations.filter((file) => fs.existsSync(file));
11171
- 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`);
11172
- else {
11173
- writeJson(destinations[0], split.global, 384);
11174
- fs.mkdirSync(path.join(hhDir, "default"), { recursive: true });
11175
- writeJson(destinations[1], split.workspace, 384);
11176
- moved.push(".control-secrets.json → .hh/.control-secrets.json + .hh/default/.secrets.json");
11177
- secretsSplit = true;
11178
- }
11179
- }
11180
- if (move(legacy.tlsDir, path.join(hhDir, ".tls"), warnings)) moved.push(".tls/ → .hh/.tls/");
11181
- if (move(legacy.uiDir, path.join(hhDir, ".ui"), warnings)) moved.push(".ui/ → .hh/.ui/");
11182
- if (move(legacy.backupsDir, path.join(hhDir, ".backups"), warnings)) moved.push(".backups/ → .hh/.backups/");
11183
- if (move(legacy.runtimePath, path.join(hhDir, "run.json"), warnings)) moved.push("run.json → .hh/run.json");
11184
- if (move(legacy.stateDir, path.join(hhDir, "default", ".state"), warnings)) moved.push(".state/ → .hh/default/.state/");
11185
- splitLogsDir(warnings);
11186
- if (configSplit) fs.rmSync(legacy.configPath, { force: true });
11187
- if (secretsSplit) fs.rmSync(legacy.secretsPath, { force: true });
11188
- fs.rmSync(legacy.configSchemaPath, { force: true });
11189
- if (!fs.existsSync(workspacesPath)) {
11190
- writeJson(workspacesPath, {
11191
- $schema: "./workspaces.schema.json",
11192
- workspaces: [{
11193
- id: "default",
11194
- label: "Default"
11195
- }]
11196
- });
11197
- moved.push("workspaces.json created with the `default` workspace");
11198
- } else warnings.push(`${workspacesPath} already exists — left the workspace registry alone`);
11199
- writeJson(path.join(hhDir, ".layout-migration.json"), {
11200
- version: 1,
11201
- at: Date.now(),
11202
- moved
11203
- });
11204
- return {
11205
- migrated: true,
11206
- moved,
11207
- warnings
11208
- };
11209
- }
11210
- /**
11211
- * Runs the relocation and reports it through the logger. Every CLI entry point
11212
- * calls this before it resolves a state path, so an existing instance keeps
11213
- * working after the upgrade without anyone having to run a command first.
11086
+ * Through 0.7.x this relocated a pre-workspace `$HHOSTED_HOME` automatically. 0.8.0
11087
+ * dropped the mover, so an instance that never crossed `.hh` is stopped instead of
11088
+ * being read as an empty one — its `servers.config.json` would otherwise sit unread
11089
+ * beside a panel supervising nothing, with no error anywhere. The migration itself is
11090
+ * unchanged and still shipped: the 0.7 line performs it on its first run.
11214
11091
  */
11215
11092
  function ensureLayout() {
11216
- const result = migrateLayout();
11217
- if (result.migrated) {
11218
- logger.info(`state layout migrated into ${hhDir} (${result.moved.length} step(s))`);
11219
- for (const warning of result.warnings) logger.warn(`layout migration: ${warning}`);
11220
- }
11221
- return result;
11093
+ if (!hasLegacyLayout()) return;
11094
+ const found = [legacy.configPath, legacy.secretsPath].filter((file) => fs.existsSync(file));
11095
+ throw new Error(`${found.join(", ")} belongs to home-hosted 0.6.x, which ${appVersion()} no longer relocates.\n Install the latest 0.7.x once — it moves that state into .hh/ on its first run — then start this release.
11096
+ Nothing has been changed or deleted.`);
11222
11097
  }
11223
- var EMPTY, KNOWN_TOP_LEVEL;
11224
11098
  var init_layout = __esmMin((() => {
11225
- init_logger();
11226
11099
  init_paths();
11227
- init_shape();
11228
- EMPTY = {
11229
- migrated: false,
11230
- moved: [],
11231
- warnings: []
11232
- };
11233
- KNOWN_TOP_LEVEL = /* @__PURE__ */ new Set([
11234
- "$schema",
11235
- "meta",
11236
- "control",
11237
- "defaults",
11238
- "logs",
11239
- "notifications",
11240
- "host",
11241
- "backups",
11242
- "ddns",
11243
- "servers"
11244
- ]);
11100
+ init_version();
11245
11101
  }));
11246
11102
  //#endregion
11247
11103
  //#region src/helpers/daemon.ts
@@ -11573,7 +11429,7 @@ function loadEnvFile(file) {
11573
11429
  return {
11574
11430
  env: {},
11575
11431
  path: file,
11576
- error: error instanceof Error ? error.message : String(error)
11432
+ error: errorMessage(error)
11577
11433
  };
11578
11434
  }
11579
11435
  }
@@ -11593,6 +11449,7 @@ function resolveEnvFilePath(file, cwd) {
11593
11449
  }
11594
11450
  var VARIABLE;
11595
11451
  var init_env_file = __esmMin((() => {
11452
+ init_error_message();
11596
11453
  VARIABLE = /\$\{([A-Z_]\w*)\}/gi;
11597
11454
  }));
11598
11455
  //#endregion
@@ -11853,7 +11710,7 @@ async function probeHttp(host, port, options) {
11853
11710
  return {
11854
11711
  healthy: false,
11855
11712
  ms: Date.now() - started,
11856
- detail: `request failed: ${error instanceof Error ? error.message : String(error)}`
11713
+ detail: `request failed: ${errorMessage(error)}`
11857
11714
  };
11858
11715
  }
11859
11716
  }
@@ -11875,6 +11732,7 @@ async function probeHealth(options) {
11875
11732
  }
11876
11733
  var init_health_check = __esmMin((() => {
11877
11734
  init_port();
11735
+ init_error_message();
11878
11736
  }));
11879
11737
  //#endregion
11880
11738
  //#region src/providers/process.ts
@@ -11962,28 +11820,24 @@ function signalPid(pid, signal, killGroup) {
11962
11820
  process.kill(pid, signal);
11963
11821
  } catch {}
11964
11822
  }
11965
- function alive(pid) {
11966
- try {
11967
- process.kill(pid, 0);
11968
- return true;
11969
- } catch {
11970
- return false;
11971
- }
11972
- }
11973
11823
  /**
11974
11824
  * The same shutdown a supervised child gets, for a process we adopted instead of
11975
11825
  * spawned: a detached successor is not our child, so it is signalled by pid (and
11976
11826
  * by process group when asked) and its exit is polled rather than awaited.
11827
+ *
11828
+ * Liveness comes from `isProcessAlive`, never a local signal-0 copy: a second definition returned
11829
+ * `false` for `EPERM`, so a process that was there but not ours to signal read as "exited" and
11830
+ * `'force-killed'` was returned for one still running. One predicate, one answer.
11977
11831
  */
11978
11832
  async function terminatePid(pid, options) {
11979
- if (!alive(pid)) return "exited";
11833
+ if (!isProcessAlive(pid)) return "exited";
11980
11834
  signalPid(pid, options.signal, options.killGroup);
11981
11835
  const deadline = Date.now() + Math.max(0, options.graceMs);
11982
- while (Date.now() < deadline && alive(pid)) await delay$1(50);
11983
- if (!alive(pid)) return "exited";
11836
+ while (Date.now() < deadline && isProcessAlive(pid)) await delay$1(50);
11837
+ if (!isProcessAlive(pid)) return "exited";
11984
11838
  signalPid(pid, "SIGKILL", options.killGroup);
11985
11839
  const hardDeadline = Date.now() + 2e3;
11986
- while (Date.now() < hardDeadline && alive(pid)) await delay$1(50);
11840
+ while (Date.now() < hardDeadline && isProcessAlive(pid)) await delay$1(50);
11987
11841
  return "force-killed";
11988
11842
  }
11989
11843
  function delay$1(ms) {
@@ -12007,6 +11861,7 @@ function waitForExit$1(child, timeoutMs) {
12007
11861
  var execFileAsync$1, WINDOWS_SHIM_EXTENSIONS;
12008
11862
  var init_process = __esmMin((() => {
12009
11863
  init_paths();
11864
+ init_port();
12010
11865
  execFileAsync$1 = promisify(execFile);
12011
11866
  WINDOWS_SHIM_EXTENSIONS = [
12012
11867
  ".cmd",
@@ -12296,6 +12151,7 @@ var TAIL_INTERVAL_MS, LogRelay;
12296
12151
  var init_log_relay = __esmMin((() => {
12297
12152
  init_logger();
12298
12153
  init_log_tail();
12154
+ init_error_message();
12299
12155
  TAIL_INTERVAL_MS = 250;
12300
12156
  LogRelay = class {
12301
12157
  options;
@@ -12356,7 +12212,7 @@ var init_log_relay = __esmMin((() => {
12356
12212
  try {
12357
12213
  this.poll();
12358
12214
  } catch (error) {
12359
- logger.warn(`tail failed: ${error instanceof Error ? error.message : String(error)}`);
12215
+ logger.warn(`tail failed: ${errorMessage(error)}`);
12360
12216
  }
12361
12217
  }, this.options.intervalMs ?? TAIL_INTERVAL_MS);
12362
12218
  this.timer.unref();
@@ -12823,6 +12679,7 @@ var init_supervisor = __esmMin((() => {
12823
12679
  nannyExit: null,
12824
12680
  starting: false,
12825
12681
  stopping: false,
12682
+ removed: false,
12826
12683
  bootstrapDone: !config.bootstrap,
12827
12684
  logs: new LogBuffer(config.logBufferLines),
12828
12685
  echoed: /* @__PURE__ */ new Set(),
@@ -12841,6 +12698,7 @@ var init_supervisor = __esmMin((() => {
12841
12698
  this.relay.unfollow(id);
12842
12699
  this.options.logFiles.clear(id);
12843
12700
  this.options.history.forget(id);
12701
+ entry.removed = true;
12844
12702
  this.stopEntry(entry).catch((error) => {
12845
12703
  logger.error(`could not stop the removed server ${id}`, error);
12846
12704
  });
@@ -12938,21 +12796,7 @@ var init_supervisor = __esmMin((() => {
12938
12796
  return { kind: "free" };
12939
12797
  }
12940
12798
  this.log(entry, "system", `port ${port} is held by pid ${foreign.join(", ")} — onPortConflict: kill, stopping the holder`);
12941
- const { forced } = await terminatePids(foreign, { graceMs: entry.config.stop.graceMs });
12942
- if (forced.length > 0) this.log(entry, "system", `pid ${forced.join(", ")} ignored SIGTERM and was killed`);
12943
- await delay(PORT_RELEASE_RECHECK_MS);
12944
- if (await freeOnAll()) {
12945
- entry.portState = "free";
12946
- return { kind: "free" };
12947
- }
12948
- entry.status = "conflict";
12949
- entry.lastError = `port ${port} is still in use after killing pid ${foreign.join(", ")}`;
12950
- this.log(entry, "system", entry.lastError);
12951
- this.publishServer(entry);
12952
- return {
12953
- kind: "blocked",
12954
- error: entry.lastError
12955
- };
12799
+ return await this.stopPortHolders(entry, foreign, port, "killing", freeOnAll);
12956
12800
  }
12957
12801
  if (own !== null && entry.config.onPortConflict === "follow") return {
12958
12802
  kind: "adopt",
@@ -12960,21 +12804,7 @@ var init_supervisor = __esmMin((() => {
12960
12804
  };
12961
12805
  if (own !== null && entry.config.onPortConflict === "reclaim") {
12962
12806
  this.log(entry, "system", `port ${port} is held by pid ${own}, a detached restart of this entry — replacing it with a supervised process`);
12963
- const { forced } = await terminatePids([own], { graceMs: entry.config.stop.graceMs });
12964
- if (forced.length > 0) this.log(entry, "system", `pid ${forced.join(", ")} ignored SIGTERM and was killed`);
12965
- await delay(PORT_RELEASE_RECHECK_MS);
12966
- if (await freeOnAll()) {
12967
- entry.portState = "free";
12968
- return { kind: "free" };
12969
- }
12970
- entry.status = "conflict";
12971
- entry.lastError = `port ${port} is still in use after replacing pid ${own}`;
12972
- this.log(entry, "system", entry.lastError);
12973
- this.publishServer(entry);
12974
- return {
12975
- kind: "blocked",
12976
- error: entry.lastError
12977
- };
12807
+ return await this.stopPortHolders(entry, [own], port, "replacing", freeOnAll);
12978
12808
  }
12979
12809
  const hint = own !== null ? ` — pid ${own} is a detached restart of this entry: set onPortConflict to "follow" to adopt it, "reclaim" to replace it with a supervised process, or "kill" to stop whatever holds the port` : "";
12980
12810
  if (entry.config.onPortConflict !== "warn") {
@@ -13000,6 +12830,31 @@ var init_supervisor = __esmMin((() => {
13000
12830
  * persistent entry whose state file was lost would show its history but never
13001
12831
  * another live line.
13002
12832
  */
12833
+ /**
12834
+ * Stops the listed port holders and reports whether the port came free.
12835
+ *
12836
+ * `kill` and `reclaim` are the same sequence over a different pid list — the foreign
12837
+ * holder versus this entry's own detached successor — and they have to stay the same:
12838
+ * a stop that does not re-probe, or that reports the port free without waiting, spawns
12839
+ * onto a listener that is still there.
12840
+ */
12841
+ async stopPortHolders(entry, pids, port, word, freeOnAll) {
12842
+ const { forced } = await terminatePids(pids, { graceMs: entry.config.stop.graceMs });
12843
+ if (forced.length > 0) this.log(entry, "system", `pid ${forced.join(", ")} ignored SIGTERM and was killed`);
12844
+ await delay(PORT_RELEASE_RECHECK_MS);
12845
+ if (await freeOnAll()) {
12846
+ entry.portState = "free";
12847
+ return { kind: "free" };
12848
+ }
12849
+ entry.status = "conflict";
12850
+ entry.lastError = `port ${port} is still in use after ${word} pid ${pids.join(", ")}`;
12851
+ this.log(entry, "system", entry.lastError);
12852
+ this.publishServer(entry);
12853
+ return {
12854
+ kind: "blocked",
12855
+ error: entry.lastError
12856
+ };
12857
+ }
13003
12858
  adoptEntry(entry, pid) {
13004
12859
  entry.adopted = true;
13005
12860
  entry.pid = pid;
@@ -13026,7 +12881,7 @@ var init_supervisor = __esmMin((() => {
13026
12881
  entry.responseMs = null;
13027
12882
  entry.exitCode = null;
13028
12883
  entry.exitSignal = null;
13029
- this.options.history.record(entry.config.id, {
12884
+ if (!entry.removed) this.options.history.record(entry.config.id, {
13030
12885
  type: "exit",
13031
12886
  detail,
13032
12887
  runtimeMs: ranForMs
@@ -13425,7 +13280,7 @@ var init_supervisor = __esmMin((() => {
13425
13280
  at: Date.now()
13426
13281
  });
13427
13282
  const ranForMs = nannyExit?.runtimeMs ?? (entry.startedAt === null ? 0 : Date.now() - entry.startedAt);
13428
- this.options.history.record(entry.config.id, {
13283
+ if (!entry.removed) this.options.history.record(entry.config.id, {
13429
13284
  type: "exit",
13430
13285
  detail,
13431
13286
  runtimeMs: ranForMs
@@ -13616,6 +13471,10 @@ var init_supervisor = __esmMin((() => {
13616
13471
  };
13617
13472
  }
13618
13473
  log(entry, stream, text) {
13474
+ if (entry.removed) {
13475
+ logger.debug(`[${entry.config.id}] ${text}`);
13476
+ return;
13477
+ }
13619
13478
  const line = {
13620
13479
  ts: Date.now(),
13621
13480
  stream,
@@ -13888,6 +13747,7 @@ var init_backups = __esmMin((() => {
13888
13747
  init_template();
13889
13748
  init_archive();
13890
13749
  init_supervisor();
13750
+ init_error_message();
13891
13751
  init_generated();
13892
13752
  init_shape();
13893
13753
  MANIFEST = "manifest.json";
@@ -14042,7 +13902,7 @@ var init_backups = __esmMin((() => {
14042
13902
  fs.rmSync(destination, { force: true });
14043
13903
  return {
14044
13904
  ok: false,
14045
- error: error instanceof Error ? error.message : String(error)
13905
+ error: errorMessage(error)
14046
13906
  };
14047
13907
  }
14048
13908
  }
@@ -14083,7 +13943,7 @@ var init_backups = __esmMin((() => {
14083
13943
  } catch (error) {
14084
13944
  return {
14085
13945
  ...plan,
14086
- error: `the archive could not be read: ${error instanceof Error ? error.message : String(error)}`
13946
+ error: `the archive could not be read: ${errorMessage(error)}`
14087
13947
  };
14088
13948
  }
14089
13949
  plan.encrypted = entries.some((entry) => entry.encrypted);
@@ -14368,7 +14228,7 @@ var init_backups = __esmMin((() => {
14368
14228
  const done = plan.applied.length > 0 ? ` — already applied: ${plan.applied.join(", ")}` : "";
14369
14229
  return {
14370
14230
  ...plan,
14371
- error: `${error instanceof Error ? error.message : String(error)}${done}`
14231
+ error: `${errorMessage(error)}${done}`
14372
14232
  };
14373
14233
  } finally {
14374
14234
  fs.rmSync(staging, {
@@ -14633,6 +14493,7 @@ var ControlServer;
14633
14493
  var init_control_server = __esmMin((() => {
14634
14494
  init_bind();
14635
14495
  init_port();
14496
+ init_error_message();
14636
14497
  ControlServer = class {
14637
14498
  options;
14638
14499
  endpoint;
@@ -14666,7 +14527,7 @@ var init_control_server = __esmMin((() => {
14666
14527
  } catch (error) {
14667
14528
  return {
14668
14529
  ok: false,
14669
- error: `restart failed: ${error instanceof Error ? error.message : String(error)}`
14530
+ error: `restart failed: ${errorMessage(error)}`
14670
14531
  };
14671
14532
  }
14672
14533
  }
@@ -14690,7 +14551,7 @@ var init_control_server = __esmMin((() => {
14690
14551
  await this.listenWithRetry(next.host, next.port);
14691
14552
  return { ok: true };
14692
14553
  } catch (error) {
14693
- const message = error instanceof Error ? error.message : String(error);
14554
+ const message = errorMessage(error);
14694
14555
  try {
14695
14556
  await this.listenWithRetry(previous.host, previous.port);
14696
14557
  } catch {}
@@ -15195,7 +15056,7 @@ async function detectPublicAddress(family, overrideUrl, fetchImpl, signal) {
15195
15056
  };
15196
15057
  lastError = `${endpoint} did not answer with a public ${family === "A" ? "IPv4" : "IPv6"} address`;
15197
15058
  } catch (error) {
15198
- lastError = `${endpoint}: ${error instanceof Error ? error.message : String(error)}`;
15059
+ lastError = `${endpoint}: ${errorMessage(error)}`;
15199
15060
  }
15200
15061
  return {
15201
15062
  ip: null,
@@ -15204,6 +15065,7 @@ async function detectPublicAddress(family, overrideUrl, fetchImpl, signal) {
15204
15065
  }
15205
15066
  var DEFAULT_IPV4_ENDPOINTS, DEFAULT_IPV6_ENDPOINTS;
15206
15067
  var init_ip = __esmMin((() => {
15068
+ init_error_message();
15207
15069
  DEFAULT_IPV4_ENDPOINTS = [
15208
15070
  "https://api4.ipify.org",
15209
15071
  "https://ipv4.icanhazip.com",
@@ -15251,6 +15113,7 @@ var init_ddns = __esmMin((() => {
15251
15113
  init_ddns$2();
15252
15114
  init_ip();
15253
15115
  init_types();
15116
+ init_error_message();
15254
15117
  RUN_TIMEOUT_MS = 6e4;
15255
15118
  DdnsService = class {
15256
15119
  deps;
@@ -15487,7 +15350,7 @@ var init_ddns = __esmMin((() => {
15487
15350
  try {
15488
15351
  writeFileAtomic(this.deps.statePath, `${JSON.stringify(this.persisted, null, 2)}\n`);
15489
15352
  } catch (error) {
15490
- logger.warn(`ddns: could not save its state (${error instanceof Error ? error.message : String(error)})`);
15353
+ logger.warn(`ddns: could not save its state (${errorMessage(error)})`);
15491
15354
  }
15492
15355
  }
15493
15356
  dispose() {
@@ -15695,6 +15558,7 @@ var init_log_files = __esmMin((() => {
15695
15558
  return lines.slice(-tail);
15696
15559
  }
15697
15560
  clear(serverId) {
15561
+ this.pending.delete(serverId);
15698
15562
  for (const file of this.rotateTargets(serverId)) try {
15699
15563
  fs.rmSync(file, { force: true });
15700
15564
  } catch {}
@@ -15797,6 +15661,7 @@ var REASON_LABEL, TITLE, NotificationService;
15797
15661
  var init_notifications = __esmMin((() => {
15798
15662
  init_logger();
15799
15663
  init_telegram();
15664
+ init_error_message();
15800
15665
  REASON_LABEL = {
15801
15666
  "crash": "gave up restarting",
15802
15667
  "unhealthy": "health check failing",
@@ -15822,14 +15687,12 @@ var init_notifications = __esmMin((() => {
15822
15687
  NotificationService = class {
15823
15688
  secrets;
15824
15689
  getConfig;
15825
- getLogsConfig;
15826
15690
  cooldowns = /* @__PURE__ */ new Map();
15827
15691
  lastResult = null;
15828
15692
  lastResultAt = null;
15829
- constructor(secrets, getConfig, getLogsConfig) {
15693
+ constructor(secrets, getConfig) {
15830
15694
  this.secrets = secrets;
15831
15695
  this.getConfig = getConfig;
15832
- this.getLogsConfig = getLogsConfig;
15833
15696
  }
15834
15697
  get telegramTokenSet() {
15835
15698
  return this.secrets.telegramTokenSet;
@@ -15876,7 +15739,7 @@ var init_notifications = __esmMin((() => {
15876
15739
  /** Fire-and-forget by design: supervision must never wait on a chat API. */
15877
15740
  notify(event) {
15878
15741
  this.dispatch(event).catch((error) => {
15879
- logger.warn(`notification failed: ${error instanceof Error ? error.message : String(error)}`);
15742
+ logger.warn(`notification failed: ${errorMessage(error)}`);
15880
15743
  });
15881
15744
  }
15882
15745
  async dispatch(event) {
@@ -16009,6 +15872,7 @@ var init_panel = __esmMin((() => {
16009
15872
  init_notifications();
16010
15873
  init_state$1();
16011
15874
  init_supervisor();
15875
+ init_error_message();
16012
15876
  TICK_MS = 1e3;
16013
15877
  PanelService = class {
16014
15878
  options;
@@ -16218,7 +16082,7 @@ var init_panel = __esmMin((() => {
16218
16082
  const secrets = new SecretsStore(workspaceSecretsPath(id), void 0, "workspace");
16219
16083
  const logFiles = new LogFiles(workspaceLogsDir(id), () => store.logs);
16220
16084
  const history = new HistoryStore(workspaceHistoryPath(id));
16221
- const notifications = new NotificationService(secrets, () => store.notifications, () => store.logs);
16085
+ const notifications = new NotificationService(secrets, () => store.notifications);
16222
16086
  const ddns = new DdnsService({
16223
16087
  getConfig: () => store.ddns,
16224
16088
  getCredentials: (accountId, provider) => secrets.getDdnsCredentials(accountId, provider)?.values ?? null,
@@ -16261,11 +16125,11 @@ var init_panel = __esmMin((() => {
16261
16125
  runtime.watches = [new ConfigWatch({
16262
16126
  file: store.path,
16263
16127
  onChange: () => this.handleConfigChange(id),
16264
- onError: (error) => logger.warn(`cannot watch ${path.basename(store.path)}: ${error instanceof Error ? error.message : String(error)}`)
16128
+ onError: (error) => logger.warn(`cannot watch ${path.basename(store.path)}: ${errorMessage(error)}`)
16265
16129
  }), new ConfigWatch({
16266
16130
  file: store.settingsPath,
16267
16131
  onChange: () => this.handleConfigChange(id),
16268
- onError: (error) => logger.warn(`cannot watch ${path.basename(store.settingsPath)}: ${error instanceof Error ? error.message : String(error)}`)
16132
+ onError: (error) => logger.warn(`cannot watch ${path.basename(store.settingsPath)}: ${errorMessage(error)}`)
16269
16133
  })];
16270
16134
  for (const watch of runtime.watches) watch.start();
16271
16135
  return runtime;
@@ -16466,7 +16330,7 @@ function readDataRoot(dataRoot) {
16466
16330
  }
16467
16331
  }
16468
16332
  function describeError$1(error) {
16469
- return error instanceof Error ? error.message : String(error);
16333
+ return errorMessage(error);
16470
16334
  }
16471
16335
  /**
16472
16336
  * Where the site actually starts: the archive root, or a single wrapper directory
@@ -16500,6 +16364,7 @@ var init_ui = __esmMin((() => {
16500
16364
  init_atomic();
16501
16365
  init_archive();
16502
16366
  init_contracts();
16367
+ init_error_message();
16503
16368
  META = "ui.json";
16504
16369
  MAX_ENTRIES = 2e4;
16505
16370
  MAX_BYTES = 536870912;
@@ -16561,7 +16426,7 @@ var init_ui = __esmMin((() => {
16561
16426
  } catch (error) {
16562
16427
  return {
16563
16428
  ok: false,
16564
- error: error instanceof Error ? error.message : String(error)
16429
+ error: errorMessage(error)
16565
16430
  };
16566
16431
  } finally {
16567
16432
  fs.rmSync(staging, {
@@ -16586,7 +16451,7 @@ var init_ui = __esmMin((() => {
16586
16451
  } catch (error) {
16587
16452
  return {
16588
16453
  ok: false,
16589
- error: `the archive could not be read: ${error instanceof Error ? error.message : String(error)}`
16454
+ error: `the archive could not be read: ${errorMessage(error)}`
16590
16455
  };
16591
16456
  }
16592
16457
  if (entries.length === 0) return {
@@ -16898,7 +16763,7 @@ function formatBytes(bytes) {
16898
16763
  return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
16899
16764
  }
16900
16765
  function describeError(error) {
16901
- return error instanceof Error ? error.message : String(error);
16766
+ return errorMessage(error);
16902
16767
  }
16903
16768
  /**
16904
16769
  * Tag ordering: `v1.2.3` beats `v1.2.2`, a prerelease loses to its release, and anything
@@ -16946,6 +16811,7 @@ function parseTag(tag) {
16946
16811
  }
16947
16812
  var DEFAULT_REPO, MAX_DOWNLOAD_BYTES, REQUEST_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS;
16948
16813
  var init_ui_release = __esmMin((() => {
16814
+ init_error_message();
16949
16815
  DEFAULT_REPO = "NamesMT/home-hosted";
16950
16816
  MAX_DOWNLOAD_BYTES = 536870912;
16951
16817
  REQUEST_TIMEOUT_MS = 3e4;
@@ -17023,7 +16889,7 @@ async function syncOfficialUi(ui, runningVersion = appVersion()) {
17023
16889
  } catch (error) {
17024
16890
  return {
17025
16891
  kind: "failed",
17026
- error: error instanceof Error ? error.message : String(error)
16892
+ error: errorMessage(error)
17027
16893
  };
17028
16894
  }
17029
16895
  }
@@ -17042,13 +16908,14 @@ function autoUpdateOfficialUi(ui, runningVersion = appVersion()) {
17042
16908
  if (result.kind === "updated") logger.info(`ui: updated to ${result.tag} — refresh the browser`);
17043
16909
  else if (result.kind === "failed") logger.warn(`ui: could not update to ${plan.tag}: ${result.error}`);
17044
16910
  }).catch((error) => {
17045
- logger.warn(`ui: could not update: ${error instanceof Error ? error.message : String(error)}`);
16911
+ logger.warn(`ui: could not update: ${errorMessage(error)}`);
17046
16912
  });
17047
16913
  }
17048
16914
  var init_ui_update$1 = __esmMin((() => {
17049
16915
  init_logger();
17050
16916
  init_version();
17051
16917
  init_ui_release();
16918
+ init_error_message();
17052
16919
  }));
17053
16920
  //#endregion
17054
16921
  //#region src/index.ts
@@ -17074,7 +16941,12 @@ function probeHostFor(bindHost) {
17074
16941
  * directly.
17075
16942
  */
17076
16943
  async function runControlPlane(options) {
17077
- ensureLayout();
16944
+ try {
16945
+ ensureLayout();
16946
+ } catch (error) {
16947
+ logger.error(errorMessage(error));
16948
+ process.exit(1);
16949
+ }
17078
16950
  const existing = readRuntime();
17079
16951
  if (existing !== null && existing.pid !== process.pid && await runtimeIsAlive(existing)) {
17080
16952
  logger.error(`already running (pid ${existing.pid}) at ${existing.url} — run \`home-hosted down\` first`);
@@ -17101,6 +16973,11 @@ async function runControlPlane(options) {
17101
16973
  process.exit(1);
17102
16974
  }
17103
16975
  const secrets = new SecretsStore(globalSecretsPath, void 0, "global");
16976
+ if (secrets.configError !== null) {
16977
+ logger.error(`refusing to start: ${secrets.configError}`);
16978
+ logger.info("fix or restore the file, or move it aside to start fresh — starting fresh discards it");
16979
+ process.exit(1);
16980
+ }
17104
16981
  const auth = new AuthService(secrets, () => settings.control.auth);
17105
16982
  const tls = new TlsStore(tlsDir);
17106
16983
  const hub = new EventHub();
@@ -17247,6 +17124,11 @@ async function runControlPlane(options) {
17247
17124
  logger.info(`fix ${store.path} and ${store.settingsPath}, or install the release that wrote them`);
17248
17125
  process.exit(1);
17249
17126
  }
17127
+ if (workspace.secrets.configError !== null) {
17128
+ logger.error(`refusing to start: workspace "${workspace.id}": ${workspace.secrets.configError}`);
17129
+ logger.info("fix or restore the file, or move it aside to start fresh — starting fresh discards it");
17130
+ process.exit(1);
17131
+ }
17250
17132
  if (store.pendingMigrations.length > 0) {
17251
17133
  logger.error(`refusing to start: workspace "${workspace.id}" needs ${store.pendingMigrations.length} migration(s) before ${appVersion()} can use it`);
17252
17134
  logger.info("run `home-hosted migrate` to see and apply them");
@@ -17394,6 +17276,7 @@ var init_src = __esmMin((() => {
17394
17276
  init_ui();
17395
17277
  init_ui_update$1();
17396
17278
  init_contracts();
17279
+ init_error_message();
17397
17280
  packageRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
17398
17281
  }));
17399
17282
  //#endregion
@@ -17957,6 +17840,7 @@ async function runLogs(input) {
17957
17840
  var logsArgs, DEFAULT_LINES, logsCommand;
17958
17841
  var init_logs = __esmMin((() => {
17959
17842
  init_io();
17843
+ init_error_message();
17960
17844
  logsArgs = {
17961
17845
  lines: {
17962
17846
  type: "string",
@@ -17983,7 +17867,7 @@ var init_logs = __esmMin((() => {
17983
17867
  json: args.json
17984
17868
  });
17985
17869
  } catch (error) {
17986
- fail(error instanceof Error ? error.message : String(error));
17870
+ fail(errorMessage(error));
17987
17871
  }
17988
17872
  }
17989
17873
  });
@@ -18150,12 +18034,6 @@ function targets(configOverride) {
18150
18034
  return list;
18151
18035
  }
18152
18036
  async function runMigrate(config, dryRun, yes) {
18153
- const relocated = migrateLayout();
18154
- if (relocated.migrated) {
18155
- process.stdout.write(`${green("state layout migrated")} into the current .hh layout\n`);
18156
- for (const step of relocated.moved) process.stdout.write(` ${dim(step)}\n`);
18157
- for (const warning of relocated.warnings) process.stdout.write(` ${dim(`warning: ${warning}`)}\n`);
18158
- }
18159
18037
  const existing = targets(config).filter((target) => fs.existsSync(target.file));
18160
18038
  if (existing.length === 0) fail(`no home-hosted state to migrate — nothing at ${globalSettingsPath} or any workspace`);
18161
18039
  const consented = yes || (process.env.HHOSTED_MIGRATE ?? "").toLowerCase() === "allow";
@@ -18165,7 +18043,7 @@ async function runMigrate(config, dryRun, yes) {
18165
18043
  try {
18166
18044
  raw = JSON.parse(fs.readFileSync(target.file, "utf8"));
18167
18045
  } catch (error) {
18168
- fail(`cannot read ${target.file}: ${error instanceof Error ? error.message : String(error)}`);
18046
+ fail(`cannot read ${target.file}: ${errorMessage(error)}`);
18169
18047
  }
18170
18048
  const meta = typeof raw.meta === "object" && raw.meta !== null ? raw.meta : {};
18171
18049
  const from = typeof meta.schema === "number" ? meta.schema : 1;
@@ -18210,13 +18088,13 @@ async function runMigrate(config, dryRun, yes) {
18210
18088
  var migrateArgs, migrateCommand;
18211
18089
  var init_migrate = __esmMin((() => {
18212
18090
  init_io();
18213
- init_layout();
18214
18091
  init_migrations();
18215
18092
  init_parse();
18216
18093
  init_workspaces();
18217
18094
  init_atomic();
18218
18095
  init_paths();
18219
18096
  init_version();
18097
+ init_error_message();
18220
18098
  migrateArgs = {
18221
18099
  config: {
18222
18100
  type: "string",
@@ -18380,7 +18258,7 @@ async function runInit(options) {
18380
18258
  process.stdout.write(`${green("project created")} in ${result.dir}\n`);
18381
18259
  for (const file of result.files) process.stdout.write(` ${dim(file)}\n`);
18382
18260
  } catch (error) {
18383
- fail(error instanceof Error ? error.message : String(error));
18261
+ fail(errorMessage(error));
18384
18262
  }
18385
18263
  const target = path.resolve(dir);
18386
18264
  if (git) {
@@ -18411,6 +18289,7 @@ var initArgs, initCommand;
18411
18289
  var init_init = __esmMin((() => {
18412
18290
  init_io();
18413
18291
  init_init$1();
18292
+ init_error_message();
18414
18293
  initArgs = {
18415
18294
  dir: {
18416
18295
  type: "string",
@@ -19057,6 +18936,7 @@ var init_nanny = __esmMin((() => {
19057
18936
  }));
19058
18937
  //#endregion
19059
18938
  //#region src/cli.ts
18939
+ init_error_message();
19060
18940
  init_args();
19061
18941
  init_io();
19062
18942
  init_nearest();
@@ -19353,7 +19233,7 @@ async function main() {
19353
19233
  try {
19354
19234
  await runCommand(nannyCommand, { rawArgs: dirFlags.rest });
19355
19235
  } catch (error) {
19356
- fail(error instanceof Error ? error.message : String(error));
19236
+ fail(errorMessage(error));
19357
19237
  }
19358
19238
  return;
19359
19239
  }
@@ -19382,11 +19262,11 @@ async function main() {
19382
19262
  try {
19383
19263
  await runCommand(rootCommand, { rawArgs: invocation.argv });
19384
19264
  } catch (error) {
19385
- fail(error instanceof Error ? error.message : String(error));
19265
+ fail(errorMessage(error));
19386
19266
  }
19387
19267
  }
19388
19268
  main().catch((error) => {
19389
- fail(error instanceof Error ? error.message : String(error));
19269
+ fail(errorMessage(error));
19390
19270
  });
19391
19271
  //#endregion
19392
19272
  export { commandHelp, suggestCommand };