home-hosted 0.6.0 → 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/dist/cli.js CHANGED
@@ -897,10 +897,20 @@ password: passwordValueSchema.optional() }).onUndeclaredKey("reject");
897
897
  detail: "unknown"
898
898
  }).onUndeclaredKey("reject");
899
899
  uiMetaSchema = type({
900
- name: "string",
901
- version: "string | null",
902
- uploadedAt: "number",
903
- files: "number.integer >= 1"
900
+ "name?": "string",
901
+ "version?": "string | null",
902
+ /** Set by the panel, not by the author. */
903
+ "uploadedAt?": "number",
904
+ /** Counted by the panel, not declared. */
905
+ "files?": "number.integer >= 1",
906
+ /** `owner/name` of the UI's own repository, for `ui-update`. */
907
+ "repo?": "string",
908
+ /** The release tag this build came from, e.g. `v0.6.0`. */
909
+ "tag?": "string",
910
+ /** The release asset name, e.g. `home-hosted-ui-noc-console`. */
911
+ "asset?": "string",
912
+ /** When the UI was built, in unix epoch seconds. */
913
+ "unix?": "number.integer >= 0"
904
914
  });
905
915
  uiStatusSchema = type({
906
916
  /** A user-supplied UI is being served instead of the stock one. */
@@ -6811,7 +6821,7 @@ var init_config_watch = __esmMin((() => {
6811
6821
  if (this.disposed || this.watcher !== null) return;
6812
6822
  try {
6813
6823
  this.watcher = fs.watch(path.dirname(this.options.file), (_event, filename) => {
6814
- if (filename !== null && filename !== path.basename(this.options.file)) return;
6824
+ if (filename !== null && path.basename(filename) !== path.basename(this.options.file)) return;
6815
6825
  this.schedule();
6816
6826
  });
6817
6827
  this.watcher.on("error", (error) => {
@@ -7775,6 +7785,104 @@ function isSafeUiEntry(entry) {
7775
7785
  if (cleaned.length === 0) return false;
7776
7786
  return cleaned.split("/").every((segment) => segment !== "" && segment !== "." && segment !== ".." && !segment.includes(":"));
7777
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
+ }
7778
7886
  /**
7779
7887
  * Where the site actually starts: the archive root, or a single wrapper directory
7780
7888
  * (`zip -r ui.zip dist` is a common way to build one).
@@ -7802,7 +7910,7 @@ function countFiles(root) {
7802
7910
  })) if (entry.isFile() && entry.name !== META) total += 1;
7803
7911
  return Math.max(1, total);
7804
7912
  }
7805
- var META, MAX_ENTRIES, MAX_BYTES, MAX_NAME, manifestSchema, UiService;
7913
+ var META, MAX_ENTRIES, MAX_BYTES, MAX_NAME, manifestSchema, UiService, installLocks;
7806
7914
  var init_ui = __esmMin((() => {
7807
7915
  init_atomic();
7808
7916
  init_archive();
@@ -7813,7 +7921,11 @@ var init_ui = __esmMin((() => {
7813
7921
  MAX_NAME = 120;
7814
7922
  manifestSchema = type({
7815
7923
  "name?": "string",
7816
- "version?": "string"
7924
+ "version?": "string",
7925
+ "repo?": "string",
7926
+ "tag?": "string",
7927
+ "asset?": "string",
7928
+ "unix?": "number.integer >= 0"
7817
7929
  });
7818
7930
  UiService = class {
7819
7931
  options;
@@ -7853,14 +7965,14 @@ var init_ui = __esmMin((() => {
7853
7965
  * swapped in, so a failed upload leaves the previous UI (or the stock one)
7854
7966
  * serving.
7855
7967
  */
7856
- async install(archivePath, fallbackName = "custom-ui") {
7968
+ async install(archivePath, fallbackName = "custom-ui", installedTag) {
7857
7969
  if (!isZipArchive(archivePath)) return {
7858
7970
  ok: false,
7859
7971
  error: "the upload is not a zip archive"
7860
7972
  };
7861
- 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)}`);
7862
7974
  try {
7863
- return await this.stage(archivePath, staging, fallbackName);
7975
+ return await this.stage(archivePath, staging, fallbackName, installedTag);
7864
7976
  } catch (error) {
7865
7977
  return {
7866
7978
  ok: false,
@@ -7882,7 +7994,7 @@ var init_ui = __esmMin((() => {
7882
7994
  });
7883
7995
  return existed;
7884
7996
  }
7885
- async stage(archivePath, staging, fallbackName) {
7997
+ async stage(archivePath, staging, fallbackName, installedTag) {
7886
7998
  let entries;
7887
7999
  try {
7888
8000
  entries = await listZip(archivePath);
@@ -7921,101 +8033,499 @@ var init_ui = __esmMin((() => {
7921
8033
  name: manifest?.name ?? fallbackName,
7922
8034
  version: manifest?.version ?? null,
7923
8035
  uploadedAt: Date.now(),
7924
- files: countFiles(root)
8036
+ files: countFiles(root),
8037
+ ...manifest?.repo === void 0 ? {} : { repo: manifest.repo },
8038
+ ...installedTag !== void 0 ? { tag: installedTag } : manifest?.tag === void 0 ? {} : { tag: manifest.tag },
8039
+ ...manifest?.asset === void 0 ? {} : { asset: manifest.asset },
8040
+ ...manifest?.unix === void 0 ? {} : { unix: manifest.unix }
7925
8041
  };
7926
- const previous = `${this.directory}.previous`;
7927
- fs.rmSync(previous, {
7928
- recursive: true,
7929
- force: true
7930
- });
7931
- if (fs.existsSync(this.directory)) fs.renameSync(this.directory, previous);
7932
- try {
7933
- fs.renameSync(root, this.directory);
7934
- writeFileAtomic(path.join(this.directory, META), `${JSON.stringify(meta, null, 2)}\n`);
7935
- } catch (error) {
7936
- fs.rmSync(this.directory, {
7937
- recursive: true,
7938
- force: true
7939
- });
7940
- if (fs.existsSync(previous)) fs.renameSync(previous, this.directory);
7941
- return {
7942
- ok: false,
7943
- error: error instanceof Error ? error.message : String(error)
7944
- };
7945
- } finally {
7946
- fs.rmSync(previous, {
7947
- recursive: true,
7948
- force: true
7949
- });
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, {
8060
+ recursive: true,
8061
+ force: true
8062
+ });
8063
+ fs.renameSync(backup, resting);
8064
+ restored = path.basename(backup);
8065
+ }
7950
8066
  }
7951
8067
  return {
7952
- ok: true,
7953
- meta
8068
+ restored,
8069
+ swept: sweepJunk(this.options.dataRoot)
7954
8070
  };
7955
8071
  }
7956
8072
  };
8073
+ installLocks = /* @__PURE__ */ new Map();
7957
8074
  }));
7958
8075
  //#endregion
7959
- //#region src/index.ts
7960
- var src_exports = /* @__PURE__ */ __exportAll({
7961
- packageRoot: () => packageRoot,
7962
- runControlPlane: () => runControlPlane
7963
- });
7964
- function packageVersion() {
8076
+ //#region src/providers/ui-release.ts
8077
+ /** `owner/name`, the only slug GitHub releases are addressed by. */
8078
+ function parseRepoSlug(value) {
8079
+ const match = /^([\w.-]+)\/([\w.-]+)$/.exec(value.trim());
8080
+ if (match === null) return null;
8081
+ return {
8082
+ owner: match[1],
8083
+ name: match[2]
8084
+ };
8085
+ }
8086
+ function repoSlug(repo) {
8087
+ return `${repo.owner}/${repo.name}`;
8088
+ }
8089
+ /** The repo the default tag rule is about; `DEFAULT_REPO` is its only definition. */
8090
+ function isOwnRepo(repo) {
8091
+ return repoSlug(repo).toLowerCase() === DEFAULT_REPO.toLowerCase();
8092
+ }
8093
+ /**
8094
+ * Our own release tag matches this CLI's version, so a UI is paired with the panel
8095
+ * it was built for. Another repo has no such pairing, so its latest release is used.
8096
+ * `null` means "latest".
8097
+ */
8098
+ function defaultReleaseTag(repo, version) {
8099
+ return isOwnRepo(repo) ? `v${version}` : null;
8100
+ }
8101
+ function releaseApiUrl(repo, tag) {
8102
+ const base = `https://api.github.com/repos/${repo.owner}/${repo.name}/releases`;
8103
+ if (tag === null || tag.length === 0 || tag === "latest") return `${base}/latest`;
8104
+ return `${base}/tags/${encodeURIComponent(tag)}`;
8105
+ }
8106
+ /** Every published release, newest first, as GitHub orders them. */
8107
+ function releasesApiUrl(repo, perPage = 30) {
8108
+ return `https://api.github.com/repos/${repo.owner}/${repo.name}/releases?per_page=${perPage}`;
8109
+ }
8110
+ /** Generous and predictable: a UI bundle is an asset whose name ends in `.zip`. */
8111
+ function isUiAsset(name) {
8112
+ return /\.zip$/i.test(name.trim());
8113
+ }
8114
+ /**
8115
+ * Resolves a wanted asset: an exact name, a case-insensitive name, or a single
8116
+ * unambiguous substring (`--asset stock` for `home-hosted-ui-stock.zip`).
8117
+ */
8118
+ function matchAsset(names, query) {
8119
+ const wanted = query.trim();
8120
+ if (wanted.length === 0) return {
8121
+ ok: false,
8122
+ error: "no asset name was given"
8123
+ };
8124
+ const exact = names.find((name) => name === wanted);
8125
+ if (exact !== void 0) return {
8126
+ ok: true,
8127
+ name: exact
8128
+ };
8129
+ const lower = wanted.toLowerCase();
8130
+ const insensitive = names.filter((name) => name.toLowerCase() === lower);
8131
+ if (insensitive.length === 1) return {
8132
+ ok: true,
8133
+ name: insensitive[0]
8134
+ };
8135
+ const partial = names.filter((name) => name.toLowerCase().includes(lower));
8136
+ if (partial.length === 0) return {
8137
+ ok: false,
8138
+ error: `no asset matches "${wanted}" (available: ${names.join(", ") || "none"})`
8139
+ };
8140
+ if (partial.length > 1) return {
8141
+ ok: false,
8142
+ error: `"${wanted}" matches more than one asset: ${partial.join(", ")} — use the full name`
8143
+ };
8144
+ return {
8145
+ ok: true,
8146
+ name: partial[0]
8147
+ };
8148
+ }
8149
+ /** `^https?://` means a URL; anything else is a filesystem path with `~` expanded. */
8150
+ function parseFileSource(value) {
8151
+ const trimmed = value.trim();
8152
+ if (/^https?:\/\//i.test(trimmed)) return {
8153
+ kind: "url",
8154
+ url: trimmed
8155
+ };
8156
+ return {
8157
+ kind: "path",
8158
+ path: expandHome(trimmed)
8159
+ };
8160
+ }
8161
+ function expandHome(value) {
8162
+ if (value === "~") return os.homedir();
8163
+ if (value.startsWith("~/") || value.startsWith("~\\")) return path.join(os.homedir(), value.slice(2));
8164
+ return value;
8165
+ }
8166
+ /**
8167
+ * A token is only ever sent to GitHub: `--file <url>` may point anywhere, and a
8168
+ * credential must not leak to a host the user did not vouch for.
8169
+ */
8170
+ function isGithubHost(url) {
7965
8171
  try {
7966
- return JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8")).version ?? "0.0.0";
8172
+ const host = new URL(url).hostname.toLowerCase();
8173
+ return host === "github.com" || host.endsWith(".github.com") || host === "githubusercontent.com" || host.endsWith(".githubusercontent.com");
7967
8174
  } catch {
7968
- return "0.0.0";
8175
+ return false;
7969
8176
  }
7970
8177
  }
7971
- /** A probe target for a `lan` bind, which is not connectable as `0.0.0.0`. */
7972
- function probeHostFor(bindHost) {
7973
- return bindHost === "0.0.0.0" || bindHost === "::" ? "127.0.0.1" : bindHost;
8178
+ function timeoutSignal(ms) {
8179
+ return typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(ms) : void 0;
7974
8180
  }
7975
- /**
7976
- * Runs the control plane in *this* process until it is stopped. `home-hosted up`
7977
- * detaches a child that calls this; `--foreground` (systemd, docker) calls it
7978
- * directly.
7979
- */
7980
- async function runControlPlane(options) {
7981
- const existing = readRuntime();
7982
- if (existing !== null && existing.pid !== process.pid && isProcessAlive$1(existing.pid)) {
7983
- logger.error(`already running (pid ${existing.pid}) at ${existing.url} — run \`home-hosted down\` first`);
7984
- process.exit(1);
8181
+ function isTimeout(error) {
8182
+ return error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
8183
+ }
8184
+ /** What `UiService` names the install when the archive carries no `ui.json`. */
8185
+ function fallbackUiName(source) {
8186
+ const stripped = path.basename(source).replace(/\.zip$/i, "").replace(/^home-hosted-ui-/i, "");
8187
+ return stripped.length > 0 ? stripped : "custom-ui";
8188
+ }
8189
+ /** One release's assets, or a message that says what was wrong with the answer. */
8190
+ async function fetchRelease(repo, tag, context) {
8191
+ const url = releaseApiUrl(repo, tag);
8192
+ let response;
8193
+ try {
8194
+ response = await fetch(url, {
8195
+ headers: apiHeaders(context),
8196
+ signal: timeoutSignal(REQUEST_TIMEOUT_MS)
8197
+ });
8198
+ } catch (error) {
8199
+ throw new Error(isTimeout(error) ? `GitHub did not answer within ${REQUEST_TIMEOUT_MS / 1e3}s` : `could not reach GitHub: ${describeError(error)}`);
7985
8200
  }
7986
- if (existing !== null) clearRuntime();
7987
- const configPath = options.configPath ?? defaultConfigPath;
7988
- const store = new ConfigStore(configPath, SEED_CONFIG);
7989
- store.load();
7990
- store.writeJsonSchema();
7991
- if (store.configError !== null) {
7992
- logger.error(`refusing to start: ${store.configError}`);
7993
- logger.info(`fix ${store.path}, or install the release that wrote it`);
7994
- process.exit(1);
8201
+ if (!response.ok) throw new Error(describeReleaseFailure(response.status, repo, tag));
8202
+ const body = await response.json();
8203
+ return {
8204
+ tag: body.tag_name ?? tag ?? "latest",
8205
+ assets: Array.isArray(body.assets) ? body.assets : []
8206
+ };
8207
+ }
8208
+ /** Every published release, newest first. Drafts and unusable entries are dropped. */
8209
+ async function fetchReleases(repo, context, perPage = 30) {
8210
+ let response;
8211
+ try {
8212
+ response = await fetch(releasesApiUrl(repo, perPage), {
8213
+ headers: apiHeaders(context),
8214
+ signal: timeoutSignal(REQUEST_TIMEOUT_MS)
8215
+ });
8216
+ } catch (error) {
8217
+ throw new Error(isTimeout(error) ? `GitHub did not answer within ${REQUEST_TIMEOUT_MS / 1e3}s` : `could not reach GitHub: ${describeError(error)}`);
7995
8218
  }
7996
- if (store.pendingMigrations.length > 0) {
7997
- logger.error(`refusing to start: ${store.path} needs ${store.pendingMigrations.length} migration(s) before ${appVersion()} can use it`);
7998
- logger.info("run `home-hosted migrate` to see and apply them");
7999
- process.exit(1);
8219
+ if (!response.ok) throw new Error(describeReleaseFailure(response.status, repo, null));
8220
+ const body = await response.json();
8221
+ if (!Array.isArray(body)) return [];
8222
+ return body.filter((release) => release.draft !== true && typeof release.tag_name === "string").map((release) => ({
8223
+ tag: release.tag_name,
8224
+ assets: Array.isArray(release.assets) ? release.assets : [],
8225
+ publishedAt: typeof release.published_at === "string" ? release.published_at : null
8226
+ }));
8227
+ }
8228
+ function assetDownloadUrl(asset) {
8229
+ const url = asset.url ?? asset.browser_download_url;
8230
+ if (url === void 0 || url.length === 0) throw new Error(`the release metadata for "${asset.name ?? "an asset"}" carries no download URL`);
8231
+ return url;
8232
+ }
8233
+ /** Streams to `os.tmpdir()` so a large body is never buffered in memory. */
8234
+ async function downloadToTemp(url, headers, context) {
8235
+ let response;
8236
+ try {
8237
+ response = await fetch(url, {
8238
+ headers,
8239
+ redirect: "follow",
8240
+ signal: timeoutSignal(DOWNLOAD_TIMEOUT_MS)
8241
+ });
8242
+ } catch (error) {
8243
+ throw new Error(isTimeout(error) ? `the download stalled for ${DOWNLOAD_TIMEOUT_MS / 1e3}s: ${url}` : `could not reach ${url}: ${describeError(error)}`);
8000
8244
  }
8001
- const secrets = new SecretsStore(defaultSecretsPath);
8002
- const auth = new AuthService(secrets, () => store.config.control.auth);
8003
- const tls = new TlsStore(defaultTlsDir);
8004
- const logFiles = new LogFiles(defaultLogsDir, () => store.config.logs);
8005
- const history = new HistoryStore(defaultHistoryPath);
8006
- const notifications = new NotificationService(secrets, () => store.config.notifications, () => store.config.logs);
8007
- const hostMonitor = new HostMonitor(() => store.config.host, (target) => resolveUserPath(resolveTemplate(target, {
8008
- projectDir,
8009
- dataRoot,
8010
- home: os.homedir()
8011
- })), notifications);
8012
- let onConfigRestored;
8013
- const backups = new BackupService({
8014
- dataRoot,
8015
- getConfig: () => store.config.backups,
8016
- getSources: () => ({
8017
- configPath: store.path,
8018
- secretsPath: secrets.path,
8245
+ if (!response.ok) throw new Error(describeDownloadFailure(response.status, url));
8246
+ const declared = Number(response.headers.get("content-length") ?? "0");
8247
+ if (!Number.isFinite(declared) || declared < 0) throw new Error(`the download from ${url} reported an unusable size`);
8248
+ if (declared > 536870912) throw new Error(tooLargeMessage(declared));
8249
+ if (response.body === null) throw new Error(`the download from ${url} had no body`);
8250
+ const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "hh-ui-"));
8251
+ const file = path.join(dir, "ui.zip");
8252
+ const handle = await fs.promises.open(file, "w");
8253
+ let received = 0;
8254
+ try {
8255
+ for await (const chunk of response.body) {
8256
+ received += chunk.length;
8257
+ if (received > 536870912) throw new Error(tooLargeMessage(received));
8258
+ await handle.write(chunk);
8259
+ }
8260
+ } catch (error) {
8261
+ await handle.close();
8262
+ fs.rmSync(dir, {
8263
+ recursive: true,
8264
+ force: true
8265
+ });
8266
+ throw error instanceof Error ? error : new Error(String(error));
8267
+ }
8268
+ await handle.close();
8269
+ if (context.quiet !== true) context.io.write(`${context.io.style.dim(`downloaded ${formatBytes(received)}`)}\n`);
8270
+ return {
8271
+ dir,
8272
+ file
8273
+ };
8274
+ }
8275
+ function apiHeaders(context) {
8276
+ const headers = {
8277
+ "accept": "application/vnd.github+json",
8278
+ "user-agent": `home-hosted/${context.version}`,
8279
+ "x-github-api-version": "2022-11-28"
8280
+ };
8281
+ if (context.token !== null && context.token.length > 0) headers.authorization = `Bearer ${context.token}`;
8282
+ return headers;
8283
+ }
8284
+ function noAssetsMessage(where, skipped, tag) {
8285
+ const lines = [`no usable UI assets in ${where} (expected a .zip)`];
8286
+ lines.push(skipped.length > 0 ? ` skipped: ${skipped.join(", ")}` : " the release has no assets at all");
8287
+ if (tag !== null && tag !== "latest") lines.push(" installing a different version silently is worse than failing: try the newest release with --tag latest");
8288
+ return lines.join("\n");
8289
+ }
8290
+ function describeReleaseFailure(status, repo, tag) {
8291
+ const slug = repoSlug(repo);
8292
+ if (status === 404) {
8293
+ if (tag !== null && tag !== "latest") return `no release tagged "${tag}" in ${slug}\n list what exists with: home-hosted ui-switch --repo ${slug} --tag latest --list`;
8294
+ return `no repository or published release at ${slug}\n check the --repo slug (owner/name), and that the repository is public`;
8295
+ }
8296
+ if (status === 403 || status === 429) return `GitHub refused the request (HTTP ${status}) — the unauthenticated API rate limit is per address.\n set a token to raise it: --token <token>, or GITHUB_TOKEN / GH_TOKEN`;
8297
+ if (status === 401) return "GitHub rejected the token (HTTP 401) — check --token, GITHUB_TOKEN or GH_TOKEN";
8298
+ return `GitHub answered HTTP ${status} while reading the release of ${slug}`;
8299
+ }
8300
+ function describeDownloadFailure(status, url) {
8301
+ const github = isGithubHost(url);
8302
+ if (status === 404) return github ? `the asset is gone (HTTP 404) — ${url}\n the release may have been rebuilt since it was listed; run the command again` : `nothing is served at that URL (HTTP 404) — ${url}`;
8303
+ if (status === 403 || status === 429) return github ? `GitHub refused the download (HTTP ${status}) — a token raises the rate limit: --token <token>, or GITHUB_TOKEN / GH_TOKEN` : `the host refused the download (HTTP ${status}) — ${url}`;
8304
+ if (status === 401 && github) return "GitHub rejected the token on the download (HTTP 401) — check --token, GITHUB_TOKEN or GH_TOKEN";
8305
+ return `the download failed (HTTP ${status}) — ${url}`;
8306
+ }
8307
+ function tooLargeMessage(bytes) {
8308
+ return `the download is ${formatBytes(bytes)}, larger than the ${MAX_DOWNLOAD_BYTES / 1024 / 1024}MB a UI may be`;
8309
+ }
8310
+ function formatBytes(bytes) {
8311
+ if (bytes < 1024) return `${bytes} B`;
8312
+ if (bytes < 1048576) return `${Math.round(bytes / 1024)} KB`;
8313
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
8314
+ }
8315
+ function describeError(error) {
8316
+ return error instanceof Error ? error.message : String(error);
8317
+ }
8318
+ /**
8319
+ * Tag ordering: `v1.2.3` beats `v1.2.2`, a prerelease loses to its release, and anything
8320
+ * that is not a version sorts below every version that is. Enough to answer "is this
8321
+ * release newer than the one the UI came from" without a semver dependency.
8322
+ */
8323
+ function compareTags(a, b) {
8324
+ const left = parseTag(a);
8325
+ const right = parseTag(b);
8326
+ if (left === null && right === null) return a.localeCompare(b);
8327
+ if (left === null) return -1;
8328
+ if (right === null) return 1;
8329
+ for (let index = 0; index < 3; index++) {
8330
+ const difference = (left.parts[index] ?? 0) - (right.parts[index] ?? 0);
8331
+ if (difference !== 0) return difference;
8332
+ }
8333
+ if (left.prerelease.length === 0 && right.prerelease.length === 0) return 0;
8334
+ if (left.prerelease.length === 0) return 1;
8335
+ if (right.prerelease.length === 0) return -1;
8336
+ for (let index = 0; index < Math.max(left.prerelease.length, right.prerelease.length); index++) {
8337
+ const one = left.prerelease[index];
8338
+ const two = right.prerelease[index];
8339
+ if (one === void 0) return -1;
8340
+ if (two === void 0) return 1;
8341
+ if (one === two) continue;
8342
+ const numeric = /^\d+$/;
8343
+ if (numeric.test(one) && numeric.test(two)) return Number(one) - Number(two);
8344
+ if (numeric.test(one)) return -1;
8345
+ if (numeric.test(two)) return 1;
8346
+ return one.localeCompare(two);
8347
+ }
8348
+ return 0;
8349
+ }
8350
+ function parseTag(tag) {
8351
+ const match = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+.*)?$/.exec(tag.trim());
8352
+ if (match === null) return null;
8353
+ return {
8354
+ parts: [
8355
+ Number(match[1]),
8356
+ Number(match[2] ?? 0),
8357
+ Number(match[3] ?? 0)
8358
+ ],
8359
+ prerelease: match[4] === void 0 ? [] : match[4].split(".")
8360
+ };
8361
+ }
8362
+ var DEFAULT_REPO, MAX_DOWNLOAD_BYTES, REQUEST_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS;
8363
+ var init_ui_release = __esmMin((() => {
8364
+ DEFAULT_REPO = "NamesMT/home-hosted";
8365
+ MAX_DOWNLOAD_BYTES = 536870912;
8366
+ REQUEST_TIMEOUT_MS = 3e4;
8367
+ DOWNLOAD_TIMEOUT_MS = 12e4;
8368
+ }));
8369
+ //#endregion
8370
+ //#region src/services/ui-update.ts
8371
+ /**
8372
+ * `ui.json` says which release this build came from. Anything else — an unofficial UI,
8373
+ * or one that never declared itself — is left for `home-hosted ui-update`.
8374
+ */
8375
+ function officialTagFor(meta, runningVersion) {
8376
+ if (meta === null || typeof meta.repo !== "string") return null;
8377
+ const repo = parseRepoSlug(meta.repo);
8378
+ if (repo === null || !isOwnRepo(repo)) return null;
8379
+ return {
8380
+ tag: `v${runningVersion}`,
8381
+ repo: `${repo.owner}/${repo.name}`
8382
+ };
8383
+ }
8384
+ async function syncOfficialUi(ui, runningVersion = appVersion()) {
8385
+ if (!ui.custom) return { kind: "not-custom" };
8386
+ const meta = ui.readMeta();
8387
+ if (meta === null) return { kind: "no-identity" };
8388
+ const repo = meta.repo === void 0 ? null : parseRepoSlug(meta.repo);
8389
+ if (repo === null) return { kind: "no-identity" };
8390
+ if (!isOwnRepo(repo)) return { kind: "foreign" };
8391
+ const target = `v${runningVersion}`;
8392
+ if (meta.tag === target) return {
8393
+ kind: "current",
8394
+ tag: target
8395
+ };
8396
+ const context = {
8397
+ io: {
8398
+ write: () => {},
8399
+ style: {
8400
+ bold: (t) => t,
8401
+ dim: (t) => t,
8402
+ green: (t) => t
8403
+ }
8404
+ },
8405
+ version: runningVersion,
8406
+ token: process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? null,
8407
+ quiet: true
8408
+ };
8409
+ try {
8410
+ const release = await fetchRelease(repo, target, context);
8411
+ const names = release.assets.map((asset) => asset.name ?? "").filter(isUiAsset);
8412
+ if (names.length === 0) throw new Error(`no UI asset in ${DEFAULT_REPO}@${release.tag}`);
8413
+ const wanted = meta.asset ?? "";
8414
+ const matched = wanted.length > 0 ? matchAsset(names, wanted) : {
8415
+ ok: true,
8416
+ name: names[0]
8417
+ };
8418
+ if (!matched.ok) throw new Error(matched.error);
8419
+ const asset = release.assets.find((entry) => entry.name === matched.name);
8420
+ if (asset === void 0) throw new Error(`no asset named ${matched.name} in ${release.tag}`);
8421
+ const download = await downloadToTemp(assetDownloadUrl(asset), {
8422
+ "accept": "application/octet-stream",
8423
+ "user-agent": `home-hosted/${runningVersion}`
8424
+ }, context);
8425
+ try {
8426
+ const result = await ui.install(download.file, matched.name.replace(/\.zip$/i, ""), release.tag);
8427
+ if (!result.ok) throw new Error(result.error);
8428
+ } finally {
8429
+ (await import("node:fs")).rmSync(download.dir, {
8430
+ recursive: true,
8431
+ force: true
8432
+ });
8433
+ }
8434
+ return {
8435
+ kind: "updated",
8436
+ tag: release.tag
8437
+ };
8438
+ } catch (error) {
8439
+ return {
8440
+ kind: "failed",
8441
+ error: error instanceof Error ? error.message : String(error)
8442
+ };
8443
+ }
8444
+ }
8445
+ /**
8446
+ * The startup hook. Never awaited by the caller and never allowed to throw: the panel
8447
+ * serves the UI it already has while this runs, and the next request picks up the new
8448
+ * one, because `UiService.resolveDir()` is read per request.
8449
+ */
8450
+ function autoUpdateOfficialUi(ui, runningVersion = appVersion()) {
8451
+ if (!ui.custom) return;
8452
+ const meta = ui.readMeta();
8453
+ const plan = officialTagFor(meta, runningVersion);
8454
+ if (plan === null || meta?.tag === plan.tag) return;
8455
+ logger.info(`ui: ${meta?.name ?? "custom UI"} ${meta?.version ?? ""} came from ${meta?.tag ?? "an unknown release"}; this panel is ${plan.tag} — updating`);
8456
+ syncOfficialUi(ui, runningVersion).then((result) => {
8457
+ if (result.kind === "updated") logger.info(`ui: updated to ${result.tag} — refresh the browser`);
8458
+ else if (result.kind === "failed") logger.warn(`ui: could not update to ${plan.tag}: ${result.error}`);
8459
+ }).catch((error) => {
8460
+ logger.warn(`ui: could not update: ${error instanceof Error ? error.message : String(error)}`);
8461
+ });
8462
+ }
8463
+ var init_ui_update$1 = __esmMin((() => {
8464
+ init_logger();
8465
+ init_version();
8466
+ init_ui_release();
8467
+ }));
8468
+ //#endregion
8469
+ //#region src/index.ts
8470
+ var src_exports = /* @__PURE__ */ __exportAll({
8471
+ packageRoot: () => packageRoot,
8472
+ runControlPlane: () => runControlPlane
8473
+ });
8474
+ function packageVersion() {
8475
+ try {
8476
+ return JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8")).version ?? "0.0.0";
8477
+ } catch {
8478
+ return "0.0.0";
8479
+ }
8480
+ }
8481
+ /** A probe target for a `lan` bind, which is not connectable as `0.0.0.0`. */
8482
+ function probeHostFor(bindHost) {
8483
+ return bindHost === "0.0.0.0" || bindHost === "::" ? "127.0.0.1" : bindHost;
8484
+ }
8485
+ /**
8486
+ * Runs the control plane in *this* process until it is stopped. `home-hosted up`
8487
+ * detaches a child that calls this; `--foreground` (systemd, docker) calls it
8488
+ * directly.
8489
+ */
8490
+ async function runControlPlane(options) {
8491
+ const existing = readRuntime();
8492
+ if (existing !== null && existing.pid !== process.pid && isProcessAlive$1(existing.pid)) {
8493
+ logger.error(`already running (pid ${existing.pid}) at ${existing.url} — run \`home-hosted down\` first`);
8494
+ process.exit(1);
8495
+ }
8496
+ if (existing !== null) clearRuntime();
8497
+ const configPath = options.configPath ?? defaultConfigPath;
8498
+ const store = new ConfigStore(configPath, SEED_CONFIG);
8499
+ store.load();
8500
+ store.writeJsonSchema();
8501
+ if (store.configError !== null) {
8502
+ logger.error(`refusing to start: ${store.configError}`);
8503
+ logger.info(`fix ${store.path}, or install the release that wrote it`);
8504
+ process.exit(1);
8505
+ }
8506
+ if (store.pendingMigrations.length > 0) {
8507
+ logger.error(`refusing to start: ${store.path} needs ${store.pendingMigrations.length} migration(s) before ${appVersion()} can use it`);
8508
+ logger.info("run `home-hosted migrate` to see and apply them");
8509
+ process.exit(1);
8510
+ }
8511
+ const secrets = new SecretsStore(defaultSecretsPath);
8512
+ const auth = new AuthService(secrets, () => store.config.control.auth);
8513
+ const tls = new TlsStore(defaultTlsDir);
8514
+ const logFiles = new LogFiles(defaultLogsDir, () => store.config.logs);
8515
+ const history = new HistoryStore(defaultHistoryPath);
8516
+ const notifications = new NotificationService(secrets, () => store.config.notifications, () => store.config.logs);
8517
+ const hostMonitor = new HostMonitor(() => store.config.host, (target) => resolveUserPath(resolveTemplate(target, {
8518
+ projectDir,
8519
+ dataRoot,
8520
+ home: os.homedir()
8521
+ })), notifications);
8522
+ let onConfigRestored;
8523
+ const backups = new BackupService({
8524
+ dataRoot,
8525
+ getConfig: () => store.config.backups,
8526
+ getSources: () => ({
8527
+ configPath: store.path,
8528
+ secretsPath: secrets.path,
8019
8529
  tlsDir: tls.directory,
8020
8530
  paths: resolveBackupPaths(store.servers, store.config.backups.includePaths)
8021
8531
  }),
@@ -8064,6 +8574,8 @@ async function runControlPlane(options) {
8064
8574
  dataRoot,
8065
8575
  stockDir: path.join(packageRoot, "uis", "stock", "dist")
8066
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`);
8067
8579
  const hub = new EventHub();
8068
8580
  let app;
8069
8581
  const token = newToken();
@@ -8192,6 +8704,7 @@ async function runControlPlane(options) {
8192
8704
  if (ui.custom) {
8193
8705
  const meta = ui.status().meta;
8194
8706
  logger.warn(`custom UI in use${meta === null ? "" : ` (${meta.name}${meta.version === null ? "" : ` ${meta.version}`})`} — if it breaks, run \`home-hosted ui-revert\``);
8707
+ autoUpdateOfficialUi(ui, runtime.version);
8195
8708
  }
8196
8709
  for (const warning of store.configWarnings) logger.warn(warning);
8197
8710
  for (const entry of supervisor.views()) logger.info(` ${entry.id.padEnd(12)} ${entry.config.command} ${entry.config.args.join(" ")}`.trimEnd());
@@ -8248,6 +8761,7 @@ var init_src = __esmMin((() => {
8248
8761
  init_supervisor();
8249
8762
  init_tls();
8250
8763
  init_ui();
8764
+ init_ui_update$1();
8251
8765
  init_contracts();
8252
8766
  packageRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
8253
8767
  }));
@@ -9029,117 +9543,30 @@ var init_init = __esmMin((() => {
9029
9543
  var ui_switch_exports = /* @__PURE__ */ __exportAll({
9030
9544
  DEFAULT_REPO: () => DEFAULT_REPO,
9031
9545
  MAX_DOWNLOAD_BYTES: () => MAX_DOWNLOAD_BYTES,
9546
+ apiHeaders: () => apiHeaders,
9547
+ assetDownloadUrl: () => assetDownloadUrl,
9548
+ compareTags: () => compareTags,
9032
9549
  defaultReleaseTag: () => defaultReleaseTag,
9550
+ describeError: () => describeError,
9551
+ describeReleaseFailure: () => describeReleaseFailure,
9552
+ downloadToTemp: () => downloadToTemp,
9033
9553
  fallbackUiName: () => fallbackUiName,
9554
+ fetchRelease: () => fetchRelease,
9555
+ fetchReleases: () => fetchReleases,
9556
+ formatBytes: () => formatBytes,
9034
9557
  isGithubHost: () => isGithubHost,
9035
9558
  isOwnRepo: () => isOwnRepo,
9036
9559
  isUiAsset: () => isUiAsset,
9037
9560
  matchAsset: () => matchAsset,
9561
+ noAssetsMessage: () => noAssetsMessage,
9038
9562
  parseFileSource: () => parseFileSource,
9039
9563
  parseRepoSlug: () => parseRepoSlug,
9040
9564
  releaseApiUrl: () => releaseApiUrl,
9565
+ releasesApiUrl: () => releasesApiUrl,
9566
+ repoSlug: () => repoSlug,
9041
9567
  uiSwitch: () => uiSwitch,
9042
9568
  uiSwitchCommand: () => uiSwitchCommand
9043
9569
  });
9044
- /** `owner/name`, the only slug GitHub releases are addressed by. */
9045
- function parseRepoSlug(value) {
9046
- const match = /^([\w.-]+)\/([\w.-]+)$/.exec(value.trim());
9047
- if (match === null) return null;
9048
- return {
9049
- owner: match[1],
9050
- name: match[2]
9051
- };
9052
- }
9053
- /** The repo the default tag rule is about; `DEFAULT_REPO` is its only definition. */
9054
- function isOwnRepo(repo) {
9055
- return `${repo.owner}/${repo.name}`.toLowerCase() === DEFAULT_REPO.toLowerCase();
9056
- }
9057
- /**
9058
- * Our own release tag matches this CLI's version, so a UI is paired with the panel
9059
- * it was built for. Another repo has no such pairing, so its latest release is used.
9060
- * `null` means "latest".
9061
- */
9062
- function defaultReleaseTag(repo, version) {
9063
- return isOwnRepo(repo) ? `v${version}` : null;
9064
- }
9065
- function releaseApiUrl(repo, tag) {
9066
- const base = `https://api.github.com/repos/${repo.owner}/${repo.name}/releases`;
9067
- if (tag === null || tag.length === 0 || tag === "latest") return `${base}/latest`;
9068
- return `${base}/tags/${encodeURIComponent(tag)}`;
9069
- }
9070
- /** Generous and predictable: a UI bundle is an asset whose name ends in `.zip`. */
9071
- function isUiAsset(name) {
9072
- return /\.zip$/i.test(name.trim());
9073
- }
9074
- /**
9075
- * Resolves `--asset`: an exact name, a case-insensitive name, or a single
9076
- * unambiguous substring (`--asset stock` for `home-hosted-ui-stock.zip`).
9077
- */
9078
- function matchAsset(names, query) {
9079
- const wanted = query.trim();
9080
- if (wanted.length === 0) return {
9081
- ok: false,
9082
- error: "no asset name was given"
9083
- };
9084
- const exact = names.find((name) => name === wanted);
9085
- if (exact !== void 0) return {
9086
- ok: true,
9087
- name: exact
9088
- };
9089
- const lower = wanted.toLowerCase();
9090
- const insensitive = names.filter((name) => name.toLowerCase() === lower);
9091
- if (insensitive.length === 1) return {
9092
- ok: true,
9093
- name: insensitive[0]
9094
- };
9095
- const partial = names.filter((name) => name.toLowerCase().includes(lower));
9096
- if (partial.length === 0) return {
9097
- ok: false,
9098
- error: `no asset matches "${wanted}" (available: ${names.join(", ") || "none"})`
9099
- };
9100
- if (partial.length > 1) return {
9101
- ok: false,
9102
- error: `"${wanted}" matches more than one asset: ${partial.join(", ")} — use the full name`
9103
- };
9104
- return {
9105
- ok: true,
9106
- name: partial[0]
9107
- };
9108
- }
9109
- /** `^https?://` means a URL; anything else is a filesystem path with `~` expanded. */
9110
- function parseFileSource(value) {
9111
- const trimmed = value.trim();
9112
- if (/^https?:\/\//i.test(trimmed)) return {
9113
- kind: "url",
9114
- url: trimmed
9115
- };
9116
- return {
9117
- kind: "path",
9118
- path: expandHome(trimmed)
9119
- };
9120
- }
9121
- function expandHome(value) {
9122
- if (value === "~") return os.homedir();
9123
- if (value.startsWith("~/") || value.startsWith("~\\")) return path.join(os.homedir(), value.slice(2));
9124
- return value;
9125
- }
9126
- /**
9127
- * A token is only ever sent to GitHub: `--file <url>` may point anywhere, and a
9128
- * credential must not leak to a host the user did not vouch for.
9129
- */
9130
- function isGithubHost(url) {
9131
- try {
9132
- const host = new URL(url).hostname.toLowerCase();
9133
- return host === "github.com" || host.endsWith(".github.com") || host === "githubusercontent.com" || host.endsWith(".githubusercontent.com");
9134
- } catch {
9135
- return false;
9136
- }
9137
- }
9138
- /** What `UiService` names the install when the archive carries no `ui.json`. */
9139
- function fallbackUiName(source) {
9140
- const stripped = path.basename(source).replace(/\.zip$/i, "").replace(/^home-hosted-ui-/i, "");
9141
- return stripped.length > 0 ? stripped : "custom-ui";
9142
- }
9143
9570
  async function uiSwitch(argv, io) {
9144
9571
  const { values } = parseArgs({
9145
9572
  args: argv,
@@ -9191,21 +9618,6 @@ async function uiSwitch(argv, io) {
9191
9618
  }
9192
9619
  await installFromUrl(assetDownloadUrl(chosen), fallbackUiName(chosen.name ?? ""), context);
9193
9620
  }
9194
- async function fetchRelease(repo, tag, context) {
9195
- const url = releaseApiUrl(repo, tag);
9196
- let response;
9197
- try {
9198
- response = await fetch(url, { headers: apiHeaders(context) });
9199
- } catch (error) {
9200
- throw new Error(`could not reach GitHub: ${describeError(error)}`);
9201
- }
9202
- if (!response.ok) throw new Error(describeReleaseFailure(response.status, repo, tag));
9203
- const body = await response.json();
9204
- return {
9205
- tag: body.tag_name ?? tag ?? "latest",
9206
- assets: Array.isArray(body.assets) ? body.assets : []
9207
- };
9208
- }
9209
9621
  async function chooseAsset(assets, values, context) {
9210
9622
  const { io } = context;
9211
9623
  if (values.asset !== void 0) {
@@ -9260,47 +9672,6 @@ async function installFromUrl(url, fallbackName, context) {
9260
9672
  });
9261
9673
  }
9262
9674
  }
9263
- /** Streams to `os.tmpdir()` so a large body is never buffered in memory. */
9264
- async function downloadToTemp(url, headers, context) {
9265
- let response;
9266
- try {
9267
- response = await fetch(url, {
9268
- headers,
9269
- redirect: "follow"
9270
- });
9271
- } catch (error) {
9272
- throw new Error(`could not reach ${url}: ${describeError(error)}`);
9273
- }
9274
- if (!response.ok) throw new Error(describeDownloadFailure(response.status, url));
9275
- const declared = Number(response.headers.get("content-length") ?? "0");
9276
- if (!Number.isFinite(declared) || declared < 0) throw new Error(`the download from ${url} reported an unusable size`);
9277
- if (declared > 536870912) throw new Error(tooLargeMessage(declared));
9278
- if (response.body === null) throw new Error(`the download from ${url} had no body`);
9279
- const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "hh-ui-"));
9280
- const file = path.join(dir, "ui.zip");
9281
- const handle = await fs.promises.open(file, "w");
9282
- let received = 0;
9283
- try {
9284
- for await (const chunk of response.body) {
9285
- received += chunk.length;
9286
- if (received > 536870912) throw new Error(tooLargeMessage(received));
9287
- await handle.write(chunk);
9288
- }
9289
- } catch (error) {
9290
- await handle.close();
9291
- fs.rmSync(dir, {
9292
- recursive: true,
9293
- force: true
9294
- });
9295
- throw error instanceof Error ? error : new Error(String(error));
9296
- }
9297
- await handle.close();
9298
- context.io.write(`${context.io.style.dim(`downloaded ${formatBytes(received)}`)}\n`);
9299
- return {
9300
- dir,
9301
- file
9302
- };
9303
- }
9304
9675
  async function installArchive(archivePath, fallbackName, context) {
9305
9676
  const { UiService } = await Promise.resolve().then(() => (init_ui(), ui_exports));
9306
9677
  const { dataRoot } = await Promise.resolve().then(() => (init_paths(), paths_exports));
@@ -9314,66 +9685,282 @@ async function installArchive(archivePath, fallbackName, context) {
9314
9685
  context.io.write(` ${context.io.style.dim("files")} ${meta.files}\n`);
9315
9686
  context.io.write(`refresh the browser to see it\n`);
9316
9687
  }
9317
- function apiHeaders(context) {
9318
- const headers = {
9319
- "accept": "application/vnd.github+json",
9320
- "user-agent": `home-hosted/${context.version}`,
9321
- "x-github-api-version": "2022-11-28"
9322
- };
9323
- if (context.token !== null && context.token.length > 0) headers.authorization = `Bearer ${context.token}`;
9324
- return headers;
9688
+ var uiSwitchCommand;
9689
+ var init_ui_switch = __esmMin((() => {
9690
+ init_io();
9691
+ init_ui_release();
9692
+ init_ui_release();
9693
+ uiSwitchCommand = defineCommand({
9694
+ meta: {
9695
+ name: "ui-switch",
9696
+ description: "install a UI from a release asset, a zip file or a URL"
9697
+ },
9698
+ run: async ({ rawArgs }) => {
9699
+ await uiSwitch(rawArgs, {
9700
+ write: (text) => process.stdout.write(text),
9701
+ prompt,
9702
+ style
9703
+ });
9704
+ }
9705
+ });
9706
+ }));
9707
+ //#endregion
9708
+ //#region src/cli/ui-update.ts
9709
+ var ui_update_exports = /* @__PURE__ */ __exportAll({
9710
+ impliedAsset: () => impliedAsset,
9711
+ planUiUpdate: () => planUiUpdate,
9712
+ uiUpdate: () => uiUpdate,
9713
+ uiUpdateCommand: () => uiUpdateCommand,
9714
+ usableCandidates: () => usableCandidates
9715
+ });
9716
+ /**
9717
+ * Which of a release list applies to what is installed. A candidate is only offered when
9718
+ * its assets carry the asset the person is actually using, so a UI is never swapped for a
9719
+ * different flavour by an update.
9720
+ */
9721
+ function usableCandidates(releases, asset, order, currentTag) {
9722
+ const found = [];
9723
+ for (const release of releases) {
9724
+ const matched = matchAsset(release.assets.map((entry) => entry.name ?? "").filter(isUiAsset), asset);
9725
+ if (!matched.ok) continue;
9726
+ if (currentTag !== null) {
9727
+ const difference = compareTags(release.tag, currentTag);
9728
+ if (order === "newer" && difference <= 0) continue;
9729
+ if (order === "older" && difference >= 0) continue;
9730
+ }
9731
+ found.push({
9732
+ tag: release.tag,
9733
+ asset: matched.name
9734
+ });
9735
+ }
9736
+ return found;
9325
9737
  }
9326
- function assetDownloadUrl(asset) {
9327
- const url = asset.url ?? asset.browser_download_url;
9328
- if (url === void 0 || url.length === 0) throw new Error(`the release metadata for "${asset.name ?? "an asset"}" carries no download URL`);
9329
- return url;
9738
+ /** What the asset the UI is using is likely called, when it never declared one. */
9739
+ function impliedAsset(identity) {
9740
+ if (identity.asset !== null && identity.asset.length > 0) return identity.asset;
9741
+ if (identity.name.length > 0) return `${identity.name}.zip`;
9742
+ return null;
9330
9743
  }
9331
- function noAssetsMessage(where, skipped, tag) {
9332
- const lines = [`no usable UI assets in ${where} (expected a .zip)`];
9333
- lines.push(skipped.length > 0 ? ` skipped: ${skipped.join(", ")}` : " the release has no assets at all");
9334
- if (tag !== null && tag !== "latest") lines.push(" installing a different version silently is worse than failing: try the newest release with --tag latest");
9335
- return lines.join("\n");
9744
+ /**
9745
+ * The decision, with no I/O in it: given what is installed and this panel's release,
9746
+ * what should `ui-update` do? `releases` is only consulted for someone else's UI.
9747
+ */
9748
+ function planUiUpdate(installed, runningTag, releases = [], order = "newer") {
9749
+ if (installed === null) return { kind: "stock" };
9750
+ const repo = installed.repo === null ? null : parseRepoSlug(installed.repo);
9751
+ if (repo === null) return {
9752
+ kind: "unidentifiable",
9753
+ identity: installed
9754
+ };
9755
+ if (isOwnRepo(repo)) return {
9756
+ kind: "official",
9757
+ identity: installed,
9758
+ target: runningTag
9759
+ };
9760
+ const asset = impliedAsset(installed);
9761
+ if (asset === null) return {
9762
+ kind: "unidentifiable",
9763
+ identity: installed
9764
+ };
9765
+ return {
9766
+ kind: "choice",
9767
+ identity: installed,
9768
+ asset,
9769
+ candidates: usableCandidates(releases, asset, order, installed.tag)
9770
+ };
9336
9771
  }
9337
- function describeReleaseFailure(status, repo, tag) {
9338
- const slug = `${repo.owner}/${repo.name}`;
9339
- if (status === 404) {
9340
- if (tag !== null && tag !== "latest") return `no release tagged "${tag}" in ${slug}\n list what exists with: home-hosted ui-switch --repo ${slug} --tag latest --list`;
9341
- return `no repository or published release at ${slug}\n check the --repo slug (owner/name), and that the repository is public`;
9772
+ /** `home-hosted ui-update` — see the module comment for the three cases. */
9773
+ async function uiUpdate(argv, io) {
9774
+ const { values } = parseArgs({
9775
+ args: argv,
9776
+ options: {
9777
+ tag: { type: "string" },
9778
+ asset: { type: "string" },
9779
+ token: { type: "string" },
9780
+ yes: {
9781
+ type: "boolean",
9782
+ short: "y"
9783
+ },
9784
+ old: { type: "boolean" },
9785
+ check: { type: "boolean" },
9786
+ repo: { type: "string" }
9787
+ },
9788
+ allowPositionals: false
9789
+ });
9790
+ const { appVersion } = await Promise.resolve().then(() => (init_version(), version_exports));
9791
+ const { dataRoot } = await Promise.resolve().then(() => (init_paths(), paths_exports));
9792
+ const { UiService } = await Promise.resolve().then(() => (init_ui(), ui_exports));
9793
+ const context = {
9794
+ io: {
9795
+ write: io.write,
9796
+ style: io.style
9797
+ },
9798
+ ask: process.stdin.isTTY === true && values.yes !== true ? io.prompt : null,
9799
+ version: appVersion(),
9800
+ token: values.token ?? process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? null
9801
+ };
9802
+ const installed = identityOf(new UiService({ dataRoot }).status().meta);
9803
+ if (installed === null) {
9804
+ io.write(`${io.style.dim("the stock UI is in use — it ships with the panel and is always current")}\n`);
9805
+ return;
9342
9806
  }
9343
- if (status === 403 || status === 429) return `GitHub refused the request (HTTP ${status}) — the unauthenticated API rate limit is per address.\n set a token to raise it: --token <token>, or GITHUB_TOKEN / GH_TOKEN`;
9344
- if (status === 401) return "GitHub rejected the token (HTTP 401) — check --token, GITHUB_TOKEN or GH_TOKEN";
9345
- return `GitHub answered HTTP ${status} while reading the release of ${slug}`;
9807
+ if (values.repo !== void 0) {
9808
+ const repo = parseRepoSlug(values.repo);
9809
+ if (repo === null) throw new Error(`invalid --repo "${values.repo}" — expected an "owner/name" slug, e.g. ${DEFAULT_REPO}`);
9810
+ installed.repo = repoSlug(repo);
9811
+ }
9812
+ const order = values.old === true ? "older" : "newer";
9813
+ const repo = installed.repo === null ? null : parseRepoSlug(installed.repo);
9814
+ if (values.tag !== void 0 && repo === null) throw new Error(["--tag needs a repository to fetch from, and this UI does not declare one", ` pass it: home-hosted ui-update --repo ${DEFAULT_REPO} --tag ${values.tag}`].join("\n"));
9815
+ if (values.check === true && repo !== null) {
9816
+ if (isOwnRepo(repo)) {
9817
+ printCheck(io, installed, values.tag ?? `v${context.version}`);
9818
+ return;
9819
+ }
9820
+ const releases = await fetchReleases(repo, context);
9821
+ const asset = impliedAsset(installed);
9822
+ printCheck(io, installed, null, asset === null ? [] : usableCandidates(releases, asset, order, installed.tag));
9823
+ return;
9824
+ }
9825
+ if (values.tag !== void 0 && repo !== null) {
9826
+ await installTag(repo, values.asset ?? installed.asset ?? null, values.tag, context);
9827
+ return;
9828
+ }
9829
+ let releases = [];
9830
+ if (repo !== null && !isOwnRepo(repo)) releases = await fetchReleases(repo, context);
9831
+ const plan = planUiUpdate(installed, `v${context.version}`, releases, order);
9832
+ if (plan.kind === "unidentifiable") {
9833
+ io.write(`${io.style.bold("this UI does not say where it came from")} — nothing to follow automatically\n`);
9834
+ printIdentity(io, plan.identity);
9835
+ io.write(` ${io.style.dim("point it at a repo to make this work: home-hosted ui-update --repo owner/name")}\n`);
9836
+ return;
9837
+ }
9838
+ if (plan.kind === "official") {
9839
+ if (plan.identity.tag === plan.target) {
9840
+ io.write(`${io.style.green("already current")} — ${io.style.bold(plan.identity.name)} is at ${plan.target}\n`);
9841
+ return;
9842
+ }
9843
+ io.write(`${io.style.bold(`${plan.identity.name} ${plan.identity.version ?? ""}`.trim())} is on ${plan.identity.tag ?? "an unknown tag"}; this panel is ${plan.target} — updating\n`);
9844
+ await installTag(repo, plan.identity.asset ?? values.asset ?? null, plan.target, context);
9845
+ return;
9846
+ }
9847
+ if (plan.kind !== "choice") return;
9848
+ io.write(`${io.style.bold(plan.identity.name)} ${plan.identity.version ?? ""} — ${repoSlug(repo)}@${plan.identity.tag ?? "unknown tag"}\n`);
9849
+ io.write(` ${io.style.dim(`asset: ${plan.asset}`)}\n`);
9850
+ if (plan.candidates.length === 0) {
9851
+ const direction = order === "newer" ? "newer" : "older";
9852
+ io.write(`no ${direction} releases carry ${plan.asset}\n`);
9853
+ if (order === "newer") io.write(` ${io.style.dim("list older releases with: home-hosted ui-update --old")}\n`);
9854
+ return;
9855
+ }
9856
+ const chosen = await chooseCandidate(plan.candidates, context, order);
9857
+ if (chosen === null) {
9858
+ io.write("cancelled — nothing was installed\n");
9859
+ return;
9860
+ }
9861
+ await installTag(repo, chosen.asset, chosen.tag, context);
9346
9862
  }
9347
- function describeDownloadFailure(status, url) {
9348
- const github = isGithubHost(url);
9349
- if (status === 404) return github ? `the asset is gone (HTTP 404) — ${url}\n the release may have been rebuilt since it was listed; run the command again` : `nothing is served at that URL (HTTP 404) — ${url}`;
9350
- if (status === 403 || status === 429) return github ? `GitHub refused the download (HTTP ${status}) — a token raises the rate limit: --token <token>, or GITHUB_TOKEN / GH_TOKEN` : `the host refused the download (HTTP ${status}) — ${url}`;
9351
- if (status === 401 && github) return "GitHub rejected the token on the download (HTTP 401) — check --token, GITHUB_TOKEN or GH_TOKEN";
9352
- return `the download failed (HTTP ${status}) — ${url}`;
9863
+ function identityOf(meta) {
9864
+ if (meta === null) return null;
9865
+ return {
9866
+ name: meta.name ?? "custom-ui",
9867
+ version: meta.version ?? null,
9868
+ repo: meta.repo ?? null,
9869
+ tag: meta.tag ?? null,
9870
+ asset: meta.asset ?? null,
9871
+ unix: meta.unix ?? null
9872
+ };
9353
9873
  }
9354
- function tooLargeMessage(bytes) {
9355
- return `the download is ${formatBytes(bytes)}, larger than the ${MAX_DOWNLOAD_BYTES / 1024 / 1024}MB a UI may be`;
9874
+ function printIdentity(io, identity) {
9875
+ io.write(` name ${identity.name}\n`);
9876
+ io.write(` version ${identity.version ?? "unspecified"}\n`);
9877
+ io.write(` repo ${identity.repo ?? "unspecified"}${identity.tag === null ? "" : ` @ ${identity.tag}`}\n`);
9878
+ if (identity.unix !== null) io.write(` built ${(/* @__PURE__ */ new Date(identity.unix * 1e3)).toISOString()}\n`);
9356
9879
  }
9357
- function formatBytes(bytes) {
9358
- if (bytes < 1024) return `${bytes} B`;
9359
- if (bytes < 1048576) return `${Math.round(bytes / 1024)} KB`;
9360
- return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
9880
+ function printCheck(io, identity, target, candidates = []) {
9881
+ if (target !== null) {
9882
+ if (identity.tag === target) {
9883
+ io.write(`${io.style.green("up to date")} — ${identity.version ?? identity.name} at ${target}\n`);
9884
+ return;
9885
+ }
9886
+ io.write(`update available: ${identity.tag ?? "unknown"} → ${target}\n`);
9887
+ return;
9888
+ }
9889
+ if (candidates.length === 0) {
9890
+ io.write(`up to date — no release carries ${identity.asset ?? identity.name}.zip\n`);
9891
+ return;
9892
+ }
9893
+ io.write(`${candidates.length} release(s) available:\n`);
9894
+ for (const candidate of candidates) io.write(` ${candidate.tag} ${io.style.dim(candidate.asset)}\n`);
9361
9895
  }
9362
- function describeError(error) {
9363
- return error instanceof Error ? error.message : String(error);
9896
+ async function chooseCandidate(candidates, context, order) {
9897
+ const headline = order === "newer" ? "Newer releases" : "Older releases";
9898
+ const { io } = context;
9899
+ io.write(`${io.style.bold(headline)}\n`);
9900
+ for (const [index, candidate] of candidates.entries()) io.write(` ${index + 1}) ${candidate.tag}\n`);
9901
+ if (context.ask === null) {
9902
+ if (candidates.length === 1) return candidates[0];
9903
+ throw new Error([
9904
+ `${candidates.length} releases are available and this session cannot ask which one:`,
9905
+ ...candidates.map((candidate) => ` ${candidate.tag}`),
9906
+ " run it in a terminal, or pass --tag <tag>"
9907
+ ].join("\n"));
9908
+ }
9909
+ for (;;) {
9910
+ const answer = (await context.ask(`Select a release [1-${candidates.length}] (empty to cancel) `)).trim();
9911
+ if (answer.length === 0) return null;
9912
+ if (/^\d+$/.test(answer)) {
9913
+ const index = Number.parseInt(answer, 10);
9914
+ if (index >= 1 && index <= candidates.length) return candidates[index - 1];
9915
+ }
9916
+ const byTag = candidates.find((candidate) => candidate.tag === answer);
9917
+ if (byTag !== void 0) return byTag;
9918
+ io.write(` ${io.style.dim(`no such choice — enter 1-${candidates.length} or a tag`)}\n`);
9919
+ }
9364
9920
  }
9365
- var DEFAULT_REPO, MAX_DOWNLOAD_BYTES, uiSwitchCommand;
9366
- var init_ui_switch = __esmMin((() => {
9921
+ /** Fetches one release's asset and installs it, reporting what landed. */
9922
+ async function installTag(repo, wantedAsset, tag, context) {
9923
+ const release = await fetchRelease(repo, tag, context);
9924
+ const names = release.assets.map((asset) => asset.name ?? "").filter(isUiAsset);
9925
+ let assetName;
9926
+ if (wantedAsset !== null && wantedAsset.length > 0) {
9927
+ const matched = matchAsset(names, wantedAsset);
9928
+ if (!matched.ok) throw new Error(`${matched.error}\n in ${repo.owner}/${repo.name}@${release.tag}`);
9929
+ assetName = matched.name;
9930
+ } else if (names.length === 1) assetName = names[0];
9931
+ else throw new Error(`${names.length} UI assets in ${repo.owner}/${repo.name}@${release.tag} — name one with --asset`);
9932
+ const download = await downloadToTemp(assetDownloadUrl(release.assets.find((entry) => entry.name === assetName)), {
9933
+ "accept": "application/octet-stream",
9934
+ "user-agent": `home-hosted/${context.version}`,
9935
+ ...context.token !== null && context.token.length > 0 ? { authorization: `Bearer ${context.token}` } : {}
9936
+ }, context);
9937
+ try {
9938
+ const { UiService } = await Promise.resolve().then(() => (init_ui(), ui_exports));
9939
+ const { dataRoot } = await Promise.resolve().then(() => (init_paths(), paths_exports));
9940
+ const result = await new UiService({ dataRoot }).install(download.file, assetName);
9941
+ if (!result.ok) throw new Error(`nothing was installed: ${result.error}`);
9942
+ const { meta } = result;
9943
+ const { io } = context;
9944
+ io.write(`${io.style.green("UI updated")} — ${meta.name} ${meta.version ?? ""} at ${release.tag}\n`);
9945
+ io.write(` ${io.style.dim("refresh the browser to see it")}\n`);
9946
+ } finally {
9947
+ fs.rmSync(download.dir, {
9948
+ recursive: true,
9949
+ force: true
9950
+ });
9951
+ }
9952
+ }
9953
+ var uiUpdateCommand;
9954
+ var init_ui_update = __esmMin((() => {
9367
9955
  init_io();
9368
- DEFAULT_REPO = "NamesMT/home-hosted";
9369
- MAX_DOWNLOAD_BYTES = 536870912;
9370
- uiSwitchCommand = defineCommand({
9956
+ init_ui_release();
9957
+ uiUpdateCommand = defineCommand({
9371
9958
  meta: {
9372
- name: "ui-switch",
9373
- description: "install a UI from a release asset, a zip file or a URL"
9959
+ name: "ui-update",
9960
+ description: "update the installed UI to match this panel, or pick a release"
9374
9961
  },
9375
9962
  run: async ({ rawArgs }) => {
9376
- await uiSwitch(rawArgs, {
9963
+ await uiUpdate(rawArgs, {
9377
9964
  write: (text) => process.stdout.write(text),
9378
9965
  prompt,
9379
9966
  style
@@ -9448,6 +10035,7 @@ var SYNOPSIS = {
9448
10035
  "migrate": "home-hosted migrate",
9449
10036
  "init": "home-hosted init",
9450
10037
  "ui-switch": "home-hosted ui-switch",
10038
+ "ui-update": "home-hosted ui-update",
9451
10039
  "ui-revert": "home-hosted ui-revert"
9452
10040
  };
9453
10041
  var SUMMARIES = {
@@ -9460,6 +10048,7 @@ var SUMMARIES = {
9460
10048
  "migrate": "bring the config up to this release's schema",
9461
10049
  "init": "scaffold a project that keeps its state in the repo",
9462
10050
  "ui-switch": "install a UI from a release asset, a zip file or a URL",
10051
+ "ui-update": "bring the installed UI up to date, or pick a release",
9463
10052
  "ui-revert": "go back to the stock control panel UI"
9464
10053
  };
9465
10054
  /** Every section lays its left column out at this width, so the two views align. */
@@ -9514,6 +10103,18 @@ var UI_SWITCH_SECTION = {
9514
10103
  ["-y, --yes", "take the only asset instead of asking"]
9515
10104
  ]
9516
10105
  };
10106
+ var UI_UPDATE_SECTION = {
10107
+ heading: "Options for ui-update",
10108
+ lines: [
10109
+ ["--check", "report whether an update is available and install nothing"],
10110
+ ["--tag <tag>", "install that release instead of asking"],
10111
+ ["--asset <name>", "asset to install (defaults to the one in use)"],
10112
+ ["--old", "list older releases instead of newer ones"],
10113
+ ["--repo <owner/name>", "for a UI that does not declare its own repo"],
10114
+ ["--token <token>", "GitHub token (or GITHUB_TOKEN / GH_TOKEN)"],
10115
+ ["-y, --yes", "take the only release instead of asking"]
10116
+ ]
10117
+ };
9517
10118
  var EVERYWHERE_SECTION = {
9518
10119
  heading: "Everywhere",
9519
10120
  lines: [
@@ -9588,7 +10189,8 @@ var SECTIONS = {
9588
10189
  "set-token": SET_TOKEN_SECTION,
9589
10190
  "migrate": MIGRATE_SECTION,
9590
10191
  "init": INIT_SECTION,
9591
- "ui-switch": UI_SWITCH_SECTION
10192
+ "ui-switch": UI_SWITCH_SECTION,
10193
+ "ui-update": UI_UPDATE_SECTION
9592
10194
  };
9593
10195
  /**
9594
10196
  * `home-hosted <command> --help`: the usage line, that command's own options,
@@ -9634,6 +10236,7 @@ var COMMANDS = {
9634
10236
  "migrate": () => Promise.resolve().then(() => (init_migrate(), migrate_exports)).then((module) => module.migrateCommand),
9635
10237
  "init": () => Promise.resolve().then(() => (init_init(), init_exports)).then((module) => module.initCommand),
9636
10238
  "ui-switch": () => Promise.resolve().then(() => (init_ui_switch(), ui_switch_exports)).then((module) => module.uiSwitchCommand),
10239
+ "ui-update": () => Promise.resolve().then(() => (init_ui_update(), ui_update_exports)).then((module) => module.uiUpdateCommand),
9637
10240
  "ui-revert": () => Promise.resolve().then(() => (init_ui_revert(), ui_revert_exports)).then((module) => module.uiRevertCommand)
9638
10241
  };
9639
10242
  var rootCommand = defineCommand({