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/AGENTS.md +13 -0
- package/README.md +6 -2
- package/dist/cli.js +807 -299
- 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. */
|
|
@@ -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
|
-
|
|
7937
|
-
|
|
7938
|
-
|
|
7939
|
-
|
|
7940
|
-
|
|
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/
|
|
7960
|
-
|
|
7961
|
-
|
|
7962
|
-
|
|
7963
|
-
|
|
7964
|
-
|
|
7965
|
-
|
|
7966
|
-
|
|
7967
|
-
}
|
|
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
|
-
|
|
7972
|
-
|
|
7973
|
-
|
|
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
|
-
*
|
|
7977
|
-
*
|
|
7978
|
-
*
|
|
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
|
-
|
|
7981
|
-
|
|
7982
|
-
|
|
7983
|
-
|
|
7984
|
-
|
|
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
|
-
|
|
7987
|
-
|
|
7988
|
-
|
|
7989
|
-
|
|
7990
|
-
|
|
7991
|
-
|
|
7992
|
-
|
|
7993
|
-
|
|
7994
|
-
|
|
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 (
|
|
7997
|
-
|
|
7998
|
-
|
|
7999
|
-
|
|
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
|
-
|
|
8002
|
-
const
|
|
8003
|
-
|
|
8004
|
-
|
|
8005
|
-
|
|
8006
|
-
|
|
8007
|
-
|
|
8008
|
-
|
|
8009
|
-
|
|
8010
|
-
|
|
8011
|
-
|
|
8012
|
-
|
|
8013
|
-
|
|
8014
|
-
|
|
8015
|
-
|
|
8016
|
-
|
|
8017
|
-
|
|
8018
|
-
|
|
8019
|
-
|
|
8020
|
-
|
|
8021
|
-
|
|
8022
|
-
|
|
8023
|
-
|
|
8024
|
-
|
|
8025
|
-
|
|
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
|
-
|
|
8029
|
-
const
|
|
8030
|
-
if (
|
|
8031
|
-
|
|
8032
|
-
|
|
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
|
-
|
|
8035
|
-
|
|
8036
|
-
|
|
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
|
-
|
|
8039
|
-
|
|
8040
|
-
|
|
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
|
-
|
|
9318
|
-
|
|
9319
|
-
|
|
9320
|
-
|
|
9321
|
-
|
|
9322
|
-
|
|
9323
|
-
|
|
9324
|
-
|
|
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
|
-
|
|
9327
|
-
|
|
9328
|
-
if (
|
|
9329
|
-
return
|
|
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
|
-
|
|
9332
|
-
|
|
9333
|
-
|
|
9334
|
-
|
|
9335
|
-
|
|
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
|
-
|
|
9338
|
-
|
|
9339
|
-
|
|
9340
|
-
|
|
9341
|
-
|
|
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 (
|
|
9344
|
-
|
|
9345
|
-
|
|
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
|
|
9348
|
-
|
|
9349
|
-
|
|
9350
|
-
|
|
9351
|
-
|
|
9352
|
-
|
|
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
|
|
9355
|
-
|
|
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
|
|
9358
|
-
if (
|
|
9359
|
-
|
|
9360
|
-
|
|
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
|
|
9363
|
-
|
|
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
|
-
|
|
9366
|
-
|
|
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
|
-
|
|
9369
|
-
|
|
9370
|
-
uiSwitchCommand = defineCommand({
|
|
9861
|
+
init_ui_release();
|
|
9862
|
+
uiUpdateCommand = defineCommand({
|
|
9371
9863
|
meta: {
|
|
9372
|
-
name: "ui-
|
|
9373
|
-
description: "
|
|
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
|
|
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({
|