home-hosted 0.7.4 → 0.7.5
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 +7 -5
- package/README.md +5 -2
- package/dist/cli.js +158 -47
- package/dist/cli.js.map +1 -1
- package/docs/NOTIFICATIONS.md +1 -1
- package/docs/REVERSE_PROXY.md +5 -5
- package/docs/SERVERS.md +3 -2
- package/docs/UI_CREATION.md +5 -4
- package/package.json +1 -1
- package/uis/stock/dist/assets/{index-DoDrajgK.js → index-5B9zsc8b.js} +3 -3
- package/uis/stock/dist/assets/{index-JGpABIqV.css → index-D8S55m4E.css} +1 -1
- package/uis/stock/dist/index.html +2 -2
- package/uis/stock/dist/ui.json +3 -3
package/AGENTS.md
CHANGED
|
@@ -12,8 +12,9 @@ level: `settings.json` (listener, auth, TLS policy, host vitals, backups, revers
|
|
|
12
12
|
`.backups/`, `.ui/`, `.logs/` (the panel console), `.proxy/` (the reverse-proxy engine, its generated
|
|
13
13
|
configuration and its nanny state), and `run.json` — the live daemon's pid/url/token, 0600. Every workspace owns
|
|
14
14
|
`.hh/<id>/`: `settings.json` (server defaults, log retention, notifications, DDNS), `servers.config.json`,
|
|
15
|
-
`.secrets.json` (0600: Telegram bot token
|
|
16
|
-
`HHOSTED_DDNS_SECRET`
|
|
15
|
+
`.secrets.json` (0600: Telegram bot token, plus DDNS credentials sealed with AES-256-GCM under
|
|
16
|
+
`HHOSTED_DDNS_SECRET` — only DDNS is sealed, because it is the one thing replayed to a third party),
|
|
17
|
+
`.logs/`, and `.state/` (a persistent entry's nanny state plus its 0600 spawn
|
|
17
18
|
spec until the nanny reads it, plus `ddns.json`). A pre-`.hh` instance is relocated automatically by
|
|
18
19
|
`ensureLayout()` (`src/config/layout.ts`). The package ships **no servers**: never commit a config, a
|
|
19
20
|
seed entry, or a path that names one.
|
|
@@ -165,9 +166,10 @@ a minor without one.
|
|
|
165
166
|
- A single on/off setting is a `ToggleSwitch`; `CheckField` is only for picking items out of a set
|
|
166
167
|
(the restore plan). A checkbox in a `FieldGroup` grid reads as misaligned next to the inputs.
|
|
167
168
|
- A create body carries only what differs from what the entry would inherit — the schema's defaults
|
|
168
|
-
with `Workspace Settings → Server defaults` on top (`inheritBaseline`
|
|
169
|
-
|
|
170
|
-
person did not decide stays out; the editor applies the
|
|
169
|
+
with `Workspace Settings → Server defaults` on top (each UI's own `inheritBaseline` plus the shared
|
|
170
|
+
`diffFields` in `src/shared/patch-diff.ts`). A value written into `servers.config.json` stops
|
|
171
|
+
following those defaults, so anything the person did not decide stays out; the editor applies the
|
|
172
|
+
same rule to an edit.
|
|
171
173
|
- ArkType at every runtime boundary: routes use `validate('json'|'query'|'param', schema)` then
|
|
172
174
|
`c.req.valid(...)`; ad-hoc payloads use `parseOrThrow`. Schemas reject undeclared keys.
|
|
173
175
|
- Every failure is a `DetailedError` (`@namesmt/utils`), mapped by `src/helpers/error.ts` into one
|
package/README.md
CHANGED
|
@@ -232,6 +232,7 @@ default workspace.
|
|
|
232
232
|
| `GET`/`PATCH /api/settings/workspace` | server defaults, logs, notifications | ✅ |
|
|
233
233
|
| `GET`/`PATCH /api/settings` | panel-wide: listener, authentication, TLS, host vitals, backups | ❌ |
|
|
234
234
|
| `GET`/`POST /api/backups`, restore | archives | ❌ |
|
|
235
|
+
| `GET`/`PATCH /api/proxy`, `POST /api/proxy/{engine,start,stop,apply,revert}`, `PUT`/`DELETE /api/proxy/certificates/:id` | the reverse proxy: its engine, its route table, and its uploaded certificates | ❌ |
|
|
235
236
|
| `GET /healthz` | no session needed — the one an external monitor wants (its per-server detail needs a credential) | ❌ |
|
|
236
237
|
| `GET /api/metrics` | Prometheus text (needs a token or session, like every `/api` route) | ❌ |
|
|
237
238
|
|
|
@@ -346,7 +347,7 @@ restarts itself), and how hand-edits are validated: [SERVERS.md](./docs/SERVERS.
|
|
|
346
347
|
| `home-hosted set-password` | set the panel password without opening a browser |
|
|
347
348
|
| `home-hosted set-token` | set the API token scripts and agents use (`--generate`, `--clear`) |
|
|
348
349
|
| `home-hosted migrate` | relocate a pre-workspaces state directory and stamp every config for this release (`--dry-run`, `--yes`) |
|
|
349
|
-
| `home-hosted init` | scaffold a self-contained project
|
|
350
|
+
| `home-hosted init` | scaffold a self-contained project, whose scripts keep state in the repo (`--home ./state`) |
|
|
350
351
|
| `home-hosted ui-switch` | install a UI from a release asset, a zip file or a URL (interactive) |
|
|
351
352
|
| `home-hosted ui-update` | bring an installed UI up to date automatically (official UI) or pick a release (`--old`, `--check`) |
|
|
352
353
|
| `home-hosted ui-revert` | go back to the stock panel UI after uploading your own |
|
|
@@ -578,7 +579,9 @@ or `4480`/`4443` unprivileged with your router forwarding to them
|
|
|
578
579
|
|
|
579
580
|
Yes. Process trees are sampled from Win32_Process, termination uses `taskkill /T`, and the shipped
|
|
580
581
|
examples avoid POSIX-only commands. CPU temperature and swap are best-effort where the OS does not
|
|
581
|
-
expose them to an unprivileged process
|
|
582
|
+
expose them to an unprivileged process. Adopting a self-restarted process works on Windows too, off
|
|
583
|
+
its argv instead of the environment marker; a launcher whose real process is a `.cmd` shim is the one
|
|
584
|
+
case it cannot see through, and `kill` is the answer there.
|
|
582
585
|
|
|
583
586
|
</details>
|
|
584
587
|
|
package/dist/cli.js
CHANGED
|
@@ -5340,6 +5340,10 @@ var init_store = __esmMin((() => {
|
|
|
5340
5340
|
this.listeners.add(listener);
|
|
5341
5341
|
return () => this.listeners.delete(listener);
|
|
5342
5342
|
}
|
|
5343
|
+
/** How many listeners are attached; a disposed owner must not add to it. */
|
|
5344
|
+
get listenerCount() {
|
|
5345
|
+
return this.listeners.size;
|
|
5346
|
+
}
|
|
5343
5347
|
getServer(id) {
|
|
5344
5348
|
return this.servers.find((server) => server.id === id);
|
|
5345
5349
|
}
|
|
@@ -7546,9 +7550,9 @@ function parseUpstream(value) {
|
|
|
7546
7550
|
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
|
|
7547
7551
|
try {
|
|
7548
7552
|
const url = new URL(withScheme);
|
|
7549
|
-
|
|
7553
|
+
const port = url.port.length > 0 ? url.port : url.protocol === "https:" ? "443" : "80";
|
|
7550
7554
|
return {
|
|
7551
|
-
dial: `${url.hostname}:${
|
|
7555
|
+
dial: `${url.hostname}:${port}`,
|
|
7552
7556
|
tls: url.protocol === "https:"
|
|
7553
7557
|
};
|
|
7554
7558
|
} catch {
|
|
@@ -7594,7 +7598,7 @@ function validateProxyConfig(config, dnsAccount) {
|
|
|
7594
7598
|
for (const route of config.routes) {
|
|
7595
7599
|
if (ids.has(route.id)) errors.push(`route id "${route.id}" is used twice`);
|
|
7596
7600
|
ids.add(route.id);
|
|
7597
|
-
const key = `${route.host.toLowerCase()}${route.path}`;
|
|
7601
|
+
const key = `${route.host.toLowerCase()}${route.path.trim().replace(/\/+$/, "")}`;
|
|
7598
7602
|
if (seen.has(key)) errors.push(`"${route.host}" is routed twice`);
|
|
7599
7603
|
seen.add(key);
|
|
7600
7604
|
if (route.target === "server" && (route.workspace.length === 0 || route.server.length === 0)) errors.push(`route "${route.id}" needs a workspace and a server`);
|
|
@@ -7613,7 +7617,7 @@ function isPublicHost(host) {
|
|
|
7613
7617
|
const name = host.toLowerCase();
|
|
7614
7618
|
if (name === "localhost" || !name.includes(".")) return false;
|
|
7615
7619
|
if (LOCAL_TLDS.some((tld) => name.endsWith(tld))) return false;
|
|
7616
|
-
return !/^\d{1,3}(?:\.\d{1,3}){3}$/.test(name);
|
|
7620
|
+
return !/^\d{1,3}(?:\.\d{1,3}){3,}$/.test(name);
|
|
7617
7621
|
}
|
|
7618
7622
|
/** Caddy answers with `{"error": "..."}`; that sentence is the useful part. */
|
|
7619
7623
|
function engineMessage(body) {
|
|
@@ -7668,7 +7672,7 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
7668
7672
|
port: "number = 0",
|
|
7669
7673
|
origin: "string = \"\""
|
|
7670
7674
|
}).onUndeclaredKey("reject");
|
|
7671
|
-
ProxyService = class {
|
|
7675
|
+
ProxyService = class ProxyService {
|
|
7672
7676
|
options;
|
|
7673
7677
|
nanny = null;
|
|
7674
7678
|
admin = null;
|
|
@@ -7677,6 +7681,8 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
7677
7681
|
moduleCache = null;
|
|
7678
7682
|
/** A legacy `engine.json` is reported once, not on every state frame. */
|
|
7679
7683
|
legacyChecksumWarned = false;
|
|
7684
|
+
/** Why the engine's nanny could not be spawned, cleared on the next start. */
|
|
7685
|
+
nannyError = null;
|
|
7680
7686
|
certCache = null;
|
|
7681
7687
|
/** The certificate state the last apply was built from, so a change re-applies once. */
|
|
7682
7688
|
certificateSignature = null;
|
|
@@ -8518,11 +8524,15 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
8518
8524
|
subjects: routes.filter((route) => route.tls === "auto" && isPublicHost(route.host)).map((route) => route.host)
|
|
8519
8525
|
};
|
|
8520
8526
|
}
|
|
8527
|
+
/** The route's path as an engine prefix: trailing slashes are not a different route. */
|
|
8528
|
+
static pathKey(path) {
|
|
8529
|
+
return path.trim().replace(/\/+$/, "");
|
|
8530
|
+
}
|
|
8521
8531
|
/** Every route, resolved far enough to render. */
|
|
8522
8532
|
resolveRoutes(exclude) {
|
|
8523
8533
|
const seen = /* @__PURE__ */ new Map();
|
|
8524
8534
|
return this.config.routes.filter((route) => exclude?.has(route.id) !== true).map((route) => {
|
|
8525
|
-
const key = `${route.host.toLowerCase()}${route.path}`;
|
|
8535
|
+
const key = `${route.host.toLowerCase()}${ProxyService.pathKey(route.path)}`;
|
|
8526
8536
|
const clash = seen.get(key);
|
|
8527
8537
|
if (clash !== void 0) return {
|
|
8528
8538
|
route,
|
|
@@ -8802,11 +8812,16 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
8802
8812
|
let settled = false;
|
|
8803
8813
|
let timer;
|
|
8804
8814
|
let killTimer;
|
|
8815
|
+
/** The escalation is cancelled by a real exit, never by `finish`. */
|
|
8816
|
+
const stopEscalation = () => {
|
|
8817
|
+
if (killTimer === void 0) return;
|
|
8818
|
+
clearTimeout(killTimer);
|
|
8819
|
+
killTimer = void 0;
|
|
8820
|
+
};
|
|
8805
8821
|
const finish = (value) => {
|
|
8806
8822
|
if (settled) return;
|
|
8807
8823
|
settled = true;
|
|
8808
8824
|
if (timer !== void 0) clearTimeout(timer);
|
|
8809
|
-
if (killTimer !== void 0) clearTimeout(killTimer);
|
|
8810
8825
|
resolve(value);
|
|
8811
8826
|
};
|
|
8812
8827
|
timer = setTimeout(() => {
|
|
@@ -8818,8 +8833,14 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
8818
8833
|
child.stdout?.on("data", (chunk) => {
|
|
8819
8834
|
output += chunk.toString("utf8");
|
|
8820
8835
|
});
|
|
8821
|
-
child.on("error", () =>
|
|
8822
|
-
|
|
8836
|
+
child.on("error", () => {
|
|
8837
|
+
stopEscalation();
|
|
8838
|
+
finish(null);
|
|
8839
|
+
});
|
|
8840
|
+
child.on("exit", () => {
|
|
8841
|
+
stopEscalation();
|
|
8842
|
+
finish(output);
|
|
8843
|
+
});
|
|
8823
8844
|
});
|
|
8824
8845
|
}
|
|
8825
8846
|
/** Runs `version` once and reports what the binary actually is. */
|
|
@@ -8881,7 +8902,7 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
8881
8902
|
return;
|
|
8882
8903
|
}
|
|
8883
8904
|
await this.assertPortsFree(config.httpPort, config.httpsPort);
|
|
8884
|
-
const admin = await this.chooseAdmin();
|
|
8905
|
+
const admin = await (this.options.chooseAdmin ?? this.chooseAdmin.bind(this))();
|
|
8885
8906
|
this.admin = admin;
|
|
8886
8907
|
const invalid = this.resolveRoutes().find((view) => view.status === "error");
|
|
8887
8908
|
if (invalid !== void 0) throw new DetailedError(invalid.message ?? "a route cannot be built", {
|
|
@@ -8937,7 +8958,7 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
8937
8958
|
killPortHolders: false
|
|
8938
8959
|
}
|
|
8939
8960
|
});
|
|
8940
|
-
|
|
8961
|
+
const nanny = spawn(process.execPath, nannyArgv(nannyEntryPoint(), PROXY_ID, specPath, statePath), {
|
|
8941
8962
|
cwd: this.options.engineDir,
|
|
8942
8963
|
env: {
|
|
8943
8964
|
...process.env,
|
|
@@ -8953,16 +8974,26 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
8953
8974
|
detached: true,
|
|
8954
8975
|
windowsHide: true
|
|
8955
8976
|
});
|
|
8956
|
-
this.
|
|
8977
|
+
this.nannyError = null;
|
|
8978
|
+
this.nanny = nanny;
|
|
8979
|
+
const nannyGone = () => {
|
|
8980
|
+
if (this.nanny !== nanny) return;
|
|
8957
8981
|
this.nanny = null;
|
|
8958
8982
|
this.admin = null;
|
|
8959
8983
|
fs.rmSync(this.options.adminPath, { force: true });
|
|
8960
8984
|
this.options.onStateChange();
|
|
8985
|
+
};
|
|
8986
|
+
nanny.on("error", (error) => {
|
|
8987
|
+
this.nannyError = `the proxy engine's nanny could not be started: ${error.message}`;
|
|
8988
|
+
logger.error(`proxy: ${this.nannyError}`);
|
|
8989
|
+
nannyGone();
|
|
8961
8990
|
});
|
|
8991
|
+
nanny.on("exit", nannyGone);
|
|
8962
8992
|
if (!await this.waitReady()) {
|
|
8963
|
-
this.
|
|
8993
|
+
const failure = this.nannyError ?? `the engine did not answer on its admin endpoint within ${Math.round(READY_TIMEOUT_MS / 1e3)}s: ${this.logTail()}`;
|
|
8964
8994
|
await this.stop().catch(() => void 0);
|
|
8965
|
-
|
|
8995
|
+
this.lastError = failure;
|
|
8996
|
+
throw new DetailedError(failure, {
|
|
8966
8997
|
statusCode: 502,
|
|
8967
8998
|
code: "ENGINE_NOT_READY"
|
|
8968
8999
|
});
|
|
@@ -8990,11 +9021,10 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
8990
9021
|
logger.warn(`proxy: could not signal pid ${pid}`, error);
|
|
8991
9022
|
}
|
|
8992
9023
|
}
|
|
8993
|
-
const deadline = Date.now() + 1e4;
|
|
9024
|
+
const deadline = Date.now() + (this.options.stopGraceMs ?? 1e4);
|
|
8994
9025
|
while (Date.now() < deadline && pids.some((pid) => isProcessAlive$1(pid))) await new Promise((resolve) => setTimeout(resolve, 100));
|
|
8995
|
-
const
|
|
8996
|
-
|
|
8997
|
-
process.kill(survivor, "SIGKILL");
|
|
9026
|
+
for (const pid of pids.filter(isProcessAlive$1)) try {
|
|
9027
|
+
process.kill(pid, "SIGKILL");
|
|
8998
9028
|
} catch {}
|
|
8999
9029
|
fs.rmSync(statePath, { force: true });
|
|
9000
9030
|
}
|
|
@@ -9087,6 +9117,17 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
9087
9117
|
}
|
|
9088
9118
|
if (admin.kind === "tcp") headers.origin = admin.origin;
|
|
9089
9119
|
return new Promise((resolve, reject) => {
|
|
9120
|
+
let settled = false;
|
|
9121
|
+
const succeed = (value) => {
|
|
9122
|
+
if (settled) return;
|
|
9123
|
+
settled = true;
|
|
9124
|
+
resolve(value);
|
|
9125
|
+
};
|
|
9126
|
+
const fail = (error) => {
|
|
9127
|
+
if (settled) return;
|
|
9128
|
+
settled = true;
|
|
9129
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
9130
|
+
};
|
|
9090
9131
|
const request = http.request({
|
|
9091
9132
|
...admin.kind === "unix" ? { socketPath: admin.path } : {
|
|
9092
9133
|
host: admin.host,
|
|
@@ -9099,20 +9140,24 @@ var init_proxy$1 = __esmMin((() => {
|
|
|
9099
9140
|
}, (response) => {
|
|
9100
9141
|
const chunks = [];
|
|
9101
9142
|
response.on("data", (chunk) => chunks.push(chunk));
|
|
9102
|
-
response.on("end", () =>
|
|
9143
|
+
response.on("end", () => succeed({
|
|
9103
9144
|
status: response.statusCode ?? 0,
|
|
9104
9145
|
body: Buffer$1.concat(chunks).toString("utf8")
|
|
9105
9146
|
}));
|
|
9147
|
+
response.on("error", fail);
|
|
9148
|
+
response.on("aborted", () => fail(/* @__PURE__ */ new Error("the engine closed the connection before it answered")));
|
|
9106
9149
|
});
|
|
9107
9150
|
request.on("timeout", () => request.destroy(/* @__PURE__ */ new Error("the engine did not answer in time")));
|
|
9108
|
-
request.on("error",
|
|
9151
|
+
request.on("error", fail);
|
|
9109
9152
|
if (payload !== null) request.write(payload);
|
|
9110
9153
|
request.end();
|
|
9111
9154
|
});
|
|
9112
9155
|
}
|
|
9113
9156
|
async waitReady() {
|
|
9157
|
+
if (this.options.waitReady !== void 0) return this.options.waitReady();
|
|
9114
9158
|
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
9115
9159
|
while (Date.now() < deadline) {
|
|
9160
|
+
if (this.nanny === null) return false;
|
|
9116
9161
|
try {
|
|
9117
9162
|
if ((await this.request("GET", "/config/")).status < 400) return true;
|
|
9118
9163
|
} catch {}
|
|
@@ -10869,10 +10914,23 @@ async function open(file, password) {
|
|
|
10869
10914
|
const blob = await fs.openAsBlob(file, { type: "application/zip" });
|
|
10870
10915
|
return password === void 0 ? new ZipReader(new BlobReader(blob)) : new ZipReader(new BlobReader(blob), { password });
|
|
10871
10916
|
}
|
|
10872
|
-
/**
|
|
10917
|
+
/**
|
|
10918
|
+
* Sorted, deterministic walk with symlinks resolved and directory loops broken.
|
|
10919
|
+
*
|
|
10920
|
+
* Every read is best-effort: a directory that vanishes mid-walk (a rotated log, a
|
|
10921
|
+
* build directory being cleaned, a container being torn down) must leave it out of
|
|
10922
|
+
* the archive rather than reject `createZip` and turn a backup into a failure.
|
|
10923
|
+
*/
|
|
10873
10924
|
function walk(root) {
|
|
10874
10925
|
const files = [];
|
|
10875
10926
|
const seen = /* @__PURE__ */ new Set();
|
|
10927
|
+
const readDir = (absolute) => {
|
|
10928
|
+
try {
|
|
10929
|
+
return fs.readdirSync(absolute).sort();
|
|
10930
|
+
} catch {
|
|
10931
|
+
return [];
|
|
10932
|
+
}
|
|
10933
|
+
};
|
|
10876
10934
|
const visit = (absolute, name) => {
|
|
10877
10935
|
let stats;
|
|
10878
10936
|
try {
|
|
@@ -10881,7 +10939,12 @@ function walk(root) {
|
|
|
10881
10939
|
return;
|
|
10882
10940
|
}
|
|
10883
10941
|
if (stats.isDirectory()) {
|
|
10884
|
-
|
|
10942
|
+
let real;
|
|
10943
|
+
try {
|
|
10944
|
+
real = fs.realpathSync(absolute);
|
|
10945
|
+
} catch {
|
|
10946
|
+
return;
|
|
10947
|
+
}
|
|
10885
10948
|
if (seen.has(real)) return;
|
|
10886
10949
|
seen.add(real);
|
|
10887
10950
|
files.push({
|
|
@@ -10890,7 +10953,7 @@ function walk(root) {
|
|
|
10890
10953
|
directory: true,
|
|
10891
10954
|
size: 0
|
|
10892
10955
|
});
|
|
10893
|
-
for (const child of
|
|
10956
|
+
for (const child of readDir(absolute)) visit(path.join(absolute, child), `${name}/${child}`);
|
|
10894
10957
|
return;
|
|
10895
10958
|
}
|
|
10896
10959
|
if (stats.isFile()) files.push({
|
|
@@ -10900,7 +10963,7 @@ function walk(root) {
|
|
|
10900
10963
|
size: stats.size
|
|
10901
10964
|
});
|
|
10902
10965
|
};
|
|
10903
|
-
for (const child of
|
|
10966
|
+
for (const child of readDir(root)) visit(path.join(root, child), child);
|
|
10904
10967
|
return files;
|
|
10905
10968
|
}
|
|
10906
10969
|
var ZIPS;
|
|
@@ -11335,8 +11398,13 @@ var init_log_tail = __esmMin((() => {
|
|
|
11335
11398
|
try {
|
|
11336
11399
|
const buffer = Buffer$1.alloc(size - start);
|
|
11337
11400
|
const bytes = fs.readSync(handle, buffer, 0, buffer.length, start);
|
|
11338
|
-
const raw = buffer.subarray(0, bytes).toString("utf8").split("\n")
|
|
11339
|
-
|
|
11401
|
+
const raw = buffer.subarray(0, bytes).toString("utf8").split("\n");
|
|
11402
|
+
let partialStart = false;
|
|
11403
|
+
if (start > 0) {
|
|
11404
|
+
const previous = Buffer$1.alloc(1);
|
|
11405
|
+
partialStart = fs.readSync(handle, previous, 0, 1, start - 1) === 1 && previous[0] !== 10;
|
|
11406
|
+
}
|
|
11407
|
+
return (partialStart ? raw.slice(1) : raw).filter((line) => line.trim().length > 0).slice(-count).map(parseLogLine).filter((line) => line !== null);
|
|
11340
11408
|
} catch {
|
|
11341
11409
|
return [];
|
|
11342
11410
|
} finally {
|
|
@@ -11562,6 +11630,8 @@ var init_supervisor = __esmMin((() => {
|
|
|
11562
11630
|
relay = new LogRelay({ onLines: (serverId, lines) => this.ingestExternalLines(serverId, lines) });
|
|
11563
11631
|
entries = /* @__PURE__ */ new Map();
|
|
11564
11632
|
tickTimer;
|
|
11633
|
+
/** Detaches this supervisor from the store's listener set in `dispose()`. */
|
|
11634
|
+
storeUnsubscribe;
|
|
11565
11635
|
disposed = false;
|
|
11566
11636
|
constructor(store, hub, options) {
|
|
11567
11637
|
this.store = store;
|
|
@@ -11570,7 +11640,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
11570
11640
|
const swept = sweepNannySpecs(this.options.nannyDir);
|
|
11571
11641
|
if (swept > 0) logger.warn(`removed ${swept} unread nanny spec file(s) from ${this.options.nannyDir}`);
|
|
11572
11642
|
this.sync();
|
|
11573
|
-
this.store.onChange(() => this.sync());
|
|
11643
|
+
this.storeUnsubscribe = this.store.onChange(() => this.sync());
|
|
11574
11644
|
this.tickTimer = setInterval(() => {
|
|
11575
11645
|
this.tick().catch((error) => logger.error("the supervisor tick failed", error));
|
|
11576
11646
|
}, TICK_INTERVAL_MS);
|
|
@@ -11807,6 +11877,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
11807
11877
|
async dispose() {
|
|
11808
11878
|
this.disposed = true;
|
|
11809
11879
|
clearInterval(this.tickTimer);
|
|
11880
|
+
this.storeUnsubscribe();
|
|
11810
11881
|
for (const entry of this.entries.values()) this.clearRetry(entry);
|
|
11811
11882
|
await Promise.all([...this.entries.values()].filter((entry) => !entry.config.persistent).map((entry) => this.stopEntry(entry)));
|
|
11812
11883
|
this.relay.dispose();
|
|
@@ -11870,6 +11941,9 @@ var init_supervisor = __esmMin((() => {
|
|
|
11870
11941
|
this.clearRetry(entry);
|
|
11871
11942
|
entry.nextRetryAt = null;
|
|
11872
11943
|
entry.stopping = true;
|
|
11944
|
+
if (entry.config.persistent && entry.child === null && entry.pid === null) await this.resumePersistent(entry).catch((error) => {
|
|
11945
|
+
logger.error(`could not reattach the persistent server ${entry.config.id} to stop it`, error);
|
|
11946
|
+
});
|
|
11873
11947
|
if (entry.child === null && entry.pid === null) {
|
|
11874
11948
|
entry.stopping = false;
|
|
11875
11949
|
entry.status = "stopped";
|
|
@@ -12154,19 +12228,26 @@ var init_supervisor = __esmMin((() => {
|
|
|
12154
12228
|
const splitter = new LineSplitter((_stream, text) => {
|
|
12155
12229
|
if (text.trim().length > 0) this.log(entry, "system", `[bootstrap] ${text}`);
|
|
12156
12230
|
});
|
|
12157
|
-
|
|
12158
|
-
|
|
12159
|
-
|
|
12160
|
-
|
|
12161
|
-
|
|
12162
|
-
|
|
12163
|
-
|
|
12164
|
-
|
|
12165
|
-
|
|
12166
|
-
|
|
12167
|
-
|
|
12168
|
-
|
|
12169
|
-
|
|
12231
|
+
let child;
|
|
12232
|
+
try {
|
|
12233
|
+
child = spawn(resolveCommand(spec.command, cwd, projectDir), args, {
|
|
12234
|
+
cwd,
|
|
12235
|
+
env: {
|
|
12236
|
+
...process.env,
|
|
12237
|
+
...resolveRecord(spec.env, vars)
|
|
12238
|
+
},
|
|
12239
|
+
stdio: [
|
|
12240
|
+
"ignore",
|
|
12241
|
+
"pipe",
|
|
12242
|
+
"pipe"
|
|
12243
|
+
],
|
|
12244
|
+
windowsHide: true
|
|
12245
|
+
});
|
|
12246
|
+
} catch (error) {
|
|
12247
|
+
this.log(entry, "system", `bootstrap could not start: ${error.message} — continuing anyway`);
|
|
12248
|
+
entry.bootstrapDone = true;
|
|
12249
|
+
return;
|
|
12250
|
+
}
|
|
12170
12251
|
child.stdout?.on("data", (chunk) => splitter.push("stdout", chunk));
|
|
12171
12252
|
child.stderr?.on("data", (chunk) => splitter.push("stderr", chunk));
|
|
12172
12253
|
const code = await new Promise((resolve) => {
|
|
@@ -12832,6 +12913,29 @@ function isInside(parent, child) {
|
|
|
12832
12913
|
const relative = path.relative(parent, child);
|
|
12833
12914
|
return !path.isAbsolute(relative) && (relative.length === 0 || !relative.startsWith(".."));
|
|
12834
12915
|
}
|
|
12916
|
+
/** Resolves symlinks on the deepest existing ancestor, keeping the rest verbatim. */
|
|
12917
|
+
function resolveExisting(target) {
|
|
12918
|
+
let prefix = target;
|
|
12919
|
+
const rest = [];
|
|
12920
|
+
for (;;) try {
|
|
12921
|
+
return path.join(fs.realpathSync(prefix), ...rest);
|
|
12922
|
+
} catch {
|
|
12923
|
+
const parent = path.dirname(prefix);
|
|
12924
|
+
if (parent === prefix) return target;
|
|
12925
|
+
rest.unshift(path.basename(prefix));
|
|
12926
|
+
prefix = parent;
|
|
12927
|
+
}
|
|
12928
|
+
}
|
|
12929
|
+
/**
|
|
12930
|
+
* `isInside` after resolving symlinks, so a link inside a declared path cannot stand
|
|
12931
|
+
* for somewhere else. `cpSync` follows links already present in the tree it writes
|
|
12932
|
+
* into* — a link to an uploads or cache directory is routine — which would let an
|
|
12933
|
+
* archive write outside the path the config declared, and on capture let a file from
|
|
12934
|
+
* outside it into the archive. Both sides check with this.
|
|
12935
|
+
*/
|
|
12936
|
+
function isInsideResolved(parent, child) {
|
|
12937
|
+
return isInside(resolveExisting(path.resolve(parent)), resolveExisting(path.resolve(child)));
|
|
12938
|
+
}
|
|
12835
12939
|
/**
|
|
12836
12940
|
* Every path a backup should capture: the global list, then each server's
|
|
12837
12941
|
* `backupPaths` and the values of its `dataEnvs`. A path already covered by a
|
|
@@ -13408,7 +13512,8 @@ var init_backups = __esmMin((() => {
|
|
|
13408
13512
|
note: target.path === archivedPath ? null : `restored from ${archivedPath}`
|
|
13409
13513
|
}, () => fs.cpSync(from, target.path, {
|
|
13410
13514
|
recursive: true,
|
|
13411
|
-
force: true
|
|
13515
|
+
force: true,
|
|
13516
|
+
filter: (_source, destination) => isInsideResolved(target.path, destination)
|
|
13412
13517
|
}), aliases);
|
|
13413
13518
|
}
|
|
13414
13519
|
if (parsedGlobal !== null && actions.has("global:settings") && settingsText !== null) {
|
|
@@ -17229,7 +17334,7 @@ async function uiSwitch(argv, io) {
|
|
|
17229
17334
|
io.write("cancelled — nothing was installed\n");
|
|
17230
17335
|
return;
|
|
17231
17336
|
}
|
|
17232
|
-
await installFromUrl(assetDownloadUrl(chosen), fallbackUiName(chosen.name ?? ""), context);
|
|
17337
|
+
await installFromUrl(assetDownloadUrl(chosen), fallbackUiName(chosen.name ?? ""), context, release.tag);
|
|
17233
17338
|
}
|
|
17234
17339
|
async function chooseAsset(assets, values, context) {
|
|
17235
17340
|
const { io } = context;
|
|
@@ -17269,7 +17374,13 @@ async function installFromFile(value, context) {
|
|
|
17269
17374
|
}
|
|
17270
17375
|
await installFromUrl(source.url, fallbackUiName(new URL(source.url).pathname), context);
|
|
17271
17376
|
}
|
|
17272
|
-
|
|
17377
|
+
/**
|
|
17378
|
+
* `installedTag` is the release the asset came from, never the one inside the archive:
|
|
17379
|
+
* a UI zip is built before its release is cut, so it carries the previous tag at best,
|
|
17380
|
+
* and recording that makes the panel re-install the same UI on every boot. A local file
|
|
17381
|
+
* or a bare URL names no release, so it has none to record.
|
|
17382
|
+
*/
|
|
17383
|
+
async function installFromUrl(url, fallbackName, context, installedTag) {
|
|
17273
17384
|
const headers = {
|
|
17274
17385
|
"accept": "application/octet-stream",
|
|
17275
17386
|
"user-agent": `home-hosted/${context.version}`
|
|
@@ -17277,7 +17388,7 @@ async function installFromUrl(url, fallbackName, context) {
|
|
|
17277
17388
|
if (context.token !== null && context.token.length > 0 && isGithubHost(url)) headers.authorization = `Bearer ${context.token}`;
|
|
17278
17389
|
const download = await downloadToTemp(url, headers, context);
|
|
17279
17390
|
try {
|
|
17280
|
-
await installArchive(download.file, fallbackName, context);
|
|
17391
|
+
await installArchive(download.file, fallbackName, context, installedTag);
|
|
17281
17392
|
} finally {
|
|
17282
17393
|
fs.rmSync(download.dir, {
|
|
17283
17394
|
recursive: true,
|
|
@@ -17285,11 +17396,11 @@ async function installFromUrl(url, fallbackName, context) {
|
|
|
17285
17396
|
});
|
|
17286
17397
|
}
|
|
17287
17398
|
}
|
|
17288
|
-
async function installArchive(archivePath, fallbackName, context) {
|
|
17399
|
+
async function installArchive(archivePath, fallbackName, context, installedTag) {
|
|
17289
17400
|
const { UiService } = await Promise.resolve().then(() => (init_ui(), ui_exports));
|
|
17290
17401
|
const { hhDir: dataRoot } = await Promise.resolve().then(() => (init_paths(), paths_exports));
|
|
17291
17402
|
const ui = new UiService({ dataRoot });
|
|
17292
|
-
const result = await ui.install(archivePath, fallbackName);
|
|
17403
|
+
const result = await ui.install(archivePath, fallbackName, installedTag);
|
|
17293
17404
|
if (!result.ok) throw new Error(`nothing was installed: ${result.error}`);
|
|
17294
17405
|
const { meta } = result;
|
|
17295
17406
|
const label = meta.version === null ? meta.name : `${meta.name} ${meta.version}`;
|
|
@@ -17550,7 +17661,7 @@ async function installTag(repo, wantedAsset, tag, context) {
|
|
|
17550
17661
|
try {
|
|
17551
17662
|
const { UiService } = await Promise.resolve().then(() => (init_ui(), ui_exports));
|
|
17552
17663
|
const { hhDir: dataRoot } = await Promise.resolve().then(() => (init_paths(), paths_exports));
|
|
17553
|
-
const result = await new UiService({ dataRoot }).install(download.file, assetName);
|
|
17664
|
+
const result = await new UiService({ dataRoot }).install(download.file, assetName, release.tag);
|
|
17554
17665
|
if (!result.ok) throw new Error(`nothing was installed: ${result.error}`);
|
|
17555
17666
|
const { meta } = result;
|
|
17556
17667
|
const { io } = context;
|