home-hosted 0.7.21 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -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.
@@ -1895,7 +1908,11 @@ function isSealedDdns(value) {
1895
1908
  function isPlainDdns(value) {
1896
1909
  return "plain" in value;
1897
1910
  }
1898
- /** 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
+ */
1899
1916
  function readStoredDdns(value) {
1900
1917
  if (!isRecord(value)) return {};
1901
1918
  const out = {};
@@ -1904,12 +1921,12 @@ function readStoredDdns(value) {
1904
1921
  out[accountId] = entry;
1905
1922
  continue;
1906
1923
  }
1907
- if (!isRecord(entry)) continue;
1908
- 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);
1909
1926
  if (Object.keys(values).length === 0) continue;
1910
1927
  out[accountId] = {
1911
1928
  plain: true,
1912
- provider: typeof entry.provider === "string" ? entry.provider : "",
1929
+ provider: entry.provider,
1913
1930
  values
1914
1931
  };
1915
1932
  }
@@ -1977,6 +1994,7 @@ var COST, KEYLEN, SALT_BYTES, DDNS_KEYLEN, DDNS_IV_BYTES, DDNS_AAD_PREFIX, DDNS_
1977
1994
  var init_secrets = __esmMin((() => {
1978
1995
  init_atomic();
1979
1996
  init_logger();
1997
+ init_error_message();
1980
1998
  init_shape();
1981
1999
  COST = {
1982
2000
  N: 16384,
@@ -1996,6 +2014,7 @@ var init_secrets = __esmMin((() => {
1996
2014
  scope;
1997
2015
  cache = null;
1998
2016
  cacheKey = "";
2017
+ error = null;
1999
2018
  ddnsKeyCache = null;
2000
2019
  warnedAccounts = /* @__PURE__ */ new Set();
2001
2020
  warnedDefaultSecret = false;
@@ -2120,7 +2139,7 @@ var init_secrets = __esmMin((() => {
2120
2139
  if (stored === void 0) return null;
2121
2140
  const record = this.openStored(accountId, stored);
2122
2141
  if (record === null) return null;
2123
- return record.provider.length > 0 && record.provider !== provider ? null : record;
2142
+ return record.provider === provider ? record : null;
2124
2143
  }
2125
2144
  /** An empty field is dropped, so clearing every field forgets the account. */
2126
2145
  setDdnsCredentials(accountId, provider, credentials) {
@@ -2191,9 +2210,22 @@ var init_secrets = __esmMin((() => {
2191
2210
  this.warnedAccounts.add(accountId);
2192
2211
  logger.warn(`ddns: cannot read the credentials for "${accountId}" — ${reason}`);
2193
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
+ }
2194
2226
  read() {
2227
+ this.error = null;
2195
2228
  const empty = {
2196
- version: 3,
2197
2229
  password: null,
2198
2230
  apiToken: null,
2199
2231
  telegram: null,
@@ -2201,17 +2233,24 @@ var init_secrets = __esmMin((() => {
2201
2233
  ddnsKdf: null
2202
2234
  };
2203
2235
  if (!fs.existsSync(this.file)) return this.inScope(empty);
2236
+ let text;
2204
2237
  try {
2205
- 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);
2206
2245
  return this.inScope({
2207
- version: 3,
2208
2246
  password: parsed?.password ?? null,
2209
2247
  apiToken: parsed?.apiToken?.hash ? parsed.apiToken : null,
2210
2248
  telegram: parsed?.telegram?.botToken ? { botToken: parsed.telegram.botToken } : null,
2211
2249
  ddns: readStoredDdns(parsed?.ddns),
2212
2250
  ddnsKdf: readDdnsKdf(parsed?.ddnsKdf)
2213
2251
  });
2214
- } catch {
2252
+ } catch (error) {
2253
+ this.error = `${this.file} is not readable JSON (${errorMessage(error)}) — it was left untouched`;
2215
2254
  return this.inScope(empty);
2216
2255
  }
2217
2256
  }
@@ -2231,6 +2270,7 @@ var init_secrets = __esmMin((() => {
2231
2270
  return contents;
2232
2271
  }
2233
2272
  save(contents) {
2273
+ if (this.configError !== null) throw new Error(`refusing to write ${this.file}: ${this.error}`);
2234
2274
  const sealed = this.sealStored(this.inScope(contents));
2235
2275
  writeFileAtomic(this.file, `${JSON.stringify(sealed, null, 2)}\n`, { mode: 384 });
2236
2276
  this.cache = sealed;
@@ -2919,7 +2959,11 @@ function workspaceIdsOnDisk() {
2919
2959
  return [];
2920
2960
  }
2921
2961
  }
2922
- /** 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
+ */
2923
2967
  function hasLegacyLayout() {
2924
2968
  if (fs.existsSync(workspacesPath)) return false;
2925
2969
  return fs.existsSync(legacy.configPath) || fs.existsSync(legacy.secretsPath);
@@ -2955,15 +2999,7 @@ var init_paths = __esmMin((() => {
2955
2999
  proxyChallengeAuthPath = path.join(proxyStateDir, "challenge.json");
2956
3000
  legacy = {
2957
3001
  configPath: path.join(dataRoot, "servers.config.json"),
2958
- configSchemaPath: path.join(dataRoot, "servers.config.schema.json"),
2959
- secretsPath: path.join(dataRoot, ".control-secrets.json"),
2960
- tlsDir: path.join(dataRoot, ".tls"),
2961
- logsDir: path.join(dataRoot, ".logs"),
2962
- historyPath: path.join(dataRoot, ".logs", "history.json"),
2963
- stateDir: path.join(dataRoot, ".state"),
2964
- backupsDir: path.join(dataRoot, ".backups"),
2965
- uiDir: path.join(dataRoot, ".ui"),
2966
- runtimePath: path.join(dataRoot, "run.json")
3002
+ secretsPath: path.join(dataRoot, ".control-secrets.json")
2967
3003
  };
2968
3004
  }));
2969
3005
  //#endregion
@@ -3624,7 +3660,7 @@ function planConfigMigrations(from, options = {}) {
3624
3660
  to,
3625
3661
  steps,
3626
3662
  tooNew: from > to,
3627
- reached: steps.reduce((at, step) => step.to, from)
3663
+ reached: steps.at(-1)?.to ?? from
3628
3664
  };
3629
3665
  }
3630
3666
  /** Applies the plan in order; the caller owns writing the result. */
@@ -3692,6 +3728,35 @@ function parseTolerant(value, schema, prefix, unknownKeys) {
3692
3728
  };
3693
3729
  }
3694
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
+ /**
3695
3760
  * Reads one settings file from any release: schema guard, unrecognized keys
3696
3761
  * reported and left on disk, blocking problems returned for the caller to refuse on.
3697
3762
  */
@@ -3710,18 +3775,11 @@ function parseGrouped(raw, label, definition, options) {
3710
3775
  errors.push(`the ${label} must contain a JSON object`);
3711
3776
  return result;
3712
3777
  }
3713
- const meta = isRecord(raw.meta) ? raw.meta : null;
3714
- const schemaVersion = typeof meta?.schema === "number" ? meta.schema : 1;
3715
- const writtenBy = typeof meta?.writtenBy === "string" && meta.writtenBy.length > 0 ? meta.writtenBy : null;
3716
- result.schemaVersion = schemaVersion;
3717
- result.writtenBy = writtenBy;
3718
- if (schemaVersion > 1) {
3719
- errors.push(`written by home-hosted ${writtenBy ?? "a newer release"} (config schema ${schemaVersion}); this release understands schema 1`);
3720
- return result;
3721
- }
3722
- const { steps } = planConfigMigrations(schemaVersion, options);
3723
- if (steps.length > 0) {
3724
- 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);
3725
3783
  return result;
3726
3784
  }
3727
3785
  for (const key of Object.keys(raw)) if (!definition.keys.includes(key)) unknownKeys.push(key);
@@ -3733,8 +3791,8 @@ function parseGrouped(raw, label, definition, options) {
3733
3791
  }
3734
3792
  if (errors.length > 0) return result;
3735
3793
  result.config = definition.build({
3736
- writtenBy: writtenBy ?? "",
3737
- schema: schemaVersion
3794
+ writtenBy: stamp.writtenBy ?? "",
3795
+ schema: stamp.schemaVersion
3738
3796
  }, groups);
3739
3797
  return result;
3740
3798
  }
@@ -3796,18 +3854,11 @@ function parseServersFile(raw, defaults, options = {}) {
3796
3854
  errors.push("the servers config must contain a JSON object");
3797
3855
  return result;
3798
3856
  }
3799
- const meta = isRecord(raw.meta) ? raw.meta : null;
3800
- const schemaVersion = typeof meta?.schema === "number" ? meta.schema : 1;
3801
- const writtenBy = typeof meta?.writtenBy === "string" && meta.writtenBy.length > 0 ? meta.writtenBy : null;
3802
- result.schemaVersion = schemaVersion;
3803
- result.writtenBy = writtenBy;
3804
- if (schemaVersion > 1) {
3805
- errors.push(`written by home-hosted ${writtenBy ?? "a newer release"} (config schema ${schemaVersion}); this release understands schema 1`);
3806
- return result;
3807
- }
3808
- const { steps } = planConfigMigrations(schemaVersion, options);
3809
- if (steps.length > 0) {
3810
- 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);
3811
3862
  return result;
3812
3863
  }
3813
3864
  for (const key of Object.keys(raw)) if (!SERVERS_FILE_KEYS.includes(key)) unknownKeys.push(key);
@@ -4202,6 +4253,18 @@ function failed(error) {
4202
4253
  error
4203
4254
  };
4204
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
+ }
4205
4268
  /** Missing required credential keys, as a sentence for the settings page. */
4206
4269
  function missingFields(credentials, keys) {
4207
4270
  const missing = keys.filter((key) => (credentials?.[key] ?? "").trim().length === 0);
@@ -4279,7 +4342,7 @@ async function call$1(path, headers, context, init = {}) {
4279
4342
  return {
4280
4343
  data: null,
4281
4344
  status: 0,
4282
- error: error instanceof Error ? error.message : String(error)
4345
+ error: errorMessage(error)
4283
4346
  };
4284
4347
  }
4285
4348
  }
@@ -4338,6 +4401,7 @@ async function findRecord(zoneId, record, headers, context, cached) {
4338
4401
  var API$5, cloudflareProvider;
4339
4402
  var init_cloudflare = __esmMin((() => {
4340
4403
  init_types();
4404
+ init_error_message();
4341
4405
  API$5 = "https://api.cloudflare.com/client/v4";
4342
4406
  cloudflareProvider = {
4343
4407
  id: "cloudflare",
@@ -4414,7 +4478,7 @@ var init_cloudflare = __esmMin((() => {
4414
4478
  }
4415
4479
  };
4416
4480
  } catch (error) {
4417
- return failed(error instanceof Error ? error.message : String(error));
4481
+ return failed(errorMessage(error));
4418
4482
  }
4419
4483
  },
4420
4484
  /**
@@ -4481,7 +4545,7 @@ var init_cloudflare = __esmMin((() => {
4481
4545
  } catch (error) {
4482
4546
  return {
4483
4547
  ok: false,
4484
- message: error instanceof Error ? error.message : String(error)
4548
+ message: errorMessage(error)
4485
4549
  };
4486
4550
  }
4487
4551
  }
@@ -4612,6 +4676,7 @@ var ENDPOINT$7, desecProvider;
4612
4676
  var init_desec = __esmMin((() => {
4613
4677
  init_dyndns2();
4614
4678
  init_types();
4679
+ init_error_message();
4615
4680
  ENDPOINT$7 = "https://update.dedyn.io/";
4616
4681
  desecProvider = {
4617
4682
  id: "desec",
@@ -4641,7 +4706,7 @@ var init_desec = __esmMin((() => {
4641
4706
  });
4642
4707
  return describeDyndnsResponse(response, await response.text(), "desec");
4643
4708
  } catch (error) {
4644
- return failed(error instanceof Error ? error.message : String(error));
4709
+ return failed(errorMessage(error));
4645
4710
  }
4646
4711
  }
4647
4712
  };
@@ -4709,6 +4774,7 @@ var API$4, digitaloceanProvider;
4709
4774
  var init_digitalocean = __esmMin((() => {
4710
4775
  init_types();
4711
4776
  init_zone();
4777
+ init_error_message();
4712
4778
  API$4 = "https://api.digitalocean.com/v2/domains";
4713
4779
  digitaloceanProvider = {
4714
4780
  id: "digitalocean",
@@ -4742,7 +4808,7 @@ var init_digitalocean = __esmMin((() => {
4742
4808
  if (!listed.ok) return failed(await errorText(listed));
4743
4809
  const existing = ((await listed.json()).domain_records ?? []).find((entry) => entry.name === name && entry.type === record.type);
4744
4810
  if (existing !== void 0 && existing.data === record.ip) return succeeded(`already ${record.ip}`, false);
4745
- const written = existing === void 0 ? await context.fetch(collection, {
4811
+ return await wrote(existing === void 0 ? await context.fetch(collection, {
4746
4812
  method: "POST",
4747
4813
  headers,
4748
4814
  body: JSON.stringify({
@@ -4762,11 +4828,9 @@ var init_digitalocean = __esmMin((() => {
4762
4828
  ttl
4763
4829
  }),
4764
4830
  signal: context.signal
4765
- });
4766
- if (!written.ok) return failed(await errorText(written));
4767
- return succeeded(`set ${record.host} ${record.type} to ${record.ip}`, true);
4831
+ }), record);
4768
4832
  } catch (error) {
4769
- return failed(error instanceof Error ? error.message : String(error));
4833
+ return failed(errorMessage(error));
4770
4834
  }
4771
4835
  }
4772
4836
  };
@@ -4781,6 +4845,7 @@ function subdomain(host) {
4781
4845
  var ENDPOINT$6, duckdnsProvider;
4782
4846
  var init_duckdns = __esmMin((() => {
4783
4847
  init_types();
4848
+ init_error_message();
4784
4849
  ENDPOINT$6 = "https://www.duckdns.org/update";
4785
4850
  duckdnsProvider = {
4786
4851
  id: "duckdns",
@@ -4809,7 +4874,7 @@ var init_duckdns = __esmMin((() => {
4809
4874
  if (body.toUpperCase() === "OK") return succeeded(`set ${record.host} ${record.type} to ${record.ip}`, true);
4810
4875
  return failed(`duckdns answered "${body.slice(0, 120) || `HTTP ${response.status}`}"`);
4811
4876
  } catch (error) {
4812
- return failed(error instanceof Error ? error.message : String(error));
4877
+ return failed(errorMessage(error));
4813
4878
  }
4814
4879
  }
4815
4880
  };
@@ -4820,6 +4885,7 @@ var ENDPOINT$5, dynuProvider;
4820
4885
  var init_dynu = __esmMin((() => {
4821
4886
  init_dyndns2();
4822
4887
  init_types();
4888
+ init_error_message();
4823
4889
  ENDPOINT$5 = "https://api.dynu.com/nic/update";
4824
4890
  dynuProvider = {
4825
4891
  id: "dynu",
@@ -4855,7 +4921,7 @@ var init_dynu = __esmMin((() => {
4855
4921
  });
4856
4922
  return describeDyndnsResponse(response, await response.text(), "dynu");
4857
4923
  } catch (error) {
4858
- return failed(error instanceof Error ? error.message : String(error));
4924
+ return failed(errorMessage(error));
4859
4925
  }
4860
4926
  }
4861
4927
  };
@@ -4874,6 +4940,7 @@ function updateToken(credentials) {
4874
4940
  var ENDPOINT$4, freednsProvider;
4875
4941
  var init_freedns = __esmMin((() => {
4876
4942
  init_types();
4943
+ init_error_message();
4877
4944
  ENDPOINT$4 = "https://freedns.afraid.org/dynamic/update.php";
4878
4945
  freednsProvider = {
4879
4946
  id: "freedns",
@@ -4900,7 +4967,7 @@ var init_freedns = __esmMin((() => {
4900
4967
  if (!response.ok || /^ERROR/i.test(body)) return failed(`freedns answered "${body.slice(0, 160) || `HTTP ${response.status}`}"`);
4901
4968
  return succeeded(`set ${record.host} ${record.type} to ${record.ip}`, true);
4902
4969
  } catch (error) {
4903
- return failed(error instanceof Error ? error.message : String(error));
4970
+ return failed(errorMessage(error));
4904
4971
  }
4905
4972
  }
4906
4973
  };
@@ -4919,6 +4986,7 @@ var API$3, gandiProvider;
4919
4986
  var init_gandi = __esmMin((() => {
4920
4987
  init_types();
4921
4988
  init_zone();
4989
+ init_error_message();
4922
4990
  API$3 = "https://api.gandi.net/v5/livedns/domains";
4923
4991
  gandiProvider = {
4924
4992
  id: "gandi",
@@ -4955,7 +5023,7 @@ var init_gandi = __esmMin((() => {
4955
5023
  const body = await current.json();
4956
5024
  if (body.rrset_values?.length === 1 && body.rrset_values[0] === record.ip) return succeeded(`already ${record.ip}`, false);
4957
5025
  }
4958
- const written = await context.fetch(url, {
5026
+ return await wrote(await context.fetch(url, {
4959
5027
  method: "PUT",
4960
5028
  headers,
4961
5029
  body: JSON.stringify({
@@ -4963,11 +5031,9 @@ var init_gandi = __esmMin((() => {
4963
5031
  rrset_ttl: ttl
4964
5032
  }),
4965
5033
  signal: context.signal
4966
- });
4967
- if (!written.ok) return failed(await errorText(written));
4968
- return succeeded(`set ${record.host} ${record.type} to ${record.ip}`, true);
5034
+ }), record);
4969
5035
  } catch (error) {
4970
- return failed(error instanceof Error ? error.message : String(error));
5036
+ return failed(errorMessage(error));
4971
5037
  }
4972
5038
  }
4973
5039
  };
@@ -4982,6 +5048,7 @@ var API$2, godaddyProvider;
4982
5048
  var init_godaddy = __esmMin((() => {
4983
5049
  init_types();
4984
5050
  init_zone();
5051
+ init_error_message();
4985
5052
  API$2 = "https://api.godaddy.com/v1/domains";
4986
5053
  godaddyProvider = {
4987
5054
  id: "godaddy",
@@ -5030,7 +5097,7 @@ var init_godaddy = __esmMin((() => {
5030
5097
  const body = await current.json();
5031
5098
  if (body.length === 1 && body[0]?.data === record.ip) return succeeded(`already ${record.ip}`, false);
5032
5099
  }
5033
- const written = await context.fetch(url, {
5100
+ return await wrote(await context.fetch(url, {
5034
5101
  method: "PUT",
5035
5102
  headers,
5036
5103
  body: JSON.stringify([{
@@ -5038,11 +5105,9 @@ var init_godaddy = __esmMin((() => {
5038
5105
  ttl
5039
5106
  }]),
5040
5107
  signal: context.signal
5041
- });
5042
- if (!written.ok) return failed(await errorText(written));
5043
- return succeeded(`set ${record.host} ${record.type} to ${record.ip}`, true);
5108
+ }), record);
5044
5109
  } catch (error) {
5045
- return failed(error instanceof Error ? error.message : String(error));
5110
+ return failed(errorMessage(error));
5046
5111
  }
5047
5112
  }
5048
5113
  };
@@ -5053,6 +5118,7 @@ var ENDPOINT$3, henetProvider;
5053
5118
  var init_henet = __esmMin((() => {
5054
5119
  init_dyndns2();
5055
5120
  init_types();
5121
+ init_error_message();
5056
5122
  ENDPOINT$3 = "https://dyn.dns.he.net/nic/update";
5057
5123
  henetProvider = {
5058
5124
  id: "henet",
@@ -5078,7 +5144,7 @@ var init_henet = __esmMin((() => {
5078
5144
  const response = await context.fetch(url, { signal: context.signal });
5079
5145
  return describeDyndnsResponse(response, await response.text(), "hurricane electric");
5080
5146
  } catch (error) {
5081
- return failed(error instanceof Error ? error.message : String(error));
5147
+ return failed(errorMessage(error));
5082
5148
  }
5083
5149
  }
5084
5150
  };
@@ -5092,6 +5158,7 @@ var ENDPOINT$2, namecheapProvider;
5092
5158
  var init_namecheap = __esmMin((() => {
5093
5159
  init_types();
5094
5160
  init_zone();
5161
+ init_error_message();
5095
5162
  ENDPOINT$2 = "https://dynamicdns.park-your-domain.com/update";
5096
5163
  namecheapProvider = {
5097
5164
  id: "namecheap",
@@ -5126,7 +5193,7 @@ var init_namecheap = __esmMin((() => {
5126
5193
  if (ip === null) return failed(`namecheap answered without an IP: ${body.replace(/\s+/g, " ").slice(0, 160)}`);
5127
5194
  return succeeded(`${host === "@" ? zone : `${host}.${zone}`} now ${ip}`, ip !== record.ip);
5128
5195
  } catch (error) {
5129
- return failed(error instanceof Error ? error.message : String(error));
5196
+ return failed(errorMessage(error));
5130
5197
  }
5131
5198
  }
5132
5199
  };
@@ -5218,7 +5285,7 @@ async function call(command, params, context) {
5218
5285
  body
5219
5286
  };
5220
5287
  } catch (error) {
5221
- return error instanceof Error ? error.message : String(error);
5288
+ return errorMessage(error);
5222
5289
  }
5223
5290
  }
5224
5291
  /** The registered domain split into the `SLD`/`TLD` pair the API asks for. */
@@ -5265,6 +5332,7 @@ var ENDPOINT$1, RECORD_LIMIT, CHALLENGE_TTL, XML_ENTITIES, namecheapApiProvider;
5265
5332
  var init_namecheap_api = __esmMin((() => {
5266
5333
  init_types();
5267
5334
  init_zone();
5335
+ init_error_message();
5268
5336
  ENDPOINT$1 = "https://api.namecheap.com/xml.response";
5269
5337
  RECORD_LIMIT = 150;
5270
5338
  CHALLENGE_TTL = 60;
@@ -5379,6 +5447,7 @@ var ENDPOINT, DEFAULT_AGENT, noipProvider;
5379
5447
  var init_noip = __esmMin((() => {
5380
5448
  init_dyndns2();
5381
5449
  init_types();
5450
+ init_error_message();
5382
5451
  ENDPOINT = "https://dynupdate.no-ip.com/nic/update";
5383
5452
  DEFAULT_AGENT = "home-hosted/1.0 (+https://github.com/NamesMT/home-hosted)";
5384
5453
  noipProvider = {
@@ -5427,7 +5496,7 @@ var init_noip = __esmMin((() => {
5427
5496
  });
5428
5497
  return describeDyndnsResponse(response, await response.text(), "no-ip");
5429
5498
  } catch (error) {
5430
- return failed(error instanceof Error ? error.message : String(error));
5499
+ return failed(errorMessage(error));
5431
5500
  }
5432
5501
  }
5433
5502
  };
@@ -5462,7 +5531,7 @@ async function post(path, context, body) {
5462
5531
  } catch (error) {
5463
5532
  return {
5464
5533
  data: null,
5465
- error: error instanceof Error ? error.message : String(error)
5534
+ error: errorMessage(error)
5466
5535
  };
5467
5536
  }
5468
5537
  }
@@ -5470,6 +5539,7 @@ var API$1, porkbunProvider;
5470
5539
  var init_porkbun = __esmMin((() => {
5471
5540
  init_types();
5472
5541
  init_zone();
5542
+ init_error_message();
5473
5543
  API$1 = "https://api.porkbun.com/api/json/v3";
5474
5544
  porkbunProvider = {
5475
5545
  id: "porkbun",
@@ -5518,6 +5588,7 @@ var API, PAGE_SIZE, spaceshipProvider;
5518
5588
  var init_spaceship = __esmMin((() => {
5519
5589
  init_types();
5520
5590
  init_zone();
5591
+ init_error_message();
5521
5592
  API = "https://spaceship.dev/api/v1/dns/records";
5522
5593
  PAGE_SIZE = 500;
5523
5594
  spaceshipProvider = {
@@ -5562,7 +5633,7 @@ var init_spaceship = __esmMin((() => {
5562
5633
  if (found !== void 0 || items.length < PAGE_SIZE) break;
5563
5634
  }
5564
5635
  if (found?.address === record.ip) return succeeded(`already ${record.ip}`, false);
5565
- const written = await context.fetch(`${API}/${encodeURIComponent(zone)}`, {
5636
+ return await wrote(await context.fetch(`${API}/${encodeURIComponent(zone)}`, {
5566
5637
  method: "PUT",
5567
5638
  headers,
5568
5639
  body: JSON.stringify({
@@ -5575,11 +5646,9 @@ var init_spaceship = __esmMin((() => {
5575
5646
  }]
5576
5647
  }),
5577
5648
  signal: context.signal
5578
- });
5579
- if (!written.ok) return failed(await errorText(written));
5580
- return succeeded(`set ${record.host} ${record.type} to ${record.ip}`, true);
5649
+ }), record);
5581
5650
  } catch (error) {
5582
- return failed(error instanceof Error ? error.message : String(error));
5651
+ return failed(errorMessage(error));
5583
5652
  }
5584
5653
  }
5585
5654
  };
@@ -6560,7 +6629,7 @@ function describeTelegramError(error) {
6560
6629
  const description = candidate.description ?? candidate.message;
6561
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`}`;
6562
6631
  }
6563
- return error instanceof Error ? error.message : String(error);
6632
+ return errorMessage(error);
6564
6633
  }
6565
6634
  async function sendTelegramMessage(token, chatId, html) {
6566
6635
  try {
@@ -6628,6 +6697,7 @@ async function listTelegramChats(token) {
6628
6697
  }
6629
6698
  var bots;
6630
6699
  var init_telegram = __esmMin((() => {
6700
+ init_error_message();
6631
6701
  bots = /* @__PURE__ */ new Map();
6632
6702
  }));
6633
6703
  //#endregion
@@ -7786,15 +7856,15 @@ function acmeIssuers(acme, challenge = {}) {
7786
7856
  }, { module: "internal" }];
7787
7857
  }
7788
7858
  /**
7789
- * The page a visitor gets when the hostname they reached is not routed at all. It is
7790
- * the answer on both ports, so a request never lands on a redirect into a handshake
7791
- * 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.
7792
7862
  */
7793
- function notConfiguredPage() {
7863
+ function htmlPage(title, heading, body) {
7794
7864
  return `<!doctype html>
7795
7865
  <html lang="en">
7796
7866
  <head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
7797
- <title>No route for this hostname</title>
7867
+ <title>${title}</title>
7798
7868
  <style>
7799
7869
  :root { color-scheme: light dark }
7800
7870
  body { margin: 0; min-height: 100dvh; display: grid; place-items: center; font: 15px/1.6 system-ui, sans-serif }
@@ -7805,13 +7875,21 @@ function notConfiguredPage() {
7805
7875
  footer { color: #888; font-size: .85em }
7806
7876
  </style></head>
7807
7877
  <body><main>
7808
- <h1>This route is not configured for any target yet</h1>
7809
- <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
7810
7889
  <code>{http.request.host}</code>, so there is nothing here to serve.</p>
7811
7890
  <p>Add a route for it under <strong>Others → Reverse Proxy</strong>, or check that you
7812
7891
  reached the hostname you meant to.</p>
7813
- <footer>home-hosted reverse proxy</footer>
7814
- </main></body></html>`;
7892
+ <footer>home-hosted reverse proxy</footer>`);
7815
7893
  }
7816
7894
  /** The catch-all: it answers last, so every configured route wins over it. */
7817
7895
  function notConfiguredRoute() {
@@ -7824,30 +7902,14 @@ function notConfiguredRoute() {
7824
7902
  }
7825
7903
  /** The page a visitor gets when a name has no certificate yet and HTTPS cannot work. */
7826
7904
  function noticePage(host, httpPort, httpsPort) {
7827
- return `<!doctype html>
7828
- <html lang="en">
7829
- <head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
7830
- <title>Certificate not ready — ${host}</title>
7831
- <style>
7832
- :root { color-scheme: light dark }
7833
- body { margin: 0; min-height: 100dvh; display: grid; place-items: center; font: 15px/1.6 system-ui, sans-serif }
7834
- main { max-width: 34rem; padding: 2rem }
7835
- h1 { font-size: 1.15rem; margin: 0 0 .75rem }
7836
- p { margin: 0 0 .75rem }
7837
- code { font-family: ui-monospace, monospace; font-size: .9em }
7838
- footer { color: #888; font-size: .85em }
7839
- </style></head>
7840
- <body><main>
7841
- <h1>No certificate yet for ${host}</h1>
7842
- <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
7843
7906
  for it yet, so HTTPS cannot complete a handshake. The engine keeps trying in the
7844
7907
  background and will serve the name as soon as it succeeds.</p>
7845
7908
  <p>The usual cause is that the CA cannot reach this machine: with the proxy on
7846
7909
  <code>${httpPort}</code>/<code>${httpsPort}</code>, forward <code>80 → ${httpPort}</code>
7847
7910
  and <code>443 → ${httpsPort}</code> on your router. Otherwise <code>http://${host}</code>
7848
7911
  will keep landing here.</p>
7849
- <footer>Details, and the engine's own reason, are in the panel: Others → Reverse Proxy.</footer>
7850
- </main></body></html>`;
7912
+ <footer>Details, and the engine's own reason, are in the panel: Others → Reverse Proxy.</footer>`);
7851
7913
  }
7852
7914
  /**
7853
7915
  * The same match, minus the ACME challenge path: the CA has to be able to read it on
@@ -8033,7 +8095,7 @@ function validatePair(certificate, privateKey) {
8033
8095
  } catch (error) {
8034
8096
  return {
8035
8097
  ok: false,
8036
- error: `certificate is not a valid PEM: ${error instanceof Error ? error.message : String(error)}`
8098
+ error: `certificate is not a valid PEM: ${errorMessage(error)}`
8037
8099
  };
8038
8100
  }
8039
8101
  try {
@@ -8044,7 +8106,7 @@ function validatePair(certificate, privateKey) {
8044
8106
  } catch (error) {
8045
8107
  return {
8046
8108
  ok: false,
8047
- 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)}`
8048
8110
  };
8049
8111
  }
8050
8112
  const invalid = validityError(x509);
@@ -8058,6 +8120,7 @@ var TlsStore;
8058
8120
  var init_tls$1 = __esmMin((() => {
8059
8121
  init_atomic();
8060
8122
  init_logger();
8123
+ init_error_message();
8061
8124
  TlsStore = class {
8062
8125
  dir;
8063
8126
  name;
@@ -8184,7 +8247,7 @@ var init_tls$1 = __esmMin((() => {
8184
8247
  } catch (error) {
8185
8248
  return {
8186
8249
  ...base,
8187
- error: `invalid certificate: ${error instanceof Error ? error.message : String(error)}`
8250
+ error: `invalid certificate: ${errorMessage(error)}`
8188
8251
  };
8189
8252
  }
8190
8253
  }
@@ -8347,6 +8410,7 @@ var init_proxy$1 = __esmMin((() => {
8347
8410
  init_proxy$2();
8348
8411
  init_proxy_config();
8349
8412
  init_tls$1();
8413
+ init_error_message();
8350
8414
  init_proxy_form();
8351
8415
  PROXY_ID = "proxy";
8352
8416
  ADMIN_TIMEOUT_MS = 5e3;
@@ -8388,8 +8452,6 @@ var init_proxy$1 = __esmMin((() => {
8388
8452
  failureCache = null;
8389
8453
  /** The installed binary's modules, read once per process; an install clears it. */
8390
8454
  moduleCache = null;
8391
- /** A legacy `engine.json` is reported once, not on every state frame. */
8392
- legacyChecksumWarned = false;
8393
8455
  /** Why the engine's nanny could not be spawned, cleared on the next start. */
8394
8456
  nannyError = null;
8395
8457
  certCache = null;
@@ -8678,7 +8740,7 @@ var init_proxy$1 = __esmMin((() => {
8678
8740
  } catch (error) {
8679
8741
  return {
8680
8742
  ...base,
8681
- error: `unreadable certificate: ${error instanceof Error ? error.message : String(error)}`
8743
+ error: `unreadable certificate: ${errorMessage(error)}`
8682
8744
  };
8683
8745
  }
8684
8746
  });
@@ -8890,14 +8952,14 @@ var init_proxy$1 = __esmMin((() => {
8890
8952
  if (this.live()) {
8891
8953
  logger.info(`proxy: reattached to the running ${this.config.engine} engine`);
8892
8954
  await this.apply().catch((error) => {
8893
- 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)}`);
8894
8956
  });
8895
8957
  return;
8896
8958
  }
8897
8959
  try {
8898
8960
  await this.start();
8899
8961
  } catch (error) {
8900
- this.lastError = error instanceof Error ? error.message : String(error);
8962
+ this.lastError = errorMessage(error);
8901
8963
  logger.error(`proxy: could not start: ${this.lastError}`);
8902
8964
  }
8903
8965
  }
@@ -9125,7 +9187,7 @@ var init_proxy$1 = __esmMin((() => {
9125
9187
  */
9126
9188
  async postConfiguration(rendered) {
9127
9189
  const result = await this.request("POST", "/load", rendered).catch((error) => {
9128
- 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)}`;
9129
9191
  throw new DetailedError(this.lastError, {
9130
9192
  statusCode: 502,
9131
9193
  code: "ENGINE_UNREACHABLE"
@@ -9193,7 +9255,7 @@ var init_proxy$1 = __esmMin((() => {
9193
9255
  this.certificateSignature = signature;
9194
9256
  if (first) return;
9195
9257
  await this.apply().catch((error) => {
9196
- 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)}`);
9197
9259
  });
9198
9260
  }
9199
9261
  /**
@@ -9400,15 +9462,7 @@ var init_proxy$1 = __esmMin((() => {
9400
9462
  try {
9401
9463
  const parsed = JSON.parse(fs.readFileSync(path.join(this.options.binDir, "engine.json"), "utf8"));
9402
9464
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
9403
- const record = parsed;
9404
- if ("sha256" in record) {
9405
- delete record.sha256;
9406
- if (!this.legacyChecksumWarned) {
9407
- this.legacyChecksumWarned = true;
9408
- logger.warn("proxy: dropped the recorded engine checksum; nothing verifies it");
9409
- }
9410
- }
9411
- const parsedRecord = engineRecordSchema(record);
9465
+ const parsedRecord = engineRecordSchema(parsed);
9412
9466
  return parsedRecord instanceof type.errors ? null : parsedRecord;
9413
9467
  } catch {
9414
9468
  return null;
@@ -9959,7 +10013,7 @@ function createProxyRoute(deps) {
9959
10013
  if (proxy().status().state === "running") await proxy().apply();
9960
10014
  else await proxy().start();
9961
10015
  } catch (error) {
9962
- proxy().recordError(error instanceof Error ? error.message : String(error));
10016
+ proxy().recordError(errorMessage(error));
9963
10017
  throw error;
9964
10018
  }
9965
10019
  };
@@ -10118,6 +10172,7 @@ var init_proxy = __esmMin((() => {
10118
10172
  init_exposure();
10119
10173
  init_proxy$1();
10120
10174
  init_contracts();
10175
+ init_error_message();
10121
10176
  certificateParam = type({ id: "/^[a-z0-9][a-z0-9_-]*$/" });
10122
10177
  }));
10123
10178
  //#endregion
@@ -11023,237 +11078,26 @@ var init_app = __esmMin((() => {
11023
11078
  }));
11024
11079
  //#endregion
11025
11080
  //#region src/config/layout.ts
11026
- var layout_exports = /* @__PURE__ */ __exportAll({
11027
- ensureLayout: () => ensureLayout,
11028
- migrateLayout: () => migrateLayout
11029
- });
11030
- function readJson(file) {
11031
- try {
11032
- const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
11033
- return isRecord(parsed) ? parsed : null;
11034
- } catch {
11035
- return null;
11036
- }
11037
- }
11081
+ var layout_exports = /* @__PURE__ */ __exportAll({ ensureLayout: () => ensureLayout });
11038
11082
  /**
11039
- * 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.
11040
11085
  *
11041
- * `readJson` collapses both into `null`, and a split that is skipped reads exactly like
11042
- * one with nothing to do — so a corrupt legacy config used to be abandoned silently
11043
- * while `workspaces.json` was still created, after which `hasLegacyLayout()` is false
11044
- * and no later run ever looks at that file again. The definitions would sit unread
11045
- * beside a panel supervising nothing.
11046
- */
11047
- function readLegacy(file) {
11048
- if (!fs.existsSync(file)) return {
11049
- parsed: null,
11050
- unreadable: false
11051
- };
11052
- const parsed = readJson(file);
11053
- return {
11054
- parsed,
11055
- unreadable: parsed === null
11056
- };
11057
- }
11058
- function writeJson(file, value, mode) {
11059
- fs.mkdirSync(path.dirname(file), { recursive: true });
11060
- fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, mode === void 0 ? {} : { mode });
11061
- }
11062
- /** `renameSync` across devices fails with EXDEV; fall back to a copy. */
11063
- function move(source, destination, warnings) {
11064
- if (!fs.existsSync(source)) return false;
11065
- if (fs.existsSync(destination)) {
11066
- warnings.push(`${destination} already exists — left ${source} alone`);
11067
- return false;
11068
- }
11069
- fs.mkdirSync(path.dirname(destination), { recursive: true });
11070
- try {
11071
- fs.renameSync(source, destination);
11072
- } catch {
11073
- fs.cpSync(source, destination, {
11074
- recursive: true,
11075
- force: true
11076
- });
11077
- fs.rmSync(source, {
11078
- recursive: true,
11079
- force: true
11080
- });
11081
- }
11082
- return true;
11083
- }
11084
- function metaOf(raw) {
11085
- const meta = raw?.meta;
11086
- if (typeof meta !== "object" || meta === null) return void 0;
11087
- return meta;
11088
- }
11089
- /** The old config split three ways: global settings, workspace settings, servers. */
11090
- function splitConfig(raw) {
11091
- const meta = metaOf(raw);
11092
- const unknown = {};
11093
- for (const [key, value] of Object.entries(raw)) if (!KNOWN_TOP_LEVEL.has(key)) unknown[key] = value;
11094
- return {
11095
- global: {
11096
- $schema: "./settings.schema.json",
11097
- ...meta === void 0 ? {} : { meta },
11098
- control: raw.control ?? {},
11099
- host: raw.host ?? {},
11100
- backups: raw.backups ?? {},
11101
- ...unknown
11102
- },
11103
- settings: {
11104
- $schema: "./settings.schema.json",
11105
- ...meta === void 0 ? {} : { meta },
11106
- defaults: raw.defaults ?? {},
11107
- logs: raw.logs ?? {},
11108
- notifications: raw.notifications ?? {},
11109
- ddns: raw.ddns ?? {}
11110
- },
11111
- servers: {
11112
- $schema: "./servers.config.schema.json",
11113
- ...meta === void 0 ? {} : { meta },
11114
- servers: Array.isArray(raw.servers) ? raw.servers : []
11115
- }
11116
- };
11117
- }
11118
- /** The old secrets file split global (password, token) vs. workspace (Telegram, DDNS). */
11119
- function splitSecrets(raw) {
11120
- return {
11121
- global: {
11122
- version: 3,
11123
- password: raw?.password ?? null,
11124
- apiToken: raw?.apiToken ?? null
11125
- },
11126
- workspace: {
11127
- version: 3,
11128
- telegram: raw?.telegram ?? null,
11129
- ddns: raw?.ddns ?? {},
11130
- ddnsKdf: raw?.ddnsKdf ?? null
11131
- }
11132
- };
11133
- }
11134
- /**
11135
- * Moves the panel's console log apart from the per-server logs: both used to
11136
- * live in `<root>/.logs`, and only the console log is global now.
11137
- */
11138
- function splitLogsDir(warnings) {
11139
- if (!fs.existsSync(legacy.logsDir)) return;
11140
- const workspaceLogs = path.join(hhDir, "default", ".logs");
11141
- for (const name of fs.readdirSync(legacy.logsDir)) {
11142
- const destination = name === "home-hosted.log" || name.startsWith("home-hosted.log.") ? path.join(hhDir, ".logs", name) : path.join(workspaceLogs, name);
11143
- move(path.join(legacy.logsDir, name), destination, warnings);
11144
- }
11145
- try {
11146
- if (fs.readdirSync(legacy.logsDir).length === 0) fs.rmdirSync(legacy.logsDir);
11147
- else warnings.push(`${legacy.logsDir} still holds files that could not be moved — left it in place`);
11148
- } catch {}
11149
- }
11150
- function migrateLayout() {
11151
- if (!hasLegacyLayout()) return EMPTY;
11152
- const moved = [];
11153
- const warnings = [];
11154
- fs.mkdirSync(hhDir, { recursive: true });
11155
- const legacyConfig = readLegacy(legacy.configPath);
11156
- let configSplit = false;
11157
- 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`);
11158
- if (legacyConfig.parsed !== null) {
11159
- const split = splitConfig(legacyConfig.parsed);
11160
- const destinations = [
11161
- path.join(hhDir, "settings.json"),
11162
- path.join(hhDir, "default", "settings.json"),
11163
- path.join(hhDir, "default", "servers.config.json")
11164
- ];
11165
- const taken = destinations.filter((file) => fs.existsSync(file));
11166
- 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`);
11167
- else {
11168
- writeJson(destinations[0], split.global);
11169
- fs.mkdirSync(path.join(hhDir, "default"), { recursive: true });
11170
- writeJson(destinations[1], split.settings);
11171
- writeJson(destinations[2], split.servers);
11172
- moved.push("servers.config.json → settings.json + default/settings.json + default/servers.config.json");
11173
- configSplit = true;
11174
- }
11175
- }
11176
- const legacySecrets = readLegacy(legacy.secretsPath);
11177
- let secretsSplit = false;
11178
- 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`);
11179
- if (legacySecrets.parsed !== null) {
11180
- const split = splitSecrets(legacySecrets.parsed);
11181
- const destinations = [path.join(hhDir, ".control-secrets.json"), path.join(hhDir, "default", ".secrets.json")];
11182
- const taken = destinations.filter((file) => fs.existsSync(file));
11183
- 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`);
11184
- else {
11185
- writeJson(destinations[0], split.global, 384);
11186
- fs.mkdirSync(path.join(hhDir, "default"), { recursive: true });
11187
- writeJson(destinations[1], split.workspace, 384);
11188
- moved.push(".control-secrets.json → .hh/.control-secrets.json + .hh/default/.secrets.json");
11189
- secretsSplit = true;
11190
- }
11191
- }
11192
- if (move(legacy.tlsDir, path.join(hhDir, ".tls"), warnings)) moved.push(".tls/ → .hh/.tls/");
11193
- if (move(legacy.uiDir, path.join(hhDir, ".ui"), warnings)) moved.push(".ui/ → .hh/.ui/");
11194
- if (move(legacy.backupsDir, path.join(hhDir, ".backups"), warnings)) moved.push(".backups/ → .hh/.backups/");
11195
- if (move(legacy.runtimePath, path.join(hhDir, "run.json"), warnings)) moved.push("run.json → .hh/run.json");
11196
- if (move(legacy.stateDir, path.join(hhDir, "default", ".state"), warnings)) moved.push(".state/ → .hh/default/.state/");
11197
- splitLogsDir(warnings);
11198
- if (configSplit) fs.rmSync(legacy.configPath, { force: true });
11199
- if (secretsSplit) fs.rmSync(legacy.secretsPath, { force: true });
11200
- fs.rmSync(legacy.configSchemaPath, { force: true });
11201
- if (!fs.existsSync(workspacesPath)) {
11202
- writeJson(workspacesPath, {
11203
- $schema: "./workspaces.schema.json",
11204
- workspaces: [{
11205
- id: "default",
11206
- label: "Default"
11207
- }]
11208
- });
11209
- moved.push("workspaces.json created with the `default` workspace");
11210
- } else warnings.push(`${workspacesPath} already exists — left the workspace registry alone`);
11211
- writeJson(path.join(hhDir, ".layout-migration.json"), {
11212
- version: 1,
11213
- at: Date.now(),
11214
- moved
11215
- });
11216
- return {
11217
- migrated: true,
11218
- moved,
11219
- warnings
11220
- };
11221
- }
11222
- /**
11223
- * Runs the relocation and reports it through the logger. Every CLI entry point
11224
- * calls this before it resolves a state path, so an existing instance keeps
11225
- * 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.
11226
11091
  */
11227
11092
  function ensureLayout() {
11228
- const result = migrateLayout();
11229
- if (result.migrated) {
11230
- logger.info(`state layout migrated into ${hhDir} (${result.moved.length} step(s))`);
11231
- for (const warning of result.warnings) logger.warn(`layout migration: ${warning}`);
11232
- }
11233
- 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.`);
11234
11097
  }
11235
- var EMPTY, KNOWN_TOP_LEVEL;
11236
11098
  var init_layout = __esmMin((() => {
11237
- init_logger();
11238
11099
  init_paths();
11239
- init_shape();
11240
- EMPTY = {
11241
- migrated: false,
11242
- moved: [],
11243
- warnings: []
11244
- };
11245
- KNOWN_TOP_LEVEL = /* @__PURE__ */ new Set([
11246
- "$schema",
11247
- "meta",
11248
- "control",
11249
- "defaults",
11250
- "logs",
11251
- "notifications",
11252
- "host",
11253
- "backups",
11254
- "ddns",
11255
- "servers"
11256
- ]);
11100
+ init_version();
11257
11101
  }));
11258
11102
  //#endregion
11259
11103
  //#region src/helpers/daemon.ts
@@ -11585,7 +11429,7 @@ function loadEnvFile(file) {
11585
11429
  return {
11586
11430
  env: {},
11587
11431
  path: file,
11588
- error: error instanceof Error ? error.message : String(error)
11432
+ error: errorMessage(error)
11589
11433
  };
11590
11434
  }
11591
11435
  }
@@ -11605,6 +11449,7 @@ function resolveEnvFilePath(file, cwd) {
11605
11449
  }
11606
11450
  var VARIABLE;
11607
11451
  var init_env_file = __esmMin((() => {
11452
+ init_error_message();
11608
11453
  VARIABLE = /\$\{([A-Z_]\w*)\}/gi;
11609
11454
  }));
11610
11455
  //#endregion
@@ -11865,7 +11710,7 @@ async function probeHttp(host, port, options) {
11865
11710
  return {
11866
11711
  healthy: false,
11867
11712
  ms: Date.now() - started,
11868
- detail: `request failed: ${error instanceof Error ? error.message : String(error)}`
11713
+ detail: `request failed: ${errorMessage(error)}`
11869
11714
  };
11870
11715
  }
11871
11716
  }
@@ -11887,6 +11732,7 @@ async function probeHealth(options) {
11887
11732
  }
11888
11733
  var init_health_check = __esmMin((() => {
11889
11734
  init_port();
11735
+ init_error_message();
11890
11736
  }));
11891
11737
  //#endregion
11892
11738
  //#region src/providers/process.ts
@@ -12305,6 +12151,7 @@ var TAIL_INTERVAL_MS, LogRelay;
12305
12151
  var init_log_relay = __esmMin((() => {
12306
12152
  init_logger();
12307
12153
  init_log_tail();
12154
+ init_error_message();
12308
12155
  TAIL_INTERVAL_MS = 250;
12309
12156
  LogRelay = class {
12310
12157
  options;
@@ -12365,7 +12212,7 @@ var init_log_relay = __esmMin((() => {
12365
12212
  try {
12366
12213
  this.poll();
12367
12214
  } catch (error) {
12368
- logger.warn(`tail failed: ${error instanceof Error ? error.message : String(error)}`);
12215
+ logger.warn(`tail failed: ${errorMessage(error)}`);
12369
12216
  }
12370
12217
  }, this.options.intervalMs ?? TAIL_INTERVAL_MS);
12371
12218
  this.timer.unref();
@@ -12949,21 +12796,7 @@ var init_supervisor = __esmMin((() => {
12949
12796
  return { kind: "free" };
12950
12797
  }
12951
12798
  this.log(entry, "system", `port ${port} is held by pid ${foreign.join(", ")} — onPortConflict: kill, stopping the holder`);
12952
- const { forced } = await terminatePids(foreign, { graceMs: entry.config.stop.graceMs });
12953
- if (forced.length > 0) this.log(entry, "system", `pid ${forced.join(", ")} ignored SIGTERM and was killed`);
12954
- await delay(PORT_RELEASE_RECHECK_MS);
12955
- if (await freeOnAll()) {
12956
- entry.portState = "free";
12957
- return { kind: "free" };
12958
- }
12959
- entry.status = "conflict";
12960
- entry.lastError = `port ${port} is still in use after killing pid ${foreign.join(", ")}`;
12961
- this.log(entry, "system", entry.lastError);
12962
- this.publishServer(entry);
12963
- return {
12964
- kind: "blocked",
12965
- error: entry.lastError
12966
- };
12799
+ return await this.stopPortHolders(entry, foreign, port, "killing", freeOnAll);
12967
12800
  }
12968
12801
  if (own !== null && entry.config.onPortConflict === "follow") return {
12969
12802
  kind: "adopt",
@@ -12971,21 +12804,7 @@ var init_supervisor = __esmMin((() => {
12971
12804
  };
12972
12805
  if (own !== null && entry.config.onPortConflict === "reclaim") {
12973
12806
  this.log(entry, "system", `port ${port} is held by pid ${own}, a detached restart of this entry — replacing it with a supervised process`);
12974
- const { forced } = await terminatePids([own], { graceMs: entry.config.stop.graceMs });
12975
- if (forced.length > 0) this.log(entry, "system", `pid ${forced.join(", ")} ignored SIGTERM and was killed`);
12976
- await delay(PORT_RELEASE_RECHECK_MS);
12977
- if (await freeOnAll()) {
12978
- entry.portState = "free";
12979
- return { kind: "free" };
12980
- }
12981
- entry.status = "conflict";
12982
- entry.lastError = `port ${port} is still in use after replacing pid ${own}`;
12983
- this.log(entry, "system", entry.lastError);
12984
- this.publishServer(entry);
12985
- return {
12986
- kind: "blocked",
12987
- error: entry.lastError
12988
- };
12807
+ return await this.stopPortHolders(entry, [own], port, "replacing", freeOnAll);
12989
12808
  }
12990
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` : "";
12991
12810
  if (entry.config.onPortConflict !== "warn") {
@@ -13011,6 +12830,31 @@ var init_supervisor = __esmMin((() => {
13011
12830
  * persistent entry whose state file was lost would show its history but never
13012
12831
  * another live line.
13013
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
+ }
13014
12858
  adoptEntry(entry, pid) {
13015
12859
  entry.adopted = true;
13016
12860
  entry.pid = pid;
@@ -13903,6 +13747,7 @@ var init_backups = __esmMin((() => {
13903
13747
  init_template();
13904
13748
  init_archive();
13905
13749
  init_supervisor();
13750
+ init_error_message();
13906
13751
  init_generated();
13907
13752
  init_shape();
13908
13753
  MANIFEST = "manifest.json";
@@ -14057,7 +13902,7 @@ var init_backups = __esmMin((() => {
14057
13902
  fs.rmSync(destination, { force: true });
14058
13903
  return {
14059
13904
  ok: false,
14060
- error: error instanceof Error ? error.message : String(error)
13905
+ error: errorMessage(error)
14061
13906
  };
14062
13907
  }
14063
13908
  }
@@ -14098,7 +13943,7 @@ var init_backups = __esmMin((() => {
14098
13943
  } catch (error) {
14099
13944
  return {
14100
13945
  ...plan,
14101
- error: `the archive could not be read: ${error instanceof Error ? error.message : String(error)}`
13946
+ error: `the archive could not be read: ${errorMessage(error)}`
14102
13947
  };
14103
13948
  }
14104
13949
  plan.encrypted = entries.some((entry) => entry.encrypted);
@@ -14383,7 +14228,7 @@ var init_backups = __esmMin((() => {
14383
14228
  const done = plan.applied.length > 0 ? ` — already applied: ${plan.applied.join(", ")}` : "";
14384
14229
  return {
14385
14230
  ...plan,
14386
- error: `${error instanceof Error ? error.message : String(error)}${done}`
14231
+ error: `${errorMessage(error)}${done}`
14387
14232
  };
14388
14233
  } finally {
14389
14234
  fs.rmSync(staging, {
@@ -14648,6 +14493,7 @@ var ControlServer;
14648
14493
  var init_control_server = __esmMin((() => {
14649
14494
  init_bind();
14650
14495
  init_port();
14496
+ init_error_message();
14651
14497
  ControlServer = class {
14652
14498
  options;
14653
14499
  endpoint;
@@ -14681,7 +14527,7 @@ var init_control_server = __esmMin((() => {
14681
14527
  } catch (error) {
14682
14528
  return {
14683
14529
  ok: false,
14684
- error: `restart failed: ${error instanceof Error ? error.message : String(error)}`
14530
+ error: `restart failed: ${errorMessage(error)}`
14685
14531
  };
14686
14532
  }
14687
14533
  }
@@ -14705,7 +14551,7 @@ var init_control_server = __esmMin((() => {
14705
14551
  await this.listenWithRetry(next.host, next.port);
14706
14552
  return { ok: true };
14707
14553
  } catch (error) {
14708
- const message = error instanceof Error ? error.message : String(error);
14554
+ const message = errorMessage(error);
14709
14555
  try {
14710
14556
  await this.listenWithRetry(previous.host, previous.port);
14711
14557
  } catch {}
@@ -15210,7 +15056,7 @@ async function detectPublicAddress(family, overrideUrl, fetchImpl, signal) {
15210
15056
  };
15211
15057
  lastError = `${endpoint} did not answer with a public ${family === "A" ? "IPv4" : "IPv6"} address`;
15212
15058
  } catch (error) {
15213
- lastError = `${endpoint}: ${error instanceof Error ? error.message : String(error)}`;
15059
+ lastError = `${endpoint}: ${errorMessage(error)}`;
15214
15060
  }
15215
15061
  return {
15216
15062
  ip: null,
@@ -15219,6 +15065,7 @@ async function detectPublicAddress(family, overrideUrl, fetchImpl, signal) {
15219
15065
  }
15220
15066
  var DEFAULT_IPV4_ENDPOINTS, DEFAULT_IPV6_ENDPOINTS;
15221
15067
  var init_ip = __esmMin((() => {
15068
+ init_error_message();
15222
15069
  DEFAULT_IPV4_ENDPOINTS = [
15223
15070
  "https://api4.ipify.org",
15224
15071
  "https://ipv4.icanhazip.com",
@@ -15266,6 +15113,7 @@ var init_ddns = __esmMin((() => {
15266
15113
  init_ddns$2();
15267
15114
  init_ip();
15268
15115
  init_types();
15116
+ init_error_message();
15269
15117
  RUN_TIMEOUT_MS = 6e4;
15270
15118
  DdnsService = class {
15271
15119
  deps;
@@ -15502,7 +15350,7 @@ var init_ddns = __esmMin((() => {
15502
15350
  try {
15503
15351
  writeFileAtomic(this.deps.statePath, `${JSON.stringify(this.persisted, null, 2)}\n`);
15504
15352
  } catch (error) {
15505
- 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)})`);
15506
15354
  }
15507
15355
  }
15508
15356
  dispose() {
@@ -15813,6 +15661,7 @@ var REASON_LABEL, TITLE, NotificationService;
15813
15661
  var init_notifications = __esmMin((() => {
15814
15662
  init_logger();
15815
15663
  init_telegram();
15664
+ init_error_message();
15816
15665
  REASON_LABEL = {
15817
15666
  "crash": "gave up restarting",
15818
15667
  "unhealthy": "health check failing",
@@ -15838,14 +15687,12 @@ var init_notifications = __esmMin((() => {
15838
15687
  NotificationService = class {
15839
15688
  secrets;
15840
15689
  getConfig;
15841
- getLogsConfig;
15842
15690
  cooldowns = /* @__PURE__ */ new Map();
15843
15691
  lastResult = null;
15844
15692
  lastResultAt = null;
15845
- constructor(secrets, getConfig, getLogsConfig) {
15693
+ constructor(secrets, getConfig) {
15846
15694
  this.secrets = secrets;
15847
15695
  this.getConfig = getConfig;
15848
- this.getLogsConfig = getLogsConfig;
15849
15696
  }
15850
15697
  get telegramTokenSet() {
15851
15698
  return this.secrets.telegramTokenSet;
@@ -15892,7 +15739,7 @@ var init_notifications = __esmMin((() => {
15892
15739
  /** Fire-and-forget by design: supervision must never wait on a chat API. */
15893
15740
  notify(event) {
15894
15741
  this.dispatch(event).catch((error) => {
15895
- logger.warn(`notification failed: ${error instanceof Error ? error.message : String(error)}`);
15742
+ logger.warn(`notification failed: ${errorMessage(error)}`);
15896
15743
  });
15897
15744
  }
15898
15745
  async dispatch(event) {
@@ -16025,6 +15872,7 @@ var init_panel = __esmMin((() => {
16025
15872
  init_notifications();
16026
15873
  init_state$1();
16027
15874
  init_supervisor();
15875
+ init_error_message();
16028
15876
  TICK_MS = 1e3;
16029
15877
  PanelService = class {
16030
15878
  options;
@@ -16234,7 +16082,7 @@ var init_panel = __esmMin((() => {
16234
16082
  const secrets = new SecretsStore(workspaceSecretsPath(id), void 0, "workspace");
16235
16083
  const logFiles = new LogFiles(workspaceLogsDir(id), () => store.logs);
16236
16084
  const history = new HistoryStore(workspaceHistoryPath(id));
16237
- const notifications = new NotificationService(secrets, () => store.notifications, () => store.logs);
16085
+ const notifications = new NotificationService(secrets, () => store.notifications);
16238
16086
  const ddns = new DdnsService({
16239
16087
  getConfig: () => store.ddns,
16240
16088
  getCredentials: (accountId, provider) => secrets.getDdnsCredentials(accountId, provider)?.values ?? null,
@@ -16277,11 +16125,11 @@ var init_panel = __esmMin((() => {
16277
16125
  runtime.watches = [new ConfigWatch({
16278
16126
  file: store.path,
16279
16127
  onChange: () => this.handleConfigChange(id),
16280
- 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)}`)
16281
16129
  }), new ConfigWatch({
16282
16130
  file: store.settingsPath,
16283
16131
  onChange: () => this.handleConfigChange(id),
16284
- 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)}`)
16285
16133
  })];
16286
16134
  for (const watch of runtime.watches) watch.start();
16287
16135
  return runtime;
@@ -16482,7 +16330,7 @@ function readDataRoot(dataRoot) {
16482
16330
  }
16483
16331
  }
16484
16332
  function describeError$1(error) {
16485
- return error instanceof Error ? error.message : String(error);
16333
+ return errorMessage(error);
16486
16334
  }
16487
16335
  /**
16488
16336
  * Where the site actually starts: the archive root, or a single wrapper directory
@@ -16516,6 +16364,7 @@ var init_ui = __esmMin((() => {
16516
16364
  init_atomic();
16517
16365
  init_archive();
16518
16366
  init_contracts();
16367
+ init_error_message();
16519
16368
  META = "ui.json";
16520
16369
  MAX_ENTRIES = 2e4;
16521
16370
  MAX_BYTES = 536870912;
@@ -16577,7 +16426,7 @@ var init_ui = __esmMin((() => {
16577
16426
  } catch (error) {
16578
16427
  return {
16579
16428
  ok: false,
16580
- error: error instanceof Error ? error.message : String(error)
16429
+ error: errorMessage(error)
16581
16430
  };
16582
16431
  } finally {
16583
16432
  fs.rmSync(staging, {
@@ -16602,7 +16451,7 @@ var init_ui = __esmMin((() => {
16602
16451
  } catch (error) {
16603
16452
  return {
16604
16453
  ok: false,
16605
- error: `the archive could not be read: ${error instanceof Error ? error.message : String(error)}`
16454
+ error: `the archive could not be read: ${errorMessage(error)}`
16606
16455
  };
16607
16456
  }
16608
16457
  if (entries.length === 0) return {
@@ -16914,7 +16763,7 @@ function formatBytes(bytes) {
16914
16763
  return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
16915
16764
  }
16916
16765
  function describeError(error) {
16917
- return error instanceof Error ? error.message : String(error);
16766
+ return errorMessage(error);
16918
16767
  }
16919
16768
  /**
16920
16769
  * Tag ordering: `v1.2.3` beats `v1.2.2`, a prerelease loses to its release, and anything
@@ -16962,6 +16811,7 @@ function parseTag(tag) {
16962
16811
  }
16963
16812
  var DEFAULT_REPO, MAX_DOWNLOAD_BYTES, REQUEST_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS;
16964
16813
  var init_ui_release = __esmMin((() => {
16814
+ init_error_message();
16965
16815
  DEFAULT_REPO = "NamesMT/home-hosted";
16966
16816
  MAX_DOWNLOAD_BYTES = 536870912;
16967
16817
  REQUEST_TIMEOUT_MS = 3e4;
@@ -17039,7 +16889,7 @@ async function syncOfficialUi(ui, runningVersion = appVersion()) {
17039
16889
  } catch (error) {
17040
16890
  return {
17041
16891
  kind: "failed",
17042
- error: error instanceof Error ? error.message : String(error)
16892
+ error: errorMessage(error)
17043
16893
  };
17044
16894
  }
17045
16895
  }
@@ -17058,13 +16908,14 @@ function autoUpdateOfficialUi(ui, runningVersion = appVersion()) {
17058
16908
  if (result.kind === "updated") logger.info(`ui: updated to ${result.tag} — refresh the browser`);
17059
16909
  else if (result.kind === "failed") logger.warn(`ui: could not update to ${plan.tag}: ${result.error}`);
17060
16910
  }).catch((error) => {
17061
- logger.warn(`ui: could not update: ${error instanceof Error ? error.message : String(error)}`);
16911
+ logger.warn(`ui: could not update: ${errorMessage(error)}`);
17062
16912
  });
17063
16913
  }
17064
16914
  var init_ui_update$1 = __esmMin((() => {
17065
16915
  init_logger();
17066
16916
  init_version();
17067
16917
  init_ui_release();
16918
+ init_error_message();
17068
16919
  }));
17069
16920
  //#endregion
17070
16921
  //#region src/index.ts
@@ -17090,7 +16941,12 @@ function probeHostFor(bindHost) {
17090
16941
  * directly.
17091
16942
  */
17092
16943
  async function runControlPlane(options) {
17093
- ensureLayout();
16944
+ try {
16945
+ ensureLayout();
16946
+ } catch (error) {
16947
+ logger.error(errorMessage(error));
16948
+ process.exit(1);
16949
+ }
17094
16950
  const existing = readRuntime();
17095
16951
  if (existing !== null && existing.pid !== process.pid && await runtimeIsAlive(existing)) {
17096
16952
  logger.error(`already running (pid ${existing.pid}) at ${existing.url} — run \`home-hosted down\` first`);
@@ -17117,6 +16973,11 @@ async function runControlPlane(options) {
17117
16973
  process.exit(1);
17118
16974
  }
17119
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
+ }
17120
16981
  const auth = new AuthService(secrets, () => settings.control.auth);
17121
16982
  const tls = new TlsStore(tlsDir);
17122
16983
  const hub = new EventHub();
@@ -17263,6 +17124,11 @@ async function runControlPlane(options) {
17263
17124
  logger.info(`fix ${store.path} and ${store.settingsPath}, or install the release that wrote them`);
17264
17125
  process.exit(1);
17265
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
+ }
17266
17132
  if (store.pendingMigrations.length > 0) {
17267
17133
  logger.error(`refusing to start: workspace "${workspace.id}" needs ${store.pendingMigrations.length} migration(s) before ${appVersion()} can use it`);
17268
17134
  logger.info("run `home-hosted migrate` to see and apply them");
@@ -17410,6 +17276,7 @@ var init_src = __esmMin((() => {
17410
17276
  init_ui();
17411
17277
  init_ui_update$1();
17412
17278
  init_contracts();
17279
+ init_error_message();
17413
17280
  packageRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
17414
17281
  }));
17415
17282
  //#endregion
@@ -17973,6 +17840,7 @@ async function runLogs(input) {
17973
17840
  var logsArgs, DEFAULT_LINES, logsCommand;
17974
17841
  var init_logs = __esmMin((() => {
17975
17842
  init_io();
17843
+ init_error_message();
17976
17844
  logsArgs = {
17977
17845
  lines: {
17978
17846
  type: "string",
@@ -17999,7 +17867,7 @@ var init_logs = __esmMin((() => {
17999
17867
  json: args.json
18000
17868
  });
18001
17869
  } catch (error) {
18002
- fail(error instanceof Error ? error.message : String(error));
17870
+ fail(errorMessage(error));
18003
17871
  }
18004
17872
  }
18005
17873
  });
@@ -18166,12 +18034,6 @@ function targets(configOverride) {
18166
18034
  return list;
18167
18035
  }
18168
18036
  async function runMigrate(config, dryRun, yes) {
18169
- const relocated = migrateLayout();
18170
- if (relocated.migrated) {
18171
- process.stdout.write(`${green("state layout migrated")} into the current .hh layout\n`);
18172
- for (const step of relocated.moved) process.stdout.write(` ${dim(step)}\n`);
18173
- for (const warning of relocated.warnings) process.stdout.write(` ${dim(`warning: ${warning}`)}\n`);
18174
- }
18175
18037
  const existing = targets(config).filter((target) => fs.existsSync(target.file));
18176
18038
  if (existing.length === 0) fail(`no home-hosted state to migrate — nothing at ${globalSettingsPath} or any workspace`);
18177
18039
  const consented = yes || (process.env.HHOSTED_MIGRATE ?? "").toLowerCase() === "allow";
@@ -18181,7 +18043,7 @@ async function runMigrate(config, dryRun, yes) {
18181
18043
  try {
18182
18044
  raw = JSON.parse(fs.readFileSync(target.file, "utf8"));
18183
18045
  } catch (error) {
18184
- fail(`cannot read ${target.file}: ${error instanceof Error ? error.message : String(error)}`);
18046
+ fail(`cannot read ${target.file}: ${errorMessage(error)}`);
18185
18047
  }
18186
18048
  const meta = typeof raw.meta === "object" && raw.meta !== null ? raw.meta : {};
18187
18049
  const from = typeof meta.schema === "number" ? meta.schema : 1;
@@ -18226,13 +18088,13 @@ async function runMigrate(config, dryRun, yes) {
18226
18088
  var migrateArgs, migrateCommand;
18227
18089
  var init_migrate = __esmMin((() => {
18228
18090
  init_io();
18229
- init_layout();
18230
18091
  init_migrations();
18231
18092
  init_parse();
18232
18093
  init_workspaces();
18233
18094
  init_atomic();
18234
18095
  init_paths();
18235
18096
  init_version();
18097
+ init_error_message();
18236
18098
  migrateArgs = {
18237
18099
  config: {
18238
18100
  type: "string",
@@ -18396,7 +18258,7 @@ async function runInit(options) {
18396
18258
  process.stdout.write(`${green("project created")} in ${result.dir}\n`);
18397
18259
  for (const file of result.files) process.stdout.write(` ${dim(file)}\n`);
18398
18260
  } catch (error) {
18399
- fail(error instanceof Error ? error.message : String(error));
18261
+ fail(errorMessage(error));
18400
18262
  }
18401
18263
  const target = path.resolve(dir);
18402
18264
  if (git) {
@@ -18427,6 +18289,7 @@ var initArgs, initCommand;
18427
18289
  var init_init = __esmMin((() => {
18428
18290
  init_io();
18429
18291
  init_init$1();
18292
+ init_error_message();
18430
18293
  initArgs = {
18431
18294
  dir: {
18432
18295
  type: "string",
@@ -19073,6 +18936,7 @@ var init_nanny = __esmMin((() => {
19073
18936
  }));
19074
18937
  //#endregion
19075
18938
  //#region src/cli.ts
18939
+ init_error_message();
19076
18940
  init_args();
19077
18941
  init_io();
19078
18942
  init_nearest();
@@ -19369,7 +19233,7 @@ async function main() {
19369
19233
  try {
19370
19234
  await runCommand(nannyCommand, { rawArgs: dirFlags.rest });
19371
19235
  } catch (error) {
19372
- fail(error instanceof Error ? error.message : String(error));
19236
+ fail(errorMessage(error));
19373
19237
  }
19374
19238
  return;
19375
19239
  }
@@ -19398,11 +19262,11 @@ async function main() {
19398
19262
  try {
19399
19263
  await runCommand(rootCommand, { rawArgs: invocation.argv });
19400
19264
  } catch (error) {
19401
- fail(error instanceof Error ? error.message : String(error));
19265
+ fail(errorMessage(error));
19402
19266
  }
19403
19267
  }
19404
19268
  main().catch((error) => {
19405
- fail(error instanceof Error ? error.message : String(error));
19269
+ fail(errorMessage(error));
19406
19270
  });
19407
19271
  //#endregion
19408
19272
  export { commandHelp, suggestCommand };