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/AGENTS.md +13 -0
- package/README.md +20 -10
- package/dist/cli.js +896 -293
- package/dist/cli.js.map +1 -1
- package/docs/UI_CREATION.md +35 -4
- package/package.json +1 -1
- package/uis/stock/dist/assets/{index-4_Q69j7h.js → index-sj5eYUeD.js} +1 -1
- package/uis/stock/dist/index.html +1 -1
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
|
-
|
|
903
|
-
|
|
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
|
-
|
|
7927
|
-
|
|
7928
|
-
|
|
7929
|
-
|
|
7930
|
-
|
|
7931
|
-
|
|
7932
|
-
|
|
7933
|
-
|
|
7934
|
-
|
|
7935
|
-
|
|
7936
|
-
|
|
7937
|
-
|
|
7938
|
-
|
|
7939
|
-
|
|
7940
|
-
|
|
7941
|
-
|
|
7942
|
-
|
|
7943
|
-
|
|
7944
|
-
|
|
7945
|
-
|
|
7946
|
-
|
|
7947
|
-
|
|
7948
|
-
|
|
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
|
-
|
|
7953
|
-
|
|
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/
|
|
7960
|
-
|
|
7961
|
-
|
|
7962
|
-
|
|
7963
|
-
|
|
7964
|
-
|
|
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
|
-
|
|
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
|
|
8175
|
+
return false;
|
|
7969
8176
|
}
|
|
7970
8177
|
}
|
|
7971
|
-
|
|
7972
|
-
function
|
|
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
|
-
|
|
7977
|
-
|
|
7978
|
-
|
|
7979
|
-
|
|
7980
|
-
|
|
7981
|
-
|
|
7982
|
-
|
|
7983
|
-
|
|
7984
|
-
|
|
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 (
|
|
7987
|
-
const
|
|
7988
|
-
|
|
7989
|
-
|
|
7990
|
-
|
|
7991
|
-
|
|
7992
|
-
|
|
7993
|
-
|
|
7994
|
-
|
|
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 (
|
|
7997
|
-
|
|
7998
|
-
|
|
7999
|
-
|
|
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
|
-
|
|
8002
|
-
const
|
|
8003
|
-
|
|
8004
|
-
|
|
8005
|
-
|
|
8006
|
-
const
|
|
8007
|
-
const
|
|
8008
|
-
|
|
8009
|
-
|
|
8010
|
-
|
|
8011
|
-
|
|
8012
|
-
|
|
8013
|
-
|
|
8014
|
-
|
|
8015
|
-
|
|
8016
|
-
|
|
8017
|
-
|
|
8018
|
-
|
|
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
|
-
|
|
9318
|
-
|
|
9319
|
-
|
|
9320
|
-
|
|
9321
|
-
|
|
9322
|
-
|
|
9323
|
-
|
|
9324
|
-
|
|
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
|
-
|
|
9327
|
-
|
|
9328
|
-
if (
|
|
9329
|
-
return
|
|
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
|
-
|
|
9332
|
-
|
|
9333
|
-
|
|
9334
|
-
|
|
9335
|
-
|
|
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
|
-
|
|
9338
|
-
|
|
9339
|
-
|
|
9340
|
-
|
|
9341
|
-
|
|
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 (
|
|
9344
|
-
|
|
9345
|
-
|
|
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
|
|
9348
|
-
|
|
9349
|
-
|
|
9350
|
-
|
|
9351
|
-
|
|
9352
|
-
|
|
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
|
|
9355
|
-
|
|
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
|
|
9358
|
-
if (
|
|
9359
|
-
|
|
9360
|
-
|
|
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
|
|
9363
|
-
|
|
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
|
-
|
|
9366
|
-
|
|
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
|
-
|
|
9369
|
-
|
|
9370
|
-
uiSwitchCommand = defineCommand({
|
|
9956
|
+
init_ui_release();
|
|
9957
|
+
uiUpdateCommand = defineCommand({
|
|
9371
9958
|
meta: {
|
|
9372
|
-
name: "ui-
|
|
9373
|
-
description: "
|
|
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
|
|
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({
|