home-hosted 0.6.0 → 0.6.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
@@ -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. */
@@ -7813,7 +7823,11 @@ var init_ui = __esmMin((() => {
7813
7823
  MAX_NAME = 120;
7814
7824
  manifestSchema = type({
7815
7825
  "name?": "string",
7816
- "version?": "string"
7826
+ "version?": "string",
7827
+ "repo?": "string",
7828
+ "tag?": "string",
7829
+ "asset?": "string",
7830
+ "unix?": "number.integer >= 0"
7817
7831
  });
7818
7832
  UiService = class {
7819
7833
  options;
@@ -7853,14 +7867,14 @@ var init_ui = __esmMin((() => {
7853
7867
  * swapped in, so a failed upload leaves the previous UI (or the stock one)
7854
7868
  * serving.
7855
7869
  */
7856
- async install(archivePath, fallbackName = "custom-ui") {
7870
+ async install(archivePath, fallbackName = "custom-ui", installedTag) {
7857
7871
  if (!isZipArchive(archivePath)) return {
7858
7872
  ok: false,
7859
7873
  error: "the upload is not a zip archive"
7860
7874
  };
7861
7875
  const staging = path.join(this.options.dataRoot, `.ui-staging-${Date.now()}`);
7862
7876
  try {
7863
- return await this.stage(archivePath, staging, fallbackName);
7877
+ return await this.stage(archivePath, staging, fallbackName, installedTag);
7864
7878
  } catch (error) {
7865
7879
  return {
7866
7880
  ok: false,
@@ -7882,7 +7896,7 @@ var init_ui = __esmMin((() => {
7882
7896
  });
7883
7897
  return existed;
7884
7898
  }
7885
- async stage(archivePath, staging, fallbackName) {
7899
+ async stage(archivePath, staging, fallbackName, installedTag) {
7886
7900
  let entries;
7887
7901
  try {
7888
7902
  entries = await listZip(archivePath);
@@ -7921,33 +7935,43 @@ var init_ui = __esmMin((() => {
7921
7935
  name: manifest?.name ?? fallbackName,
7922
7936
  version: manifest?.version ?? null,
7923
7937
  uploadedAt: Date.now(),
7924
- files: countFiles(root)
7938
+ files: countFiles(root),
7939
+ ...manifest?.repo === void 0 ? {} : { repo: manifest.repo },
7940
+ ...installedTag !== void 0 ? { tag: installedTag } : manifest?.tag === void 0 ? {} : { tag: manifest.tag },
7941
+ ...manifest?.asset === void 0 ? {} : { asset: manifest.asset },
7942
+ ...manifest?.unix === void 0 ? {} : { unix: manifest.unix }
7925
7943
  };
7926
- const previous = `${this.directory}.previous`;
7927
- fs.rmSync(previous, {
7928
- recursive: true,
7929
- force: true
7930
- });
7944
+ const previous = `${this.directory}.previous-${process.pid}-${Date.now()}`;
7931
7945
  if (fs.existsSync(this.directory)) fs.renameSync(this.directory, previous);
7932
7946
  try {
7933
7947
  fs.renameSync(root, this.directory);
7934
7948
  writeFileAtomic(path.join(this.directory, META), `${JSON.stringify(meta, null, 2)}\n`);
7935
7949
  } catch (error) {
7936
- fs.rmSync(this.directory, {
7937
- recursive: true,
7938
- force: true
7939
- });
7940
- if (fs.existsSync(previous)) fs.renameSync(previous, this.directory);
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, {
7957
+ recursive: true,
7958
+ force: true
7959
+ });
7960
+ } catch {
7961
+ return {
7962
+ ok: false,
7963
+ error: `${error instanceof Error ? error.message : String(error)} — the previous UI is kept at ${previous}`
7964
+ };
7965
+ }
7941
7966
  return {
7942
7967
  ok: false,
7943
7968
  error: error instanceof Error ? error.message : String(error)
7944
7969
  };
7945
- } finally {
7946
- fs.rmSync(previous, {
7947
- recursive: true,
7948
- force: true
7949
- });
7950
7970
  }
7971
+ fs.rmSync(previous, {
7972
+ recursive: true,
7973
+ force: true
7974
+ });
7951
7975
  return {
7952
7976
  ok: true,
7953
7977
  meta
@@ -7956,88 +7980,481 @@ var init_ui = __esmMin((() => {
7956
7980
  };
7957
7981
  }));
7958
7982
  //#endregion
7959
- //#region src/index.ts
7960
- var src_exports = /* @__PURE__ */ __exportAll({
7961
- packageRoot: () => packageRoot,
7962
- runControlPlane: () => runControlPlane
7963
- });
7964
- function packageVersion() {
7965
- try {
7966
- return JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8")).version ?? "0.0.0";
7967
- } catch {
7968
- return "0.0.0";
7969
- }
7983
+ //#region src/providers/ui-release.ts
7984
+ /** `owner/name`, the only slug GitHub releases are addressed by. */
7985
+ function parseRepoSlug(value) {
7986
+ const match = /^([\w.-]+)\/([\w.-]+)$/.exec(value.trim());
7987
+ if (match === null) return null;
7988
+ return {
7989
+ owner: match[1],
7990
+ name: match[2]
7991
+ };
7970
7992
  }
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;
7993
+ function repoSlug(repo) {
7994
+ return `${repo.owner}/${repo.name}`;
7995
+ }
7996
+ /** The repo the default tag rule is about; `DEFAULT_REPO` is its only definition. */
7997
+ function isOwnRepo(repo) {
7998
+ return repoSlug(repo).toLowerCase() === DEFAULT_REPO.toLowerCase();
7974
7999
  }
7975
8000
  /**
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.
8001
+ * Our own release tag matches this CLI's version, so a UI is paired with the panel
8002
+ * it was built for. Another repo has no such pairing, so its latest release is used.
8003
+ * `null` means "latest".
7979
8004
  */
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);
8005
+ function defaultReleaseTag(repo, version) {
8006
+ return isOwnRepo(repo) ? `v${version}` : null;
8007
+ }
8008
+ function releaseApiUrl(repo, tag) {
8009
+ const base = `https://api.github.com/repos/${repo.owner}/${repo.name}/releases`;
8010
+ if (tag === null || tag.length === 0 || tag === "latest") return `${base}/latest`;
8011
+ return `${base}/tags/${encodeURIComponent(tag)}`;
8012
+ }
8013
+ /** Every published release, newest first, as GitHub orders them. */
8014
+ function releasesApiUrl(repo, perPage = 30) {
8015
+ return `https://api.github.com/repos/${repo.owner}/${repo.name}/releases?per_page=${perPage}`;
8016
+ }
8017
+ /** Generous and predictable: a UI bundle is an asset whose name ends in `.zip`. */
8018
+ function isUiAsset(name) {
8019
+ return /\.zip$/i.test(name.trim());
8020
+ }
8021
+ /**
8022
+ * Resolves a wanted asset: an exact name, a case-insensitive name, or a single
8023
+ * unambiguous substring (`--asset stock` for `home-hosted-ui-stock.zip`).
8024
+ */
8025
+ function matchAsset(names, query) {
8026
+ const wanted = query.trim();
8027
+ if (wanted.length === 0) return {
8028
+ ok: false,
8029
+ error: "no asset name was given"
8030
+ };
8031
+ const exact = names.find((name) => name === wanted);
8032
+ if (exact !== void 0) return {
8033
+ ok: true,
8034
+ name: exact
8035
+ };
8036
+ const lower = wanted.toLowerCase();
8037
+ const insensitive = names.filter((name) => name.toLowerCase() === lower);
8038
+ if (insensitive.length === 1) return {
8039
+ ok: true,
8040
+ name: insensitive[0]
8041
+ };
8042
+ const partial = names.filter((name) => name.toLowerCase().includes(lower));
8043
+ if (partial.length === 0) return {
8044
+ ok: false,
8045
+ error: `no asset matches "${wanted}" (available: ${names.join(", ") || "none"})`
8046
+ };
8047
+ if (partial.length > 1) return {
8048
+ ok: false,
8049
+ error: `"${wanted}" matches more than one asset: ${partial.join(", ")} — use the full name`
8050
+ };
8051
+ return {
8052
+ ok: true,
8053
+ name: partial[0]
8054
+ };
8055
+ }
8056
+ /** `^https?://` means a URL; anything else is a filesystem path with `~` expanded. */
8057
+ function parseFileSource(value) {
8058
+ const trimmed = value.trim();
8059
+ if (/^https?:\/\//i.test(trimmed)) return {
8060
+ kind: "url",
8061
+ url: trimmed
8062
+ };
8063
+ return {
8064
+ kind: "path",
8065
+ path: expandHome(trimmed)
8066
+ };
8067
+ }
8068
+ function expandHome(value) {
8069
+ if (value === "~") return os.homedir();
8070
+ if (value.startsWith("~/") || value.startsWith("~\\")) return path.join(os.homedir(), value.slice(2));
8071
+ return value;
8072
+ }
8073
+ /**
8074
+ * A token is only ever sent to GitHub: `--file <url>` may point anywhere, and a
8075
+ * credential must not leak to a host the user did not vouch for.
8076
+ */
8077
+ function isGithubHost(url) {
8078
+ try {
8079
+ const host = new URL(url).hostname.toLowerCase();
8080
+ return host === "github.com" || host.endsWith(".github.com") || host === "githubusercontent.com" || host.endsWith(".githubusercontent.com");
8081
+ } catch {
8082
+ return false;
7985
8083
  }
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);
8084
+ }
8085
+ function timeoutSignal(ms) {
8086
+ return typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(ms) : void 0;
8087
+ }
8088
+ function isTimeout(error) {
8089
+ return error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
8090
+ }
8091
+ /** What `UiService` names the install when the archive carries no `ui.json`. */
8092
+ function fallbackUiName(source) {
8093
+ const stripped = path.basename(source).replace(/\.zip$/i, "").replace(/^home-hosted-ui-/i, "");
8094
+ return stripped.length > 0 ? stripped : "custom-ui";
8095
+ }
8096
+ /** One release's assets, or a message that says what was wrong with the answer. */
8097
+ async function fetchRelease(repo, tag, context) {
8098
+ const url = releaseApiUrl(repo, tag);
8099
+ let response;
8100
+ try {
8101
+ response = await fetch(url, {
8102
+ headers: apiHeaders(context),
8103
+ signal: timeoutSignal(REQUEST_TIMEOUT_MS)
8104
+ });
8105
+ } catch (error) {
8106
+ throw new Error(isTimeout(error) ? `GitHub did not answer within ${REQUEST_TIMEOUT_MS / 1e3}s` : `could not reach GitHub: ${describeError(error)}`);
7995
8107
  }
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);
8108
+ if (!response.ok) throw new Error(describeReleaseFailure(response.status, repo, tag));
8109
+ const body = await response.json();
8110
+ return {
8111
+ tag: body.tag_name ?? tag ?? "latest",
8112
+ assets: Array.isArray(body.assets) ? body.assets : []
8113
+ };
8114
+ }
8115
+ /** Every published release, newest first. Drafts and unusable entries are dropped. */
8116
+ async function fetchReleases(repo, context, perPage = 30) {
8117
+ let response;
8118
+ try {
8119
+ response = await fetch(releasesApiUrl(repo, perPage), {
8120
+ headers: apiHeaders(context),
8121
+ signal: timeoutSignal(REQUEST_TIMEOUT_MS)
8122
+ });
8123
+ } catch (error) {
8124
+ throw new Error(isTimeout(error) ? `GitHub did not answer within ${REQUEST_TIMEOUT_MS / 1e3}s` : `could not reach GitHub: ${describeError(error)}`);
8000
8125
  }
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,
8019
- tlsDir: tls.directory,
8020
- paths: resolveBackupPaths(store.servers, store.config.backups.includePaths)
8021
- }),
8022
- onConfigRestored: () => onConfigRestored?.()
8023
- });
8024
- if (!auth.passwordSet) {
8025
- auth.ensureDefaultPassword("hh");
8026
- logger.warn(`no password was set — created the default "hh"; change it in Settings → Authentication`);
8126
+ if (!response.ok) throw new Error(describeReleaseFailure(response.status, repo, null));
8127
+ const body = await response.json();
8128
+ if (!Array.isArray(body)) return [];
8129
+ return body.filter((release) => release.draft !== true && typeof release.tag_name === "string").map((release) => ({
8130
+ tag: release.tag_name,
8131
+ assets: Array.isArray(release.assets) ? release.assets : [],
8132
+ publishedAt: typeof release.published_at === "string" ? release.published_at : null
8133
+ }));
8134
+ }
8135
+ function assetDownloadUrl(asset) {
8136
+ const url = asset.url ?? asset.browser_download_url;
8137
+ if (url === void 0 || url.length === 0) throw new Error(`the release metadata for "${asset.name ?? "an asset"}" carries no download URL`);
8138
+ return url;
8139
+ }
8140
+ /** Streams to `os.tmpdir()` so a large body is never buffered in memory. */
8141
+ async function downloadToTemp(url, headers, context) {
8142
+ let response;
8143
+ try {
8144
+ response = await fetch(url, {
8145
+ headers,
8146
+ redirect: "follow",
8147
+ signal: timeoutSignal(DOWNLOAD_TIMEOUT_MS)
8148
+ });
8149
+ } catch (error) {
8150
+ throw new Error(isTimeout(error) ? `the download stalled for ${DOWNLOAD_TIMEOUT_MS / 1e3}s: ${url}` : `could not reach ${url}: ${describeError(error)}`);
8027
8151
  }
8028
- const configured = store.config.control;
8029
- const intendedHost = options.host === void 0 ? configured.host : parseBind(options.host);
8030
- if (intendedHost === null) {
8031
- logger.error(`invalid control host: ${String(options.host)} (expected local, lan or an ipv4 address)`);
8032
- process.exit(1);
8152
+ if (!response.ok) throw new Error(describeDownloadFailure(response.status, url));
8153
+ const declared = Number(response.headers.get("content-length") ?? "0");
8154
+ if (!Number.isFinite(declared) || declared < 0) throw new Error(`the download from ${url} reported an unusable size`);
8155
+ if (declared > 536870912) throw new Error(tooLargeMessage(declared));
8156
+ if (response.body === null) throw new Error(`the download from ${url} had no body`);
8157
+ const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "hh-ui-"));
8158
+ const file = path.join(dir, "ui.zip");
8159
+ const handle = await fs.promises.open(file, "w");
8160
+ let received = 0;
8161
+ try {
8162
+ for await (const chunk of response.body) {
8163
+ received += chunk.length;
8164
+ if (received > 536870912) throw new Error(tooLargeMessage(received));
8165
+ await handle.write(chunk);
8166
+ }
8167
+ } catch (error) {
8168
+ await handle.close();
8169
+ fs.rmSync(dir, {
8170
+ recursive: true,
8171
+ force: true
8172
+ });
8173
+ throw error instanceof Error ? error : new Error(String(error));
8033
8174
  }
8034
- const intended = {
8035
- host: intendedHost,
8036
- port: options.port ?? configured.port
8175
+ await handle.close();
8176
+ if (context.quiet !== true) context.io.write(`${context.io.style.dim(`downloaded ${formatBytes(received)}`)}\n`);
8177
+ return {
8178
+ dir,
8179
+ file
8037
8180
  };
8038
- if (!Number.isInteger(intended.port) || intended.port <= 0 || intended.port > 65535) {
8039
- logger.error(`invalid control port: ${String(options.port)}`);
8040
- process.exit(1);
8181
+ }
8182
+ function apiHeaders(context) {
8183
+ const headers = {
8184
+ "accept": "application/vnd.github+json",
8185
+ "user-agent": `home-hosted/${context.version}`,
8186
+ "x-github-api-version": "2022-11-28"
8187
+ };
8188
+ if (context.token !== null && context.token.length > 0) headers.authorization = `Bearer ${context.token}`;
8189
+ return headers;
8190
+ }
8191
+ function noAssetsMessage(where, skipped, tag) {
8192
+ const lines = [`no usable UI assets in ${where} (expected a .zip)`];
8193
+ lines.push(skipped.length > 0 ? ` skipped: ${skipped.join(", ")}` : " the release has no assets at all");
8194
+ if (tag !== null && tag !== "latest") lines.push(" installing a different version silently is worse than failing: try the newest release with --tag latest");
8195
+ return lines.join("\n");
8196
+ }
8197
+ function describeReleaseFailure(status, repo, tag) {
8198
+ const slug = repoSlug(repo);
8199
+ if (status === 404) {
8200
+ 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`;
8201
+ return `no repository or published release at ${slug}\n check the --repo slug (owner/name), and that the repository is public`;
8202
+ }
8203
+ 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`;
8204
+ if (status === 401) return "GitHub rejected the token (HTTP 401) — check --token, GITHUB_TOKEN or GH_TOKEN";
8205
+ return `GitHub answered HTTP ${status} while reading the release of ${slug}`;
8206
+ }
8207
+ function describeDownloadFailure(status, url) {
8208
+ const github = isGithubHost(url);
8209
+ 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}`;
8210
+ 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}`;
8211
+ if (status === 401 && github) return "GitHub rejected the token on the download (HTTP 401) — check --token, GITHUB_TOKEN or GH_TOKEN";
8212
+ return `the download failed (HTTP ${status}) — ${url}`;
8213
+ }
8214
+ function tooLargeMessage(bytes) {
8215
+ return `the download is ${formatBytes(bytes)}, larger than the ${MAX_DOWNLOAD_BYTES / 1024 / 1024}MB a UI may be`;
8216
+ }
8217
+ function formatBytes(bytes) {
8218
+ if (bytes < 1024) return `${bytes} B`;
8219
+ if (bytes < 1048576) return `${Math.round(bytes / 1024)} KB`;
8220
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
8221
+ }
8222
+ function describeError(error) {
8223
+ return error instanceof Error ? error.message : String(error);
8224
+ }
8225
+ /**
8226
+ * Tag ordering: `v1.2.3` beats `v1.2.2`, a prerelease loses to its release, and anything
8227
+ * that is not a version sorts below every version that is. Enough to answer "is this
8228
+ * release newer than the one the UI came from" without a semver dependency.
8229
+ */
8230
+ function compareTags(a, b) {
8231
+ const left = parseTag(a);
8232
+ const right = parseTag(b);
8233
+ if (left === null && right === null) return a.localeCompare(b);
8234
+ if (left === null) return -1;
8235
+ if (right === null) return 1;
8236
+ for (let index = 0; index < 3; index++) {
8237
+ const difference = (left.parts[index] ?? 0) - (right.parts[index] ?? 0);
8238
+ if (difference !== 0) return difference;
8239
+ }
8240
+ if (left.prerelease.length === 0 && right.prerelease.length === 0) return 0;
8241
+ if (left.prerelease.length === 0) return 1;
8242
+ if (right.prerelease.length === 0) return -1;
8243
+ for (let index = 0; index < Math.max(left.prerelease.length, right.prerelease.length); index++) {
8244
+ const one = left.prerelease[index];
8245
+ const two = right.prerelease[index];
8246
+ if (one === void 0) return -1;
8247
+ if (two === void 0) return 1;
8248
+ if (one === two) continue;
8249
+ const numeric = /^\d+$/;
8250
+ if (numeric.test(one) && numeric.test(two)) return Number(one) - Number(two);
8251
+ if (numeric.test(one)) return -1;
8252
+ if (numeric.test(two)) return 1;
8253
+ return one.localeCompare(two);
8254
+ }
8255
+ return 0;
8256
+ }
8257
+ function parseTag(tag) {
8258
+ const match = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+.*)?$/.exec(tag.trim());
8259
+ if (match === null) return null;
8260
+ return {
8261
+ parts: [
8262
+ Number(match[1]),
8263
+ Number(match[2] ?? 0),
8264
+ Number(match[3] ?? 0)
8265
+ ],
8266
+ prerelease: match[4] === void 0 ? [] : match[4].split(".")
8267
+ };
8268
+ }
8269
+ var DEFAULT_REPO, MAX_DOWNLOAD_BYTES, REQUEST_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS;
8270
+ var init_ui_release = __esmMin((() => {
8271
+ DEFAULT_REPO = "NamesMT/home-hosted";
8272
+ MAX_DOWNLOAD_BYTES = 536870912;
8273
+ REQUEST_TIMEOUT_MS = 3e4;
8274
+ DOWNLOAD_TIMEOUT_MS = 12e4;
8275
+ }));
8276
+ //#endregion
8277
+ //#region src/services/ui-update.ts
8278
+ /**
8279
+ * `ui.json` says which release this build came from. Anything else — an unofficial UI,
8280
+ * or one that never declared itself — is left for `home-hosted ui-update`.
8281
+ */
8282
+ function officialTagFor(meta, runningVersion) {
8283
+ if (meta === null || typeof meta.repo !== "string") return null;
8284
+ const repo = parseRepoSlug(meta.repo);
8285
+ if (repo === null || !isOwnRepo(repo)) return null;
8286
+ return {
8287
+ tag: `v${runningVersion}`,
8288
+ repo: `${repo.owner}/${repo.name}`
8289
+ };
8290
+ }
8291
+ async function syncOfficialUi(ui, runningVersion = appVersion()) {
8292
+ if (!ui.custom) return { kind: "not-custom" };
8293
+ const meta = ui.readMeta();
8294
+ if (meta === null) return { kind: "no-identity" };
8295
+ const repo = meta.repo === void 0 ? null : parseRepoSlug(meta.repo);
8296
+ if (repo === null) return { kind: "no-identity" };
8297
+ if (!isOwnRepo(repo)) return { kind: "foreign" };
8298
+ const target = `v${runningVersion}`;
8299
+ if (meta.tag === target) return {
8300
+ kind: "current",
8301
+ tag: target
8302
+ };
8303
+ const context = {
8304
+ io: {
8305
+ write: () => {},
8306
+ style: {
8307
+ bold: (t) => t,
8308
+ dim: (t) => t,
8309
+ green: (t) => t
8310
+ }
8311
+ },
8312
+ version: runningVersion,
8313
+ token: process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? null,
8314
+ quiet: true
8315
+ };
8316
+ try {
8317
+ const release = await fetchRelease(repo, target, context);
8318
+ const names = release.assets.map((asset) => asset.name ?? "").filter(isUiAsset);
8319
+ if (names.length === 0) throw new Error(`no UI asset in ${DEFAULT_REPO}@${release.tag}`);
8320
+ const wanted = meta.asset ?? "";
8321
+ const matched = wanted.length > 0 ? matchAsset(names, wanted) : {
8322
+ ok: true,
8323
+ name: names[0]
8324
+ };
8325
+ if (!matched.ok) throw new Error(matched.error);
8326
+ const asset = release.assets.find((entry) => entry.name === matched.name);
8327
+ if (asset === void 0) throw new Error(`no asset named ${matched.name} in ${release.tag}`);
8328
+ const download = await downloadToTemp(assetDownloadUrl(asset), {
8329
+ "accept": "application/octet-stream",
8330
+ "user-agent": `home-hosted/${runningVersion}`
8331
+ }, context);
8332
+ try {
8333
+ const result = await ui.install(download.file, matched.name.replace(/\.zip$/i, ""), release.tag);
8334
+ if (!result.ok) throw new Error(result.error);
8335
+ } finally {
8336
+ (await import("node:fs")).rmSync(download.dir, {
8337
+ recursive: true,
8338
+ force: true
8339
+ });
8340
+ }
8341
+ return {
8342
+ kind: "updated",
8343
+ tag: release.tag
8344
+ };
8345
+ } catch (error) {
8346
+ return {
8347
+ kind: "failed",
8348
+ error: error instanceof Error ? error.message : String(error)
8349
+ };
8350
+ }
8351
+ }
8352
+ /**
8353
+ * The startup hook. Never awaited by the caller and never allowed to throw: the panel
8354
+ * serves the UI it already has while this runs, and the next request picks up the new
8355
+ * one, because `UiService.resolveDir()` is read per request.
8356
+ */
8357
+ function autoUpdateOfficialUi(ui, runningVersion = appVersion()) {
8358
+ if (!ui.custom) return;
8359
+ const meta = ui.readMeta();
8360
+ const plan = officialTagFor(meta, runningVersion);
8361
+ if (plan === null || meta?.tag === plan.tag) return;
8362
+ logger.info(`ui: ${meta?.name ?? "custom UI"} ${meta?.version ?? ""} came from ${meta?.tag ?? "an unknown release"}; this panel is ${plan.tag} — updating`);
8363
+ syncOfficialUi(ui, runningVersion).then((result) => {
8364
+ if (result.kind === "updated") logger.info(`ui: updated to ${result.tag} — refresh the browser`);
8365
+ else if (result.kind === "failed") logger.warn(`ui: could not update to ${plan.tag}: ${result.error}`);
8366
+ }).catch((error) => {
8367
+ logger.warn(`ui: could not update: ${error instanceof Error ? error.message : String(error)}`);
8368
+ });
8369
+ }
8370
+ var init_ui_update$1 = __esmMin((() => {
8371
+ init_logger();
8372
+ init_version();
8373
+ init_ui_release();
8374
+ }));
8375
+ //#endregion
8376
+ //#region src/index.ts
8377
+ var src_exports = /* @__PURE__ */ __exportAll({
8378
+ packageRoot: () => packageRoot,
8379
+ runControlPlane: () => runControlPlane
8380
+ });
8381
+ function packageVersion() {
8382
+ try {
8383
+ return JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8")).version ?? "0.0.0";
8384
+ } catch {
8385
+ return "0.0.0";
8386
+ }
8387
+ }
8388
+ /** A probe target for a `lan` bind, which is not connectable as `0.0.0.0`. */
8389
+ function probeHostFor(bindHost) {
8390
+ return bindHost === "0.0.0.0" || bindHost === "::" ? "127.0.0.1" : bindHost;
8391
+ }
8392
+ /**
8393
+ * Runs the control plane in *this* process until it is stopped. `home-hosted up`
8394
+ * detaches a child that calls this; `--foreground` (systemd, docker) calls it
8395
+ * directly.
8396
+ */
8397
+ async function runControlPlane(options) {
8398
+ const existing = readRuntime();
8399
+ if (existing !== null && existing.pid !== process.pid && isProcessAlive$1(existing.pid)) {
8400
+ logger.error(`already running (pid ${existing.pid}) at ${existing.url} — run \`home-hosted down\` first`);
8401
+ process.exit(1);
8402
+ }
8403
+ if (existing !== null) clearRuntime();
8404
+ const configPath = options.configPath ?? defaultConfigPath;
8405
+ const store = new ConfigStore(configPath, SEED_CONFIG);
8406
+ store.load();
8407
+ store.writeJsonSchema();
8408
+ if (store.configError !== null) {
8409
+ logger.error(`refusing to start: ${store.configError}`);
8410
+ logger.info(`fix ${store.path}, or install the release that wrote it`);
8411
+ process.exit(1);
8412
+ }
8413
+ if (store.pendingMigrations.length > 0) {
8414
+ logger.error(`refusing to start: ${store.path} needs ${store.pendingMigrations.length} migration(s) before ${appVersion()} can use it`);
8415
+ logger.info("run `home-hosted migrate` to see and apply them");
8416
+ process.exit(1);
8417
+ }
8418
+ const secrets = new SecretsStore(defaultSecretsPath);
8419
+ const auth = new AuthService(secrets, () => store.config.control.auth);
8420
+ const tls = new TlsStore(defaultTlsDir);
8421
+ const logFiles = new LogFiles(defaultLogsDir, () => store.config.logs);
8422
+ const history = new HistoryStore(defaultHistoryPath);
8423
+ const notifications = new NotificationService(secrets, () => store.config.notifications, () => store.config.logs);
8424
+ const hostMonitor = new HostMonitor(() => store.config.host, (target) => resolveUserPath(resolveTemplate(target, {
8425
+ projectDir,
8426
+ dataRoot,
8427
+ home: os.homedir()
8428
+ })), notifications);
8429
+ let onConfigRestored;
8430
+ const backups = new BackupService({
8431
+ dataRoot,
8432
+ getConfig: () => store.config.backups,
8433
+ getSources: () => ({
8434
+ configPath: store.path,
8435
+ secretsPath: secrets.path,
8436
+ tlsDir: tls.directory,
8437
+ paths: resolveBackupPaths(store.servers, store.config.backups.includePaths)
8438
+ }),
8439
+ onConfigRestored: () => onConfigRestored?.()
8440
+ });
8441
+ if (!auth.passwordSet) {
8442
+ auth.ensureDefaultPassword("hh");
8443
+ logger.warn(`no password was set — created the default "hh"; change it in Settings → Authentication`);
8444
+ }
8445
+ const configured = store.config.control;
8446
+ const intendedHost = options.host === void 0 ? configured.host : parseBind(options.host);
8447
+ if (intendedHost === null) {
8448
+ logger.error(`invalid control host: ${String(options.host)} (expected local, lan or an ipv4 address)`);
8449
+ process.exit(1);
8450
+ }
8451
+ const intended = {
8452
+ host: intendedHost,
8453
+ port: options.port ?? configured.port
8454
+ };
8455
+ if (!Number.isInteger(intended.port) || intended.port <= 0 || intended.port > 65535) {
8456
+ logger.error(`invalid control port: ${String(options.port)}`);
8457
+ process.exit(1);
8041
8458
  }
8042
8459
  if (options.printConfig) {
8043
8460
  process.stdout.write(`${JSON.stringify(store.config, null, 2)}\n`);
@@ -8192,6 +8609,7 @@ async function runControlPlane(options) {
8192
8609
  if (ui.custom) {
8193
8610
  const meta = ui.status().meta;
8194
8611
  logger.warn(`custom UI in use${meta === null ? "" : ` (${meta.name}${meta.version === null ? "" : ` ${meta.version}`})`} — if it breaks, run \`home-hosted ui-revert\``);
8612
+ autoUpdateOfficialUi(ui, runtime.version);
8195
8613
  }
8196
8614
  for (const warning of store.configWarnings) logger.warn(warning);
8197
8615
  for (const entry of supervisor.views()) logger.info(` ${entry.id.padEnd(12)} ${entry.config.command} ${entry.config.args.join(" ")}`.trimEnd());
@@ -8248,6 +8666,7 @@ var init_src = __esmMin((() => {
8248
8666
  init_supervisor();
8249
8667
  init_tls();
8250
8668
  init_ui();
8669
+ init_ui_update$1();
8251
8670
  init_contracts();
8252
8671
  packageRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
8253
8672
  }));
@@ -9029,117 +9448,30 @@ var init_init = __esmMin((() => {
9029
9448
  var ui_switch_exports = /* @__PURE__ */ __exportAll({
9030
9449
  DEFAULT_REPO: () => DEFAULT_REPO,
9031
9450
  MAX_DOWNLOAD_BYTES: () => MAX_DOWNLOAD_BYTES,
9451
+ apiHeaders: () => apiHeaders,
9452
+ assetDownloadUrl: () => assetDownloadUrl,
9453
+ compareTags: () => compareTags,
9032
9454
  defaultReleaseTag: () => defaultReleaseTag,
9455
+ describeError: () => describeError,
9456
+ describeReleaseFailure: () => describeReleaseFailure,
9457
+ downloadToTemp: () => downloadToTemp,
9033
9458
  fallbackUiName: () => fallbackUiName,
9459
+ fetchRelease: () => fetchRelease,
9460
+ fetchReleases: () => fetchReleases,
9461
+ formatBytes: () => formatBytes,
9034
9462
  isGithubHost: () => isGithubHost,
9035
9463
  isOwnRepo: () => isOwnRepo,
9036
9464
  isUiAsset: () => isUiAsset,
9037
9465
  matchAsset: () => matchAsset,
9466
+ noAssetsMessage: () => noAssetsMessage,
9038
9467
  parseFileSource: () => parseFileSource,
9039
9468
  parseRepoSlug: () => parseRepoSlug,
9040
9469
  releaseApiUrl: () => releaseApiUrl,
9470
+ releasesApiUrl: () => releasesApiUrl,
9471
+ repoSlug: () => repoSlug,
9041
9472
  uiSwitch: () => uiSwitch,
9042
9473
  uiSwitchCommand: () => uiSwitchCommand
9043
9474
  });
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
9475
  async function uiSwitch(argv, io) {
9144
9476
  const { values } = parseArgs({
9145
9477
  args: argv,
@@ -9191,21 +9523,6 @@ async function uiSwitch(argv, io) {
9191
9523
  }
9192
9524
  await installFromUrl(assetDownloadUrl(chosen), fallbackUiName(chosen.name ?? ""), context);
9193
9525
  }
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
9526
  async function chooseAsset(assets, values, context) {
9210
9527
  const { io } = context;
9211
9528
  if (values.asset !== void 0) {
@@ -9260,47 +9577,6 @@ async function installFromUrl(url, fallbackName, context) {
9260
9577
  });
9261
9578
  }
9262
9579
  }
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
9580
  async function installArchive(archivePath, fallbackName, context) {
9305
9581
  const { UiService } = await Promise.resolve().then(() => (init_ui(), ui_exports));
9306
9582
  const { dataRoot } = await Promise.resolve().then(() => (init_paths(), paths_exports));
@@ -9314,66 +9590,282 @@ async function installArchive(archivePath, fallbackName, context) {
9314
9590
  context.io.write(` ${context.io.style.dim("files")} ${meta.files}\n`);
9315
9591
  context.io.write(`refresh the browser to see it\n`);
9316
9592
  }
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;
9593
+ var uiSwitchCommand;
9594
+ var init_ui_switch = __esmMin((() => {
9595
+ init_io();
9596
+ init_ui_release();
9597
+ init_ui_release();
9598
+ uiSwitchCommand = defineCommand({
9599
+ meta: {
9600
+ name: "ui-switch",
9601
+ description: "install a UI from a release asset, a zip file or a URL"
9602
+ },
9603
+ run: async ({ rawArgs }) => {
9604
+ await uiSwitch(rawArgs, {
9605
+ write: (text) => process.stdout.write(text),
9606
+ prompt,
9607
+ style
9608
+ });
9609
+ }
9610
+ });
9611
+ }));
9612
+ //#endregion
9613
+ //#region src/cli/ui-update.ts
9614
+ var ui_update_exports = /* @__PURE__ */ __exportAll({
9615
+ impliedAsset: () => impliedAsset,
9616
+ planUiUpdate: () => planUiUpdate,
9617
+ uiUpdate: () => uiUpdate,
9618
+ uiUpdateCommand: () => uiUpdateCommand,
9619
+ usableCandidates: () => usableCandidates
9620
+ });
9621
+ /**
9622
+ * Which of a release list applies to what is installed. A candidate is only offered when
9623
+ * its assets carry the asset the person is actually using, so a UI is never swapped for a
9624
+ * different flavour by an update.
9625
+ */
9626
+ function usableCandidates(releases, asset, order, currentTag) {
9627
+ const found = [];
9628
+ for (const release of releases) {
9629
+ const matched = matchAsset(release.assets.map((entry) => entry.name ?? "").filter(isUiAsset), asset);
9630
+ if (!matched.ok) continue;
9631
+ if (currentTag !== null) {
9632
+ const difference = compareTags(release.tag, currentTag);
9633
+ if (order === "newer" && difference <= 0) continue;
9634
+ if (order === "older" && difference >= 0) continue;
9635
+ }
9636
+ found.push({
9637
+ tag: release.tag,
9638
+ asset: matched.name
9639
+ });
9640
+ }
9641
+ return found;
9325
9642
  }
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;
9643
+ /** What the asset the UI is using is likely called, when it never declared one. */
9644
+ function impliedAsset(identity) {
9645
+ if (identity.asset !== null && identity.asset.length > 0) return identity.asset;
9646
+ if (identity.name.length > 0) return `${identity.name}.zip`;
9647
+ return null;
9330
9648
  }
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");
9649
+ /**
9650
+ * The decision, with no I/O in it: given what is installed and this panel's release,
9651
+ * what should `ui-update` do? `releases` is only consulted for someone else's UI.
9652
+ */
9653
+ function planUiUpdate(installed, runningTag, releases = [], order = "newer") {
9654
+ if (installed === null) return { kind: "stock" };
9655
+ const repo = installed.repo === null ? null : parseRepoSlug(installed.repo);
9656
+ if (repo === null) return {
9657
+ kind: "unidentifiable",
9658
+ identity: installed
9659
+ };
9660
+ if (isOwnRepo(repo)) return {
9661
+ kind: "official",
9662
+ identity: installed,
9663
+ target: runningTag
9664
+ };
9665
+ const asset = impliedAsset(installed);
9666
+ if (asset === null) return {
9667
+ kind: "unidentifiable",
9668
+ identity: installed
9669
+ };
9670
+ return {
9671
+ kind: "choice",
9672
+ identity: installed,
9673
+ asset,
9674
+ candidates: usableCandidates(releases, asset, order, installed.tag)
9675
+ };
9336
9676
  }
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`;
9677
+ /** `home-hosted ui-update` — see the module comment for the three cases. */
9678
+ async function uiUpdate(argv, io) {
9679
+ const { values } = parseArgs({
9680
+ args: argv,
9681
+ options: {
9682
+ tag: { type: "string" },
9683
+ asset: { type: "string" },
9684
+ token: { type: "string" },
9685
+ yes: {
9686
+ type: "boolean",
9687
+ short: "y"
9688
+ },
9689
+ old: { type: "boolean" },
9690
+ check: { type: "boolean" },
9691
+ repo: { type: "string" }
9692
+ },
9693
+ allowPositionals: false
9694
+ });
9695
+ const { appVersion } = await Promise.resolve().then(() => (init_version(), version_exports));
9696
+ const { dataRoot } = await Promise.resolve().then(() => (init_paths(), paths_exports));
9697
+ const { UiService } = await Promise.resolve().then(() => (init_ui(), ui_exports));
9698
+ const context = {
9699
+ io: {
9700
+ write: io.write,
9701
+ style: io.style
9702
+ },
9703
+ ask: process.stdin.isTTY === true && values.yes !== true ? io.prompt : null,
9704
+ version: appVersion(),
9705
+ token: values.token ?? process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? null
9706
+ };
9707
+ const installed = identityOf(new UiService({ dataRoot }).status().meta);
9708
+ if (installed === null) {
9709
+ io.write(`${io.style.dim("the stock UI is in use — it ships with the panel and is always current")}\n`);
9710
+ return;
9342
9711
  }
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}`;
9712
+ if (values.repo !== void 0) {
9713
+ const repo = parseRepoSlug(values.repo);
9714
+ if (repo === null) throw new Error(`invalid --repo "${values.repo}" — expected an "owner/name" slug, e.g. ${DEFAULT_REPO}`);
9715
+ installed.repo = repoSlug(repo);
9716
+ }
9717
+ const order = values.old === true ? "older" : "newer";
9718
+ const repo = installed.repo === null ? null : parseRepoSlug(installed.repo);
9719
+ 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"));
9720
+ if (values.check === true && repo !== null) {
9721
+ if (isOwnRepo(repo)) {
9722
+ printCheck(io, installed, values.tag ?? `v${context.version}`);
9723
+ return;
9724
+ }
9725
+ const releases = await fetchReleases(repo, context);
9726
+ const asset = impliedAsset(installed);
9727
+ printCheck(io, installed, null, asset === null ? [] : usableCandidates(releases, asset, order, installed.tag));
9728
+ return;
9729
+ }
9730
+ if (values.tag !== void 0 && repo !== null) {
9731
+ await installTag(repo, values.asset ?? installed.asset ?? null, values.tag, context);
9732
+ return;
9733
+ }
9734
+ let releases = [];
9735
+ if (repo !== null && !isOwnRepo(repo)) releases = await fetchReleases(repo, context);
9736
+ const plan = planUiUpdate(installed, `v${context.version}`, releases, order);
9737
+ if (plan.kind === "unidentifiable") {
9738
+ io.write(`${io.style.bold("this UI does not say where it came from")} — nothing to follow automatically\n`);
9739
+ printIdentity(io, plan.identity);
9740
+ io.write(` ${io.style.dim("point it at a repo to make this work: home-hosted ui-update --repo owner/name")}\n`);
9741
+ return;
9742
+ }
9743
+ if (plan.kind === "official") {
9744
+ if (plan.identity.tag === plan.target) {
9745
+ io.write(`${io.style.green("already current")} — ${io.style.bold(plan.identity.name)} is at ${plan.target}\n`);
9746
+ return;
9747
+ }
9748
+ 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`);
9749
+ await installTag(repo, plan.identity.asset ?? values.asset ?? null, plan.target, context);
9750
+ return;
9751
+ }
9752
+ if (plan.kind !== "choice") return;
9753
+ io.write(`${io.style.bold(plan.identity.name)} ${plan.identity.version ?? ""} — ${repoSlug(repo)}@${plan.identity.tag ?? "unknown tag"}\n`);
9754
+ io.write(` ${io.style.dim(`asset: ${plan.asset}`)}\n`);
9755
+ if (plan.candidates.length === 0) {
9756
+ const direction = order === "newer" ? "newer" : "older";
9757
+ io.write(`no ${direction} releases carry ${plan.asset}\n`);
9758
+ if (order === "newer") io.write(` ${io.style.dim("list older releases with: home-hosted ui-update --old")}\n`);
9759
+ return;
9760
+ }
9761
+ const chosen = await chooseCandidate(plan.candidates, context, order);
9762
+ if (chosen === null) {
9763
+ io.write("cancelled — nothing was installed\n");
9764
+ return;
9765
+ }
9766
+ await installTag(repo, chosen.asset, chosen.tag, context);
9346
9767
  }
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}`;
9768
+ function identityOf(meta) {
9769
+ if (meta === null) return null;
9770
+ return {
9771
+ name: meta.name ?? "custom-ui",
9772
+ version: meta.version ?? null,
9773
+ repo: meta.repo ?? null,
9774
+ tag: meta.tag ?? null,
9775
+ asset: meta.asset ?? null,
9776
+ unix: meta.unix ?? null
9777
+ };
9353
9778
  }
9354
- function tooLargeMessage(bytes) {
9355
- return `the download is ${formatBytes(bytes)}, larger than the ${MAX_DOWNLOAD_BYTES / 1024 / 1024}MB a UI may be`;
9779
+ function printIdentity(io, identity) {
9780
+ io.write(` name ${identity.name}\n`);
9781
+ io.write(` version ${identity.version ?? "unspecified"}\n`);
9782
+ io.write(` repo ${identity.repo ?? "unspecified"}${identity.tag === null ? "" : ` @ ${identity.tag}`}\n`);
9783
+ if (identity.unix !== null) io.write(` built ${(/* @__PURE__ */ new Date(identity.unix * 1e3)).toISOString()}\n`);
9356
9784
  }
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`;
9785
+ function printCheck(io, identity, target, candidates = []) {
9786
+ if (target !== null) {
9787
+ if (identity.tag === target) {
9788
+ io.write(`${io.style.green("up to date")} — ${identity.version ?? identity.name} at ${target}\n`);
9789
+ return;
9790
+ }
9791
+ io.write(`update available: ${identity.tag ?? "unknown"} → ${target}\n`);
9792
+ return;
9793
+ }
9794
+ if (candidates.length === 0) {
9795
+ io.write(`up to date — no release carries ${identity.asset ?? identity.name}.zip\n`);
9796
+ return;
9797
+ }
9798
+ io.write(`${candidates.length} release(s) available:\n`);
9799
+ for (const candidate of candidates) io.write(` ${candidate.tag} ${io.style.dim(candidate.asset)}\n`);
9361
9800
  }
9362
- function describeError(error) {
9363
- return error instanceof Error ? error.message : String(error);
9801
+ async function chooseCandidate(candidates, context, order) {
9802
+ const headline = order === "newer" ? "Newer releases" : "Older releases";
9803
+ const { io } = context;
9804
+ io.write(`${io.style.bold(headline)}\n`);
9805
+ for (const [index, candidate] of candidates.entries()) io.write(` ${index + 1}) ${candidate.tag}\n`);
9806
+ if (context.ask === null) {
9807
+ if (candidates.length === 1) return candidates[0];
9808
+ throw new Error([
9809
+ `${candidates.length} releases are available and this session cannot ask which one:`,
9810
+ ...candidates.map((candidate) => ` ${candidate.tag}`),
9811
+ " run it in a terminal, or pass --tag <tag>"
9812
+ ].join("\n"));
9813
+ }
9814
+ for (;;) {
9815
+ const answer = (await context.ask(`Select a release [1-${candidates.length}] (empty to cancel) `)).trim();
9816
+ if (answer.length === 0) return null;
9817
+ if (/^\d+$/.test(answer)) {
9818
+ const index = Number.parseInt(answer, 10);
9819
+ if (index >= 1 && index <= candidates.length) return candidates[index - 1];
9820
+ }
9821
+ const byTag = candidates.find((candidate) => candidate.tag === answer);
9822
+ if (byTag !== void 0) return byTag;
9823
+ io.write(` ${io.style.dim(`no such choice — enter 1-${candidates.length} or a tag`)}\n`);
9824
+ }
9364
9825
  }
9365
- var DEFAULT_REPO, MAX_DOWNLOAD_BYTES, uiSwitchCommand;
9366
- var init_ui_switch = __esmMin((() => {
9826
+ /** Fetches one release's asset and installs it, reporting what landed. */
9827
+ async function installTag(repo, wantedAsset, tag, context) {
9828
+ const release = await fetchRelease(repo, tag, context);
9829
+ const names = release.assets.map((asset) => asset.name ?? "").filter(isUiAsset);
9830
+ let assetName;
9831
+ if (wantedAsset !== null && wantedAsset.length > 0) {
9832
+ const matched = matchAsset(names, wantedAsset);
9833
+ if (!matched.ok) throw new Error(`${matched.error}\n in ${repo.owner}/${repo.name}@${release.tag}`);
9834
+ assetName = matched.name;
9835
+ } else if (names.length === 1) assetName = names[0];
9836
+ else throw new Error(`${names.length} UI assets in ${repo.owner}/${repo.name}@${release.tag} — name one with --asset`);
9837
+ const download = await downloadToTemp(assetDownloadUrl(release.assets.find((entry) => entry.name === assetName)), {
9838
+ "accept": "application/octet-stream",
9839
+ "user-agent": `home-hosted/${context.version}`,
9840
+ ...context.token !== null && context.token.length > 0 ? { authorization: `Bearer ${context.token}` } : {}
9841
+ }, context);
9842
+ try {
9843
+ const { UiService } = await Promise.resolve().then(() => (init_ui(), ui_exports));
9844
+ const { dataRoot } = await Promise.resolve().then(() => (init_paths(), paths_exports));
9845
+ const result = await new UiService({ dataRoot }).install(download.file, assetName);
9846
+ if (!result.ok) throw new Error(`nothing was installed: ${result.error}`);
9847
+ const { meta } = result;
9848
+ const { io } = context;
9849
+ io.write(`${io.style.green("UI updated")} — ${meta.name} ${meta.version ?? ""} at ${release.tag}\n`);
9850
+ io.write(` ${io.style.dim("refresh the browser to see it")}\n`);
9851
+ } finally {
9852
+ fs.rmSync(download.dir, {
9853
+ recursive: true,
9854
+ force: true
9855
+ });
9856
+ }
9857
+ }
9858
+ var uiUpdateCommand;
9859
+ var init_ui_update = __esmMin((() => {
9367
9860
  init_io();
9368
- DEFAULT_REPO = "NamesMT/home-hosted";
9369
- MAX_DOWNLOAD_BYTES = 536870912;
9370
- uiSwitchCommand = defineCommand({
9861
+ init_ui_release();
9862
+ uiUpdateCommand = defineCommand({
9371
9863
  meta: {
9372
- name: "ui-switch",
9373
- description: "install a UI from a release asset, a zip file or a URL"
9864
+ name: "ui-update",
9865
+ description: "update the installed UI to match this panel, or pick a release"
9374
9866
  },
9375
9867
  run: async ({ rawArgs }) => {
9376
- await uiSwitch(rawArgs, {
9868
+ await uiUpdate(rawArgs, {
9377
9869
  write: (text) => process.stdout.write(text),
9378
9870
  prompt,
9379
9871
  style
@@ -9448,6 +9940,7 @@ var SYNOPSIS = {
9448
9940
  "migrate": "home-hosted migrate",
9449
9941
  "init": "home-hosted init",
9450
9942
  "ui-switch": "home-hosted ui-switch",
9943
+ "ui-update": "home-hosted ui-update",
9451
9944
  "ui-revert": "home-hosted ui-revert"
9452
9945
  };
9453
9946
  var SUMMARIES = {
@@ -9460,6 +9953,7 @@ var SUMMARIES = {
9460
9953
  "migrate": "bring the config up to this release's schema",
9461
9954
  "init": "scaffold a project that keeps its state in the repo",
9462
9955
  "ui-switch": "install a UI from a release asset, a zip file or a URL",
9956
+ "ui-update": "bring the installed UI up to date, or pick a release",
9463
9957
  "ui-revert": "go back to the stock control panel UI"
9464
9958
  };
9465
9959
  /** Every section lays its left column out at this width, so the two views align. */
@@ -9514,6 +10008,18 @@ var UI_SWITCH_SECTION = {
9514
10008
  ["-y, --yes", "take the only asset instead of asking"]
9515
10009
  ]
9516
10010
  };
10011
+ var UI_UPDATE_SECTION = {
10012
+ heading: "Options for ui-update",
10013
+ lines: [
10014
+ ["--check", "report whether an update is available and install nothing"],
10015
+ ["--tag <tag>", "install that release instead of asking"],
10016
+ ["--asset <name>", "asset to install (defaults to the one in use)"],
10017
+ ["--old", "list older releases instead of newer ones"],
10018
+ ["--repo <owner/name>", "for a UI that does not declare its own repo"],
10019
+ ["--token <token>", "GitHub token (or GITHUB_TOKEN / GH_TOKEN)"],
10020
+ ["-y, --yes", "take the only release instead of asking"]
10021
+ ]
10022
+ };
9517
10023
  var EVERYWHERE_SECTION = {
9518
10024
  heading: "Everywhere",
9519
10025
  lines: [
@@ -9588,7 +10094,8 @@ var SECTIONS = {
9588
10094
  "set-token": SET_TOKEN_SECTION,
9589
10095
  "migrate": MIGRATE_SECTION,
9590
10096
  "init": INIT_SECTION,
9591
- "ui-switch": UI_SWITCH_SECTION
10097
+ "ui-switch": UI_SWITCH_SECTION,
10098
+ "ui-update": UI_UPDATE_SECTION
9592
10099
  };
9593
10100
  /**
9594
10101
  * `home-hosted <command> --help`: the usage line, that command's own options,
@@ -9634,6 +10141,7 @@ var COMMANDS = {
9634
10141
  "migrate": () => Promise.resolve().then(() => (init_migrate(), migrate_exports)).then((module) => module.migrateCommand),
9635
10142
  "init": () => Promise.resolve().then(() => (init_init(), init_exports)).then((module) => module.initCommand),
9636
10143
  "ui-switch": () => Promise.resolve().then(() => (init_ui_switch(), ui_switch_exports)).then((module) => module.uiSwitchCommand),
10144
+ "ui-update": () => Promise.resolve().then(() => (init_ui_update(), ui_update_exports)).then((module) => module.uiUpdateCommand),
9637
10145
  "ui-revert": () => Promise.resolve().then(() => (init_ui_revert(), ui_revert_exports)).then((module) => module.uiRevertCommand)
9638
10146
  };
9639
10147
  var rootCommand = defineCommand({