home-hosted 0.7.4 → 0.7.6
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 +189 -53
- 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 {}
|
|
@@ -10255,6 +10300,26 @@ function readJson(file) {
|
|
|
10255
10300
|
return null;
|
|
10256
10301
|
}
|
|
10257
10302
|
}
|
|
10303
|
+
/**
|
|
10304
|
+
* A legacy file, with "absent" told apart from "present but unreadable".
|
|
10305
|
+
*
|
|
10306
|
+
* `readJson` collapses both into `null`, and a split that is skipped reads exactly like
|
|
10307
|
+
* one with nothing to do — so a corrupt legacy config used to be abandoned silently
|
|
10308
|
+
* while `workspaces.json` was still created, after which `hasLegacyLayout()` is false
|
|
10309
|
+
* and no later run ever looks at that file again. The definitions would sit unread
|
|
10310
|
+
* beside a panel supervising nothing.
|
|
10311
|
+
*/
|
|
10312
|
+
function readLegacy(file) {
|
|
10313
|
+
if (!fs.existsSync(file)) return {
|
|
10314
|
+
parsed: null,
|
|
10315
|
+
unreadable: false
|
|
10316
|
+
};
|
|
10317
|
+
const parsed = readJson(file);
|
|
10318
|
+
return {
|
|
10319
|
+
parsed,
|
|
10320
|
+
unreadable: parsed === null
|
|
10321
|
+
};
|
|
10322
|
+
}
|
|
10258
10323
|
function writeJson(file, value, mode) {
|
|
10259
10324
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
10260
10325
|
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, mode === void 0 ? {} : { mode });
|
|
@@ -10352,10 +10417,11 @@ function migrateLayout() {
|
|
|
10352
10417
|
const moved = [];
|
|
10353
10418
|
const warnings = [];
|
|
10354
10419
|
fs.mkdirSync(hhDir, { recursive: true });
|
|
10355
|
-
const legacyConfig =
|
|
10420
|
+
const legacyConfig = readLegacy(legacy.configPath);
|
|
10356
10421
|
let configSplit = false;
|
|
10357
|
-
if (legacyConfig
|
|
10358
|
-
|
|
10422
|
+
if (legacyConfig.unreadable) warnings.push(`${legacy.configPath} could not be read — it was NOT migrated and is still there; fix or remove it, then move the file in by hand`);
|
|
10423
|
+
if (legacyConfig.parsed !== null) {
|
|
10424
|
+
const split = splitConfig(legacyConfig.parsed);
|
|
10359
10425
|
const destinations = [
|
|
10360
10426
|
path.join(hhDir, "settings.json"),
|
|
10361
10427
|
path.join(hhDir, "default", "settings.json"),
|
|
@@ -10372,10 +10438,11 @@ function migrateLayout() {
|
|
|
10372
10438
|
configSplit = true;
|
|
10373
10439
|
}
|
|
10374
10440
|
}
|
|
10375
|
-
const legacySecrets =
|
|
10441
|
+
const legacySecrets = readLegacy(legacy.secretsPath);
|
|
10376
10442
|
let secretsSplit = false;
|
|
10377
|
-
if (legacySecrets
|
|
10378
|
-
|
|
10443
|
+
if (legacySecrets.unreadable) warnings.push(`${legacy.secretsPath} could not be read — it was NOT migrated and is still there; a panel without it starts on the default password`);
|
|
10444
|
+
if (legacySecrets.parsed !== null) {
|
|
10445
|
+
const split = splitSecrets(legacySecrets.parsed);
|
|
10379
10446
|
const destinations = [path.join(hhDir, ".control-secrets.json"), path.join(hhDir, "default", ".secrets.json")];
|
|
10380
10447
|
const taken = destinations.filter((file) => fs.existsSync(file));
|
|
10381
10448
|
if (taken.length > 0) warnings.push(`${legacy.secretsPath} could not be split — ${taken.map((file) => path.relative(hhDir, file)).join(", ")} already exist(s); left it in place`);
|
|
@@ -10555,6 +10622,8 @@ function localCall(runtime, path, method, token, timeoutMs) {
|
|
|
10555
10622
|
status: response.statusCode ?? 0,
|
|
10556
10623
|
body
|
|
10557
10624
|
}));
|
|
10625
|
+
response.once("aborted", () => resolve(null));
|
|
10626
|
+
response.once("error", () => resolve(null));
|
|
10558
10627
|
});
|
|
10559
10628
|
request.once("error", () => resolve(null));
|
|
10560
10629
|
request.once("timeout", () => {
|
|
@@ -10869,10 +10938,23 @@ async function open(file, password) {
|
|
|
10869
10938
|
const blob = await fs.openAsBlob(file, { type: "application/zip" });
|
|
10870
10939
|
return password === void 0 ? new ZipReader(new BlobReader(blob)) : new ZipReader(new BlobReader(blob), { password });
|
|
10871
10940
|
}
|
|
10872
|
-
/**
|
|
10941
|
+
/**
|
|
10942
|
+
* Sorted, deterministic walk with symlinks resolved and directory loops broken.
|
|
10943
|
+
*
|
|
10944
|
+
* Every read is best-effort: a directory that vanishes mid-walk (a rotated log, a
|
|
10945
|
+
* build directory being cleaned, a container being torn down) must leave it out of
|
|
10946
|
+
* the archive rather than reject `createZip` and turn a backup into a failure.
|
|
10947
|
+
*/
|
|
10873
10948
|
function walk(root) {
|
|
10874
10949
|
const files = [];
|
|
10875
10950
|
const seen = /* @__PURE__ */ new Set();
|
|
10951
|
+
const readDir = (absolute) => {
|
|
10952
|
+
try {
|
|
10953
|
+
return fs.readdirSync(absolute).sort();
|
|
10954
|
+
} catch {
|
|
10955
|
+
return [];
|
|
10956
|
+
}
|
|
10957
|
+
};
|
|
10876
10958
|
const visit = (absolute, name) => {
|
|
10877
10959
|
let stats;
|
|
10878
10960
|
try {
|
|
@@ -10881,7 +10963,12 @@ function walk(root) {
|
|
|
10881
10963
|
return;
|
|
10882
10964
|
}
|
|
10883
10965
|
if (stats.isDirectory()) {
|
|
10884
|
-
|
|
10966
|
+
let real;
|
|
10967
|
+
try {
|
|
10968
|
+
real = fs.realpathSync(absolute);
|
|
10969
|
+
} catch {
|
|
10970
|
+
return;
|
|
10971
|
+
}
|
|
10885
10972
|
if (seen.has(real)) return;
|
|
10886
10973
|
seen.add(real);
|
|
10887
10974
|
files.push({
|
|
@@ -10890,7 +10977,7 @@ function walk(root) {
|
|
|
10890
10977
|
directory: true,
|
|
10891
10978
|
size: 0
|
|
10892
10979
|
});
|
|
10893
|
-
for (const child of
|
|
10980
|
+
for (const child of readDir(absolute)) visit(path.join(absolute, child), `${name}/${child}`);
|
|
10894
10981
|
return;
|
|
10895
10982
|
}
|
|
10896
10983
|
if (stats.isFile()) files.push({
|
|
@@ -10900,7 +10987,7 @@ function walk(root) {
|
|
|
10900
10987
|
size: stats.size
|
|
10901
10988
|
});
|
|
10902
10989
|
};
|
|
10903
|
-
for (const child of
|
|
10990
|
+
for (const child of readDir(root)) visit(path.join(root, child), child);
|
|
10904
10991
|
return files;
|
|
10905
10992
|
}
|
|
10906
10993
|
var ZIPS;
|
|
@@ -11335,8 +11422,13 @@ var init_log_tail = __esmMin((() => {
|
|
|
11335
11422
|
try {
|
|
11336
11423
|
const buffer = Buffer$1.alloc(size - start);
|
|
11337
11424
|
const bytes = fs.readSync(handle, buffer, 0, buffer.length, start);
|
|
11338
|
-
const raw = buffer.subarray(0, bytes).toString("utf8").split("\n")
|
|
11339
|
-
|
|
11425
|
+
const raw = buffer.subarray(0, bytes).toString("utf8").split("\n");
|
|
11426
|
+
let partialStart = false;
|
|
11427
|
+
if (start > 0) {
|
|
11428
|
+
const previous = Buffer$1.alloc(1);
|
|
11429
|
+
partialStart = fs.readSync(handle, previous, 0, 1, start - 1) === 1 && previous[0] !== 10;
|
|
11430
|
+
}
|
|
11431
|
+
return (partialStart ? raw.slice(1) : raw).filter((line) => line.trim().length > 0).slice(-count).map(parseLogLine).filter((line) => line !== null);
|
|
11340
11432
|
} catch {
|
|
11341
11433
|
return [];
|
|
11342
11434
|
} finally {
|
|
@@ -11562,6 +11654,8 @@ var init_supervisor = __esmMin((() => {
|
|
|
11562
11654
|
relay = new LogRelay({ onLines: (serverId, lines) => this.ingestExternalLines(serverId, lines) });
|
|
11563
11655
|
entries = /* @__PURE__ */ new Map();
|
|
11564
11656
|
tickTimer;
|
|
11657
|
+
/** Detaches this supervisor from the store's listener set in `dispose()`. */
|
|
11658
|
+
storeUnsubscribe;
|
|
11565
11659
|
disposed = false;
|
|
11566
11660
|
constructor(store, hub, options) {
|
|
11567
11661
|
this.store = store;
|
|
@@ -11570,7 +11664,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
11570
11664
|
const swept = sweepNannySpecs(this.options.nannyDir);
|
|
11571
11665
|
if (swept > 0) logger.warn(`removed ${swept} unread nanny spec file(s) from ${this.options.nannyDir}`);
|
|
11572
11666
|
this.sync();
|
|
11573
|
-
this.store.onChange(() => this.sync());
|
|
11667
|
+
this.storeUnsubscribe = this.store.onChange(() => this.sync());
|
|
11574
11668
|
this.tickTimer = setInterval(() => {
|
|
11575
11669
|
this.tick().catch((error) => logger.error("the supervisor tick failed", error));
|
|
11576
11670
|
}, TICK_INTERVAL_MS);
|
|
@@ -11807,6 +11901,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
11807
11901
|
async dispose() {
|
|
11808
11902
|
this.disposed = true;
|
|
11809
11903
|
clearInterval(this.tickTimer);
|
|
11904
|
+
this.storeUnsubscribe();
|
|
11810
11905
|
for (const entry of this.entries.values()) this.clearRetry(entry);
|
|
11811
11906
|
await Promise.all([...this.entries.values()].filter((entry) => !entry.config.persistent).map((entry) => this.stopEntry(entry)));
|
|
11812
11907
|
this.relay.dispose();
|
|
@@ -11870,6 +11965,9 @@ var init_supervisor = __esmMin((() => {
|
|
|
11870
11965
|
this.clearRetry(entry);
|
|
11871
11966
|
entry.nextRetryAt = null;
|
|
11872
11967
|
entry.stopping = true;
|
|
11968
|
+
if (entry.config.persistent && entry.child === null && entry.pid === null) await this.resumePersistent(entry).catch((error) => {
|
|
11969
|
+
logger.error(`could not reattach the persistent server ${entry.config.id} to stop it`, error);
|
|
11970
|
+
});
|
|
11873
11971
|
if (entry.child === null && entry.pid === null) {
|
|
11874
11972
|
entry.stopping = false;
|
|
11875
11973
|
entry.status = "stopped";
|
|
@@ -12154,19 +12252,26 @@ var init_supervisor = __esmMin((() => {
|
|
|
12154
12252
|
const splitter = new LineSplitter((_stream, text) => {
|
|
12155
12253
|
if (text.trim().length > 0) this.log(entry, "system", `[bootstrap] ${text}`);
|
|
12156
12254
|
});
|
|
12157
|
-
|
|
12158
|
-
|
|
12159
|
-
|
|
12160
|
-
|
|
12161
|
-
|
|
12162
|
-
|
|
12163
|
-
|
|
12164
|
-
|
|
12165
|
-
|
|
12166
|
-
|
|
12167
|
-
|
|
12168
|
-
|
|
12169
|
-
|
|
12255
|
+
let child;
|
|
12256
|
+
try {
|
|
12257
|
+
child = spawn(resolveCommand(spec.command, cwd, projectDir), args, {
|
|
12258
|
+
cwd,
|
|
12259
|
+
env: {
|
|
12260
|
+
...process.env,
|
|
12261
|
+
...resolveRecord(spec.env, vars)
|
|
12262
|
+
},
|
|
12263
|
+
stdio: [
|
|
12264
|
+
"ignore",
|
|
12265
|
+
"pipe",
|
|
12266
|
+
"pipe"
|
|
12267
|
+
],
|
|
12268
|
+
windowsHide: true
|
|
12269
|
+
});
|
|
12270
|
+
} catch (error) {
|
|
12271
|
+
this.log(entry, "system", `bootstrap could not start: ${error.message} — continuing anyway`);
|
|
12272
|
+
entry.bootstrapDone = true;
|
|
12273
|
+
return;
|
|
12274
|
+
}
|
|
12170
12275
|
child.stdout?.on("data", (chunk) => splitter.push("stdout", chunk));
|
|
12171
12276
|
child.stderr?.on("data", (chunk) => splitter.push("stderr", chunk));
|
|
12172
12277
|
const code = await new Promise((resolve) => {
|
|
@@ -12832,6 +12937,29 @@ function isInside(parent, child) {
|
|
|
12832
12937
|
const relative = path.relative(parent, child);
|
|
12833
12938
|
return !path.isAbsolute(relative) && (relative.length === 0 || !relative.startsWith(".."));
|
|
12834
12939
|
}
|
|
12940
|
+
/** Resolves symlinks on the deepest existing ancestor, keeping the rest verbatim. */
|
|
12941
|
+
function resolveExisting(target) {
|
|
12942
|
+
let prefix = target;
|
|
12943
|
+
const rest = [];
|
|
12944
|
+
for (;;) try {
|
|
12945
|
+
return path.join(fs.realpathSync(prefix), ...rest);
|
|
12946
|
+
} catch {
|
|
12947
|
+
const parent = path.dirname(prefix);
|
|
12948
|
+
if (parent === prefix) return target;
|
|
12949
|
+
rest.unshift(path.basename(prefix));
|
|
12950
|
+
prefix = parent;
|
|
12951
|
+
}
|
|
12952
|
+
}
|
|
12953
|
+
/**
|
|
12954
|
+
* `isInside` after resolving symlinks, so a link inside a declared path cannot stand
|
|
12955
|
+
* for somewhere else. `cpSync` follows links already present in the tree it writes
|
|
12956
|
+
* into* — a link to an uploads or cache directory is routine — which would let an
|
|
12957
|
+
* archive write outside the path the config declared, and on capture let a file from
|
|
12958
|
+
* outside it into the archive. Both sides check with this.
|
|
12959
|
+
*/
|
|
12960
|
+
function isInsideResolved(parent, child) {
|
|
12961
|
+
return isInside(resolveExisting(path.resolve(parent)), resolveExisting(path.resolve(child)));
|
|
12962
|
+
}
|
|
12835
12963
|
/**
|
|
12836
12964
|
* Every path a backup should capture: the global list, then each server's
|
|
12837
12965
|
* `backupPaths` and the values of its `dataEnvs`. A path already covered by a
|
|
@@ -13408,7 +13536,8 @@ var init_backups = __esmMin((() => {
|
|
|
13408
13536
|
note: target.path === archivedPath ? null : `restored from ${archivedPath}`
|
|
13409
13537
|
}, () => fs.cpSync(from, target.path, {
|
|
13410
13538
|
recursive: true,
|
|
13411
|
-
force: true
|
|
13539
|
+
force: true,
|
|
13540
|
+
filter: (_source, destination) => isInsideResolved(target.path, destination)
|
|
13412
13541
|
}), aliases);
|
|
13413
13542
|
}
|
|
13414
13543
|
if (parsedGlobal !== null && actions.has("global:settings") && settingsText !== null) {
|
|
@@ -16982,6 +17111,7 @@ state/.hh/*
|
|
|
16982
17111
|
!state/.hh/settings.json
|
|
16983
17112
|
!state/.hh/workspaces.json
|
|
16984
17113
|
state/.hh/*/*
|
|
17114
|
+
!state/.hh/*/
|
|
16985
17115
|
!state/.hh/*/settings.json
|
|
16986
17116
|
!state/.hh/*/servers.config.json
|
|
16987
17117
|
data/
|
|
@@ -17229,7 +17359,7 @@ async function uiSwitch(argv, io) {
|
|
|
17229
17359
|
io.write("cancelled — nothing was installed\n");
|
|
17230
17360
|
return;
|
|
17231
17361
|
}
|
|
17232
|
-
await installFromUrl(assetDownloadUrl(chosen), fallbackUiName(chosen.name ?? ""), context);
|
|
17362
|
+
await installFromUrl(assetDownloadUrl(chosen), fallbackUiName(chosen.name ?? ""), context, release.tag);
|
|
17233
17363
|
}
|
|
17234
17364
|
async function chooseAsset(assets, values, context) {
|
|
17235
17365
|
const { io } = context;
|
|
@@ -17269,7 +17399,13 @@ async function installFromFile(value, context) {
|
|
|
17269
17399
|
}
|
|
17270
17400
|
await installFromUrl(source.url, fallbackUiName(new URL(source.url).pathname), context);
|
|
17271
17401
|
}
|
|
17272
|
-
|
|
17402
|
+
/**
|
|
17403
|
+
* `installedTag` is the release the asset came from, never the one inside the archive:
|
|
17404
|
+
* a UI zip is built before its release is cut, so it carries the previous tag at best,
|
|
17405
|
+
* and recording that makes the panel re-install the same UI on every boot. A local file
|
|
17406
|
+
* or a bare URL names no release, so it has none to record.
|
|
17407
|
+
*/
|
|
17408
|
+
async function installFromUrl(url, fallbackName, context, installedTag) {
|
|
17273
17409
|
const headers = {
|
|
17274
17410
|
"accept": "application/octet-stream",
|
|
17275
17411
|
"user-agent": `home-hosted/${context.version}`
|
|
@@ -17277,7 +17413,7 @@ async function installFromUrl(url, fallbackName, context) {
|
|
|
17277
17413
|
if (context.token !== null && context.token.length > 0 && isGithubHost(url)) headers.authorization = `Bearer ${context.token}`;
|
|
17278
17414
|
const download = await downloadToTemp(url, headers, context);
|
|
17279
17415
|
try {
|
|
17280
|
-
await installArchive(download.file, fallbackName, context);
|
|
17416
|
+
await installArchive(download.file, fallbackName, context, installedTag);
|
|
17281
17417
|
} finally {
|
|
17282
17418
|
fs.rmSync(download.dir, {
|
|
17283
17419
|
recursive: true,
|
|
@@ -17285,11 +17421,11 @@ async function installFromUrl(url, fallbackName, context) {
|
|
|
17285
17421
|
});
|
|
17286
17422
|
}
|
|
17287
17423
|
}
|
|
17288
|
-
async function installArchive(archivePath, fallbackName, context) {
|
|
17424
|
+
async function installArchive(archivePath, fallbackName, context, installedTag) {
|
|
17289
17425
|
const { UiService } = await Promise.resolve().then(() => (init_ui(), ui_exports));
|
|
17290
17426
|
const { hhDir: dataRoot } = await Promise.resolve().then(() => (init_paths(), paths_exports));
|
|
17291
17427
|
const ui = new UiService({ dataRoot });
|
|
17292
|
-
const result = await ui.install(archivePath, fallbackName);
|
|
17428
|
+
const result = await ui.install(archivePath, fallbackName, installedTag);
|
|
17293
17429
|
if (!result.ok) throw new Error(`nothing was installed: ${result.error}`);
|
|
17294
17430
|
const { meta } = result;
|
|
17295
17431
|
const label = meta.version === null ? meta.name : `${meta.name} ${meta.version}`;
|
|
@@ -17550,7 +17686,7 @@ async function installTag(repo, wantedAsset, tag, context) {
|
|
|
17550
17686
|
try {
|
|
17551
17687
|
const { UiService } = await Promise.resolve().then(() => (init_ui(), ui_exports));
|
|
17552
17688
|
const { hhDir: dataRoot } = await Promise.resolve().then(() => (init_paths(), paths_exports));
|
|
17553
|
-
const result = await new UiService({ dataRoot }).install(download.file, assetName);
|
|
17689
|
+
const result = await new UiService({ dataRoot }).install(download.file, assetName, release.tag);
|
|
17554
17690
|
if (!result.ok) throw new Error(`nothing was installed: ${result.error}`);
|
|
17555
17691
|
const { meta } = result;
|
|
17556
17692
|
const { io } = context;
|