home-hosted 0.6.1 → 0.6.2

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/README.md CHANGED
@@ -40,8 +40,9 @@ You run a handful of services at home. The usual choices are extremes — 🧟 `
40
40
  forget about, 📜 a hand-written systemd unit per service (times six), or 🐳 a whole docker/k8s
41
41
  stack??? - too extreme! — plus 😩 monitoring, rebooting and changing the host machine, yuck!
42
42
 
43
- 🙂 home-hosted, well, joins in somewhere: a panel/supervisor that starts them, watches them, restarts what dies, and
44
- puts the whole stack on one page, with deep backup support.
43
+ 🙂✨ home-hosted enhances on top: a panel/supervisor that starts them, watches them, restarts what
44
+ dies, and puts the whole stack on one page, with deep backup support — whether a server is a plain
45
+ command or a `docker compose` stack.
45
46
 
46
47
  ```text
47
48
  ┌──────────────────────────────────────┐
@@ -54,8 +55,13 @@ puts the whole stack on one page, with deep backup support.
54
55
  ┌─────────┐ ┌─────────┐ ┌─────────┐
55
56
  │ gateway │ │ files │ │ bot │
56
57
  │ :4000 │ │ :4010 │ │ ... │
57
- └─────────┘ └─────────┘ └─────────┘
58
- health ✓ health ✓ restarts ↻
58
+ └────┬────┘ └─────────┘ └─────────┘
59
+ │ compose up -d
60
+ ▼
61
+ ┌────────────┬────────────┬────────────┐
62
+ │ gateway │ postgres │ redis │ restarts ↻
63
+ └────────────┴────────────┴────────────┘
64
+ health ✓ (the published port is the probe)
59
65
  ```
60
66
 
61
67
  | | |
@@ -147,7 +153,7 @@ data/ # per-server data directories, declared through da
147
153
 
148
154
  One clone, `pnpm install --frozen-lockfile`, `pnpm run up` — the setup is up on any machine with Node.
149
155
  Worked example, with per-server data inside the project:
150
- **[hhosted-9router-dsh](https://github.com/NamesMT/hhosted-9router-dsh)**.
156
+ **[hhosted-ai-pack](https://github.com/NamesMT/hhosted-ai-pack)**.
151
157
 
152
158
  <sub>Call them as `pnpm run up` — `pnpm up` is pnpm's own update, not your script.</sub>
153
159
 
@@ -190,8 +196,8 @@ home-hosted set-token --generate
190
196
  # hh_9uA2… (printed once; only its hash is kept, mode 0600)
191
197
 
192
198
  curl -H "Authorization: Bearer hh_9uA2…" http://127.0.0.1:3999/api/state
193
- curl -H "Authorization: Bearer hh_9uA2…" -X POST http://127.0.0.1:3999/api/servers/9router/restart
194
- curl -N -H "Authorization: Bearer hh_9uA2…" 'http://127.0.0.1:3999/api/events?serverId=9router' # SSE
199
+ curl -H "Authorization: Bearer hh_9uA2…" -X POST http://127.0.0.1:3999/api/servers/omniroute/restart
200
+ curl -N -H "Authorization: Bearer hh_9uA2…" 'http://127.0.0.1:3999/api/events?serverId=omniroute' # SSE
195
201
  home-hosted status --json # machine-readable: pid, url, health, paths
196
202
  ```
197
203
 
@@ -396,7 +402,7 @@ archive and restore. Definitions come back, data lands where *this* machine's co
396
402
  `autostart` entries come up immediately.
397
403
 
398
404
  It works because an archive carries its own `servers.config.json` and paths are matched by the
399
- **declaration** (`9router:DATA_DIR`), not by an absolute path from the source machine. A restore never
405
+ **declaration** (`omniroute:DATA_DIR`), not by an absolute path from the source machine. A restore never
400
406
  writes where no config declares.
401
407
 
402
408
  </details>
package/dist/cli.js CHANGED
@@ -6821,7 +6821,7 @@ var init_config_watch = __esmMin((() => {
6821
6821
  if (this.disposed || this.watcher !== null) return;
6822
6822
  try {
6823
6823
  this.watcher = fs.watch(path.dirname(this.options.file), (_event, filename) => {
6824
- if (filename !== null && filename !== path.basename(this.options.file)) return;
6824
+ if (filename !== null && path.basename(filename) !== path.basename(this.options.file)) return;
6825
6825
  this.schedule();
6826
6826
  });
6827
6827
  this.watcher.on("error", (error) => {
@@ -7785,6 +7785,104 @@ function isSafeUiEntry(entry) {
7785
7785
  if (cleaned.length === 0) return false;
7786
7786
  return cleaned.split("/").every((segment) => segment !== "" && segment !== "." && segment !== ".." && !segment.includes(":"));
7787
7787
  }
7788
+ function withInstallLock(dataRoot, run) {
7789
+ const key = path.resolve(dataRoot);
7790
+ const next = (installLocks.get(key) ?? Promise.resolve()).then(run, run);
7791
+ installLocks.set(key, next.catch(() => {}));
7792
+ return next;
7793
+ }
7794
+ /** Renames the staged tree into place, keeping the old one until that has certainly worked. */
7795
+ function commitSwap(dataRoot, resting, staged, meta) {
7796
+ return withInstallLock(dataRoot, async () => {
7797
+ try {
7798
+ fs.renameSync(staged, resting);
7799
+ } catch {
7800
+ const previous = `${resting}.previous-${process.pid}-${Date.now()}`;
7801
+ const hadPrevious = fs.existsSync(resting);
7802
+ if (hadPrevious) fs.renameSync(resting, previous);
7803
+ try {
7804
+ fs.renameSync(staged, resting);
7805
+ } catch (error) {
7806
+ try {
7807
+ fs.rmSync(resting, {
7808
+ recursive: true,
7809
+ force: true
7810
+ });
7811
+ if (hadPrevious && fs.existsSync(previous)) fs.renameSync(previous, resting);
7812
+ fs.rmSync(previous, {
7813
+ recursive: true,
7814
+ force: true
7815
+ });
7816
+ } catch {
7817
+ return {
7818
+ ok: false,
7819
+ error: `${describeError$1(error)} — the previous UI is kept at ${previous}`
7820
+ };
7821
+ }
7822
+ return {
7823
+ ok: false,
7824
+ error: describeError$1(error)
7825
+ };
7826
+ }
7827
+ fs.rmSync(previous, {
7828
+ recursive: true,
7829
+ force: true
7830
+ });
7831
+ }
7832
+ sweepBackups(dataRoot, resting);
7833
+ return {
7834
+ ok: true,
7835
+ meta
7836
+ };
7837
+ });
7838
+ }
7839
+ function sweepBackups(dataRoot, resting) {
7840
+ const prefix = `${path.basename(resting)}.previous-`;
7841
+ for (const entry of readDataRoot(dataRoot)) if (entry.startsWith(prefix)) fs.rmSync(path.join(dataRoot, entry), {
7842
+ recursive: true,
7843
+ force: true
7844
+ });
7845
+ }
7846
+ /** The most recently abandoned UI tree, or null when there is not one. */
7847
+ function newestBackup(dataRoot, resting) {
7848
+ const prefix = `${path.basename(resting)}.previous-`;
7849
+ let best = null;
7850
+ let bestStamp = -1;
7851
+ for (const entry of readDataRoot(dataRoot)) {
7852
+ if (!entry.startsWith(prefix)) continue;
7853
+ const full = path.join(dataRoot, entry);
7854
+ if (!fs.existsSync(path.join(full, "index.html"))) continue;
7855
+ const stamp = Number(entry.slice(prefix.length).split("-").pop() ?? "");
7856
+ if (Number.isFinite(stamp) && stamp > bestStamp) {
7857
+ bestStamp = stamp;
7858
+ best = full;
7859
+ }
7860
+ }
7861
+ return best;
7862
+ }
7863
+ /** Dropped staging trees and superseded backups, once the live UI is whole. */
7864
+ function sweepJunk(dataRoot) {
7865
+ let swept = 0;
7866
+ for (const entry of readDataRoot(dataRoot)) {
7867
+ if (!entry.startsWith(".ui-staging-") && !entry.startsWith(`.ui.previous-`)) continue;
7868
+ fs.rmSync(path.join(dataRoot, entry), {
7869
+ recursive: true,
7870
+ force: true
7871
+ });
7872
+ swept += 1;
7873
+ }
7874
+ return swept;
7875
+ }
7876
+ function readDataRoot(dataRoot) {
7877
+ try {
7878
+ return fs.readdirSync(dataRoot);
7879
+ } catch {
7880
+ return [];
7881
+ }
7882
+ }
7883
+ function describeError$1(error) {
7884
+ return error instanceof Error ? error.message : String(error);
7885
+ }
7788
7886
  /**
7789
7887
  * Where the site actually starts: the archive root, or a single wrapper directory
7790
7888
  * (`zip -r ui.zip dist` is a common way to build one).
@@ -7812,7 +7910,7 @@ function countFiles(root) {
7812
7910
  })) if (entry.isFile() && entry.name !== META) total += 1;
7813
7911
  return Math.max(1, total);
7814
7912
  }
7815
- var META, MAX_ENTRIES, MAX_BYTES, MAX_NAME, manifestSchema, UiService;
7913
+ var META, MAX_ENTRIES, MAX_BYTES, MAX_NAME, manifestSchema, UiService, installLocks;
7816
7914
  var init_ui = __esmMin((() => {
7817
7915
  init_atomic();
7818
7916
  init_archive();
@@ -7872,7 +7970,7 @@ var init_ui = __esmMin((() => {
7872
7970
  ok: false,
7873
7971
  error: "the upload is not a zip archive"
7874
7972
  };
7875
- const staging = path.join(this.options.dataRoot, `.ui-staging-${Date.now()}`);
7973
+ const staging = path.join(this.options.dataRoot, `.ui-staging-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
7876
7974
  try {
7877
7975
  return await this.stage(archivePath, staging, fallbackName, installedTag);
7878
7976
  } catch (error) {
@@ -7941,43 +8039,38 @@ var init_ui = __esmMin((() => {
7941
8039
  ...manifest?.asset === void 0 ? {} : { asset: manifest.asset },
7942
8040
  ...manifest?.unix === void 0 ? {} : { unix: manifest.unix }
7943
8041
  };
7944
- const previous = `${this.directory}.previous-${process.pid}-${Date.now()}`;
7945
- if (fs.existsSync(this.directory)) fs.renameSync(this.directory, previous);
7946
- try {
7947
- fs.renameSync(root, this.directory);
7948
- writeFileAtomic(path.join(this.directory, META), `${JSON.stringify(meta, null, 2)}\n`);
7949
- } catch (error) {
7950
- try {
7951
- fs.rmSync(this.directory, {
7952
- recursive: true,
7953
- force: true
7954
- });
7955
- if (fs.existsSync(previous)) fs.renameSync(previous, this.directory);
7956
- fs.rmSync(previous, {
8042
+ writeFileAtomic(path.join(root, META), `${JSON.stringify(meta, null, 2)}\n`);
8043
+ return commitSwap(this.options.dataRoot, this.directory, root, meta);
8044
+ }
8045
+ /**
8046
+ * Puts a half-finished install back together, and is safe to call on every boot.
8047
+ *
8048
+ * An install that was interrupted between its two renames — a kill, a power cut, a
8049
+ * crash — leaves `.ui` missing with the user's only copy sitting in a `.previous-*`
8050
+ * sibling. Without this the panel would quietly serve the stock UI forever, because
8051
+ * `custom` is false and nothing ever looked at the backup.
8052
+ */
8053
+ recover() {
8054
+ const resting = this.directory;
8055
+ let restored = null;
8056
+ if (!fs.existsSync(path.join(resting, "index.html"))) {
8057
+ const backup = newestBackup(this.options.dataRoot, resting);
8058
+ if (backup !== null) {
8059
+ fs.rmSync(resting, {
7957
8060
  recursive: true,
7958
8061
  force: true
7959
8062
  });
7960
- } catch {
7961
- return {
7962
- ok: false,
7963
- error: `${error instanceof Error ? error.message : String(error)} — the previous UI is kept at ${previous}`
7964
- };
8063
+ fs.renameSync(backup, resting);
8064
+ restored = path.basename(backup);
7965
8065
  }
7966
- return {
7967
- ok: false,
7968
- error: error instanceof Error ? error.message : String(error)
7969
- };
7970
8066
  }
7971
- fs.rmSync(previous, {
7972
- recursive: true,
7973
- force: true
7974
- });
7975
8067
  return {
7976
- ok: true,
7977
- meta
8068
+ restored,
8069
+ swept: sweepJunk(this.options.dataRoot)
7978
8070
  };
7979
8071
  }
7980
8072
  };
8073
+ installLocks = /* @__PURE__ */ new Map();
7981
8074
  }));
7982
8075
  //#endregion
7983
8076
  //#region src/providers/ui-release.ts
@@ -8481,6 +8574,8 @@ async function runControlPlane(options) {
8481
8574
  dataRoot,
8482
8575
  stockDir: path.join(packageRoot, "uis", "stock", "dist")
8483
8576
  });
8577
+ const uiRecovery = ui.recover();
8578
+ if (uiRecovery.restored !== null) logger.warn(`ui: restored the installed UI from ${uiRecovery.restored} — an earlier update was interrupted`);
8484
8579
  const hub = new EventHub();
8485
8580
  let app;
8486
8581
  const token = newToken();