home-hosted 0.4.1 → 0.6.0
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 +101 -26
- package/README.md +113 -43
- package/dist/cli.js +2153 -765
- package/dist/cli.js.map +1 -1
- package/{SERVERS.md → docs/SERVERS.md} +61 -8
- package/{UI_CREATION.md → docs/UI_CREATION.md} +34 -2
- package/package.json +9 -5
- package/uis/stock/dist/assets/index-4_Q69j7h.js +46 -0
- package/uis/stock/dist/assets/index-DMo6krK0.css +2 -0
- package/uis/stock/dist/index.html +2 -2
- package/uis/stock/dist/assets/index-DOD80X00.css +0 -2
- package/uis/stock/dist/assets/index-Dl2nDThm.js +0 -47
- /package/{NOTIFICATIONS.md → docs/NOTIFICATIONS.md} +0 -0
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { exec, execFile, spawn, spawnSync } from "node:child_process";
|
|
2
1
|
import fs from "node:fs";
|
|
3
|
-
import path from "node:path";
|
|
4
2
|
import process from "node:process";
|
|
5
|
-
import readline from "node:readline";
|
|
6
3
|
import { fileURLToPath } from "node:url";
|
|
7
|
-
import {
|
|
4
|
+
import { defineCommand, runCommand } from "citty";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import readline from "node:readline";
|
|
7
|
+
import { exec, execFile, spawn, spawnSync } from "node:child_process";
|
|
8
8
|
import os from "node:os";
|
|
9
9
|
import { DetailedError } from "@namesmt/utils";
|
|
10
10
|
import { describeRoute, openAPIRouteHandler, resolver, validator } from "hono-openapi";
|
|
@@ -23,6 +23,7 @@ import { Scalar } from "@scalar/hono-api-reference";
|
|
|
23
23
|
import http from "node:http";
|
|
24
24
|
import https from "node:https";
|
|
25
25
|
import net from "node:net";
|
|
26
|
+
import { parseArgs, promisify } from "node:util";
|
|
26
27
|
import { Readable, Writable } from "node:stream";
|
|
27
28
|
import { finished } from "node:stream/promises";
|
|
28
29
|
import { BlobReader, ZipReader, ZipWriter, configure } from "@zip.js/zip.js";
|
|
@@ -47,6 +48,221 @@ var __exportAll = (all, no_symbols) => {
|
|
|
47
48
|
return target;
|
|
48
49
|
};
|
|
49
50
|
//#endregion
|
|
51
|
+
//#region src/cli/args.ts
|
|
52
|
+
/** `--home`/`--project` are handled for every command, so they are peeled off first. */
|
|
53
|
+
function extractDirFlags(argv) {
|
|
54
|
+
const rest = [];
|
|
55
|
+
let project;
|
|
56
|
+
let home;
|
|
57
|
+
for (let index = 0; index < argv.length; index++) {
|
|
58
|
+
const arg = argv[index];
|
|
59
|
+
const equals = arg.indexOf("=");
|
|
60
|
+
const name = equals === -1 ? arg : arg.slice(0, equals);
|
|
61
|
+
if (name !== "--project" && name !== "--home") {
|
|
62
|
+
rest.push(arg);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const value = equals === -1 ? argv[++index] : arg.slice(equals + 1);
|
|
66
|
+
if (value === void 0 || value.length === 0) return {
|
|
67
|
+
rest,
|
|
68
|
+
project,
|
|
69
|
+
home,
|
|
70
|
+
error: `${name} needs a directory`
|
|
71
|
+
};
|
|
72
|
+
if (name === "--project") project = value;
|
|
73
|
+
else home = value;
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
rest,
|
|
77
|
+
project,
|
|
78
|
+
home
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/** Set before any state module is imported, so it decides where state lives. */
|
|
82
|
+
function applyDirFlags(flags) {
|
|
83
|
+
if (flags.project !== void 0) process.env.HHOSTED_PROJECT = path.resolve(flags.project);
|
|
84
|
+
if (flags.home !== void 0) process.env.HHOSTED_HOME = path.resolve(flags.home);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* What the stripped argv means, before citty sees it.
|
|
88
|
+
*
|
|
89
|
+
* `help`/`version` are commands here, not flags, and a help or version flag on a
|
|
90
|
+
* command is answered the same way instead of being parsed as one of that
|
|
91
|
+
* command's options. A help flag after a command keeps that command's name, so
|
|
92
|
+
* the curated text can stay scoped to it; the bare `help` (or a top-level flag)
|
|
93
|
+
* is the whole reference. A first token that starts with `-` is the one-shot
|
|
94
|
+
* form (`home-hosted -p 4000`), so `up` is prepended. Anything else has to name
|
|
95
|
+
* a command, which keeps the old `unknown command:` text exact.
|
|
96
|
+
*/
|
|
97
|
+
function resolveInvocation(argv, commands) {
|
|
98
|
+
if (argv.length === 0) return {
|
|
99
|
+
kind: "command",
|
|
100
|
+
argv: ["up"]
|
|
101
|
+
};
|
|
102
|
+
const first = argv[0];
|
|
103
|
+
if (HELP_TOKENS.has(first)) return { kind: "help" };
|
|
104
|
+
if (VERSION_TOKENS.has(first)) return { kind: "version" };
|
|
105
|
+
if (first.startsWith("-")) return {
|
|
106
|
+
kind: "command",
|
|
107
|
+
argv: ["up", ...argv]
|
|
108
|
+
};
|
|
109
|
+
if (!commands.includes(first)) return {
|
|
110
|
+
kind: "unknown",
|
|
111
|
+
command: first
|
|
112
|
+
};
|
|
113
|
+
for (const arg of argv.slice(1)) {
|
|
114
|
+
if (HELP_FLAGS.has(arg)) return {
|
|
115
|
+
kind: "help",
|
|
116
|
+
command: first
|
|
117
|
+
};
|
|
118
|
+
if (VERSION_FLAGS.has(arg)) return { kind: "version" };
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
kind: "command",
|
|
122
|
+
argv
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/** citty takes camelCase definitions; the flag a person types is kebab-case. */
|
|
126
|
+
function kebab(name) {
|
|
127
|
+
return name.replace(CAMEL, (match) => `-${match.toLowerCase()}`);
|
|
128
|
+
}
|
|
129
|
+
function aliasesOf(def) {
|
|
130
|
+
if (def === void 0 || !("alias" in def) || def.alias === void 0) return [];
|
|
131
|
+
return Array.isArray(def.alias) ? def.alias : [def.alias];
|
|
132
|
+
}
|
|
133
|
+
function findArg(argsDef, name) {
|
|
134
|
+
for (const [key, def] of Object.entries(argsDef)) {
|
|
135
|
+
if (def === void 0) continue;
|
|
136
|
+
if ((/* @__PURE__ */ new Set([
|
|
137
|
+
key,
|
|
138
|
+
kebab(key),
|
|
139
|
+
...aliasesOf(def)
|
|
140
|
+
])).has(name)) return def;
|
|
141
|
+
if (def.type === "boolean" && (name === `no-${key}` || name === `no-${kebab(key)}`)) return def;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* citty parses permissively (`strict: false`), so a mistyped flag would quietly do
|
|
146
|
+
* nothing — `--autostart` where `--no-autostart` was meant would start the panel
|
|
147
|
+
* with the wrong policy. This keeps the refusal the command line always had, from
|
|
148
|
+
* citty's own definitions rather than a second list.
|
|
149
|
+
*
|
|
150
|
+
* A command that declares no arguments parses its own argv (and rejects its own
|
|
151
|
+
* unknown flags), so it is left alone. Returns the message to print, or null.
|
|
152
|
+
*/
|
|
153
|
+
function rejectUnknownFlags(argv, argsDef) {
|
|
154
|
+
if (argsDef === void 0 || Object.keys(argsDef).length === 0) return null;
|
|
155
|
+
for (let index = 0; index < argv.length; index++) {
|
|
156
|
+
const token = argv[index];
|
|
157
|
+
if (token === "--") return null;
|
|
158
|
+
if (!token.startsWith("-") || token.length === 1) return `Unexpected argument '${token}'`;
|
|
159
|
+
const body = token.startsWith("--") ? token.slice(2) : token.slice(1);
|
|
160
|
+
const equals = body.indexOf("=");
|
|
161
|
+
const name = equals === -1 ? body : body.slice(0, equals);
|
|
162
|
+
const def = name.length === 0 ? void 0 : findArg(argsDef, name);
|
|
163
|
+
if (def === void 0) return `Unknown option '${token}'`;
|
|
164
|
+
if (def.type === "string" && equals === -1 && argv[index + 1] === void 0) return `Option '${token}' needs a value`;
|
|
165
|
+
if (def.type === "string" && equals === -1) index += 1;
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The daemon gets the same instructions, but never `--foreground` (that is what
|
|
171
|
+
* makes it the daemon) and never `--print-config` (that one is answered in the
|
|
172
|
+
* calling process).
|
|
173
|
+
*/
|
|
174
|
+
function buildDaemonArgv(flags) {
|
|
175
|
+
const args = ["up", "--foreground"];
|
|
176
|
+
if (flags.config !== void 0) args.push("--config", flags.config);
|
|
177
|
+
if (flags.port !== void 0) args.push("--port", String(flags.port));
|
|
178
|
+
if (flags.host !== void 0) args.push("--host", flags.host);
|
|
179
|
+
if (!flags.autostart) args.push("--no-autostart");
|
|
180
|
+
if (flags.open) args.push("--open");
|
|
181
|
+
return args;
|
|
182
|
+
}
|
|
183
|
+
var HELP_TOKENS, VERSION_TOKENS, HELP_FLAGS, VERSION_FLAGS, CAMEL;
|
|
184
|
+
var init_args = __esmMin((() => {
|
|
185
|
+
HELP_TOKENS = /* @__PURE__ */ new Set([
|
|
186
|
+
"help",
|
|
187
|
+
"--help",
|
|
188
|
+
"-h"
|
|
189
|
+
]);
|
|
190
|
+
VERSION_TOKENS = /* @__PURE__ */ new Set([
|
|
191
|
+
"version",
|
|
192
|
+
"--version",
|
|
193
|
+
"-v"
|
|
194
|
+
]);
|
|
195
|
+
HELP_FLAGS = /* @__PURE__ */ new Set(["--help", "-h"]);
|
|
196
|
+
VERSION_FLAGS = /* @__PURE__ */ new Set(["--version", "-v"]);
|
|
197
|
+
CAMEL = /[A-Z]/g;
|
|
198
|
+
}));
|
|
199
|
+
//#endregion
|
|
200
|
+
//#region src/cli/io.ts
|
|
201
|
+
/** One shape for every failure: a red `error <message>` on stderr and exit 1. */
|
|
202
|
+
function fail(message) {
|
|
203
|
+
process.stderr.write(`${paint("31", "error")} ${message}\n`);
|
|
204
|
+
process.exit(1);
|
|
205
|
+
}
|
|
206
|
+
function delay$3(ms) {
|
|
207
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
208
|
+
}
|
|
209
|
+
/** A plain y/N question, for decisions that are not secrets. */
|
|
210
|
+
function prompt(question) {
|
|
211
|
+
return new Promise((resolve) => {
|
|
212
|
+
const rl = readline.createInterface({
|
|
213
|
+
input: process.stdin,
|
|
214
|
+
output: process.stdout
|
|
215
|
+
});
|
|
216
|
+
rl.question(question, (answer) => {
|
|
217
|
+
rl.close();
|
|
218
|
+
resolve(answer);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
/** Reads a line with echo suppressed, so the password never lands in scrollback. */
|
|
223
|
+
function promptHidden(question) {
|
|
224
|
+
return new Promise((resolve) => {
|
|
225
|
+
const rl = readline.createInterface({
|
|
226
|
+
input: process.stdin,
|
|
227
|
+
output: process.stdout,
|
|
228
|
+
terminal: true
|
|
229
|
+
});
|
|
230
|
+
const onData = () => {
|
|
231
|
+
readline.clearLine(process.stdout, 0);
|
|
232
|
+
readline.cursorTo(process.stdout, 0);
|
|
233
|
+
process.stdout.write(question);
|
|
234
|
+
};
|
|
235
|
+
process.stdin.on("data", onData);
|
|
236
|
+
rl.question(question, (answer) => {
|
|
237
|
+
process.stdin.off("data", onData);
|
|
238
|
+
rl.close();
|
|
239
|
+
process.stdout.write("\n");
|
|
240
|
+
resolve(answer);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
/** Asks a yes/no question with a default, so an empty answer is a real answer. */
|
|
245
|
+
async function confirm(question, fallback) {
|
|
246
|
+
const answer = (await prompt(`${question} ${fallback ? "[Y/n]" : "[y/N]"} `)).trim().toLowerCase();
|
|
247
|
+
if (answer.length === 0) return fallback;
|
|
248
|
+
return answer === "y" || answer === "yes";
|
|
249
|
+
}
|
|
250
|
+
var isTty, paint, dim, bold, cyan, green, heading, style;
|
|
251
|
+
var init_io = __esmMin((() => {
|
|
252
|
+
isTty = () => process.stdout.isTTY === true;
|
|
253
|
+
paint = (code, text) => isTty() ? `\x1B[${code}m${text}\x1B[0m` : text;
|
|
254
|
+
dim = (text) => paint("2", text);
|
|
255
|
+
bold = (text) => paint("1", text);
|
|
256
|
+
cyan = (text) => paint("36", text);
|
|
257
|
+
green = (text) => paint("32", text);
|
|
258
|
+
heading = (text) => paint("1;4", text);
|
|
259
|
+
style = {
|
|
260
|
+
bold,
|
|
261
|
+
dim,
|
|
262
|
+
green
|
|
263
|
+
};
|
|
264
|
+
}));
|
|
265
|
+
//#endregion
|
|
50
266
|
//#region src/helpers/cookies.ts
|
|
51
267
|
function parseCookies(header) {
|
|
52
268
|
const cookies = {};
|
|
@@ -88,10 +304,12 @@ function parseBind(value) {
|
|
|
88
304
|
const parsed = bindSchema(value);
|
|
89
305
|
return parsed instanceof type.errors ? null : parsed;
|
|
90
306
|
}
|
|
91
|
-
var bindSchema, portSchema, restartSchema, httpCheckSchema, resourcesSchema, healthSchema, stopSchema, bootstrapSchema, bootstrapOrNullSchema, logBufferLinesSchema, serverSchema, authSchema, telegramSchema, notificationsSchema, logsSchema, tlsSchema, hostSchema, backupsSchema, controlSchema, defaultsSchema, restartPatchSchema, httpCheckPatchSchema, resourcesPatchSchema, healthPatchSchema, stopPatchSchema, authPatchSchema, telegramPatchSchema, notificationsPatchSchema, hostPatchSchema, backupsPatchSchema, logsPatchSchema, tlsPatchSchema, controlPatchSchema, defaultsPatchSchema, editableFields, serverPatchSchema, serverCreateSchema, settingsPatchSchema, authStatusSchema, tlsStatusSchema, telegramStatusSchema, notificationViewSchema, sessionViewSchema, loginSchema, passwordValueSchema, passwordSchema, serverStatusSchema, healthStateSchema, portStateSchema, logStreamSchema, logLineSchema, historyEventSchema, serverHistorySchema, processResourcesSchema, hostDiskSchema, hostViewSchema, backupFileSchema, backupPathSchema, backupsViewSchema, restoreItemSchema, restorePlanSchema, backupCreateSchema, restoreRequestSchema, serverViewSchema, controlViewSchema, appStateSchema, logQuerySchema, freePortResultSchema, logHistoryQuerySchema, logFileInfoSchema, logServerViewSchema, logServersViewSchema, notificationActionSchema, telegramTokenSchema, apiErrorSchema, uiMetaSchema, tlsUploadSchema;
|
|
307
|
+
var bindSchema, portSchema, onPortConflictSchema, onPortConflictDefaultSchema, restartSchema, httpCheckSchema, resourcesSchema, healthSchema, stopSchema, bootstrapSchema, bootstrapOrNullSchema, logBufferLinesSchema, serverSchema, authSchema, telegramSchema, notificationsSchema, logsSchema, tlsSchema, hostSchema, backupsSchema, controlSchema, defaultsSchema, restartPatchSchema, httpCheckPatchSchema, resourcesPatchSchema, healthPatchSchema, stopPatchSchema, authPatchSchema, telegramPatchSchema, notificationsPatchSchema, hostPatchSchema, backupsPatchSchema, logsPatchSchema, tlsPatchSchema, controlPatchSchema, defaultsPatchSchema, editableFields, serverPatchSchema, serverCreateSchema, settingsPatchSchema, authStatusSchema, tlsStatusSchema, telegramStatusSchema, notificationViewSchema, sessionViewSchema, loginSchema, passwordValueSchema, passwordSchema, serverStatusSchema, healthStateSchema, portStateSchema, logStreamSchema, logLineSchema, historyEventSchema, serverHistorySchema, processResourcesSchema, hostDiskSchema, hostViewSchema, backupFileSchema, backupPathSchema, backupsViewSchema, restoreItemSchema, restorePlanSchema, backupCreateSchema, restoreRequestSchema, serverViewSchema, controlViewSchema, appStateSchema, logQuerySchema, freePortResultSchema, logHistoryQuerySchema, logFileInfoSchema, logServerViewSchema, logServersViewSchema, notificationActionSchema, telegramTokenSchema, apiErrorSchema, uiMetaSchema, uiStatusSchema, tlsUploadSchema, settingsViewSchema, settingsSavedSchema;
|
|
92
308
|
var init_contracts = __esmMin((() => {
|
|
93
309
|
bindSchema = type("\"local\" | \"lan\" | /^\\d{1,3}(?:\\.\\d{1,3}){3}$/");
|
|
94
310
|
portSchema = type("1 <= number.integer <= 65535 | null");
|
|
311
|
+
onPortConflictSchema = type.enumerated("block", "warn", "follow", "reclaim", "kill");
|
|
312
|
+
onPortConflictDefaultSchema = onPortConflictSchema.default("block");
|
|
95
313
|
restartSchema = type({
|
|
96
314
|
enabled: "boolean = true",
|
|
97
315
|
maxRetries: "number.integer >= 0 = 3",
|
|
@@ -162,7 +380,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
162
380
|
bootstrap: bootstrapOrNullSchema.optional(),
|
|
163
381
|
port: portSchema.optional(),
|
|
164
382
|
bind: bindSchema.default(() => "local"),
|
|
165
|
-
onPortConflict:
|
|
383
|
+
onPortConflict: onPortConflictDefaultSchema,
|
|
166
384
|
restart: restartSchema.default(() => ({})),
|
|
167
385
|
health: healthSchema.default(() => ({})),
|
|
168
386
|
stop: stopSchema.default(() => ({})),
|
|
@@ -173,7 +391,12 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
173
391
|
envFile: "string = \"\"",
|
|
174
392
|
resources: resourcesSchema.default(() => ({})),
|
|
175
393
|
/** Paths included in backups for this server (templates allowed). */
|
|
176
|
-
backupPaths: type("string[]").default(() => [])
|
|
394
|
+
backupPaths: type("string[]").default(() => []),
|
|
395
|
+
/**
|
|
396
|
+
* Skip well-known build output and dependency directories (`node_modules`,
|
|
397
|
+
* `dist`, `.next`, caches, …) inside the paths this entry declares.
|
|
398
|
+
*/
|
|
399
|
+
backupIgnoreGenerated: "boolean = true"
|
|
177
400
|
}).onUndeclaredKey("reject");
|
|
178
401
|
authSchema = type({
|
|
179
402
|
enabled: "boolean = true",
|
|
@@ -238,7 +461,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
238
461
|
enabled: "boolean = true",
|
|
239
462
|
autostart: "boolean = false",
|
|
240
463
|
bind: bindSchema.default(() => "local"),
|
|
241
|
-
onPortConflict:
|
|
464
|
+
onPortConflict: onPortConflictDefaultSchema,
|
|
242
465
|
restart: restartSchema.default(() => ({})),
|
|
243
466
|
health: healthSchema.default(() => ({})),
|
|
244
467
|
stop: stopSchema.default(() => ({})),
|
|
@@ -329,7 +552,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
329
552
|
enabled: "boolean?",
|
|
330
553
|
autostart: "boolean?",
|
|
331
554
|
bind: bindSchema.optional(),
|
|
332
|
-
onPortConflict:
|
|
555
|
+
onPortConflict: onPortConflictSchema.optional(),
|
|
333
556
|
restart: restartPatchSchema.optional(),
|
|
334
557
|
health: healthPatchSchema.optional(),
|
|
335
558
|
stop: stopPatchSchema.optional(),
|
|
@@ -347,7 +570,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
347
570
|
bootstrap: bootstrapOrNullSchema.optional(),
|
|
348
571
|
port: portSchema.optional(),
|
|
349
572
|
bind: bindSchema.optional(),
|
|
350
|
-
onPortConflict:
|
|
573
|
+
onPortConflict: onPortConflictSchema.optional(),
|
|
351
574
|
restart: restartPatchSchema.optional(),
|
|
352
575
|
health: healthPatchSchema.optional(),
|
|
353
576
|
stop: stopPatchSchema.optional(),
|
|
@@ -355,7 +578,8 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
355
578
|
dependsOn: type("string[]").optional(),
|
|
356
579
|
envFile: "string?",
|
|
357
580
|
resources: resourcesPatchSchema.optional(),
|
|
358
|
-
backupPaths: type("string[]").optional()
|
|
581
|
+
backupPaths: type("string[]").optional(),
|
|
582
|
+
backupIgnoreGenerated: "boolean?"
|
|
359
583
|
};
|
|
360
584
|
serverPatchSchema = type(editableFields).onUndeclaredKey("reject");
|
|
361
585
|
serverCreateSchema = type({
|
|
@@ -507,7 +731,9 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
507
731
|
origin: "string",
|
|
508
732
|
/** false when a parent path already covers it, or it would swallow the archive dir. */
|
|
509
733
|
included: "boolean",
|
|
510
|
-
note: "string | null"
|
|
734
|
+
note: "string | null",
|
|
735
|
+
/** The declaring entry asked for generated directories to be skipped. */
|
|
736
|
+
ignoreGenerated: "boolean?"
|
|
511
737
|
});
|
|
512
738
|
backupsViewSchema = type({
|
|
513
739
|
enabled: "boolean",
|
|
@@ -676,7 +902,7 @@ password: passwordValueSchema.optional() }).onUndeclaredKey("reject");
|
|
|
676
902
|
uploadedAt: "number",
|
|
677
903
|
files: "number.integer >= 1"
|
|
678
904
|
});
|
|
679
|
-
type({
|
|
905
|
+
uiStatusSchema = type({
|
|
680
906
|
/** A user-supplied UI is being served instead of the stock one. */
|
|
681
907
|
custom: "boolean",
|
|
682
908
|
/** Where that UI lives, whether or not it exists yet. */
|
|
@@ -687,6 +913,20 @@ password: passwordValueSchema.optional() }).onUndeclaredKey("reject");
|
|
|
687
913
|
certificate: "string >= 1",
|
|
688
914
|
privateKey: "string >= 1"
|
|
689
915
|
}).onUndeclaredKey("reject");
|
|
916
|
+
settingsViewSchema = type({
|
|
917
|
+
control: controlViewSchema,
|
|
918
|
+
defaults: defaultsSchema,
|
|
919
|
+
logs: logsSchema,
|
|
920
|
+
notifications: notificationViewSchema,
|
|
921
|
+
host: hostSchema,
|
|
922
|
+
backups: backupsViewSchema,
|
|
923
|
+
ui: uiStatusSchema
|
|
924
|
+
});
|
|
925
|
+
settingsSavedSchema = settingsViewSchema.and(type({
|
|
926
|
+
/** The listener is moving; reconnect at `targetUrl` when it stops being null. */
|
|
927
|
+
rebinding: "boolean",
|
|
928
|
+
targetUrl: "string | null"
|
|
929
|
+
}));
|
|
690
930
|
}));
|
|
691
931
|
//#endregion
|
|
692
932
|
//#region src/helpers/openapi.ts
|
|
@@ -757,7 +997,6 @@ function isLoopbackRequest(c) {
|
|
|
757
997
|
var init_loopback = __esmMin((() => {}));
|
|
758
998
|
//#endregion
|
|
759
999
|
//#region src/helpers/atomic.ts
|
|
760
|
-
var atomic_exports = /* @__PURE__ */ __exportAll({ writeFileAtomic: () => writeFileAtomic });
|
|
761
1000
|
/**
|
|
762
1001
|
* Write via a temp file in the same directory, then rename: readers never see a
|
|
763
1002
|
* half-written file, and a crash cannot truncate the previous config.
|
|
@@ -772,17 +1011,6 @@ function writeFileAtomic(file, content, options = {}) {
|
|
|
772
1011
|
var init_atomic = __esmMin((() => {}));
|
|
773
1012
|
//#endregion
|
|
774
1013
|
//#region src/config/secrets.ts
|
|
775
|
-
var secrets_exports = /* @__PURE__ */ __exportAll({
|
|
776
|
-
API_TOKEN_PREFIX: () => "hh_",
|
|
777
|
-
SecretsStore: () => SecretsStore,
|
|
778
|
-
apiTokenRecord: () => apiTokenRecord,
|
|
779
|
-
deriveKey: () => deriveKey,
|
|
780
|
-
generateApiToken: () => generateApiToken,
|
|
781
|
-
hashApiToken: () => hashApiToken,
|
|
782
|
-
hashPassword: () => hashPassword,
|
|
783
|
-
verifyApiToken: () => verifyApiToken,
|
|
784
|
-
verifyPassword: () => verifyPassword
|
|
785
|
-
});
|
|
786
1014
|
function deriveKey(password, salt, cost, keylen) {
|
|
787
1015
|
return crypto.scryptSync(password.normalize("NFKC"), salt, keylen, { ...cost });
|
|
788
1016
|
}
|
|
@@ -819,6 +1047,7 @@ function verifyApiToken(token, record) {
|
|
|
819
1047
|
if (token.length === 0) return false;
|
|
820
1048
|
const expected = Buffer$1.from(record.hash, "base64");
|
|
821
1049
|
const actual = Buffer$1.from(hashApiToken(token), "base64");
|
|
1050
|
+
if (actual.length !== expected.length) return false;
|
|
822
1051
|
return crypto.timingSafeEqual(actual, expected);
|
|
823
1052
|
}
|
|
824
1053
|
function apiTokenRecord(token, now = Date.now()) {
|
|
@@ -986,7 +1215,7 @@ var SESSION_COOKIE, MAX_SESSIONS, MAX_LOCKOUT_MS, MAX_ATTEMPT_RECORDS, ATTEMPT_R
|
|
|
986
1215
|
var init_auth$1 = __esmMin((() => {
|
|
987
1216
|
init_secrets();
|
|
988
1217
|
init_cookies();
|
|
989
|
-
SESSION_COOKIE = "
|
|
1218
|
+
SESSION_COOKIE = "hh_session";
|
|
990
1219
|
MAX_SESSIONS = 100;
|
|
991
1220
|
MAX_LOCKOUT_MS = 9e5;
|
|
992
1221
|
MAX_ATTEMPT_RECORDS = 1e4;
|
|
@@ -1074,7 +1303,7 @@ var init_auth$1 = __esmMin((() => {
|
|
|
1074
1303
|
};
|
|
1075
1304
|
}
|
|
1076
1305
|
tokenFromCookie(cookieHeader) {
|
|
1077
|
-
return parseCookies(cookieHeader)["
|
|
1306
|
+
return parseCookies(cookieHeader)["hh_session"] ?? null;
|
|
1078
1307
|
}
|
|
1079
1308
|
/** Sliding expiry: an active panel stays logged in, an idle one does not. */
|
|
1080
1309
|
validate(token) {
|
|
@@ -1539,7 +1768,7 @@ function resolveProjectDir() {
|
|
|
1539
1768
|
function resolveUserPath(target, base = projectDir) {
|
|
1540
1769
|
let value = target;
|
|
1541
1770
|
if (value === "~") value = os.homedir();
|
|
1542
|
-
else if (value.startsWith("~/")) value = path.join(os.homedir(), value.slice(2));
|
|
1771
|
+
else if (value.startsWith("~/") || value.startsWith("~\\")) value = path.join(os.homedir(), value.slice(2));
|
|
1543
1772
|
return path.isAbsolute(value) ? value : path.resolve(base, value);
|
|
1544
1773
|
}
|
|
1545
1774
|
var dataRoot, projectDir, defaultConfigPath, configSchemaPath, defaultSecretsPath, defaultLogsDir, defaultHistoryPath, defaultTlsDir, runtimePath, daemonLogPath;
|
|
@@ -1667,8 +1896,8 @@ function createBackupsRoute(deps) {
|
|
|
1667
1896
|
200: { description: "the archive (application/zip)" },
|
|
1668
1897
|
404: ERROR_RESPONSES[404]
|
|
1669
1898
|
}
|
|
1670
|
-
}), (c) => {
|
|
1671
|
-
const file = deps.backups.resolve(c.req.
|
|
1899
|
+
}), validate("param", nameParam), (c) => {
|
|
1900
|
+
const file = deps.backups.resolve(c.req.valid("param").name);
|
|
1672
1901
|
if (file === null) throw new DetailedError("unknown backup", {
|
|
1673
1902
|
statusCode: 404,
|
|
1674
1903
|
code: "UNKNOWN_BACKUP"
|
|
@@ -1686,8 +1915,8 @@ function createBackupsRoute(deps) {
|
|
|
1686
1915
|
200: { description: "Removed" },
|
|
1687
1916
|
404: ERROR_RESPONSES[404]
|
|
1688
1917
|
}
|
|
1689
|
-
}), (c) => {
|
|
1690
|
-
if (!deps.backups.remove(c.req.
|
|
1918
|
+
}), validate("param", nameParam), (c) => {
|
|
1919
|
+
if (!deps.backups.remove(c.req.valid("param").name)) throw new DetailedError("unknown backup", {
|
|
1691
1920
|
statusCode: 404,
|
|
1692
1921
|
code: "UNKNOWN_BACKUP"
|
|
1693
1922
|
});
|
|
@@ -1731,8 +1960,8 @@ function createBackupsRoute(deps) {
|
|
|
1731
1960
|
});
|
|
1732
1961
|
const uploads = path.join(deps.backups.directory, "uploads");
|
|
1733
1962
|
fs.mkdirSync(uploads, { recursive: true });
|
|
1734
|
-
uploadedTo = path.join(uploads, `upload-${
|
|
1735
|
-
fs.writeFileSync(uploadedTo, Buffer$1.from(await file.arrayBuffer()));
|
|
1963
|
+
uploadedTo = path.join(uploads, `upload-${crypto.randomUUID()}.zip`);
|
|
1964
|
+
fs.writeFileSync(uploadedTo, Buffer$1.from(await file.arrayBuffer()), { mode: 384 });
|
|
1736
1965
|
archive = uploadedTo;
|
|
1737
1966
|
const rawInclude = typeof body.include === "string" && body.include.length > 0 ? body.include : null;
|
|
1738
1967
|
let include;
|
|
@@ -1782,7 +2011,7 @@ function createBackupsRoute(deps) {
|
|
|
1782
2011
|
}
|
|
1783
2012
|
});
|
|
1784
2013
|
}
|
|
1785
|
-
var MAX_UPLOAD_BYTES;
|
|
2014
|
+
var MAX_UPLOAD_BYTES, nameParam;
|
|
1786
2015
|
var init_backups$1 = __esmMin((() => {
|
|
1787
2016
|
init_factory();
|
|
1788
2017
|
init_logger();
|
|
@@ -1792,6 +2021,7 @@ var init_backups$1 = __esmMin((() => {
|
|
|
1792
2021
|
init_state$1();
|
|
1793
2022
|
init_contracts();
|
|
1794
2023
|
MAX_UPLOAD_BYTES = 268435456;
|
|
2024
|
+
nameParam = type({ name: "string >= 1" });
|
|
1795
2025
|
}));
|
|
1796
2026
|
//#endregion
|
|
1797
2027
|
//#region src/helpers/deferred.ts
|
|
@@ -1867,7 +2097,7 @@ function createEventsRoute(deps) {
|
|
|
1867
2097
|
let closed = false;
|
|
1868
2098
|
const send = (message) => {
|
|
1869
2099
|
if (closed) return queue;
|
|
1870
|
-
if (message.type === "log" && pending > MAX_PENDING_WRITES) return queue;
|
|
2100
|
+
if (message.type === "log" && pending > MAX_PENDING_WRITES$1) return queue;
|
|
1871
2101
|
pending += 1;
|
|
1872
2102
|
queue = queue.then(() => stream.writeSSE({
|
|
1873
2103
|
event: message.type,
|
|
@@ -1903,11 +2133,11 @@ function createEventsRoute(deps) {
|
|
|
1903
2133
|
});
|
|
1904
2134
|
});
|
|
1905
2135
|
}
|
|
1906
|
-
var MAX_PENDING_WRITES, PING_INTERVAL_MS, eventsQuery;
|
|
2136
|
+
var MAX_PENDING_WRITES$1, PING_INTERVAL_MS, eventsQuery;
|
|
1907
2137
|
var init_events$1 = __esmMin((() => {
|
|
1908
2138
|
init_factory();
|
|
1909
2139
|
init_validator();
|
|
1910
|
-
MAX_PENDING_WRITES = 200;
|
|
2140
|
+
MAX_PENDING_WRITES$1 = 200;
|
|
1911
2141
|
PING_INTERVAL_MS = 15e3;
|
|
1912
2142
|
eventsQuery = type({
|
|
1913
2143
|
/** Only this server's frames. */
|
|
@@ -2086,19 +2316,19 @@ function createMetricsRoute(deps) {
|
|
|
2086
2316
|
if (samples.length === 0) return;
|
|
2087
2317
|
lines.push(`# HELP ${name} ${help}`, `# TYPE ${name} gauge`, ...samples);
|
|
2088
2318
|
};
|
|
2089
|
-
metric("
|
|
2090
|
-
metric("
|
|
2091
|
-
metric("
|
|
2092
|
-
metric("
|
|
2093
|
-
metric("
|
|
2094
|
-
metric("
|
|
2095
|
-
metric("
|
|
2096
|
-
metric("
|
|
2097
|
-
metric("
|
|
2098
|
-
metric("
|
|
2099
|
-
metric("
|
|
2100
|
-
metric("
|
|
2101
|
-
metric("
|
|
2319
|
+
metric("hh_control_up", "Control plane is serving", ["hh_control_up 1"]);
|
|
2320
|
+
metric("hh_servers_total", "Configured servers", [`hh_servers_total ${state.servers.length}`]);
|
|
2321
|
+
metric("hh_server_up", "Server process is running", state.servers.map((server) => `hh_server_up{server="${server.id}"} ${server.status === "running" ? 1 : 0}`));
|
|
2322
|
+
metric("hh_server_restarts_total", "Restarts since the control plane started", state.servers.map((server) => `hh_server_restarts_total{server="${server.id}"} ${server.restarts}`));
|
|
2323
|
+
metric("hh_server_crashes_24h", "Crashes in the last 24 hours", state.servers.map((server) => `hh_server_crashes_24h{server="${server.id}"} ${server.history.crashes}`));
|
|
2324
|
+
metric("hh_server_uptime_ratio_24h", "Share of the last 24 hours the server was up", state.servers.filter((server) => server.history.uptimeRatio !== null).map((server) => `hh_server_uptime_ratio_24h{server="${server.id}"} ${server.history.uptimeRatio.toFixed(4)}`));
|
|
2325
|
+
metric("hh_server_response_ms", "Last health probe latency in milliseconds", state.servers.filter((server) => server.responseMs !== null).map((server) => `hh_server_response_ms{server="${server.id}"} ${server.responseMs}`));
|
|
2326
|
+
metric("hh_server_rss_bytes", "RSS of the server process tree", state.servers.filter((server) => server.resources?.rssBytes != null).map((server) => `hh_server_rss_bytes{server="${server.id}"} ${server.resources.rssBytes}`));
|
|
2327
|
+
metric("hh_server_cpu_percent", "CPU percent of the server process tree", state.servers.filter((server) => server.resources?.cpuPercent != null).map((server) => `hh_server_cpu_percent{server="${server.id}"} ${server.resources.cpuPercent}`));
|
|
2328
|
+
metric("hh_host_disk_used_percent", "Disk usage percent per configured path", state.host.disks.map((disk) => `hh_host_disk_used_percent{mount="${disk.path}"} ${disk.usedPercent.toFixed(2)}`));
|
|
2329
|
+
metric("hh_host_memory_used_percent", "Memory usage percent", [`hh_host_memory_used_percent ${state.host.memoryUsedPercent.toFixed(2)}`]);
|
|
2330
|
+
metric("hh_host_swap_used_percent", "Swap usage percent", [`hh_host_swap_used_percent ${state.host.swapUsedPercent.toFixed(2)}`]);
|
|
2331
|
+
metric("hh_host_load1_per_cpu", "1 minute load average per cpu", [`hh_host_load1_per_cpu ${((state.host.loadAvg[0] ?? 0) / Math.max(1, state.host.cpus)).toFixed(3)}`]);
|
|
2102
2332
|
return c.text(`${lines.join("\n")}\n`, 200, { "Content-Type": "text/plain; version=0.0.4; charset=utf-8" });
|
|
2103
2333
|
});
|
|
2104
2334
|
}
|
|
@@ -2287,12 +2517,6 @@ var init_notifications$1 = __esmMin((() => {
|
|
|
2287
2517
|
}));
|
|
2288
2518
|
//#endregion
|
|
2289
2519
|
//#region src/config/migrations.ts
|
|
2290
|
-
var migrations_exports = /* @__PURE__ */ __exportAll({
|
|
2291
|
-
CONFIG_SCHEMA: () => 1,
|
|
2292
|
-
applyConfigMigrations: () => applyConfigMigrations,
|
|
2293
|
-
configMigrations: () => configMigrations,
|
|
2294
|
-
planConfigMigrations: () => planConfigMigrations
|
|
2295
|
-
});
|
|
2296
2520
|
/** What would have to run to bring `from` up to `to`. */
|
|
2297
2521
|
function planConfigMigrations(from, options = {}) {
|
|
2298
2522
|
const to = options.to ?? 1;
|
|
@@ -2319,6 +2543,24 @@ var init_migrations = __esmMin((() => {
|
|
|
2319
2543
|
}));
|
|
2320
2544
|
//#endregion
|
|
2321
2545
|
//#region src/config/schema.ts
|
|
2546
|
+
function isRecord$2(value) {
|
|
2547
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2548
|
+
}
|
|
2549
|
+
/**
|
|
2550
|
+
* Resolves one entry against the panel's server defaults. A group (`restart`,
|
|
2551
|
+
* `health`, `health.http`, `stop`) merges key by key, so an entry that decides one
|
|
2552
|
+
* member does not silently fall back to the *schema* default for the others — which
|
|
2553
|
+
* is the whole point of the panel having defaults at all.
|
|
2554
|
+
*/
|
|
2555
|
+
function mergeDefaults(defaults, entry) {
|
|
2556
|
+
const merged = { ...entry };
|
|
2557
|
+
for (const [key, value] of Object.entries(defaults)) {
|
|
2558
|
+
const current = merged[key];
|
|
2559
|
+
if (current === void 0) merged[key] = value;
|
|
2560
|
+
else if (isRecord$2(value) && isRecord$2(current)) merged[key] = mergeDefaults(value, current);
|
|
2561
|
+
}
|
|
2562
|
+
return merged;
|
|
2563
|
+
}
|
|
2322
2564
|
var metaSchema, configSchema, CONFIG_KEYS;
|
|
2323
2565
|
var init_schema = __esmMin((() => {
|
|
2324
2566
|
init_contracts();
|
|
@@ -2383,10 +2625,6 @@ var init_version = __esmMin((() => {
|
|
|
2383
2625
|
}));
|
|
2384
2626
|
//#endregion
|
|
2385
2627
|
//#region src/config/parse.ts
|
|
2386
|
-
var parse_exports = /* @__PURE__ */ __exportAll({
|
|
2387
|
-
parseConfig: () => parseConfig,
|
|
2388
|
-
stampConfig: () => stampConfig
|
|
2389
|
-
});
|
|
2390
2628
|
function isRecord$1(value) {
|
|
2391
2629
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2392
2630
|
}
|
|
@@ -2485,10 +2723,7 @@ function parseConfig(raw, options = {}) {
|
|
|
2485
2723
|
const seen = /* @__PURE__ */ new Set();
|
|
2486
2724
|
(Array.isArray(raw.servers) ? raw.servers : []).forEach((entry, index) => {
|
|
2487
2725
|
const label = `servers[${index}]`;
|
|
2488
|
-
const parsed = parseTolerant(isRecord$1(entry) ?
|
|
2489
|
-
...defaults,
|
|
2490
|
-
...entry
|
|
2491
|
-
} : entry, serverSchema, label, unknownKeys);
|
|
2726
|
+
const parsed = parseTolerant(isRecord$1(entry) ? mergeDefaults(defaults, entry) : entry, serverSchema, label, unknownKeys);
|
|
2492
2727
|
if (parsed.error !== null) {
|
|
2493
2728
|
const id = isRecord$1(entry) ? entry.id : void 0;
|
|
2494
2729
|
errors.push(`${label}${typeof id === "string" ? ` ("${id}")` : ""}: ${parsed.error}`);
|
|
@@ -2655,6 +2890,8 @@ var init_store = __esmMin((() => {
|
|
|
2655
2890
|
file;
|
|
2656
2891
|
seed;
|
|
2657
2892
|
raw = {};
|
|
2893
|
+
/** The bytes behind the live config, so a watcher can tell a real edit from our own write. */
|
|
2894
|
+
lastText = null;
|
|
2658
2895
|
resolvedConfig;
|
|
2659
2896
|
error = null;
|
|
2660
2897
|
warnings = [];
|
|
@@ -2703,29 +2940,75 @@ var init_store = __esmMin((() => {
|
|
|
2703
2940
|
}
|
|
2704
2941
|
/**
|
|
2705
2942
|
* Reads the file and tells the listeners, so whatever wrote it — the settings
|
|
2706
|
-
* page,
|
|
2943
|
+
* page, a restored backup, or the file watcher — becomes the live config.
|
|
2707
2944
|
*/
|
|
2708
2945
|
load() {
|
|
2709
2946
|
this.read();
|
|
2710
2947
|
this.notify();
|
|
2711
2948
|
}
|
|
2949
|
+
/**
|
|
2950
|
+
* The watcher's entry point: re-reads the file only when its bytes changed.
|
|
2951
|
+
*
|
|
2952
|
+
* `changed` means the file on disk is different from what the live config was
|
|
2953
|
+
* read from, `applied` means that difference was accepted — a file this release
|
|
2954
|
+
* cannot read is reported and the config already running is left alone, which is
|
|
2955
|
+
* what keeps an editor's typo from stopping every server.
|
|
2956
|
+
*/
|
|
2957
|
+
reloadFromDisk() {
|
|
2958
|
+
let text;
|
|
2959
|
+
try {
|
|
2960
|
+
text = fs.readFileSync(this.file, "utf8");
|
|
2961
|
+
} catch {
|
|
2962
|
+
this.error = `${path.basename(this.file)} is gone`;
|
|
2963
|
+
return {
|
|
2964
|
+
changed: false,
|
|
2965
|
+
applied: false,
|
|
2966
|
+
error: this.error
|
|
2967
|
+
};
|
|
2968
|
+
}
|
|
2969
|
+
if (text === this.lastText) {
|
|
2970
|
+
if (this.error !== null) {
|
|
2971
|
+
this.error = null;
|
|
2972
|
+
this.load();
|
|
2973
|
+
}
|
|
2974
|
+
return {
|
|
2975
|
+
changed: false,
|
|
2976
|
+
applied: false,
|
|
2977
|
+
error: null
|
|
2978
|
+
};
|
|
2979
|
+
}
|
|
2980
|
+
const before = this.resolvedConfig;
|
|
2981
|
+
this.load();
|
|
2982
|
+
return {
|
|
2983
|
+
changed: true,
|
|
2984
|
+
applied: this.resolvedConfig !== before,
|
|
2985
|
+
error: this.error
|
|
2986
|
+
};
|
|
2987
|
+
}
|
|
2712
2988
|
read() {
|
|
2713
2989
|
if (!fs.existsSync(this.file)) {
|
|
2714
2990
|
const seed = stampConfig(structuredClone(this.seed));
|
|
2715
|
-
|
|
2991
|
+
const text = `${JSON.stringify(seed, null, 2)}\n`;
|
|
2992
|
+
writeFileAtomic(this.file, text);
|
|
2993
|
+
this.lastText = text;
|
|
2716
2994
|
this.raw = seed;
|
|
2717
2995
|
this.apply(seed);
|
|
2718
2996
|
return;
|
|
2719
2997
|
}
|
|
2998
|
+
let text;
|
|
2720
2999
|
let parsed;
|
|
2721
3000
|
try {
|
|
2722
|
-
|
|
3001
|
+
text = fs.readFileSync(this.file, "utf8");
|
|
3002
|
+
parsed = JSON.parse(text);
|
|
2723
3003
|
} catch (error) {
|
|
2724
3004
|
this.error = `cannot parse ${path.basename(this.file)}: ${error.message}`;
|
|
2725
|
-
this.
|
|
2726
|
-
|
|
3005
|
+
if (this.resolvedConfig === void 0) {
|
|
3006
|
+
this.raw = {};
|
|
3007
|
+
this.resolvedConfig = this.resolveFallback();
|
|
3008
|
+
}
|
|
2727
3009
|
return;
|
|
2728
3010
|
}
|
|
3011
|
+
this.lastText = text;
|
|
2729
3012
|
this.apply(parsed);
|
|
2730
3013
|
}
|
|
2731
3014
|
notify() {
|
|
@@ -2818,10 +3101,7 @@ var init_store = __esmMin((() => {
|
|
|
2818
3101
|
if ((fs.existsSync(configSchemaPath) ? fs.readFileSync(configSchemaPath, "utf8") : null) !== schema) writeFileAtomic(configSchemaPath, schema);
|
|
2819
3102
|
}
|
|
2820
3103
|
validateServer(entry, label) {
|
|
2821
|
-
const parsed = serverSchema(
|
|
2822
|
-
...this.defaults,
|
|
2823
|
-
...entry
|
|
2824
|
-
});
|
|
3104
|
+
const parsed = serverSchema(mergeDefaults(this.defaults, entry));
|
|
2825
3105
|
if (parsed instanceof type.errors) throw new ConfigError(`${label}: ${formatErrors(parsed)}`);
|
|
2826
3106
|
return {
|
|
2827
3107
|
...parsed,
|
|
@@ -2830,7 +3110,9 @@ var init_store = __esmMin((() => {
|
|
|
2830
3110
|
}
|
|
2831
3111
|
commit(draft) {
|
|
2832
3112
|
const stamped = stampConfig(draft);
|
|
2833
|
-
|
|
3113
|
+
const text = `${JSON.stringify(stamped, null, 2)}\n`;
|
|
3114
|
+
writeFileAtomic(this.file, text);
|
|
3115
|
+
this.lastText = text;
|
|
2834
3116
|
this.raw = stamped;
|
|
2835
3117
|
this.apply(stamped);
|
|
2836
3118
|
this.notify();
|
|
@@ -2968,14 +3250,19 @@ function createServersRoute(deps) {
|
|
|
2968
3250
|
if (!deps.store.getServer(id)) throw unknownServer(id);
|
|
2969
3251
|
return streamSSE(c, async (stream) => {
|
|
2970
3252
|
let closed = false;
|
|
3253
|
+
let pending = 0;
|
|
2971
3254
|
let queue = Promise.resolve();
|
|
2972
3255
|
const send = (data, event) => {
|
|
2973
3256
|
if (closed) return;
|
|
3257
|
+
if (event === "log" && pending > MAX_PENDING_WRITES) return;
|
|
3258
|
+
pending += 1;
|
|
2974
3259
|
queue = queue.then(() => stream.writeSSE({
|
|
2975
3260
|
event,
|
|
2976
3261
|
data
|
|
2977
3262
|
})).catch(() => {
|
|
2978
3263
|
closed = true;
|
|
3264
|
+
}).finally(() => {
|
|
3265
|
+
pending -= 1;
|
|
2979
3266
|
});
|
|
2980
3267
|
};
|
|
2981
3268
|
const unsubscribe = deps.hub.subscribe(id, (message) => {
|
|
@@ -3112,7 +3399,7 @@ function createServersRoute(deps) {
|
|
|
3112
3399
|
}
|
|
3113
3400
|
});
|
|
3114
3401
|
}
|
|
3115
|
-
var idParam, serverResponse, serversResponse, okResponse;
|
|
3402
|
+
var idParam, MAX_PENDING_WRITES, serverResponse, serversResponse, okResponse;
|
|
3116
3403
|
var init_$_routes = __esmMin((() => {
|
|
3117
3404
|
init_store();
|
|
3118
3405
|
init_factory();
|
|
@@ -3120,6 +3407,7 @@ var init_$_routes = __esmMin((() => {
|
|
|
3120
3407
|
init_validator();
|
|
3121
3408
|
init_contracts();
|
|
3122
3409
|
idParam = type({ id: "string >= 1" });
|
|
3410
|
+
MAX_PENDING_WRITES = 200;
|
|
3123
3411
|
serverResponse = type({ server: serverViewSchema });
|
|
3124
3412
|
serversResponse = type({ servers: serverViewSchema.array() });
|
|
3125
3413
|
okResponse = type({ ok: "boolean" });
|
|
@@ -3132,7 +3420,14 @@ function sanitizeName(name) {
|
|
|
3132
3420
|
return cleaned.length > 0 ? cleaned.slice(0, 60) : "custom-ui";
|
|
3133
3421
|
}
|
|
3134
3422
|
function createSettingsRoute(deps) {
|
|
3135
|
-
return appFactory.createApp().get("/settings", (
|
|
3423
|
+
return appFactory.createApp().get("/settings", describeRoute({
|
|
3424
|
+
tags: ["panel"],
|
|
3425
|
+
summary: "The panel, server defaults, logs, notifications, host and backups",
|
|
3426
|
+
responses: { 200: {
|
|
3427
|
+
description: "The settings",
|
|
3428
|
+
content: jsonBody(settingsViewSchema)
|
|
3429
|
+
} }
|
|
3430
|
+
}), (c) => c.json({
|
|
3136
3431
|
control: buildControlView(deps.store, deps.auth, deps.controlServer.endpoint, deps.tls),
|
|
3137
3432
|
defaults: deps.store.defaults,
|
|
3138
3433
|
logs: deps.store.config.logs,
|
|
@@ -3144,7 +3439,10 @@ function createSettingsRoute(deps) {
|
|
|
3144
3439
|
tags: ["panel"],
|
|
3145
3440
|
summary: "Edit the panel, server defaults, logs, notifications, host and backups",
|
|
3146
3441
|
responses: {
|
|
3147
|
-
200: {
|
|
3442
|
+
200: {
|
|
3443
|
+
description: "Saved; the listener may be moving",
|
|
3444
|
+
content: jsonBody(settingsSavedSchema)
|
|
3445
|
+
},
|
|
3148
3446
|
400: ERROR_RESPONSES[400]
|
|
3149
3447
|
}
|
|
3150
3448
|
}), validate("json", settingsPatchSchema), async (c) => {
|
|
@@ -3736,11 +4034,12 @@ async function isPortFree(port, host = "127.0.0.1", timeoutMs = 1e3) {
|
|
|
3736
4034
|
return !await probePort(host, port, timeoutMs);
|
|
3737
4035
|
}
|
|
3738
4036
|
/**
|
|
3739
|
-
* Kills whatever holds the port
|
|
3740
|
-
*
|
|
4037
|
+
* Kills whatever holds the port, minus `exclude` (the panel's own process tree).
|
|
4038
|
+
* Only used as a last resort for wrappers that spawn their real server detached,
|
|
4039
|
+
* where a process-group signal cannot reach it.
|
|
3741
4040
|
*/
|
|
3742
|
-
async function killPortHolders(port) {
|
|
3743
|
-
const pids = await listPortHolders(port);
|
|
4041
|
+
async function killPortHolders(port, exclude) {
|
|
4042
|
+
const pids = (await listPortHolders(port)).filter((pid) => !exclude?.has(pid));
|
|
3744
4043
|
for (const pid of pids) try {
|
|
3745
4044
|
process.kill(pid, "SIGKILL");
|
|
3746
4045
|
} catch {}
|
|
@@ -3760,7 +4059,7 @@ function signalPid$1(pid, name) {
|
|
|
3760
4059
|
process.kill(pid, name);
|
|
3761
4060
|
} catch {}
|
|
3762
4061
|
}
|
|
3763
|
-
function delay$
|
|
4062
|
+
function delay$2(ms) {
|
|
3764
4063
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3765
4064
|
}
|
|
3766
4065
|
/**
|
|
@@ -3775,12 +4074,12 @@ async function terminatePids(pids, options = {}) {
|
|
|
3775
4074
|
const deadline = Date.now() + graceMs;
|
|
3776
4075
|
let alive = aliveBefore.filter(isProcessAlive);
|
|
3777
4076
|
while (alive.length > 0 && Date.now() < deadline) {
|
|
3778
|
-
await delay$
|
|
4077
|
+
await delay$2(100);
|
|
3779
4078
|
alive = alive.filter(isProcessAlive);
|
|
3780
4079
|
}
|
|
3781
4080
|
const stopped = aliveBefore.filter((pid) => !alive.includes(pid));
|
|
3782
4081
|
for (const pid of alive) signalPid$1(pid, "SIGKILL");
|
|
3783
|
-
if (alive.length > 0 && graceMs > 0) await delay$
|
|
4082
|
+
if (alive.length > 0 && graceMs > 0) await delay$2(150);
|
|
3784
4083
|
return {
|
|
3785
4084
|
stopped,
|
|
3786
4085
|
forced: alive
|
|
@@ -3803,7 +4102,7 @@ function parseNetstatListeners(output, port) {
|
|
|
3803
4102
|
}
|
|
3804
4103
|
async function listPortHolders(port) {
|
|
3805
4104
|
if (process.platform === "win32") try {
|
|
3806
|
-
const { stdout } = await execFileAsync$
|
|
4105
|
+
const { stdout } = await execFileAsync$4("netstat", [
|
|
3807
4106
|
"-ano",
|
|
3808
4107
|
"-p",
|
|
3809
4108
|
"tcp"
|
|
@@ -3813,7 +4112,7 @@ async function listPortHolders(port) {
|
|
|
3813
4112
|
return [];
|
|
3814
4113
|
}
|
|
3815
4114
|
try {
|
|
3816
|
-
const { stdout } = await execFileAsync$
|
|
4115
|
+
const { stdout } = await execFileAsync$4("lsof", [
|
|
3817
4116
|
"-ti",
|
|
3818
4117
|
`tcp:${port}`,
|
|
3819
4118
|
"-sTCP:LISTEN"
|
|
@@ -3821,7 +4120,7 @@ async function listPortHolders(port) {
|
|
|
3821
4120
|
return parsePids(stdout);
|
|
3822
4121
|
} catch {}
|
|
3823
4122
|
try {
|
|
3824
|
-
const { stdout } = await execFileAsync$
|
|
4123
|
+
const { stdout } = await execFileAsync$4("fuser", [`${port}/tcp`], { timeout: 3e3 });
|
|
3825
4124
|
return parsePids(stdout);
|
|
3826
4125
|
} catch {
|
|
3827
4126
|
return [];
|
|
@@ -3830,9 +4129,9 @@ async function listPortHolders(port) {
|
|
|
3830
4129
|
function parsePids(stdout) {
|
|
3831
4130
|
return [...new Set(stdout.split(/\s+/).map((entry) => Number.parseInt(entry, 10)).filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid))];
|
|
3832
4131
|
}
|
|
3833
|
-
var execFileAsync$
|
|
4132
|
+
var execFileAsync$4;
|
|
3834
4133
|
var init_port = __esmMin((() => {
|
|
3835
|
-
execFileAsync$
|
|
4134
|
+
execFileAsync$4 = promisify(execFile);
|
|
3836
4135
|
}));
|
|
3837
4136
|
//#endregion
|
|
3838
4137
|
//#region src/helpers/env-file.ts
|
|
@@ -4199,7 +4498,7 @@ function parseWindowsCsv(text) {
|
|
|
4199
4498
|
async function getClockTicks() {
|
|
4200
4499
|
if (clockTicks !== null) return clockTicks;
|
|
4201
4500
|
try {
|
|
4202
|
-
const { stdout } = await execFileAsync$
|
|
4501
|
+
const { stdout } = await execFileAsync$3("getconf", ["CLK_TCK"], { timeout: 2e3 });
|
|
4203
4502
|
const parsed = Number.parseInt(stdout.trim(), 10);
|
|
4204
4503
|
clockTicks = Number.isFinite(parsed) && parsed > 0 ? parsed : 100;
|
|
4205
4504
|
} catch {
|
|
@@ -4251,7 +4550,7 @@ async function readLinux() {
|
|
|
4251
4550
|
return rows;
|
|
4252
4551
|
}
|
|
4253
4552
|
async function readPosix() {
|
|
4254
|
-
const { stdout } = await execFileAsync$
|
|
4553
|
+
const { stdout } = await execFileAsync$3("ps", ["-Ao", "pid=,ppid=,rss=,%cpu="], {
|
|
4255
4554
|
timeout: 5e3,
|
|
4256
4555
|
maxBuffer: 16777216
|
|
4257
4556
|
});
|
|
@@ -4260,7 +4559,7 @@ async function readPosix() {
|
|
|
4260
4559
|
async function readWindows() {
|
|
4261
4560
|
const script = "Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,WorkingSetSize,KernelModeTime,UserModeTime | ConvertTo-Csv -NoTypeInformation";
|
|
4262
4561
|
try {
|
|
4263
|
-
const { stdout } = await execFileAsync$
|
|
4562
|
+
const { stdout } = await execFileAsync$3("powershell.exe", [
|
|
4264
4563
|
"-NoProfile",
|
|
4265
4564
|
"-NonInteractive",
|
|
4266
4565
|
"-Command",
|
|
@@ -4272,7 +4571,7 @@ async function readWindows() {
|
|
|
4272
4571
|
return parseWindowsCsv(stdout);
|
|
4273
4572
|
} catch {
|
|
4274
4573
|
try {
|
|
4275
|
-
const { stdout } = await execFileAsync$
|
|
4574
|
+
const { stdout } = await execFileAsync$3("wmic", [
|
|
4276
4575
|
"process",
|
|
4277
4576
|
"get",
|
|
4278
4577
|
"ProcessId,ParentProcessId,WorkingSetSize,KernelModeTime,UserModeTime",
|
|
@@ -4324,7 +4623,7 @@ async function processCarriesServerId(pid, serverId) {
|
|
|
4324
4623
|
return false;
|
|
4325
4624
|
}
|
|
4326
4625
|
if (process.platform === "darwin") try {
|
|
4327
|
-
const { stdout } = await execFileAsync$
|
|
4626
|
+
const { stdout } = await execFileAsync$3("ps", [
|
|
4328
4627
|
"-p",
|
|
4329
4628
|
String(pid),
|
|
4330
4629
|
"-E",
|
|
@@ -4338,9 +4637,9 @@ async function processCarriesServerId(pid, serverId) {
|
|
|
4338
4637
|
}
|
|
4339
4638
|
return false;
|
|
4340
4639
|
}
|
|
4341
|
-
var execFileAsync$
|
|
4640
|
+
var execFileAsync$3, clockTicks, ProcessSampler;
|
|
4342
4641
|
var init_proc = __esmMin((() => {
|
|
4343
|
-
execFileAsync$
|
|
4642
|
+
execFileAsync$3 = promisify(execFile);
|
|
4344
4643
|
clockTicks = null;
|
|
4345
4644
|
ProcessSampler = class {
|
|
4346
4645
|
previous = /* @__PURE__ */ new Map();
|
|
@@ -4413,74 +4712,298 @@ var init_proc = __esmMin((() => {
|
|
|
4413
4712
|
};
|
|
4414
4713
|
}));
|
|
4415
4714
|
//#endregion
|
|
4416
|
-
//#region src/providers/
|
|
4715
|
+
//#region src/providers/identity.ts
|
|
4417
4716
|
/**
|
|
4418
|
-
*
|
|
4419
|
-
*
|
|
4420
|
-
*
|
|
4717
|
+
* Splits a command line into words, undoing the quoting Windows put there. `CommandLine`
|
|
4718
|
+
* is the raw string from the spawn call, so `"C:\a b\x.cmd" /c` is one argv entry plus
|
|
4719
|
+
* two words, and a quoted argument that contains spaces has to come back as one word.
|
|
4720
|
+
*
|
|
4721
|
+
* Windows also *escapes* a quote inside an argument as `\"` (Node does this for any argv
|
|
4722
|
+
* containing a quote), so the escape is undone here — otherwise every argument with a
|
|
4723
|
+
* quote in it survives as a stray backslash and never matches what the panel spawned.
|
|
4421
4724
|
*/
|
|
4422
|
-
function
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
|
|
4426
|
-
|
|
4725
|
+
function splitCommandLine(line) {
|
|
4726
|
+
const words = [];
|
|
4727
|
+
let current = "";
|
|
4728
|
+
let quoted = false;
|
|
4729
|
+
let started = false;
|
|
4730
|
+
for (const char of line.trim()) {
|
|
4731
|
+
if (char === "\"") {
|
|
4732
|
+
quoted = !quoted;
|
|
4733
|
+
started = true;
|
|
4734
|
+
continue;
|
|
4735
|
+
}
|
|
4736
|
+
if (!quoted && /\s/.test(char)) {
|
|
4737
|
+
if (started) words.push(current);
|
|
4738
|
+
current = "";
|
|
4739
|
+
started = false;
|
|
4740
|
+
continue;
|
|
4741
|
+
}
|
|
4742
|
+
current += char;
|
|
4743
|
+
started = true;
|
|
4427
4744
|
}
|
|
4428
|
-
|
|
4745
|
+
if (started) words.push(current);
|
|
4746
|
+
return words;
|
|
4429
4747
|
}
|
|
4430
|
-
|
|
4431
|
-
|
|
4432
|
-
return
|
|
4748
|
+
function comparable(target) {
|
|
4749
|
+
const value = target.replace(/^"(.*)"$/, "$1").replace(/\\(?=")/g, "");
|
|
4750
|
+
return process.platform === "win32" ? value.toLowerCase() : value;
|
|
4433
4751
|
}
|
|
4434
|
-
|
|
4435
|
-
|
|
4436
|
-
|
|
4437
|
-
|
|
4438
|
-
|
|
4439
|
-
|
|
4440
|
-
|
|
4441
|
-
|
|
4442
|
-
|
|
4443
|
-
|
|
4444
|
-
|
|
4445
|
-
|
|
4446
|
-
|
|
4447
|
-
|
|
4448
|
-
|
|
4752
|
+
/**
|
|
4753
|
+
* Literal comparison for an argument: the same text, modulo the case folding Windows needs
|
|
4754
|
+
* and the quoting a command line shuffles around.
|
|
4755
|
+
*
|
|
4756
|
+
* Deliberately *not* `sameWord`: folding an argument to its basename would let this
|
|
4757
|
+
* entry's `/srv/web/build/server.js` equal a stranger's `/tmp/evil/build/server.js`, and a
|
|
4758
|
+
* match here is what `reclaim` kills. Nor is it a plain string equality: reading an argv
|
|
4759
|
+
* back out of a Windows `CommandLine` cannot preserve quotes exactly — the OS escapes an
|
|
4760
|
+
* argument's own quote as `\"` and strips the structural ones — so quotes and backslashes
|
|
4761
|
+
* are dropped from both sides. That leaves the arguments' actual text, which is what the
|
|
4762
|
+
* match is about.
|
|
4763
|
+
*/
|
|
4764
|
+
function sameArg(a, b) {
|
|
4765
|
+
const normalize = (value) => comparable(value).replace(/["\\]/g, "");
|
|
4766
|
+
return normalize(a) === normalize(b);
|
|
4449
4767
|
}
|
|
4450
|
-
/**
|
|
4451
|
-
|
|
4452
|
-
|
|
4453
|
-
const
|
|
4454
|
-
|
|
4455
|
-
|
|
4456
|
-
signalChild(child, "SIGKILL", options.killGroup);
|
|
4457
|
-
await waitForExit$1(child, 2e3);
|
|
4458
|
-
return "force-killed";
|
|
4768
|
+
/** The same file spelled differently (`node`, `node.exe`, a relative path) compares equal. */
|
|
4769
|
+
function sameWord(a, b) {
|
|
4770
|
+
const left = comparable(a);
|
|
4771
|
+
const right = comparable(b);
|
|
4772
|
+
if (left === right || path.basename(left) === path.basename(right)) return true;
|
|
4773
|
+
return path.extname(b) === "" && path.basename(left, path.extname(left)) === right;
|
|
4459
4774
|
}
|
|
4460
4775
|
/**
|
|
4461
|
-
*
|
|
4462
|
-
*
|
|
4776
|
+
* True when the argv a process is running is the entry's own: the image must match where
|
|
4777
|
+
* `spawn` would have looked it up — the image Path, or the first word of the command line
|
|
4778
|
+
* — and the words after it must open with the entry's args, compared literally. So
|
|
4779
|
+
* `spawn --port 4000` also covers `spawn -p 4000 --extra`, which is what a self-restarting
|
|
4780
|
+
* wrapper does, while `/tmp/evil/server.js` never covers `/srv/web/server.js`.
|
|
4781
|
+
*
|
|
4782
|
+
* `words` must already be the process's own argv. A command-line *string* is only correct
|
|
4783
|
+
* on Windows, where the OS hands one out; `/proc/<pid>/cmdline` quotes are literal bytes
|
|
4784
|
+
* of an argument, so re-joining that argv into one string corrupts it.
|
|
4463
4785
|
*/
|
|
4464
|
-
|
|
4786
|
+
function matchesSpawn(info, spawn) {
|
|
4787
|
+
const { words } = info;
|
|
4788
|
+
const first = words[0] ?? "";
|
|
4789
|
+
if (!(sameWord(first, spawn.command) || info.imagePath != null && info.imagePath !== "" && sameWord(info.imagePath, spawn.command))) return false;
|
|
4790
|
+
const offset = sameWord(first, spawn.command) ? 1 : 0;
|
|
4791
|
+
if (words.length < spawn.args.length + offset) return false;
|
|
4792
|
+
return spawn.args.every((arg, index) => sameArg(words[index + offset], arg));
|
|
4793
|
+
}
|
|
4794
|
+
/** The argv of a pid on the platforms whose process table can answer it; null otherwise. */
|
|
4795
|
+
async function processArgv(pid) {
|
|
4796
|
+
if (process.platform === "win32") return null;
|
|
4797
|
+
if (process.platform === "linux") try {
|
|
4798
|
+
const argv = (await fs.promises.readFile(`/proc/${pid}/cmdline`)).toString("utf8").split("\0").filter((part) => part.length > 0);
|
|
4799
|
+
return argv.length > 0 ? argv : null;
|
|
4800
|
+
} catch {
|
|
4801
|
+
return null;
|
|
4802
|
+
}
|
|
4465
4803
|
try {
|
|
4466
|
-
await execFileAsync$
|
|
4467
|
-
"
|
|
4804
|
+
const { stdout } = await execFileAsync$2("ps", [
|
|
4805
|
+
"-p",
|
|
4468
4806
|
String(pid),
|
|
4469
|
-
"
|
|
4470
|
-
"
|
|
4471
|
-
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
|
|
4476
|
-
|
|
4477
|
-
|
|
4807
|
+
"-ww",
|
|
4808
|
+
"-o",
|
|
4809
|
+
"command="
|
|
4810
|
+
], { timeout: 3e3 });
|
|
4811
|
+
const words = splitCommandLine(stdout.trim());
|
|
4812
|
+
return words.length > 0 ? words : null;
|
|
4813
|
+
} catch {
|
|
4814
|
+
return null;
|
|
4815
|
+
}
|
|
4478
4816
|
}
|
|
4479
|
-
|
|
4480
|
-
|
|
4481
|
-
|
|
4482
|
-
|
|
4483
|
-
|
|
4817
|
+
/**
|
|
4818
|
+
* The first two rows of `ConvertTo-Csv` output: the header and the first record. PowerShell
|
|
4819
|
+
* quotes and doubles its way around CSV, so `a,"b""c"` is three fields with the second
|
|
4820
|
+
* reading `b"c`.
|
|
4821
|
+
*/
|
|
4822
|
+
function parseCsvRows(output) {
|
|
4823
|
+
const rows = [];
|
|
4824
|
+
let row = [];
|
|
4825
|
+
let field = "";
|
|
4826
|
+
let quoted = false;
|
|
4827
|
+
for (let index = 0; index < output.length; index++) {
|
|
4828
|
+
const char = output[index];
|
|
4829
|
+
if (quoted) {
|
|
4830
|
+
if (char !== "\"") {
|
|
4831
|
+
field += char;
|
|
4832
|
+
continue;
|
|
4833
|
+
}
|
|
4834
|
+
if (output[index + 1] === "\"") {
|
|
4835
|
+
field += "\"";
|
|
4836
|
+
index++;
|
|
4837
|
+
continue;
|
|
4838
|
+
}
|
|
4839
|
+
quoted = false;
|
|
4840
|
+
continue;
|
|
4841
|
+
}
|
|
4842
|
+
if (char === "\"") {
|
|
4843
|
+
quoted = true;
|
|
4844
|
+
continue;
|
|
4845
|
+
}
|
|
4846
|
+
if (char === ",") {
|
|
4847
|
+
row.push(field);
|
|
4848
|
+
field = "";
|
|
4849
|
+
continue;
|
|
4850
|
+
}
|
|
4851
|
+
if (char === "\n") {
|
|
4852
|
+
row.push(field.replace(/\r$/, ""));
|
|
4853
|
+
rows.push(row);
|
|
4854
|
+
row = [];
|
|
4855
|
+
field = "";
|
|
4856
|
+
continue;
|
|
4857
|
+
}
|
|
4858
|
+
field += char;
|
|
4859
|
+
}
|
|
4860
|
+
if (field.length > 0 || row.length > 0) rows.push([...row, field.replace(/\r$/, "")]);
|
|
4861
|
+
return [rows[0] ?? null, rows[1] ?? null];
|
|
4862
|
+
}
|
|
4863
|
+
/**
|
|
4864
|
+
* `Win32_Process` for one pid, or null when it cannot be read.
|
|
4865
|
+
*
|
|
4866
|
+
* The whole round trip — launching PowerShell, loading the CIM provider, serializing —
|
|
4867
|
+
* costs seconds on a cold runner, so the timeout is generous and the result is converted
|
|
4868
|
+
* to CSV rather than JSON: CSV survives a value that contains a quote or a newline, which
|
|
4869
|
+
* an argv legitimately can.
|
|
4870
|
+
*/
|
|
4871
|
+
async function windowsProcessInfo(pid) {
|
|
4872
|
+
if (!windowsFilter.test(String(pid))) return null;
|
|
4873
|
+
try {
|
|
4874
|
+
const script = `Get-CimInstance Win32_Process -Filter "ProcessId=${pid}" | Select-Object CommandLine,ExecutablePath | ConvertTo-Csv -NoTypeInformation`;
|
|
4875
|
+
const { stdout } = await execFileAsync$2("powershell", [
|
|
4876
|
+
"-NoProfile",
|
|
4877
|
+
"-NonInteractive",
|
|
4878
|
+
"-Command",
|
|
4879
|
+
script
|
|
4880
|
+
], {
|
|
4881
|
+
timeout: 2e4,
|
|
4882
|
+
windowsHide: true
|
|
4883
|
+
});
|
|
4884
|
+
const [headers, values] = parseCsvRows(stdout);
|
|
4885
|
+
if (!headers || !values) return null;
|
|
4886
|
+
const commandLineIndex = headers.indexOf("CommandLine");
|
|
4887
|
+
const imageIndex = headers.indexOf("ExecutablePath");
|
|
4888
|
+
const commandLine = commandLineIndex >= 0 ? values[commandLineIndex] : void 0;
|
|
4889
|
+
if (commandLine === void 0 || commandLine.length === 0) return null;
|
|
4890
|
+
const imagePath = imageIndex >= 0 ? values[imageIndex] : void 0;
|
|
4891
|
+
return {
|
|
4892
|
+
commandLine,
|
|
4893
|
+
imagePath: imagePath && imagePath.length > 0 ? imagePath : null
|
|
4894
|
+
};
|
|
4895
|
+
} catch {
|
|
4896
|
+
return null;
|
|
4897
|
+
}
|
|
4898
|
+
}
|
|
4899
|
+
/**
|
|
4900
|
+
* Which of these pids look like this entry's own detached successor. The environment
|
|
4901
|
+
* marker is authoritative where the platform can read it; otherwise the answer rests on
|
|
4902
|
+
* the entry's own argv, which is the only signal a detached successor is obliged to keep
|
|
4903
|
+
* — and the only one Windows exposes at all.
|
|
4904
|
+
*/
|
|
4905
|
+
async function identifyHolders(serverId, spawn, pids) {
|
|
4906
|
+
const found = [];
|
|
4907
|
+
for (const pid of pids) {
|
|
4908
|
+
if (await processCarriesServerId(pid, serverId)) {
|
|
4909
|
+
found.push(pid);
|
|
4910
|
+
continue;
|
|
4911
|
+
}
|
|
4912
|
+
if (spawn.args.length === 0) continue;
|
|
4913
|
+
if (process.platform === "win32") {
|
|
4914
|
+
const info = await windowsProcessInfo(pid);
|
|
4915
|
+
if (info && matchesSpawn({
|
|
4916
|
+
words: splitCommandLine(info.commandLine),
|
|
4917
|
+
imagePath: info.imagePath
|
|
4918
|
+
}, spawn)) found.push(pid);
|
|
4919
|
+
continue;
|
|
4920
|
+
}
|
|
4921
|
+
const argv = await processArgv(pid);
|
|
4922
|
+
if (argv !== null && matchesSpawn({ words: argv }, spawn)) found.push(pid);
|
|
4923
|
+
}
|
|
4924
|
+
return found;
|
|
4925
|
+
}
|
|
4926
|
+
var execFileAsync$2, windowsFilter;
|
|
4927
|
+
var init_identity = __esmMin((() => {
|
|
4928
|
+
init_proc();
|
|
4929
|
+
execFileAsync$2 = promisify(execFile);
|
|
4930
|
+
windowsFilter = /^\d+$/;
|
|
4931
|
+
}));
|
|
4932
|
+
//#endregion
|
|
4933
|
+
//#region src/providers/process.ts
|
|
4934
|
+
/**
|
|
4935
|
+
* Resolves a bare command through the entry's own directory and the project's
|
|
4936
|
+
* `node_modules/.bin` first, so a server installed as a project dependency is
|
|
4937
|
+
* found even when the launcher's PATH has no pnpm-injected bin dir.
|
|
4938
|
+
*/
|
|
4939
|
+
function resolveCommand(command, ...searchDirs) {
|
|
4940
|
+
if (command.includes("/") || command.includes("\\")) return command;
|
|
4941
|
+
const candidates = process.platform === "win32" && path.extname(command) === "" ? [command, ...WINDOWS_SHIM_EXTENSIONS.map((extension) => `${command}${extension}`)] : [command];
|
|
4942
|
+
for (const dir of searchDirs) for (const candidate of candidates) {
|
|
4943
|
+
const local = path.join(dir, "node_modules", ".bin", candidate);
|
|
4944
|
+
if (fs.existsSync(local)) return local;
|
|
4945
|
+
}
|
|
4946
|
+
return command;
|
|
4947
|
+
}
|
|
4948
|
+
/** Relative entry paths belong to the project that launched the panel. */
|
|
4949
|
+
function resolveCwd(cwd, base = projectDir) {
|
|
4950
|
+
return path.resolve(base, cwd);
|
|
4951
|
+
}
|
|
4952
|
+
/** Node cannot spawn a Windows `.cmd`/`.bat` shim without a shell (EINVAL). */
|
|
4953
|
+
function needsShell(command) {
|
|
4954
|
+
return process.platform === "win32" && /\.(?:cmd|bat)$/i.test(command);
|
|
4955
|
+
}
|
|
4956
|
+
function spawnManaged(spec) {
|
|
4957
|
+
return spawn(spec.command, spec.args, {
|
|
4958
|
+
cwd: spec.cwd,
|
|
4959
|
+
env: {
|
|
4960
|
+
...process.env,
|
|
4961
|
+
...spec.env
|
|
4962
|
+
},
|
|
4963
|
+
detached: true,
|
|
4964
|
+
stdio: [
|
|
4965
|
+
"ignore",
|
|
4966
|
+
"pipe",
|
|
4967
|
+
"pipe"
|
|
4968
|
+
],
|
|
4969
|
+
shell: needsShell(spec.command),
|
|
4970
|
+
windowsHide: true
|
|
4971
|
+
});
|
|
4972
|
+
}
|
|
4973
|
+
/** SIGTERM (default) to the child, escalating to SIGKILL after the grace period. */
|
|
4974
|
+
async function terminate(child, options) {
|
|
4975
|
+
if (child.exitCode !== null || child.signalCode !== null) return "exited";
|
|
4976
|
+
const exited = waitForExit$1(child, options.graceMs);
|
|
4977
|
+
signalChild(child, options.signal, options.killGroup);
|
|
4978
|
+
if (await exited) return "exited";
|
|
4979
|
+
signalChild(child, "SIGKILL", options.killGroup);
|
|
4980
|
+
await waitForExit$1(child, 2e3);
|
|
4981
|
+
return "force-killed";
|
|
4982
|
+
}
|
|
4983
|
+
/**
|
|
4984
|
+
* Windows has no process groups and no SIGTERM: `taskkill /T` walks the tree and
|
|
4985
|
+
* `/F` is the only reliable way to stop a console process.
|
|
4986
|
+
*/
|
|
4987
|
+
async function killTreeWindows(pid) {
|
|
4988
|
+
try {
|
|
4989
|
+
await execFileAsync$1("taskkill", [
|
|
4990
|
+
"/pid",
|
|
4991
|
+
String(pid),
|
|
4992
|
+
"/T",
|
|
4993
|
+
"/F"
|
|
4994
|
+
], { timeout: 5e3 });
|
|
4995
|
+
} catch {}
|
|
4996
|
+
}
|
|
4997
|
+
function signalChild(child, signal, killGroup) {
|
|
4998
|
+
const pid = child.pid;
|
|
4999
|
+
if (pid === void 0) return;
|
|
5000
|
+
signalPid(pid, signal, killGroup);
|
|
5001
|
+
}
|
|
5002
|
+
function signalPid(pid, signal, killGroup) {
|
|
5003
|
+
if (process.platform === "win32") {
|
|
5004
|
+
if (killGroup) killTreeWindows(pid);
|
|
5005
|
+
else try {
|
|
5006
|
+
process.kill(pid, signal);
|
|
4484
5007
|
} catch {}
|
|
4485
5008
|
return;
|
|
4486
5009
|
}
|
|
@@ -4509,14 +5032,14 @@ async function terminatePid(pid, options) {
|
|
|
4509
5032
|
if (!alive(pid)) return "exited";
|
|
4510
5033
|
signalPid(pid, options.signal, options.killGroup);
|
|
4511
5034
|
const deadline = Date.now() + Math.max(0, options.graceMs);
|
|
4512
|
-
while (Date.now() < deadline && alive(pid)) await delay$
|
|
5035
|
+
while (Date.now() < deadline && alive(pid)) await delay$1(50);
|
|
4513
5036
|
if (!alive(pid)) return "exited";
|
|
4514
5037
|
signalPid(pid, "SIGKILL", options.killGroup);
|
|
4515
5038
|
const hardDeadline = Date.now() + 2e3;
|
|
4516
|
-
while (Date.now() < hardDeadline && alive(pid)) await delay$
|
|
5039
|
+
while (Date.now() < hardDeadline && alive(pid)) await delay$1(50);
|
|
4517
5040
|
return "force-killed";
|
|
4518
5041
|
}
|
|
4519
|
-
function delay$
|
|
5042
|
+
function delay$1(ms) {
|
|
4520
5043
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
4521
5044
|
}
|
|
4522
5045
|
function waitForExit$1(child, timeoutMs) {
|
|
@@ -4534,10 +5057,16 @@ function waitForExit$1(child, timeoutMs) {
|
|
|
4534
5057
|
child.once("exit", onExit);
|
|
4535
5058
|
});
|
|
4536
5059
|
}
|
|
4537
|
-
var execFileAsync$1;
|
|
5060
|
+
var execFileAsync$1, WINDOWS_SHIM_EXTENSIONS;
|
|
4538
5061
|
var init_process = __esmMin((() => {
|
|
4539
5062
|
init_paths();
|
|
4540
5063
|
execFileAsync$1 = promisify(execFile);
|
|
5064
|
+
WINDOWS_SHIM_EXTENSIONS = [
|
|
5065
|
+
".cmd",
|
|
5066
|
+
".exe",
|
|
5067
|
+
".bat",
|
|
5068
|
+
".ps1"
|
|
5069
|
+
];
|
|
4541
5070
|
}));
|
|
4542
5071
|
//#endregion
|
|
4543
5072
|
//#region src/services/dependencies.ts
|
|
@@ -4584,8 +5113,9 @@ function dependenciesOf(server, servers) {
|
|
|
4584
5113
|
var init_dependencies = __esmMin((() => {}));
|
|
4585
5114
|
//#endregion
|
|
4586
5115
|
//#region src/services/log-buffer.ts
|
|
4587
|
-
var LogBuffer, LineSplitter;
|
|
5116
|
+
var MAX_PENDING_CHARS, LogBuffer, LineSplitter;
|
|
4588
5117
|
var init_log_buffer = __esmMin((() => {
|
|
5118
|
+
MAX_PENDING_CHARS = 65536;
|
|
4589
5119
|
LogBuffer = class {
|
|
4590
5120
|
capacity;
|
|
4591
5121
|
lines = [];
|
|
@@ -4621,17 +5151,21 @@ var init_log_buffer = __esmMin((() => {
|
|
|
4621
5151
|
const parts = this.pending.split("\n");
|
|
4622
5152
|
this.pending = parts.pop() ?? "";
|
|
4623
5153
|
for (const part of parts) this.emit(stream, part.replace(/\r$/, ""));
|
|
5154
|
+
while (this.pending.length > MAX_PENDING_CHARS) {
|
|
5155
|
+
this.emit(stream, this.pending.slice(0, MAX_PENDING_CHARS));
|
|
5156
|
+
this.pending = this.pending.slice(MAX_PENDING_CHARS);
|
|
5157
|
+
}
|
|
4624
5158
|
}
|
|
4625
5159
|
flush(stream) {
|
|
4626
5160
|
if (this.pending.length === 0) return;
|
|
4627
|
-
this.emit(stream, this.pending);
|
|
5161
|
+
this.emit(stream, this.pending.replace(/\r$/, ""));
|
|
4628
5162
|
this.pending = "";
|
|
4629
5163
|
}
|
|
4630
5164
|
};
|
|
4631
5165
|
}));
|
|
4632
5166
|
//#endregion
|
|
4633
5167
|
//#region src/services/supervisor.ts
|
|
4634
|
-
function delay
|
|
5168
|
+
function delay(ms) {
|
|
4635
5169
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
4636
5170
|
}
|
|
4637
5171
|
/** The placeholder set every server entry can use; shared with path resolvers. */
|
|
@@ -4659,6 +5193,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
4659
5193
|
init_paths();
|
|
4660
5194
|
init_template();
|
|
4661
5195
|
init_health_check();
|
|
5196
|
+
init_identity();
|
|
4662
5197
|
init_port();
|
|
4663
5198
|
init_proc();
|
|
4664
5199
|
init_process();
|
|
@@ -4685,7 +5220,9 @@ var init_supervisor = __esmMin((() => {
|
|
|
4685
5220
|
this.options = options;
|
|
4686
5221
|
this.sync();
|
|
4687
5222
|
this.store.onChange(() => this.sync());
|
|
4688
|
-
this.tickTimer = setInterval(() =>
|
|
5223
|
+
this.tickTimer = setInterval(() => {
|
|
5224
|
+
this.tick().catch((error) => logger.error("the supervisor tick failed", error));
|
|
5225
|
+
}, TICK_INTERVAL_MS);
|
|
4689
5226
|
this.tickTimer.unref();
|
|
4690
5227
|
}
|
|
4691
5228
|
getState() {
|
|
@@ -4801,11 +5338,8 @@ var init_supervisor = __esmMin((() => {
|
|
|
4801
5338
|
port,
|
|
4802
5339
|
error: `server "${id}" is busy — try again in a moment`
|
|
4803
5340
|
};
|
|
4804
|
-
const
|
|
4805
|
-
|
|
4806
|
-
const ours = holders.filter((pid) => supervised.has(pid));
|
|
4807
|
-
const foreign = holders.filter((pid) => !supervised.has(pid));
|
|
4808
|
-
if (holders.length === 0) return {
|
|
5341
|
+
const { ours, foreign } = await this.portHolders(port);
|
|
5342
|
+
if ([...ours, ...foreign].length === 0) return {
|
|
4809
5343
|
...empty,
|
|
4810
5344
|
port,
|
|
4811
5345
|
error: `nothing is listening on port ${port} any more`
|
|
@@ -4820,7 +5354,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
4820
5354
|
};
|
|
4821
5355
|
}
|
|
4822
5356
|
this.log(entry, "system", `freeing port ${port}: asking pid ${foreign.join(", ")} to stop`);
|
|
4823
|
-
const { stopped, forced } = await terminatePids(foreign);
|
|
5357
|
+
const { stopped, forced } = await terminatePids(foreign, { graceMs: entry.config.stop.graceMs });
|
|
4824
5358
|
if (forced.length > 0) this.log(entry, "system", `pid ${forced.join(", ")} ignored SIGTERM and was killed`);
|
|
4825
5359
|
const free = await this.waitForPortRelease(entry, port);
|
|
4826
5360
|
if (free) {
|
|
@@ -4845,6 +5379,20 @@ var init_supervisor = __esmMin((() => {
|
|
|
4845
5379
|
free
|
|
4846
5380
|
};
|
|
4847
5381
|
}
|
|
5382
|
+
/**
|
|
5383
|
+
* Splits the port's listeners into the processes this panel owns and everyone
|
|
5384
|
+
* else. Only the foreign half may ever be signalled — a port held by a sibling
|
|
5385
|
+
* is a config mistake, not a stray process. That also keeps a stale `(pid 1234)`
|
|
5386
|
+
* in an old banner from killing a recycled pid.
|
|
5387
|
+
*/
|
|
5388
|
+
async portHolders(port) {
|
|
5389
|
+
const supervised = this.supervisedPids();
|
|
5390
|
+
const holders = await listPortHolders(port);
|
|
5391
|
+
return {
|
|
5392
|
+
ours: holders.filter((pid) => supervised.has(pid)),
|
|
5393
|
+
foreign: holders.filter((pid) => !supervised.has(pid))
|
|
5394
|
+
};
|
|
5395
|
+
}
|
|
4848
5396
|
/** Pids of the child processes this panel owns, plus itself. */
|
|
4849
5397
|
supervisedPids() {
|
|
4850
5398
|
const pids = /* @__PURE__ */ new Set([process.pid]);
|
|
@@ -4857,7 +5405,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
4857
5405
|
return (await Promise.all(this.occupancyHosts(entry).map((host) => isPortFree(port, host)))).every(Boolean);
|
|
4858
5406
|
};
|
|
4859
5407
|
if (await freeOnAll()) return true;
|
|
4860
|
-
await delay
|
|
5408
|
+
await delay(PORT_RELEASE_RECHECK_MS);
|
|
4861
5409
|
return freeOnAll();
|
|
4862
5410
|
}
|
|
4863
5411
|
clearLogs(id) {
|
|
@@ -4890,7 +5438,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
4890
5438
|
if (this.statusOf(target) !== "running") return false;
|
|
4891
5439
|
return target.health !== "unhealthy";
|
|
4892
5440
|
};
|
|
4893
|
-
while (!isReady() && Date.now() < deadline) await delay
|
|
5441
|
+
while (!isReady() && Date.now() < deadline) await delay(250);
|
|
4894
5442
|
if (!isReady()) {
|
|
4895
5443
|
const status = this.statusOf(target);
|
|
4896
5444
|
this.log(entry, "system", `dependency "${dependency.id}" is ${status}/${target.health} — starting anyway`);
|
|
@@ -4932,6 +5480,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
4932
5480
|
entry.nextRetryAt = null;
|
|
4933
5481
|
entry.stopping = true;
|
|
4934
5482
|
if (entry.child === null && entry.pid === null) {
|
|
5483
|
+
entry.stopping = false;
|
|
4935
5484
|
entry.status = "stopped";
|
|
4936
5485
|
this.publishServer(entry);
|
|
4937
5486
|
return { ok: true };
|
|
@@ -4941,11 +5490,8 @@ var init_supervisor = __esmMin((() => {
|
|
|
4941
5490
|
if ((entry.child === null && entry.pid !== null ? await terminatePid(entry.pid, entry.config.stop) : await terminate(entry.child, entry.config.stop)) === "force-killed") this.log(entry, "system", "force-killed after grace period");
|
|
4942
5491
|
const { port, stop } = entry.config;
|
|
4943
5492
|
if (stop.killPortHolders && port !== null) {
|
|
4944
|
-
const leftover = await
|
|
4945
|
-
if (leftover.length > 0) {
|
|
4946
|
-
this.log(entry, "system", `port ${port} still held by pid ${leftover.join(", ")} — killing`);
|
|
4947
|
-
await killPortHolders(port);
|
|
4948
|
-
}
|
|
5493
|
+
const leftover = await killPortHolders(port, this.supervisedPids());
|
|
5494
|
+
if (leftover.length > 0) this.log(entry, "system", `port ${port} was still held by pid ${leftover.join(", ")} — killed`);
|
|
4949
5495
|
}
|
|
4950
5496
|
entry.stopping = false;
|
|
4951
5497
|
entry.child = null;
|
|
@@ -4993,7 +5539,9 @@ var init_supervisor = __esmMin((() => {
|
|
|
4993
5539
|
const config = wanted.get(id);
|
|
4994
5540
|
if (!config) {
|
|
4995
5541
|
this.entries.delete(id);
|
|
4996
|
-
this.stopEntry(entry)
|
|
5542
|
+
this.stopEntry(entry).catch((error) => {
|
|
5543
|
+
logger.error(`could not stop the removed server ${id}`, error);
|
|
5544
|
+
});
|
|
4997
5545
|
continue;
|
|
4998
5546
|
}
|
|
4999
5547
|
const bufferChanged = entry.config.logBufferLines !== config.logBufferLines;
|
|
@@ -5003,13 +5551,15 @@ var init_supervisor = __esmMin((() => {
|
|
|
5003
5551
|
entry.logs = new LogBuffer(config.logBufferLines);
|
|
5004
5552
|
entry.logs.extend(kept);
|
|
5005
5553
|
}
|
|
5006
|
-
if (!config.enabled && this.isActive(entry)) this.stopEntry(entry)
|
|
5554
|
+
if (!config.enabled && this.isActive(entry)) this.stopEntry(entry).catch((error) => {
|
|
5555
|
+
logger.error(`could not stop the disabled server ${id}`, error);
|
|
5556
|
+
});
|
|
5007
5557
|
}
|
|
5008
5558
|
for (const [id, config] of wanted) if (!this.entries.has(id)) this.entries.set(id, this.createEntry(config));
|
|
5009
5559
|
this.publishState();
|
|
5010
5560
|
}
|
|
5011
5561
|
isActive(entry) {
|
|
5012
|
-
return entry.child !== null || entry.status === "backoff";
|
|
5562
|
+
return entry.child !== null || entry.pid !== null || entry.adopted || entry.status === "backoff";
|
|
5013
5563
|
}
|
|
5014
5564
|
/** Loopback first, then the configured address, so a custom bind is still probed. */
|
|
5015
5565
|
probeHosts(entry) {
|
|
@@ -5032,15 +5582,24 @@ var init_supervisor = __esmMin((() => {
|
|
|
5032
5582
|
return (await Promise.all(this.occupancyHosts(entry).map((host) => probePort(host, port, timeoutMs)))).some(Boolean);
|
|
5033
5583
|
}
|
|
5034
5584
|
/**
|
|
5035
|
-
* The port
|
|
5036
|
-
* itself leaves a
|
|
5037
|
-
* not a stranger to kill.
|
|
5038
|
-
*
|
|
5585
|
+
* The port holder that is this entry's own detached successor — a program that
|
|
5586
|
+
* restarted itself leaves a process behind, and that process is the *same server*,
|
|
5587
|
+
* not a stranger to kill.
|
|
5588
|
+
*
|
|
5589
|
+
* The environment marker is authoritative where the platform can read it (Linux,
|
|
5590
|
+
* macOS). Failing that — Windows has no per-process environment at all — the entry's
|
|
5591
|
+
* own argv answers, which a successor keeps unless it re-execs under a different
|
|
5592
|
+
* image. Ambiguity is not resolved by guessing: two holders that both look like this
|
|
5593
|
+
* entry means we do not know which one is ours, so we act on neither.
|
|
5039
5594
|
*/
|
|
5040
|
-
async
|
|
5041
|
-
const
|
|
5042
|
-
|
|
5043
|
-
|
|
5595
|
+
async ownPortHolder(entry, holders) {
|
|
5596
|
+
const spawn = this.resolveSpawn(entry);
|
|
5597
|
+
const candidates = await identifyHolders(entry.config.id, spawn, holders);
|
|
5598
|
+
if (candidates.length > 1) {
|
|
5599
|
+
this.log(entry, "system", `pid ${candidates.join(", ")} all look like this entry: refusing to guess which is ours, set onPortConflict to "kill" to clear the port anyway`);
|
|
5600
|
+
return null;
|
|
5601
|
+
}
|
|
5602
|
+
return candidates[0] ?? null;
|
|
5044
5603
|
}
|
|
5045
5604
|
async preflight(entry) {
|
|
5046
5605
|
const port = entry.config.port;
|
|
@@ -5051,29 +5610,62 @@ var init_supervisor = __esmMin((() => {
|
|
|
5051
5610
|
};
|
|
5052
5611
|
let free = await freeOnAll();
|
|
5053
5612
|
if (!free) {
|
|
5054
|
-
await delay
|
|
5613
|
+
await delay(PORT_RELEASE_RECHECK_MS);
|
|
5055
5614
|
free = await freeOnAll();
|
|
5056
5615
|
}
|
|
5057
5616
|
entry.portState = free ? "free" : "in-use";
|
|
5058
5617
|
if (free) return { kind: "free" };
|
|
5059
|
-
const
|
|
5060
|
-
const
|
|
5618
|
+
const { ours, foreign } = await this.portHolders(port);
|
|
5619
|
+
const holders = [...ours, ...foreign];
|
|
5620
|
+
const own = await this.ownPortHolder(entry, foreign);
|
|
5061
5621
|
const suffix = holders.length > 0 ? ` (pid ${holders.join(", ")})` : "";
|
|
5062
|
-
if (
|
|
5622
|
+
if (entry.config.onPortConflict === "kill") {
|
|
5623
|
+
if (ours.length > 0) {
|
|
5624
|
+
entry.status = "conflict";
|
|
5625
|
+
entry.lastError = `port ${port} is held by pid ${ours.join(", ")}, which this panel supervises — stop that server instead`;
|
|
5626
|
+
this.log(entry, "system", `${entry.lastError} — not starting (onPortConflict: kill)`);
|
|
5627
|
+
this.publishServer(entry);
|
|
5628
|
+
return {
|
|
5629
|
+
kind: "blocked",
|
|
5630
|
+
error: entry.lastError
|
|
5631
|
+
};
|
|
5632
|
+
}
|
|
5633
|
+
if (foreign.length === 0) {
|
|
5634
|
+
this.log(entry, "system", `port ${port} looks busy but no listener could be found — starting anyway`);
|
|
5635
|
+
return { kind: "free" };
|
|
5636
|
+
}
|
|
5637
|
+
this.log(entry, "system", `port ${port} is held by pid ${foreign.join(", ")} — onPortConflict: kill, stopping the holder`);
|
|
5638
|
+
const { forced } = await terminatePids(foreign, { graceMs: entry.config.stop.graceMs });
|
|
5639
|
+
if (forced.length > 0) this.log(entry, "system", `pid ${forced.join(", ")} ignored SIGTERM and was killed`);
|
|
5640
|
+
await delay(PORT_RELEASE_RECHECK_MS);
|
|
5641
|
+
if (await freeOnAll()) {
|
|
5642
|
+
entry.portState = "free";
|
|
5643
|
+
return { kind: "free" };
|
|
5644
|
+
}
|
|
5645
|
+
entry.status = "conflict";
|
|
5646
|
+
entry.lastError = `port ${port} is still in use after killing pid ${foreign.join(", ")}`;
|
|
5647
|
+
this.log(entry, "system", entry.lastError);
|
|
5648
|
+
this.publishServer(entry);
|
|
5649
|
+
return {
|
|
5650
|
+
kind: "blocked",
|
|
5651
|
+
error: entry.lastError
|
|
5652
|
+
};
|
|
5653
|
+
}
|
|
5654
|
+
if (own !== null && entry.config.onPortConflict === "follow") return {
|
|
5063
5655
|
kind: "adopt",
|
|
5064
|
-
pid: own
|
|
5656
|
+
pid: own
|
|
5065
5657
|
};
|
|
5066
|
-
if (own
|
|
5067
|
-
this.log(entry, "system", `port ${port} is held by pid ${own
|
|
5068
|
-
const { forced } = await terminatePids(own);
|
|
5658
|
+
if (own !== null && entry.config.onPortConflict === "reclaim") {
|
|
5659
|
+
this.log(entry, "system", `port ${port} is held by pid ${own}, a detached restart of this entry — replacing it with a supervised process`);
|
|
5660
|
+
const { forced } = await terminatePids([own], { graceMs: entry.config.stop.graceMs });
|
|
5069
5661
|
if (forced.length > 0) this.log(entry, "system", `pid ${forced.join(", ")} ignored SIGTERM and was killed`);
|
|
5070
|
-
await delay
|
|
5662
|
+
await delay(PORT_RELEASE_RECHECK_MS);
|
|
5071
5663
|
if (await freeOnAll()) {
|
|
5072
5664
|
entry.portState = "free";
|
|
5073
5665
|
return { kind: "free" };
|
|
5074
5666
|
}
|
|
5075
5667
|
entry.status = "conflict";
|
|
5076
|
-
entry.lastError = `port ${port} is still in use after replacing pid ${own
|
|
5668
|
+
entry.lastError = `port ${port} is still in use after replacing pid ${own}`;
|
|
5077
5669
|
this.log(entry, "system", entry.lastError);
|
|
5078
5670
|
this.publishServer(entry);
|
|
5079
5671
|
return {
|
|
@@ -5081,7 +5673,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
5081
5673
|
error: entry.lastError
|
|
5082
5674
|
};
|
|
5083
5675
|
}
|
|
5084
|
-
const hint = own
|
|
5676
|
+
const hint = own !== null ? ` — pid ${own} is a detached restart of this entry: set onPortConflict to "follow" to adopt it, "reclaim" to replace it with a supervised process, or "kill" to stop whatever holds the port` : "";
|
|
5085
5677
|
if (entry.config.onPortConflict !== "warn") {
|
|
5086
5678
|
entry.status = "conflict";
|
|
5087
5679
|
entry.lastError = `port ${port} is already in use${suffix}${hint}`;
|
|
@@ -5176,7 +5768,13 @@ var init_supervisor = __esmMin((() => {
|
|
|
5176
5768
|
if (code === 0) this.log(entry, "system", "bootstrap finished");
|
|
5177
5769
|
else if (code !== null) this.log(entry, "system", `bootstrap exited with code ${code} — continuing anyway`);
|
|
5178
5770
|
}
|
|
5179
|
-
|
|
5771
|
+
/**
|
|
5772
|
+
* Everything `spawn` needs for an entry: the resolved image, the expanded argv, the
|
|
5773
|
+
* cwd and the environment. The argv is also what the preflight recognizes the entry's
|
|
5774
|
+
* own detached successor by, so spawn and identification must never resolve it twice
|
|
5775
|
+
* with two different rules.
|
|
5776
|
+
*/
|
|
5777
|
+
resolveSpawn(entry) {
|
|
5180
5778
|
const vars = this.buildVars(entry);
|
|
5181
5779
|
const cwd = resolveCwd(entry.config.cwd);
|
|
5182
5780
|
const command = resolveCommand(entry.config.command, cwd, projectDir);
|
|
@@ -5192,15 +5790,26 @@ var init_supervisor = __esmMin((() => {
|
|
|
5192
5790
|
...process.env,
|
|
5193
5791
|
...fileEnv
|
|
5194
5792
|
};
|
|
5195
|
-
const
|
|
5793
|
+
const dataEnvs = resolveRecord(entry.config.dataEnvs, vars);
|
|
5196
5794
|
const env = {
|
|
5197
5795
|
...expandEnvRecord(resolveRecord(entry.config.env, vars), expansionVars),
|
|
5198
5796
|
...fileEnv,
|
|
5199
|
-
...expandEnvRecord(
|
|
5797
|
+
...expandEnvRecord(dataEnvs, expansionVars),
|
|
5200
5798
|
HHOSTED_SERVER_ID: entry.config.id,
|
|
5201
5799
|
HHOSTED_CONTROL_PORT: String(this.options.control.port)
|
|
5202
5800
|
};
|
|
5203
|
-
|
|
5801
|
+
for (const key of Object.keys(dataEnvs)) env[key] = path.resolve(cwd, env[key]);
|
|
5802
|
+
return {
|
|
5803
|
+
command,
|
|
5804
|
+
args: expandEnvList(resolveTemplates(entry.config.args, vars), expansionVars),
|
|
5805
|
+
env,
|
|
5806
|
+
cwd,
|
|
5807
|
+
loggedArgs: resolveTemplates(entry.config.args, vars)
|
|
5808
|
+
};
|
|
5809
|
+
}
|
|
5810
|
+
spawnEntry(entry) {
|
|
5811
|
+
const { command, args, env, cwd, loggedArgs } = this.resolveSpawn(entry);
|
|
5812
|
+
this.log(entry, "system", `start: ${command} ${loggedArgs.join(" ")}`);
|
|
5204
5813
|
let child;
|
|
5205
5814
|
try {
|
|
5206
5815
|
child = spawnManaged({
|
|
@@ -5289,7 +5898,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
5289
5898
|
this.publishServer(entry);
|
|
5290
5899
|
return;
|
|
5291
5900
|
}
|
|
5292
|
-
await delay
|
|
5901
|
+
await delay(300);
|
|
5293
5902
|
}
|
|
5294
5903
|
if (entry.child === child && entry.status === "starting") {
|
|
5295
5904
|
entry.status = "running";
|
|
@@ -5342,7 +5951,9 @@ var init_supervisor = __esmMin((() => {
|
|
|
5342
5951
|
this.log(entry, "system", `restart ${entry.restarts}/${restart.maxRetries} in ${backoffMs}ms`);
|
|
5343
5952
|
entry.retryTimer = setTimeout(() => {
|
|
5344
5953
|
entry.retryTimer = null;
|
|
5345
|
-
this.start(entry.config.id, { retry: true })
|
|
5954
|
+
this.start(entry.config.id, { retry: true }).catch((error) => {
|
|
5955
|
+
logger.error(`could not restart ${entry.config.id}`, error);
|
|
5956
|
+
});
|
|
5346
5957
|
}, backoffMs);
|
|
5347
5958
|
entry.retryTimer.unref();
|
|
5348
5959
|
} else {
|
|
@@ -5375,7 +5986,9 @@ var init_supervisor = __esmMin((() => {
|
|
|
5375
5986
|
await this.restart(entry.config.id);
|
|
5376
5987
|
continue;
|
|
5377
5988
|
}
|
|
5378
|
-
if (entry.status === "backoff" && entry.nextRetryAt !== null && now >= entry.nextRetryAt && entry.retryTimer === null) this.start(entry.config.id, { retry: true })
|
|
5989
|
+
if (entry.status === "backoff" && entry.nextRetryAt !== null && now >= entry.nextRetryAt && entry.retryTimer === null) this.start(entry.config.id, { retry: true }).catch((error) => {
|
|
5990
|
+
logger.error(`could not restart ${entry.config.id}`, error);
|
|
5991
|
+
});
|
|
5379
5992
|
}
|
|
5380
5993
|
this.publishState();
|
|
5381
5994
|
}
|
|
@@ -5549,6 +6162,69 @@ var init_supervisor = __esmMin((() => {
|
|
|
5549
6162
|
};
|
|
5550
6163
|
}));
|
|
5551
6164
|
//#endregion
|
|
6165
|
+
//#region src/shared/generated.ts
|
|
6166
|
+
/**
|
|
6167
|
+
* True when a path *relative to the declared root* looks generated. Accepts
|
|
6168
|
+
* either separator, so a `path.relative` result can be passed straight in.
|
|
6169
|
+
*/
|
|
6170
|
+
function isGeneratedPath(relative) {
|
|
6171
|
+
const segments = relative.split(/[\\/]+/).filter((segment) => segment.length > 0 && segment !== ".");
|
|
6172
|
+
if (segments.length === 0) return false;
|
|
6173
|
+
if (FILE_NAME_SET.has(segments[segments.length - 1])) return true;
|
|
6174
|
+
return segments.some((segment) => DIR_NAME_SET.has(segment));
|
|
6175
|
+
}
|
|
6176
|
+
var GENERATED_DIRS, GENERATED_FILES, DIR_NAME_SET, FILE_NAME_SET;
|
|
6177
|
+
var init_generated = __esmMin((() => {
|
|
6178
|
+
GENERATED_DIRS = [
|
|
6179
|
+
"node_modules",
|
|
6180
|
+
"bower_components",
|
|
6181
|
+
"jspm_packages",
|
|
6182
|
+
".yarn",
|
|
6183
|
+
".pnpm",
|
|
6184
|
+
".pnpm-store",
|
|
6185
|
+
".npm",
|
|
6186
|
+
".next",
|
|
6187
|
+
".nuxt",
|
|
6188
|
+
".svelte-kit",
|
|
6189
|
+
".astro",
|
|
6190
|
+
".output",
|
|
6191
|
+
".vercel",
|
|
6192
|
+
".netlify",
|
|
6193
|
+
".angular",
|
|
6194
|
+
"dist",
|
|
6195
|
+
"build",
|
|
6196
|
+
"out",
|
|
6197
|
+
".cache",
|
|
6198
|
+
".parcel-cache",
|
|
6199
|
+
".turbo",
|
|
6200
|
+
".vite",
|
|
6201
|
+
".rollup.cache",
|
|
6202
|
+
".swc",
|
|
6203
|
+
"coverage",
|
|
6204
|
+
".nyc_output",
|
|
6205
|
+
"__pycache__",
|
|
6206
|
+
".pytest_cache",
|
|
6207
|
+
".mypy_cache",
|
|
6208
|
+
".ruff_cache",
|
|
6209
|
+
".tox",
|
|
6210
|
+
"target",
|
|
6211
|
+
".gradle",
|
|
6212
|
+
".dart_tool"
|
|
6213
|
+
];
|
|
6214
|
+
GENERATED_FILES = [
|
|
6215
|
+
".DS_Store",
|
|
6216
|
+
"Thumbs.db",
|
|
6217
|
+
"desktop.ini",
|
|
6218
|
+
".eslintcache",
|
|
6219
|
+
".stylelintcache",
|
|
6220
|
+
"npm-debug.log",
|
|
6221
|
+
"yarn-error.log",
|
|
6222
|
+
"pnpm-debug.log"
|
|
6223
|
+
];
|
|
6224
|
+
DIR_NAME_SET = new Set(GENERATED_DIRS);
|
|
6225
|
+
FILE_NAME_SET = new Set(GENERATED_FILES);
|
|
6226
|
+
}));
|
|
6227
|
+
//#endregion
|
|
5552
6228
|
//#region src/services/backups.ts
|
|
5553
6229
|
/**
|
|
5554
6230
|
* Structural allowlist for archive entries. A regex alone is not enough: `..`
|
|
@@ -5584,21 +6260,23 @@ function resolveBackupPaths(servers, includePaths = []) {
|
|
|
5584
6260
|
dataRoot,
|
|
5585
6261
|
home: os.homedir()
|
|
5586
6262
|
};
|
|
5587
|
-
const add = (value, origin, vars) => {
|
|
6263
|
+
const add = (value, origin, vars, ignoreGenerated) => {
|
|
5588
6264
|
if (value.trim().length === 0) return;
|
|
5589
6265
|
const resolved = path.normalize(resolveUserPath(expandEnv(resolveTemplate(value, vars), process.env)));
|
|
5590
6266
|
declared.push({
|
|
5591
6267
|
path: resolved,
|
|
5592
6268
|
origin,
|
|
5593
6269
|
depth: path.normalize(resolved).split(path.sep).filter(Boolean).length,
|
|
5594
|
-
order: declared.length
|
|
6270
|
+
order: declared.length,
|
|
6271
|
+
ignoreGenerated
|
|
5595
6272
|
});
|
|
5596
6273
|
};
|
|
5597
|
-
for (const value of includePaths) add(value, "global", globalVars);
|
|
6274
|
+
for (const value of includePaths) add(value, "global", globalVars, false);
|
|
5598
6275
|
for (const config of servers) {
|
|
5599
6276
|
const vars = serverTemplateVars(config);
|
|
5600
|
-
|
|
5601
|
-
for (const value of config.
|
|
6277
|
+
const ignoreGenerated = config.backupIgnoreGenerated !== false;
|
|
6278
|
+
for (const [name, value] of Object.entries(config.dataEnvs)) add(value, `${config.id}:${name}`, vars, ignoreGenerated);
|
|
6279
|
+
for (const value of config.backupPaths) add(value, `${config.id}:backupPaths`, vars, ignoreGenerated);
|
|
5602
6280
|
}
|
|
5603
6281
|
const sorted = [...declared].sort((a, b) => a.depth - b.depth || a.order - b.order);
|
|
5604
6282
|
return sorted.map((entry, index) => {
|
|
@@ -5607,14 +6285,16 @@ function resolveBackupPaths(servers, includePaths = []) {
|
|
|
5607
6285
|
path: entry.path,
|
|
5608
6286
|
origin: entry.origin,
|
|
5609
6287
|
included: true,
|
|
5610
|
-
note: null
|
|
6288
|
+
note: null,
|
|
6289
|
+
ignoreGenerated: entry.ignoreGenerated
|
|
5611
6290
|
};
|
|
5612
6291
|
const note = parent.path === entry.path ? `already declared by ${parent.origin}` : `covered by ${parent.path}`;
|
|
5613
6292
|
return {
|
|
5614
6293
|
path: entry.path,
|
|
5615
6294
|
origin: entry.origin,
|
|
5616
6295
|
included: false,
|
|
5617
|
-
note
|
|
6296
|
+
note,
|
|
6297
|
+
ignoreGenerated: entry.ignoreGenerated
|
|
5618
6298
|
};
|
|
5619
6299
|
});
|
|
5620
6300
|
}
|
|
@@ -5684,6 +6364,7 @@ var init_backups = __esmMin((() => {
|
|
|
5684
6364
|
init_template();
|
|
5685
6365
|
init_archive();
|
|
5686
6366
|
init_supervisor();
|
|
6367
|
+
init_generated();
|
|
5687
6368
|
MANIFEST = "manifest.json";
|
|
5688
6369
|
ALLOWED_ROOTS = /* @__PURE__ */ new Set([
|
|
5689
6370
|
"config",
|
|
@@ -5770,7 +6451,7 @@ var init_backups = __esmMin((() => {
|
|
|
5770
6451
|
if (!declared.included || !fs.existsSync(declared.path)) continue;
|
|
5771
6452
|
const slug = slugifyPath(declared.path);
|
|
5772
6453
|
if (data.some((entry) => entry.slug === slug)) continue;
|
|
5773
|
-
this.copyInto(staging, path.join("data", slug), declared.path);
|
|
6454
|
+
this.copyInto(staging, path.join("data", slug), declared.path, declared.ignoreGenerated === true);
|
|
5774
6455
|
data.push({
|
|
5775
6456
|
slug,
|
|
5776
6457
|
path: declared.path,
|
|
@@ -6080,15 +6761,21 @@ var init_backups = __esmMin((() => {
|
|
|
6080
6761
|
const present = new Set(listed.map((file) => file.name));
|
|
6081
6762
|
for (const name of [...this.flags.keys()]) if (!present.has(name)) this.flags.delete(name);
|
|
6082
6763
|
}
|
|
6083
|
-
copyInto(staging, relative, source) {
|
|
6764
|
+
copyInto(staging, relative, source, ignoreGenerated = false) {
|
|
6084
6765
|
if (!fs.existsSync(source)) return;
|
|
6085
6766
|
const target = path.join(staging, relative);
|
|
6086
6767
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
6087
6768
|
const archiveDir = path.resolve(this.resolveDir());
|
|
6769
|
+
const root = path.resolve(source);
|
|
6088
6770
|
fs.cpSync(source, target, {
|
|
6089
6771
|
recursive: true,
|
|
6090
6772
|
force: true,
|
|
6091
|
-
filter: (from) =>
|
|
6773
|
+
filter: (from) => {
|
|
6774
|
+
const resolved = path.resolve(from);
|
|
6775
|
+
if (isInside(archiveDir, resolved)) return false;
|
|
6776
|
+
if (!ignoreGenerated || resolved === root) return true;
|
|
6777
|
+
return !isGeneratedPath(path.relative(root, resolved));
|
|
6778
|
+
}
|
|
6092
6779
|
});
|
|
6093
6780
|
}
|
|
6094
6781
|
prune() {
|
|
@@ -6102,6 +6789,76 @@ var init_backups = __esmMin((() => {
|
|
|
6102
6789
|
};
|
|
6103
6790
|
}));
|
|
6104
6791
|
//#endregion
|
|
6792
|
+
//#region src/services/config-watch.ts
|
|
6793
|
+
var DEFAULT_DEBOUNCE_MS, DEFAULT_POLL_MS, ConfigWatch;
|
|
6794
|
+
var init_config_watch = __esmMin((() => {
|
|
6795
|
+
DEFAULT_DEBOUNCE_MS = 150;
|
|
6796
|
+
DEFAULT_POLL_MS = 2e3;
|
|
6797
|
+
ConfigWatch = class {
|
|
6798
|
+
options;
|
|
6799
|
+
debounceMs;
|
|
6800
|
+
pollMs;
|
|
6801
|
+
watcher = null;
|
|
6802
|
+
debounceTimer = null;
|
|
6803
|
+
pollTimer = null;
|
|
6804
|
+
disposed = false;
|
|
6805
|
+
constructor(options) {
|
|
6806
|
+
this.options = options;
|
|
6807
|
+
this.debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
|
|
6808
|
+
this.pollMs = options.pollMs ?? DEFAULT_POLL_MS;
|
|
6809
|
+
}
|
|
6810
|
+
start() {
|
|
6811
|
+
if (this.disposed || this.watcher !== null) return;
|
|
6812
|
+
try {
|
|
6813
|
+
this.watcher = fs.watch(path.dirname(this.options.file), (_event, filename) => {
|
|
6814
|
+
if (filename !== null && filename !== path.basename(this.options.file)) return;
|
|
6815
|
+
this.schedule();
|
|
6816
|
+
});
|
|
6817
|
+
this.watcher.on("error", (error) => {
|
|
6818
|
+
this.options.onError?.(error);
|
|
6819
|
+
this.closeWatcher();
|
|
6820
|
+
});
|
|
6821
|
+
this.watcher.unref();
|
|
6822
|
+
} catch (error) {
|
|
6823
|
+
this.options.onError?.(error);
|
|
6824
|
+
}
|
|
6825
|
+
if (this.pollMs > 0) {
|
|
6826
|
+
this.pollTimer = setInterval(() => this.check(), this.pollMs);
|
|
6827
|
+
this.pollTimer.unref();
|
|
6828
|
+
}
|
|
6829
|
+
}
|
|
6830
|
+
/** One check, exactly what the poll does — tests drive this instead of waiting. */
|
|
6831
|
+
check() {
|
|
6832
|
+
this.schedule();
|
|
6833
|
+
}
|
|
6834
|
+
dispose() {
|
|
6835
|
+
this.disposed = true;
|
|
6836
|
+
if (this.debounceTimer !== null) {
|
|
6837
|
+
clearTimeout(this.debounceTimer);
|
|
6838
|
+
this.debounceTimer = null;
|
|
6839
|
+
}
|
|
6840
|
+
if (this.pollTimer !== null) {
|
|
6841
|
+
clearInterval(this.pollTimer);
|
|
6842
|
+
this.pollTimer = null;
|
|
6843
|
+
}
|
|
6844
|
+
this.closeWatcher();
|
|
6845
|
+
}
|
|
6846
|
+
/** One reload per burst: an editor writing in pieces is still one edit. */
|
|
6847
|
+
schedule() {
|
|
6848
|
+
if (this.disposed || this.debounceTimer !== null) return;
|
|
6849
|
+
this.debounceTimer = setTimeout(() => {
|
|
6850
|
+
this.debounceTimer = null;
|
|
6851
|
+
if (!this.disposed) this.options.onChange();
|
|
6852
|
+
}, this.debounceMs);
|
|
6853
|
+
this.debounceTimer.unref();
|
|
6854
|
+
}
|
|
6855
|
+
closeWatcher() {
|
|
6856
|
+
this.watcher?.close();
|
|
6857
|
+
this.watcher = null;
|
|
6858
|
+
}
|
|
6859
|
+
};
|
|
6860
|
+
}));
|
|
6861
|
+
//#endregion
|
|
6105
6862
|
//#region src/services/control-server.ts
|
|
6106
6863
|
var ControlServer;
|
|
6107
6864
|
var init_control_server = __esmMin((() => {
|
|
@@ -6804,7 +7561,7 @@ var init_notifications = __esmMin((() => {
|
|
|
6804
7561
|
ok: false,
|
|
6805
7562
|
error: "no bot token configured"
|
|
6806
7563
|
};
|
|
6807
|
-
const result = await sendTelegramMessage(token, chatId, formatTelegramMessage("✅ home-hosted
|
|
7564
|
+
const result = await sendTelegramMessage(token, chatId, formatTelegramMessage("✅ home-hosted test", ["notifications are wired up correctly"]));
|
|
6808
7565
|
this.remember(result.ok ? "test message sent" : result.error ?? "test failed");
|
|
6809
7566
|
return result;
|
|
6810
7567
|
}
|
|
@@ -7341,12 +8098,50 @@ async function runControlPlane(options) {
|
|
|
7341
8098
|
notifications,
|
|
7342
8099
|
hostMonitor
|
|
7343
8100
|
});
|
|
8101
|
+
/**
|
|
8102
|
+
* A config edited by hand — a text editor, a `git checkout`, a config-management
|
|
8103
|
+
* tool — is picked up without a restart. A revision this release cannot read is
|
|
8104
|
+
* reported in the state frame and the panel keeps running what it had, so a typo
|
|
8105
|
+
* never stops a server. A definition that *did* change takes effect on that
|
|
8106
|
+
* entry's next start; a newly added entry with `autostart` starts now, the way it
|
|
8107
|
+
* would after a restart, and a removed one is stopped and forgotten.
|
|
8108
|
+
*/
|
|
8109
|
+
let lastConfigError = store.configError;
|
|
8110
|
+
const configWatch = new ConfigWatch({
|
|
8111
|
+
file: store.path,
|
|
8112
|
+
onChange: () => {
|
|
8113
|
+
const before = new Set(store.servers.map((server) => server.id));
|
|
8114
|
+
const result = store.reloadFromDisk();
|
|
8115
|
+
if (store.configError !== lastConfigError) {
|
|
8116
|
+
if (store.configError === null) logger.info("the config file is readable again");
|
|
8117
|
+
else logger.error(`${store.configError} — keeping the config already running`);
|
|
8118
|
+
lastConfigError = store.configError;
|
|
8119
|
+
}
|
|
8120
|
+
if (!result.applied) {
|
|
8121
|
+
if (result.changed && result.error === null) logger.info("the config file changed, but not in a way that changes the config");
|
|
8122
|
+
return;
|
|
8123
|
+
}
|
|
8124
|
+
const added = store.servers.filter((server) => !before.has(server.id));
|
|
8125
|
+
const removed = [...before].filter((id) => !store.getServer(id));
|
|
8126
|
+
logger.info(`config reloaded from disk — ${store.servers.length} server(s)${added.length === 0 ? "" : `, ${added.length} added`}${removed.length === 0 ? "" : `, ${removed.length} removed`}`);
|
|
8127
|
+
if (!options.autostart) return;
|
|
8128
|
+
for (const server of added) {
|
|
8129
|
+
if (!server.enabled || !server.autostart) continue;
|
|
8130
|
+
supervisor.start(server.id).catch((error) => {
|
|
8131
|
+
logger.error(`could not start the added server ${server.id}`, error);
|
|
8132
|
+
});
|
|
8133
|
+
}
|
|
8134
|
+
},
|
|
8135
|
+
onError: (error) => logger.warn(`cannot watch ${path.basename(store.path)} for changes: ${error instanceof Error ? error.message : String(error)}`)
|
|
8136
|
+
});
|
|
8137
|
+
configWatch.start();
|
|
7344
8138
|
let shuttingDown = false;
|
|
7345
8139
|
const shutdown = async (reason) => {
|
|
7346
8140
|
if (shuttingDown) return;
|
|
7347
8141
|
shuttingDown = true;
|
|
7348
8142
|
logger.info(`${reason} — stopping ${supervisor.views().length} server(s)`);
|
|
7349
8143
|
clearRuntime();
|
|
8144
|
+
configWatch.dispose();
|
|
7350
8145
|
auth.dispose();
|
|
7351
8146
|
await supervisor.dispose();
|
|
7352
8147
|
logFiles.dispose();
|
|
@@ -7417,8 +8212,14 @@ async function runControlPlane(options) {
|
|
|
7417
8212
|
logger.error("could not start the restored servers", error);
|
|
7418
8213
|
});
|
|
7419
8214
|
};
|
|
7420
|
-
|
|
7421
|
-
|
|
8215
|
+
const onSignal = (signal) => {
|
|
8216
|
+
shutdown(signal).catch((error) => {
|
|
8217
|
+
logger.error(`shutdown after ${signal} failed`, error);
|
|
8218
|
+
process.exit(1);
|
|
8219
|
+
});
|
|
8220
|
+
};
|
|
8221
|
+
process.on("SIGINT", () => onSignal("SIGINT"));
|
|
8222
|
+
process.on("SIGTERM", () => onSignal("SIGTERM"));
|
|
7422
8223
|
}
|
|
7423
8224
|
var packageRoot;
|
|
7424
8225
|
var init_src = __esmMin((() => {
|
|
@@ -7435,6 +8236,7 @@ var init_src = __esmMin((() => {
|
|
|
7435
8236
|
init_port();
|
|
7436
8237
|
init_auth$1();
|
|
7437
8238
|
init_backups();
|
|
8239
|
+
init_config_watch();
|
|
7438
8240
|
init_control_server();
|
|
7439
8241
|
init_events();
|
|
7440
8242
|
init_exposure();
|
|
@@ -7450,261 +8252,34 @@ var init_src = __esmMin((() => {
|
|
|
7450
8252
|
packageRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
7451
8253
|
}));
|
|
7452
8254
|
//#endregion
|
|
7453
|
-
//#region src/
|
|
7454
|
-
var
|
|
7455
|
-
|
|
7456
|
-
|
|
7457
|
-
|
|
7458
|
-
|
|
7459
|
-
projectGitignore: () => projectGitignore,
|
|
7460
|
-
projectManifest: () => projectManifest,
|
|
7461
|
-
projectScripts: () => projectScripts,
|
|
7462
|
-
runCommand: () => runCommand,
|
|
7463
|
-
scaffold: () => scaffold
|
|
8255
|
+
//#region src/cli/up.ts
|
|
8256
|
+
var up_exports = /* @__PURE__ */ __exportAll({
|
|
8257
|
+
runUp: () => runUp,
|
|
8258
|
+
toUpFlags: () => toUpFlags,
|
|
8259
|
+
upArgs: () => upArgs,
|
|
8260
|
+
upCommand: () => upCommand
|
|
7464
8261
|
});
|
|
7465
|
-
/**
|
|
7466
|
-
function
|
|
7467
|
-
|
|
8262
|
+
/** citty only parses; the port's range and the boolean defaults are decided here. */
|
|
8263
|
+
function toUpFlags(args) {
|
|
8264
|
+
let port;
|
|
8265
|
+
if (args.port !== void 0) {
|
|
8266
|
+
port = Number.parseInt(args.port, 10);
|
|
8267
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) fail(`invalid port: ${args.port}`);
|
|
8268
|
+
}
|
|
7468
8269
|
return {
|
|
7469
|
-
|
|
7470
|
-
|
|
7471
|
-
|
|
7472
|
-
|
|
7473
|
-
|
|
7474
|
-
|
|
7475
|
-
|
|
8270
|
+
config: args.config,
|
|
8271
|
+
port,
|
|
8272
|
+
host: args.host,
|
|
8273
|
+
autostart: args.autostart !== false,
|
|
8274
|
+
open: args.open === true,
|
|
8275
|
+
foreground: args.foreground === true,
|
|
8276
|
+
printConfig: args.printConfig === true
|
|
7476
8277
|
};
|
|
7477
8278
|
}
|
|
7478
|
-
/** `home-hosted` is pinned to the version that wrote the project, and open to newer ones. */
|
|
7479
|
-
function projectManifest(name) {
|
|
7480
|
-
return `${JSON.stringify({
|
|
7481
|
-
name,
|
|
7482
|
-
version: "0.1.0",
|
|
7483
|
-
private: true,
|
|
7484
|
-
description: "Servers supervised by home-hosted.",
|
|
7485
|
-
type: "module",
|
|
7486
|
-
engines: { node: ">=24.0.0" },
|
|
7487
|
-
scripts: projectScripts(),
|
|
7488
|
-
dependencies: { "home-hosted": `^${appVersion()}` }
|
|
7489
|
-
}, null, 2)}\n`;
|
|
7490
|
-
}
|
|
7491
8279
|
/**
|
|
7492
|
-
*
|
|
7493
|
-
*
|
|
7494
|
-
|
|
7495
|
-
function projectGitignore() {
|
|
7496
|
-
return `node_modules/
|
|
7497
|
-
|
|
7498
|
-
# home-hosted: state is local, the server definitions are tracked
|
|
7499
|
-
state/*
|
|
7500
|
-
!state/servers.config.json
|
|
7501
|
-
data/
|
|
7502
|
-
`;
|
|
7503
|
-
}
|
|
7504
|
-
function isEmptyDir(dir) {
|
|
7505
|
-
try {
|
|
7506
|
-
return fs.readdirSync(dir).every((entry) => entry === ".git");
|
|
7507
|
-
} catch {
|
|
7508
|
-
return true;
|
|
7509
|
-
}
|
|
7510
|
-
}
|
|
7511
|
-
function assertUsableDir(dir) {
|
|
7512
|
-
let stats = null;
|
|
7513
|
-
try {
|
|
7514
|
-
stats = fs.statSync(dir);
|
|
7515
|
-
} catch {
|
|
7516
|
-
return;
|
|
7517
|
-
}
|
|
7518
|
-
if (!stats.isDirectory()) throw new Error(`${dir} exists and is not a directory`);
|
|
7519
|
-
if (!isEmptyDir(dir)) throw new Error(`${dir} is not empty — pick another directory, or empty it first`);
|
|
7520
|
-
}
|
|
7521
|
-
/** Writes the project skeleton. Nothing is installed or initialized here. */
|
|
7522
|
-
function scaffold(options) {
|
|
7523
|
-
const dir = path.resolve(options.dir);
|
|
7524
|
-
assertUsableDir(dir);
|
|
7525
|
-
const existed = fs.existsSync(dir);
|
|
7526
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
7527
|
-
const files = [["package.json", projectManifest(options.name)], [".gitignore", projectGitignore()]];
|
|
7528
|
-
for (const [name, contents] of files) fs.writeFileSync(path.join(dir, name), contents);
|
|
7529
|
-
return {
|
|
7530
|
-
dir,
|
|
7531
|
-
created: !existed,
|
|
7532
|
-
files: files.map(([name]) => name),
|
|
7533
|
-
installed: false,
|
|
7534
|
-
git: false
|
|
7535
|
-
};
|
|
7536
|
-
}
|
|
7537
|
-
/** The package manager this machine actually has, preferring pnpm. */
|
|
7538
|
-
function detectPackageManager(exists) {
|
|
7539
|
-
return PACKAGE_MANAGERS.find((pm) => exists(pm)) ?? "npm";
|
|
7540
|
-
}
|
|
7541
|
-
/** How to run one of the project's scripts with a given package manager. */
|
|
7542
|
-
function runCommand(pm, script) {
|
|
7543
|
-
return pm === "npm" ? `npm run ${script}` : `${pm} run ${script}`;
|
|
7544
|
-
}
|
|
7545
|
-
/** Install arguments for a scaffolded project (the manifest already lists the dependency). */
|
|
7546
|
-
function installArgs(pm) {
|
|
7547
|
-
return pm === "yarn" ? [] : ["install"];
|
|
7548
|
-
}
|
|
7549
|
-
var PACKAGE_MANAGERS;
|
|
7550
|
-
var init_init = __esmMin((() => {
|
|
7551
|
-
init_version();
|
|
7552
|
-
PACKAGE_MANAGERS = [
|
|
7553
|
-
"pnpm",
|
|
7554
|
-
"npm",
|
|
7555
|
-
"yarn",
|
|
7556
|
-
"bun"
|
|
7557
|
-
];
|
|
7558
|
-
}));
|
|
7559
|
-
//#endregion
|
|
7560
|
-
//#region src/cli.ts
|
|
7561
|
-
/**
|
|
7562
|
-
* The command line. Everything here works before the state directories are known
|
|
7563
|
-
* (so `--home`/`--project` can point them somewhere), which is why the modules
|
|
7564
|
-
* that read those paths are imported dynamically instead of at the top.
|
|
7565
|
-
*/
|
|
7566
|
-
var CLI_ENTRY = fileURLToPath(import.meta.url);
|
|
7567
|
-
var DEFAULT_PORT = 3999;
|
|
7568
|
-
var LOG_ROTATE_BYTES = 5242880;
|
|
7569
|
-
var USAGE = `home-hosted — a control panel for the processes on your home server
|
|
7570
|
-
|
|
7571
|
-
Usage
|
|
7572
|
-
home-hosted up [options] start it in the background (detached)
|
|
7573
|
-
home-hosted down stop it, and everything it supervises
|
|
7574
|
-
home-hosted restart [options] down, then up
|
|
7575
|
-
home-hosted status [--json] is it running, where, and how to reach it
|
|
7576
|
-
home-hosted set-password set the panel password without the API
|
|
7577
|
-
home-hosted set-token set the API token that scripts and agents use
|
|
7578
|
-
home-hosted migrate bring the config up to this release's schema
|
|
7579
|
-
home-hosted init scaffold a project that keeps its state in the repo
|
|
7580
|
-
home-hosted ui-revert go back to the stock control panel UI
|
|
7581
|
-
|
|
7582
|
-
Options for up/restart
|
|
7583
|
-
-c, --config <file> servers config (default: <state>/servers.config.json)
|
|
7584
|
-
-p, --port <port> control panel port (default: ${DEFAULT_PORT})
|
|
7585
|
-
--host <bind> local | lan | an ipv4 address (default: local)
|
|
7586
|
-
--open open the panel in a browser once it is up
|
|
7587
|
-
--no-autostart do not start the entries marked autostart
|
|
7588
|
-
--foreground run in this process instead of detaching (systemd/docker)
|
|
7589
|
-
--print-config print the effective config and exit
|
|
7590
|
-
|
|
7591
|
-
Options for set-token
|
|
7592
|
-
--generate create a strong token and print it once
|
|
7593
|
-
--clear remove the token, so it stops working
|
|
7594
|
-
|
|
7595
|
-
Options for migrate
|
|
7596
|
-
--dry-run print what would change, write nothing
|
|
7597
|
-
-y, --yes apply without asking (or set HHOSTED_MIGRATE=allow)
|
|
7598
|
-
|
|
7599
|
-
Options for init
|
|
7600
|
-
--dir <dir> where to scaffold (default: ./my-servers)
|
|
7601
|
-
--name <name> package name (default: the directory name)
|
|
7602
|
-
--pm <manager> pnpm | npm | yarn | bun (default: the first one installed)
|
|
7603
|
-
--no-install write the files, install nothing
|
|
7604
|
-
-y, --yes take every default, ask nothing
|
|
7605
|
-
|
|
7606
|
-
Everywhere
|
|
7607
|
-
--home <dir> state directory (default: $HHOSTED_HOME or ~/.home-hosted)
|
|
7608
|
-
--project <dir> base for relative entry paths (default: the current directory)
|
|
7609
|
-
-h, --help this text
|
|
7610
|
-
-v, --version the version
|
|
7611
|
-
|
|
7612
|
-
Environment
|
|
7613
|
-
HHOSTED_HOME where config, secrets, logs, TLS and backups live
|
|
7614
|
-
HHOSTED_PROJECT base for relative entry paths
|
|
7615
|
-
HHOSTED_PASSWORD the password for a non-interactive set-password
|
|
7616
|
-
HHOSTED_TOKEN the token for a non-interactive set-token
|
|
7617
|
-
`;
|
|
7618
|
-
var isTty = () => process.stdout.isTTY === true;
|
|
7619
|
-
var paint = (code, text) => isTty() ? `\x1B[${code}m${text}\x1B[0m` : text;
|
|
7620
|
-
var dim = (text) => paint("2", text);
|
|
7621
|
-
var bold = (text) => paint("1", text);
|
|
7622
|
-
var green = (text) => paint("32", text);
|
|
7623
|
-
function fail(message) {
|
|
7624
|
-
process.stderr.write(`${paint("31", "error")} ${message}\n`);
|
|
7625
|
-
process.exit(1);
|
|
7626
|
-
}
|
|
7627
|
-
function delay(ms) {
|
|
7628
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
7629
|
-
}
|
|
7630
|
-
/** `--home`/`--project` are handled for every command, so they are peeled off first. */
|
|
7631
|
-
function extractDirFlags(argv) {
|
|
7632
|
-
const rest = [];
|
|
7633
|
-
let project;
|
|
7634
|
-
let home;
|
|
7635
|
-
for (let index = 0; index < argv.length; index++) {
|
|
7636
|
-
const arg = argv[index];
|
|
7637
|
-
const equals = arg.indexOf("=");
|
|
7638
|
-
const name = equals === -1 ? arg : arg.slice(0, equals);
|
|
7639
|
-
if (name !== "--project" && name !== "--home") {
|
|
7640
|
-
rest.push(arg);
|
|
7641
|
-
continue;
|
|
7642
|
-
}
|
|
7643
|
-
const value = equals === -1 ? argv[++index] : arg.slice(equals + 1);
|
|
7644
|
-
if (value === void 0 || value.length === 0) fail(`${name} needs a directory`);
|
|
7645
|
-
if (name === "--project") project = value;
|
|
7646
|
-
else home = value;
|
|
7647
|
-
}
|
|
7648
|
-
return {
|
|
7649
|
-
rest,
|
|
7650
|
-
project,
|
|
7651
|
-
home
|
|
7652
|
-
};
|
|
7653
|
-
}
|
|
7654
|
-
/** Set before any state module is imported, so it decides where state lives. */
|
|
7655
|
-
function applyDirFlags(flags) {
|
|
7656
|
-
if (flags.project !== void 0) process.env.HHOSTED_PROJECT = path.resolve(flags.project);
|
|
7657
|
-
if (flags.home !== void 0) process.env.HHOSTED_HOME = path.resolve(flags.home);
|
|
7658
|
-
}
|
|
7659
|
-
function parseUpFlags(argv) {
|
|
7660
|
-
const { values } = parseArgs({
|
|
7661
|
-
args: argv,
|
|
7662
|
-
options: {
|
|
7663
|
-
"config": {
|
|
7664
|
-
type: "string",
|
|
7665
|
-
short: "c"
|
|
7666
|
-
},
|
|
7667
|
-
"port": {
|
|
7668
|
-
type: "string",
|
|
7669
|
-
short: "p"
|
|
7670
|
-
},
|
|
7671
|
-
"host": { type: "string" },
|
|
7672
|
-
"no-autostart": { type: "boolean" },
|
|
7673
|
-
"open": { type: "boolean" },
|
|
7674
|
-
"foreground": { type: "boolean" },
|
|
7675
|
-
"print-config": { type: "boolean" }
|
|
7676
|
-
},
|
|
7677
|
-
allowPositionals: false
|
|
7678
|
-
});
|
|
7679
|
-
let port;
|
|
7680
|
-
if (values.port !== void 0) {
|
|
7681
|
-
port = Number.parseInt(values.port, 10);
|
|
7682
|
-
if (!Number.isInteger(port) || port <= 0 || port > 65535) fail(`invalid port: ${values.port}`);
|
|
7683
|
-
}
|
|
7684
|
-
return {
|
|
7685
|
-
config: values.config,
|
|
7686
|
-
port,
|
|
7687
|
-
host: values.host,
|
|
7688
|
-
autostart: values["no-autostart"] !== true,
|
|
7689
|
-
open: values.open === true,
|
|
7690
|
-
foreground: values.foreground === true,
|
|
7691
|
-
printConfig: values["print-config"] === true
|
|
7692
|
-
};
|
|
7693
|
-
}
|
|
7694
|
-
/** The daemon gets the same instructions, but never `--foreground`. */
|
|
7695
|
-
function daemonFlags(flags) {
|
|
7696
|
-
const args = [];
|
|
7697
|
-
if (flags.config !== void 0) args.push("--config", flags.config);
|
|
7698
|
-
if (flags.port !== void 0) args.push("--port", String(flags.port));
|
|
7699
|
-
if (flags.host !== void 0) args.push("--host", flags.host);
|
|
7700
|
-
if (!flags.autostart) args.push("--no-autostart");
|
|
7701
|
-
if (flags.open) args.push("--open");
|
|
7702
|
-
return args;
|
|
7703
|
-
}
|
|
7704
|
-
/**
|
|
7705
|
-
* How to run this CLI again in the same runtime. Under tsx that means passing the
|
|
7706
|
-
* resolved loader too, because the daemon's working directory is the project's,
|
|
7707
|
-
* not the package's.
|
|
8280
|
+
* How to run this CLI again in the same runtime. Under tsx that means passing the
|
|
8281
|
+
* resolved loader too, because the daemon's working directory is the project's,
|
|
8282
|
+
* not the package's.
|
|
7708
8283
|
*/
|
|
7709
8284
|
function runtimeArgs() {
|
|
7710
8285
|
let resolved = null;
|
|
@@ -7730,10 +8305,9 @@ function tailLog(file, lines = 15) {
|
|
|
7730
8305
|
return "";
|
|
7731
8306
|
}
|
|
7732
8307
|
}
|
|
7733
|
-
async function
|
|
7734
|
-
const flags = parseUpFlags(argv);
|
|
8308
|
+
async function runUp(flags, entry) {
|
|
7735
8309
|
const { runControlPlane } = await Promise.resolve().then(() => (init_src(), src_exports));
|
|
7736
|
-
if (flags.foreground) {
|
|
8310
|
+
if (flags.foreground || flags.printConfig) {
|
|
7737
8311
|
await runControlPlane({
|
|
7738
8312
|
configPath: flags.config,
|
|
7739
8313
|
port: flags.port,
|
|
@@ -7758,10 +8332,8 @@ async function up(argv) {
|
|
|
7758
8332
|
const log = fs.openSync(daemonLogPath, "a");
|
|
7759
8333
|
const child = spawn(process.execPath, [
|
|
7760
8334
|
...runtimeArgs(),
|
|
7761
|
-
|
|
7762
|
-
|
|
7763
|
-
"--foreground",
|
|
7764
|
-
...daemonFlags(flags)
|
|
8335
|
+
entry,
|
|
8336
|
+
...buildDaemonArgv(flags)
|
|
7765
8337
|
], {
|
|
7766
8338
|
detached: true,
|
|
7767
8339
|
cwd: projectDir,
|
|
@@ -7800,10 +8372,69 @@ async function waitForStartup(child, timeoutMs = 2e4) {
|
|
|
7800
8372
|
const runtime = readRuntime();
|
|
7801
8373
|
if (runtime !== null && runtime.pid === child.pid) return runtime;
|
|
7802
8374
|
if (Date.now() > deadline) return null;
|
|
7803
|
-
await delay(150);
|
|
8375
|
+
await delay$3(150);
|
|
7804
8376
|
}
|
|
7805
8377
|
}
|
|
7806
|
-
|
|
8378
|
+
function upCommand(entry) {
|
|
8379
|
+
return defineCommand({
|
|
8380
|
+
meta: {
|
|
8381
|
+
name: "up",
|
|
8382
|
+
description: "start the panel in the background (detached)"
|
|
8383
|
+
},
|
|
8384
|
+
args: upArgs,
|
|
8385
|
+
run: async ({ args }) => {
|
|
8386
|
+
await runUp(toUpFlags(args), entry);
|
|
8387
|
+
}
|
|
8388
|
+
});
|
|
8389
|
+
}
|
|
8390
|
+
var DEFAULT_PORT$1, LOG_ROTATE_BYTES, upArgs;
|
|
8391
|
+
var init_up = __esmMin((() => {
|
|
8392
|
+
init_args();
|
|
8393
|
+
init_io();
|
|
8394
|
+
DEFAULT_PORT$1 = 3999;
|
|
8395
|
+
LOG_ROTATE_BYTES = 5242880;
|
|
8396
|
+
upArgs = {
|
|
8397
|
+
config: {
|
|
8398
|
+
type: "string",
|
|
8399
|
+
alias: "c",
|
|
8400
|
+
description: "servers config (default: <state>/servers.config.json)"
|
|
8401
|
+
},
|
|
8402
|
+
port: {
|
|
8403
|
+
type: "string",
|
|
8404
|
+
alias: "p",
|
|
8405
|
+
description: `control panel port (default: ${DEFAULT_PORT$1})`
|
|
8406
|
+
},
|
|
8407
|
+
host: {
|
|
8408
|
+
type: "string",
|
|
8409
|
+
description: "local | lan | an ipv4 address (default: local)"
|
|
8410
|
+
},
|
|
8411
|
+
autostart: {
|
|
8412
|
+
type: "boolean",
|
|
8413
|
+
default: true,
|
|
8414
|
+
negativeDescription: "do not start the entries marked autostart"
|
|
8415
|
+
},
|
|
8416
|
+
open: {
|
|
8417
|
+
type: "boolean",
|
|
8418
|
+
description: "open the panel in a browser once it is up"
|
|
8419
|
+
},
|
|
8420
|
+
foreground: {
|
|
8421
|
+
type: "boolean",
|
|
8422
|
+
description: "run in this process instead of detaching (systemd/docker)"
|
|
8423
|
+
},
|
|
8424
|
+
printConfig: {
|
|
8425
|
+
type: "boolean",
|
|
8426
|
+
description: "print the effective config and exit"
|
|
8427
|
+
}
|
|
8428
|
+
};
|
|
8429
|
+
}));
|
|
8430
|
+
//#endregion
|
|
8431
|
+
//#region src/cli/down.ts
|
|
8432
|
+
var down_exports = /* @__PURE__ */ __exportAll({
|
|
8433
|
+
downCommand: () => downCommand,
|
|
8434
|
+
runDown: () => runDown
|
|
8435
|
+
});
|
|
8436
|
+
/** `down` stops the panel and everything it supervises. */
|
|
8437
|
+
async function runDown() {
|
|
7807
8438
|
const { clearRuntime, isProcessAlive, readRuntime, requestShutdown } = await Promise.resolve().then(() => (init_daemon(), daemon_exports));
|
|
7808
8439
|
const runtime = readRuntime();
|
|
7809
8440
|
if (runtime === null) {
|
|
@@ -7833,7 +8464,7 @@ async function waitForExit(pid, timeoutMs) {
|
|
|
7833
8464
|
const deadline = Date.now() + timeoutMs;
|
|
7834
8465
|
while (Date.now() < deadline) {
|
|
7835
8466
|
if (!isProcessAlive(pid)) return true;
|
|
7836
|
-
await delay(200);
|
|
8467
|
+
await delay$3(200);
|
|
7837
8468
|
}
|
|
7838
8469
|
return !isProcessAlive(pid);
|
|
7839
8470
|
}
|
|
@@ -7855,22 +8486,54 @@ function forceStop(pid) {
|
|
|
7855
8486
|
}
|
|
7856
8487
|
signal(pid, "SIGKILL");
|
|
7857
8488
|
}
|
|
7858
|
-
|
|
7859
|
-
|
|
7860
|
-
|
|
7861
|
-
|
|
7862
|
-
|
|
7863
|
-
|
|
7864
|
-
|
|
7865
|
-
|
|
7866
|
-
|
|
8489
|
+
var downCommand;
|
|
8490
|
+
var init_down = __esmMin((() => {
|
|
8491
|
+
init_io();
|
|
8492
|
+
downCommand = defineCommand({
|
|
8493
|
+
meta: {
|
|
8494
|
+
name: "down",
|
|
8495
|
+
description: "stop it, and everything it supervises"
|
|
8496
|
+
},
|
|
8497
|
+
run: async () => {
|
|
8498
|
+
await runDown();
|
|
8499
|
+
}
|
|
8500
|
+
});
|
|
8501
|
+
}));
|
|
8502
|
+
//#endregion
|
|
8503
|
+
//#region src/cli/restart.ts
|
|
8504
|
+
var restart_exports = /* @__PURE__ */ __exportAll({ restartCommand: () => restartCommand });
|
|
8505
|
+
/** `restart` is `down`, then `up` with exactly the flags it was given. */
|
|
8506
|
+
function restartCommand(entry) {
|
|
8507
|
+
return defineCommand({
|
|
8508
|
+
meta: {
|
|
8509
|
+
name: "restart",
|
|
8510
|
+
description: "down, then up"
|
|
8511
|
+
},
|
|
8512
|
+
args: upArgs,
|
|
8513
|
+
run: async ({ args }) => {
|
|
8514
|
+
await runDown();
|
|
8515
|
+
await runUp(toUpFlags(args), entry);
|
|
8516
|
+
}
|
|
7867
8517
|
});
|
|
8518
|
+
}
|
|
8519
|
+
var init_restart = __esmMin((() => {
|
|
8520
|
+
init_down();
|
|
8521
|
+
init_up();
|
|
8522
|
+
}));
|
|
8523
|
+
//#endregion
|
|
8524
|
+
//#region src/cli/status.ts
|
|
8525
|
+
var status_exports = /* @__PURE__ */ __exportAll({
|
|
8526
|
+
runStatus: () => runStatus,
|
|
8527
|
+
statusArgs: () => statusArgs,
|
|
8528
|
+
statusCommand: () => statusCommand
|
|
8529
|
+
});
|
|
8530
|
+
async function runStatus(json) {
|
|
7868
8531
|
const { isProcessAlive, probeRuntime, readRuntime } = await Promise.resolve().then(() => (init_daemon(), daemon_exports));
|
|
7869
8532
|
const { UiService } = await Promise.resolve().then(() => (init_ui(), ui_exports));
|
|
7870
8533
|
const { dataRoot } = await Promise.resolve().then(() => (init_paths(), paths_exports));
|
|
7871
8534
|
const runtime = readRuntime();
|
|
7872
8535
|
if (runtime === null) {
|
|
7873
|
-
if (
|
|
8536
|
+
if (json) process.stdout.write(`${JSON.stringify({ running: false }, null, 2)}\n`);
|
|
7874
8537
|
else process.stdout.write("home-hosted is not running\n");
|
|
7875
8538
|
process.exitCode = 1;
|
|
7876
8539
|
return;
|
|
@@ -7880,7 +8543,7 @@ async function status(argv) {
|
|
|
7880
8543
|
reachable: false,
|
|
7881
8544
|
degraded: false
|
|
7882
8545
|
};
|
|
7883
|
-
if (
|
|
8546
|
+
if (json) {
|
|
7884
8547
|
const { token: _token, ...safe } = runtime;
|
|
7885
8548
|
process.stdout.write(`${JSON.stringify({
|
|
7886
8549
|
running,
|
|
@@ -7918,161 +8581,365 @@ function formatDuration(ms) {
|
|
|
7918
8581
|
if (hours < 24) return `${hours}h ${minutes % 60}m`;
|
|
7919
8582
|
return `${Math.floor(hours / 24)}d ${hours % 24}h`;
|
|
7920
8583
|
}
|
|
7921
|
-
|
|
7922
|
-
|
|
7923
|
-
|
|
7924
|
-
|
|
7925
|
-
|
|
7926
|
-
|
|
7927
|
-
|
|
7928
|
-
|
|
7929
|
-
|
|
7930
|
-
|
|
7931
|
-
|
|
7932
|
-
});
|
|
7933
|
-
}
|
|
7934
|
-
/** Reads a line with echo suppressed, so the password never lands in scrollback. */
|
|
7935
|
-
function promptHidden(question) {
|
|
7936
|
-
return new Promise((resolve) => {
|
|
7937
|
-
const rl = readline.createInterface({
|
|
7938
|
-
input: process.stdin,
|
|
7939
|
-
output: process.stdout,
|
|
7940
|
-
terminal: true
|
|
7941
|
-
});
|
|
7942
|
-
const onData = () => {
|
|
7943
|
-
readline.clearLine(process.stdout, 0);
|
|
7944
|
-
readline.cursorTo(process.stdout, 0);
|
|
7945
|
-
process.stdout.write(question);
|
|
7946
|
-
};
|
|
7947
|
-
process.stdin.on("data", onData);
|
|
7948
|
-
rl.question(question, (answer) => {
|
|
7949
|
-
process.stdin.off("data", onData);
|
|
7950
|
-
rl.close();
|
|
7951
|
-
process.stdout.write("\n");
|
|
7952
|
-
resolve(answer);
|
|
7953
|
-
});
|
|
7954
|
-
});
|
|
7955
|
-
}
|
|
7956
|
-
/**
|
|
7957
|
-
* Brings `servers.config.json` up to the schema this release understands.
|
|
7958
|
-
*
|
|
7959
|
-
* Deliberately loud and deliberate: it prints every step first, backs the file up
|
|
7960
|
-
* before writing, refuses to write a config it cannot read, and never runs on its
|
|
7961
|
-
* own — a detached daemon cannot prompt, so consent comes from `--yes`, from
|
|
7962
|
-
* `HHOSTED_MIGRATE=allow`, or from a person at a terminal.
|
|
7963
|
-
*/
|
|
7964
|
-
async function migrateConfig(argv) {
|
|
7965
|
-
const { values } = parseArgs({
|
|
7966
|
-
args: argv,
|
|
7967
|
-
options: {
|
|
7968
|
-
"config": {
|
|
7969
|
-
type: "string",
|
|
7970
|
-
short: "c"
|
|
7971
|
-
},
|
|
7972
|
-
"dry-run": { type: "boolean" },
|
|
7973
|
-
"yes": {
|
|
7974
|
-
type: "boolean",
|
|
7975
|
-
short: "y"
|
|
7976
|
-
}
|
|
8584
|
+
var statusArgs, statusCommand;
|
|
8585
|
+
var init_status = __esmMin((() => {
|
|
8586
|
+
init_io();
|
|
8587
|
+
statusArgs = { json: {
|
|
8588
|
+
type: "boolean",
|
|
8589
|
+
description: "print machine-readable JSON"
|
|
8590
|
+
} };
|
|
8591
|
+
statusCommand = defineCommand({
|
|
8592
|
+
meta: {
|
|
8593
|
+
name: "status",
|
|
8594
|
+
description: "is it running, where, and how to reach it"
|
|
7977
8595
|
},
|
|
7978
|
-
|
|
8596
|
+
args: statusArgs,
|
|
8597
|
+
run: async ({ args }) => {
|
|
8598
|
+
await runStatus(args.json === true);
|
|
8599
|
+
}
|
|
7979
8600
|
});
|
|
7980
|
-
|
|
7981
|
-
|
|
7982
|
-
|
|
7983
|
-
|
|
7984
|
-
|
|
7985
|
-
|
|
7986
|
-
|
|
7987
|
-
|
|
7988
|
-
|
|
7989
|
-
|
|
7990
|
-
|
|
7991
|
-
|
|
7992
|
-
|
|
7993
|
-
|
|
7994
|
-
const from = typeof meta.schema === "number" ? meta.schema : CONFIG_SCHEMA;
|
|
7995
|
-
const plan = planConfigMigrations(from);
|
|
7996
|
-
if (plan.tooNew) fail(`${file} was written by home-hosted ${meta.writtenBy ?? "a newer release"} (config schema ${from});\n this release understands schema ${plan.to}. Install that version, or edit the file yourself.`);
|
|
7997
|
-
const stamped = stampConfig(raw);
|
|
7998
|
-
const upToDate = plan.steps.length === 0;
|
|
7999
|
-
if (upToDate && values["dry-run"] !== true && JSON.stringify(stamped) !== JSON.stringify(raw)) {
|
|
8000
|
-
writeFileAtomic(file, `${JSON.stringify(stamped, null, 2)}\n`);
|
|
8001
|
-
process.stdout.write(`${green("config stamped")} in ${file} — written by home-hosted ${appVersion()}, schema ${plan.to}\n`);
|
|
8002
|
-
return;
|
|
8003
|
-
}
|
|
8004
|
-
if (upToDate) {
|
|
8005
|
-
process.stdout.write(`config schema ${from} is already what home-hosted ${appVersion()} understands — nothing to migrate\n`);
|
|
8601
|
+
}));
|
|
8602
|
+
//#endregion
|
|
8603
|
+
//#region src/cli/set-password.ts
|
|
8604
|
+
var set_password_exports = /* @__PURE__ */ __exportAll({
|
|
8605
|
+
runSetPassword: () => runSetPassword,
|
|
8606
|
+
setPasswordArgs: () => setPasswordArgs,
|
|
8607
|
+
setPasswordCommand: () => setPasswordCommand
|
|
8608
|
+
});
|
|
8609
|
+
async function runSetPassword(clear) {
|
|
8610
|
+
const store = new SecretsStore(defaultSecretsPath);
|
|
8611
|
+
if (clear) {
|
|
8612
|
+
store.clearPassword();
|
|
8613
|
+
process.stdout.write(`cleared the control panel password in ${defaultSecretsPath}\n`);
|
|
8614
|
+
process.stdout.write(`${dim("authentication stays disabled until you enable it again in the settings page")}\n`);
|
|
8006
8615
|
return;
|
|
8007
8616
|
}
|
|
8008
|
-
process.
|
|
8009
|
-
|
|
8010
|
-
if (
|
|
8011
|
-
|
|
8012
|
-
|
|
8617
|
+
const interactive = process.stdin.isTTY === true;
|
|
8618
|
+
let password = process.env.HHOSTED_PASSWORD;
|
|
8619
|
+
if (password === void 0 && interactive) {
|
|
8620
|
+
password = await promptHidden("New control panel password: ");
|
|
8621
|
+
const again = await promptHidden("Repeat it: ");
|
|
8622
|
+
if (password !== again) fail("the passwords do not match");
|
|
8013
8623
|
}
|
|
8014
|
-
if (
|
|
8015
|
-
|
|
8016
|
-
|
|
8017
|
-
|
|
8018
|
-
|
|
8019
|
-
|
|
8624
|
+
if (password === void 0 || password.length === 0) fail("no password given: run interactively, or set HHOSTED_PASSWORD for a non-interactive run");
|
|
8625
|
+
store.setPassword(password);
|
|
8626
|
+
process.stdout.write(`${green("password stored")} in ${defaultSecretsPath} (mode 0600)\n`);
|
|
8627
|
+
if (password.length < 8) process.stdout.write(`${dim(`"${password}" is short — easy to guess if the panel is reachable beyond loopback`)}\n`);
|
|
8628
|
+
process.stdout.write(`${dim("restart the panel for it to take effect: home-hosted restart")}\n`);
|
|
8629
|
+
}
|
|
8630
|
+
var setPasswordArgs, setPasswordCommand;
|
|
8631
|
+
var init_set_password = __esmMin((() => {
|
|
8632
|
+
init_io();
|
|
8633
|
+
init_secrets();
|
|
8634
|
+
init_paths();
|
|
8635
|
+
setPasswordArgs = { clear: {
|
|
8636
|
+
type: "boolean",
|
|
8637
|
+
description: "remove the password, which disables authentication"
|
|
8638
|
+
} };
|
|
8639
|
+
setPasswordCommand = defineCommand({
|
|
8640
|
+
meta: {
|
|
8641
|
+
name: "set-password",
|
|
8642
|
+
description: "set the panel password without the API"
|
|
8643
|
+
},
|
|
8644
|
+
args: setPasswordArgs,
|
|
8645
|
+
run: async ({ args }) => {
|
|
8646
|
+
await runSetPassword(args.clear === true);
|
|
8020
8647
|
}
|
|
8021
|
-
}
|
|
8022
|
-
|
|
8023
|
-
|
|
8648
|
+
});
|
|
8649
|
+
}));
|
|
8650
|
+
//#endregion
|
|
8651
|
+
//#region src/cli/set-token.ts
|
|
8652
|
+
var set_token_exports = /* @__PURE__ */ __exportAll({
|
|
8653
|
+
runSetToken: () => runSetToken,
|
|
8654
|
+
setTokenArgs: () => setTokenArgs,
|
|
8655
|
+
setTokenCommand: () => setTokenCommand
|
|
8656
|
+
});
|
|
8657
|
+
async function runSetToken(generate, clear) {
|
|
8658
|
+
if (generate && clear) fail("use either --generate or --clear, not both");
|
|
8659
|
+
const store = new SecretsStore(defaultSecretsPath);
|
|
8660
|
+
if (clear) {
|
|
8661
|
+
if (!store.apiTokenSet) {
|
|
8662
|
+
process.stdout.write("no API token is set — nothing to clear\n");
|
|
8663
|
+
return;
|
|
8664
|
+
}
|
|
8665
|
+
store.clearApiToken();
|
|
8666
|
+
process.stdout.write(`${green("API token cleared")} in ${defaultSecretsPath} — it stops working immediately\n`);
|
|
8667
|
+
return;
|
|
8668
|
+
}
|
|
8669
|
+
let token = process.env.HHOSTED_TOKEN ?? null;
|
|
8670
|
+
if (generate) token = generateApiToken();
|
|
8671
|
+
else if (token === null && process.stdin.isTTY === true) token = await promptHidden("API token: ");
|
|
8672
|
+
if (token !== null) token = token.trim();
|
|
8673
|
+
if (token === null || token.length === 0) fail("no token given: run `home-hosted set-token --generate`, set HHOSTED_TOKEN, or paste one interactively");
|
|
8674
|
+
store.setApiToken(token);
|
|
8675
|
+
process.stdout.write(`${green(generate ? "token generated" : "token stored")} in ${defaultSecretsPath} (mode 0600)\n`);
|
|
8676
|
+
if (generate) {
|
|
8677
|
+
process.stdout.write(` ${bold(token)}\n`);
|
|
8678
|
+
process.stdout.write(`${dim(" shown once — only its SHA-256 is kept on disk, so copy it now")}\n`);
|
|
8679
|
+
} else process.stdout.write(`${dim(` stored as ${token.slice(0, 8)}… — the file keeps only its hash`)}\n`);
|
|
8680
|
+
const { readRuntime } = await Promise.resolve().then(() => (init_daemon(), daemon_exports));
|
|
8681
|
+
const runtime = readRuntime();
|
|
8682
|
+
const base = (runtime?.url ?? `http://127.0.0.1:${DEFAULT_PORT}`).replace(/\/+$/, "");
|
|
8683
|
+
if (runtime !== null) {
|
|
8684
|
+
const verified = await verifyToken(base, token);
|
|
8685
|
+
if (verified === true) process.stdout.write(`${dim(`verified: ${base}/api/auth/session accepted it`)}\n`);
|
|
8686
|
+
else if (verified === false) process.stdout.write(`${dim(`the panel at ${base} did not accept it — is it running with this state directory?`)}\n`);
|
|
8687
|
+
}
|
|
8688
|
+
process.stdout.write(`Use it from a script or an agent:\n`);
|
|
8689
|
+
process.stdout.write(` ${bold(`curl -H "Authorization: Bearer ${generate ? token : "<token>"}" ${base}/api/state`)}\n`);
|
|
8690
|
+
process.stdout.write(`${dim("It needs no restart, outlives sessions, and holds the same access as a signed-in browser.")}\n`);
|
|
8691
|
+
process.stdout.write(`${dim("Remove it any time with: home-hosted set-token --clear")}\n`);
|
|
8692
|
+
if (store.usingDefaultPassword) process.stdout.write(`${dim("The panel password is still the default — change it before the panel is reachable beyond loopback.")}\n`);
|
|
8693
|
+
}
|
|
8694
|
+
/** Asks the live panel whether it accepts the token, without failing when it cannot. */
|
|
8695
|
+
async function verifyToken(base, token) {
|
|
8696
|
+
if (!base.startsWith("http://")) return null;
|
|
8697
|
+
try {
|
|
8698
|
+
const response = await fetch(`${base}/api/auth/session`, { headers: { authorization: `Bearer ${token}` } });
|
|
8699
|
+
if (!response.ok) return false;
|
|
8700
|
+
return (await response.json()).authenticated === true;
|
|
8701
|
+
} catch {
|
|
8702
|
+
return null;
|
|
8703
|
+
}
|
|
8704
|
+
}
|
|
8705
|
+
var DEFAULT_PORT, setTokenArgs, setTokenCommand;
|
|
8706
|
+
var init_set_token = __esmMin((() => {
|
|
8707
|
+
init_io();
|
|
8708
|
+
init_secrets();
|
|
8709
|
+
init_paths();
|
|
8710
|
+
DEFAULT_PORT = 3999;
|
|
8711
|
+
setTokenArgs = {
|
|
8712
|
+
generate: {
|
|
8713
|
+
type: "boolean",
|
|
8714
|
+
description: "create a strong token and print it once"
|
|
8715
|
+
},
|
|
8716
|
+
clear: {
|
|
8717
|
+
type: "boolean",
|
|
8718
|
+
description: "remove the token, so it stops working"
|
|
8719
|
+
}
|
|
8720
|
+
};
|
|
8721
|
+
setTokenCommand = defineCommand({
|
|
8722
|
+
meta: {
|
|
8723
|
+
name: "set-token",
|
|
8724
|
+
description: "set the API token that scripts and agents use"
|
|
8725
|
+
},
|
|
8726
|
+
args: setTokenArgs,
|
|
8727
|
+
run: async ({ args }) => {
|
|
8728
|
+
await runSetToken(args.generate === true, args.clear === true);
|
|
8729
|
+
}
|
|
8730
|
+
});
|
|
8731
|
+
}));
|
|
8732
|
+
//#endregion
|
|
8733
|
+
//#region src/cli/migrate.ts
|
|
8734
|
+
var migrate_exports = /* @__PURE__ */ __exportAll({
|
|
8735
|
+
migrateArgs: () => migrateArgs,
|
|
8736
|
+
migrateCommand: () => migrateCommand,
|
|
8737
|
+
runMigrate: () => runMigrate
|
|
8738
|
+
});
|
|
8739
|
+
async function runMigrate(config, dryRun, yes) {
|
|
8740
|
+
const file = config ?? defaultConfigPath;
|
|
8741
|
+
if (!fs.existsSync(file)) fail(`no config at ${file} — nothing to migrate`);
|
|
8742
|
+
let raw;
|
|
8743
|
+
try {
|
|
8744
|
+
raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
8745
|
+
} catch (error) {
|
|
8746
|
+
fail(`cannot read ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
8747
|
+
}
|
|
8748
|
+
const meta = typeof raw.meta === "object" && raw.meta !== null ? raw.meta : {};
|
|
8749
|
+
const from = typeof meta.schema === "number" ? meta.schema : 1;
|
|
8750
|
+
const plan = planConfigMigrations(from);
|
|
8751
|
+
if (plan.tooNew) fail(`${file} was written by home-hosted ${meta.writtenBy ?? "a newer release"} (config schema ${from});\n this release understands schema ${plan.to}. Install that version, or edit the file yourself.`);
|
|
8752
|
+
const stamped = stampConfig(raw);
|
|
8753
|
+
const upToDate = plan.steps.length === 0;
|
|
8754
|
+
if (upToDate && !dryRun && JSON.stringify(stamped) !== JSON.stringify(raw)) {
|
|
8755
|
+
writeFileAtomic(file, `${JSON.stringify(stamped, null, 2)}\n`);
|
|
8756
|
+
process.stdout.write(`${green("config stamped")} in ${file} — written by home-hosted ${appVersion()}, schema ${plan.to}\n`);
|
|
8757
|
+
return;
|
|
8758
|
+
}
|
|
8759
|
+
if (upToDate) {
|
|
8760
|
+
process.stdout.write(`config schema ${from} is already what home-hosted ${appVersion()} understands — nothing to migrate\n`);
|
|
8761
|
+
return;
|
|
8762
|
+
}
|
|
8763
|
+
process.stdout.write(`migrating ${file}: config schema ${from} → ${plan.to}\n`);
|
|
8764
|
+
for (const [index, step] of plan.steps.entries()) process.stdout.write(` ${index + 1}. ${step.describe}\n`);
|
|
8765
|
+
if (dryRun) {
|
|
8766
|
+
process.stdout.write(`${dim(`nothing was written (--dry-run, ${plan.steps.length} step(s) pending)`)}\n`);
|
|
8767
|
+
return;
|
|
8768
|
+
}
|
|
8769
|
+
if (!(yes || (process.env.HHOSTED_MIGRATE ?? "").toLowerCase() === "allow")) {
|
|
8770
|
+
if (process.stdin.isTTY !== true) fail(`this config needs ${plan.steps.length} migration(s) and this session cannot ask.\n re-run with --yes, or set HHOSTED_MIGRATE=allow for unattended runs`);
|
|
8771
|
+
const answer = await prompt(`Apply ${plan.steps.length} migration(s) to ${path.basename(file)}? [y/N] `);
|
|
8772
|
+
if (!/^yes$|^y$/i.test(answer.trim())) {
|
|
8773
|
+
process.stdout.write("cancelled — nothing was written\n");
|
|
8774
|
+
return;
|
|
8775
|
+
}
|
|
8776
|
+
}
|
|
8777
|
+
const { config: migrated, applied } = applyConfigMigrations(raw, from);
|
|
8778
|
+
const parsed = parseConfig(migrated);
|
|
8024
8779
|
if (parsed.config === null) fail(`the migration produced a config this release cannot read:\n ${parsed.errors.join("\n ")}`);
|
|
8025
8780
|
const backup = `${file}.bak`;
|
|
8026
8781
|
fs.copyFileSync(file, backup);
|
|
8027
|
-
writeFileAtomic(file, `${JSON.stringify(stampConfig(
|
|
8782
|
+
writeFileAtomic(file, `${JSON.stringify(stampConfig(migrated), null, 2)}\n`);
|
|
8028
8783
|
process.stdout.write(`${green(`migrated to schema ${plan.to}`)} (${applied.length} step(s)) in ${file}\n`);
|
|
8029
8784
|
process.stdout.write(` ${dim(`previous file kept at ${backup}`)}\n`);
|
|
8030
8785
|
for (const key of parsed.unknownKeys) process.stdout.write(` ${dim(`still ignoring an unrecognized key: ${key}`)}\n`);
|
|
8031
8786
|
}
|
|
8032
|
-
|
|
8033
|
-
|
|
8034
|
-
|
|
8035
|
-
|
|
8036
|
-
|
|
8787
|
+
var migrateArgs, migrateCommand;
|
|
8788
|
+
var init_migrate = __esmMin((() => {
|
|
8789
|
+
init_io();
|
|
8790
|
+
init_migrations();
|
|
8791
|
+
init_parse();
|
|
8792
|
+
init_atomic();
|
|
8793
|
+
init_paths();
|
|
8794
|
+
init_version();
|
|
8795
|
+
migrateArgs = {
|
|
8796
|
+
config: {
|
|
8797
|
+
type: "string",
|
|
8798
|
+
alias: "c",
|
|
8799
|
+
description: "servers config (default: <state>/servers.config.json)"
|
|
8800
|
+
},
|
|
8801
|
+
dryRun: {
|
|
8802
|
+
type: "boolean",
|
|
8803
|
+
description: "print what would change, write nothing"
|
|
8804
|
+
},
|
|
8805
|
+
yes: {
|
|
8806
|
+
type: "boolean",
|
|
8807
|
+
alias: "y",
|
|
8808
|
+
description: "apply without asking (or set HHOSTED_MIGRATE=allow)"
|
|
8809
|
+
}
|
|
8810
|
+
};
|
|
8811
|
+
migrateCommand = defineCommand({
|
|
8812
|
+
meta: {
|
|
8813
|
+
name: "migrate",
|
|
8814
|
+
description: "bring the config up to this release's schema"
|
|
8815
|
+
},
|
|
8816
|
+
args: migrateArgs,
|
|
8817
|
+
run: async ({ args }) => {
|
|
8818
|
+
await runMigrate(args.config, args.dryRun === true, args.yes === true);
|
|
8819
|
+
}
|
|
8820
|
+
});
|
|
8821
|
+
}));
|
|
8822
|
+
//#endregion
|
|
8823
|
+
//#region src/services/init.ts
|
|
8824
|
+
/** The scripts every scaffolded project gets; all of them keep state inside the project. */
|
|
8825
|
+
function projectScripts() {
|
|
8826
|
+
const withState = (args) => `home-hosted ${args} --home ./state`;
|
|
8827
|
+
return {
|
|
8828
|
+
"up": withState("up"),
|
|
8829
|
+
"down": withState("down"),
|
|
8830
|
+
"restart": withState("restart"),
|
|
8831
|
+
"status": withState("status"),
|
|
8832
|
+
"set-password": withState("set-password"),
|
|
8833
|
+
"set-token": withState("set-token"),
|
|
8834
|
+
"migrate": withState("migrate")
|
|
8835
|
+
};
|
|
8836
|
+
}
|
|
8837
|
+
/** `home-hosted` is pinned to the version that wrote the project, and open to newer ones. */
|
|
8838
|
+
function projectManifest(name) {
|
|
8839
|
+
return `${JSON.stringify({
|
|
8840
|
+
name,
|
|
8841
|
+
version: "0.1.0",
|
|
8842
|
+
private: true,
|
|
8843
|
+
description: "Servers supervised by home-hosted.",
|
|
8844
|
+
type: "module",
|
|
8845
|
+
engines: { node: ">=24.0.0" },
|
|
8846
|
+
scripts: projectScripts(),
|
|
8847
|
+
dependencies: { "home-hosted": `^${appVersion()}` }
|
|
8848
|
+
}, null, 2)}\n`;
|
|
8849
|
+
}
|
|
8850
|
+
/**
|
|
8851
|
+
* State is generated, so it stays out — except the file that declares the servers,
|
|
8852
|
+
* which is the one thing worth committing.
|
|
8853
|
+
*/
|
|
8854
|
+
function projectGitignore() {
|
|
8855
|
+
return `node_modules/
|
|
8856
|
+
|
|
8857
|
+
# home-hosted: state is local, the server definitions are tracked
|
|
8858
|
+
state/*
|
|
8859
|
+
!state/servers.config.json
|
|
8860
|
+
data/
|
|
8861
|
+
`;
|
|
8862
|
+
}
|
|
8863
|
+
function isEmptyDir(dir) {
|
|
8864
|
+
try {
|
|
8865
|
+
return fs.readdirSync(dir).every((entry) => entry === ".git");
|
|
8866
|
+
} catch {
|
|
8867
|
+
return true;
|
|
8868
|
+
}
|
|
8869
|
+
}
|
|
8870
|
+
function assertUsableDir(dir) {
|
|
8871
|
+
let stats = null;
|
|
8872
|
+
try {
|
|
8873
|
+
stats = fs.statSync(dir);
|
|
8874
|
+
} catch {
|
|
8875
|
+
return;
|
|
8876
|
+
}
|
|
8877
|
+
if (!stats.isDirectory()) throw new Error(`${dir} exists and is not a directory`);
|
|
8878
|
+
if (!isEmptyDir(dir)) throw new Error(`${dir} is not empty — pick another directory, or empty it first`);
|
|
8037
8879
|
}
|
|
8880
|
+
/** Writes the project skeleton. Nothing is installed or initialized here. */
|
|
8881
|
+
function scaffold(options) {
|
|
8882
|
+
const dir = path.resolve(options.dir);
|
|
8883
|
+
assertUsableDir(dir);
|
|
8884
|
+
const existed = fs.existsSync(dir);
|
|
8885
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
8886
|
+
const files = [["package.json", projectManifest(options.name)], [".gitignore", projectGitignore()]];
|
|
8887
|
+
for (const [name, contents] of files) fs.writeFileSync(path.join(dir, name), contents);
|
|
8888
|
+
return {
|
|
8889
|
+
dir,
|
|
8890
|
+
created: !existed,
|
|
8891
|
+
files: files.map(([name]) => name),
|
|
8892
|
+
installed: false,
|
|
8893
|
+
git: false
|
|
8894
|
+
};
|
|
8895
|
+
}
|
|
8896
|
+
/** The package manager this machine actually has, preferring pnpm. */
|
|
8897
|
+
function detectPackageManager(exists) {
|
|
8898
|
+
return PACKAGE_MANAGERS.find((pm) => exists(pm)) ?? "npm";
|
|
8899
|
+
}
|
|
8900
|
+
/** How to run one of the project's scripts with a given package manager. */
|
|
8901
|
+
function runCommand$1(pm, script) {
|
|
8902
|
+
return pm === "npm" ? `npm run ${script}` : `${pm} run ${script}`;
|
|
8903
|
+
}
|
|
8904
|
+
/** Install arguments for a scaffolded project (the manifest already lists the dependency). */
|
|
8905
|
+
function installArgs(pm) {
|
|
8906
|
+
return pm === "yarn" ? [] : ["install"];
|
|
8907
|
+
}
|
|
8908
|
+
var PACKAGE_MANAGERS;
|
|
8909
|
+
var init_init$1 = __esmMin((() => {
|
|
8910
|
+
init_version();
|
|
8911
|
+
PACKAGE_MANAGERS = [
|
|
8912
|
+
"pnpm",
|
|
8913
|
+
"npm",
|
|
8914
|
+
"yarn",
|
|
8915
|
+
"bun"
|
|
8916
|
+
];
|
|
8917
|
+
}));
|
|
8918
|
+
//#endregion
|
|
8919
|
+
//#region src/cli/init.ts
|
|
8920
|
+
var init_exports = /* @__PURE__ */ __exportAll({
|
|
8921
|
+
initArgs: () => initArgs,
|
|
8922
|
+
initCommand: () => initCommand,
|
|
8923
|
+
runInit: () => runInit
|
|
8924
|
+
});
|
|
8038
8925
|
function which(command) {
|
|
8039
8926
|
return spawnSync(command, ["--version"], {
|
|
8040
8927
|
stdio: "ignore",
|
|
8041
8928
|
shell: process.platform === "win32"
|
|
8042
8929
|
}).status === 0;
|
|
8043
8930
|
}
|
|
8044
|
-
|
|
8045
|
-
|
|
8046
|
-
* definitions — inside its own directory. Interactive by nature, but `--yes`
|
|
8047
|
-
* takes every default so an agent or a CI job can run it unattended.
|
|
8048
|
-
*/
|
|
8049
|
-
async function initProject(argv) {
|
|
8050
|
-
const { values } = parseArgs({
|
|
8051
|
-
args: argv,
|
|
8052
|
-
options: {
|
|
8053
|
-
"dir": { type: "string" },
|
|
8054
|
-
"name": { type: "string" },
|
|
8055
|
-
"pm": { type: "string" },
|
|
8056
|
-
"no-install": { type: "boolean" },
|
|
8057
|
-
"yes": {
|
|
8058
|
-
type: "boolean",
|
|
8059
|
-
short: "y"
|
|
8060
|
-
}
|
|
8061
|
-
},
|
|
8062
|
-
allowPositionals: false
|
|
8063
|
-
});
|
|
8064
|
-
const { detectPackageManager, installArgs, PACKAGE_MANAGERS, runCommand, scaffold } = await Promise.resolve().then(() => (init_init(), init_exports));
|
|
8065
|
-
const assumeYes = values.yes === true;
|
|
8931
|
+
async function runInit(options) {
|
|
8932
|
+
const assumeYes = options.yes;
|
|
8066
8933
|
if (!assumeYes && process.stdin.isTTY !== true) fail("init needs a terminal to ask in.\n take the defaults with: home-hosted init --yes [--dir <dir>]");
|
|
8067
|
-
const defaultDir =
|
|
8934
|
+
const defaultDir = options.dir ?? "./my-servers";
|
|
8068
8935
|
const dir = assumeYes ? defaultDir : (await prompt(`Project directory (${defaultDir}) `)).trim() || defaultDir;
|
|
8069
8936
|
const defaultName = path.basename(path.resolve(dir));
|
|
8070
|
-
const name =
|
|
8071
|
-
let pm =
|
|
8937
|
+
const name = options.name ?? (assumeYes ? defaultName : (await prompt(`Package name (${defaultName}) `)).trim() || defaultName);
|
|
8938
|
+
let pm = options.pm;
|
|
8072
8939
|
if (pm !== void 0 && !PACKAGE_MANAGERS.includes(pm)) fail(`unknown package manager: ${pm} (expected one of ${PACKAGE_MANAGERS.join(", ")})`);
|
|
8073
8940
|
const detected = detectPackageManager(which);
|
|
8074
8941
|
if (pm === void 0) pm = assumeYes ? detected : (await prompt(`Package manager (${detected}) `)).trim() || detected;
|
|
8075
|
-
const install =
|
|
8942
|
+
const install = options.noInstall ? false : assumeYes || await confirm("Install the dependencies now?", true);
|
|
8076
8943
|
const git = assumeYes ? which("git") : which("git") && await confirm("Initialize a git repository?", true);
|
|
8077
8944
|
try {
|
|
8078
8945
|
const result = scaffold({
|
|
@@ -8108,186 +8975,707 @@ async function initProject(argv) {
|
|
|
8108
8975
|
const where = relative.length === 0 || relative.startsWith("..") ? target : relative;
|
|
8109
8976
|
const cd = relative.length === 0 ? "" : `cd ${where} && `;
|
|
8110
8977
|
process.stdout.write(`\nNext:\n`);
|
|
8111
|
-
process.stdout.write(` ${bold(`${cd}${runCommand(pm, "up")}`)} start the panel (default password \`hh\`)\n`);
|
|
8978
|
+
process.stdout.write(` ${bold(`${cd}${runCommand$1(pm, "up")}`)} start the panel (default password \`hh\`)\n`);
|
|
8112
8979
|
process.stdout.write(` ${dim("then change that password under Settings → Authentication, and add your servers")}\n`);
|
|
8113
|
-
process.stdout.write(` ${dim(`${runCommand(pm, "set-token")} --generate for scripts and agents`)}\n`);
|
|
8980
|
+
process.stdout.write(` ${dim(`${runCommand$1(pm, "set-token")} --generate for scripts and agents`)}\n`);
|
|
8114
8981
|
}
|
|
8115
|
-
|
|
8116
|
-
|
|
8117
|
-
|
|
8118
|
-
|
|
8119
|
-
|
|
8120
|
-
|
|
8121
|
-
|
|
8122
|
-
|
|
8982
|
+
var initArgs, initCommand;
|
|
8983
|
+
var init_init = __esmMin((() => {
|
|
8984
|
+
init_io();
|
|
8985
|
+
init_init$1();
|
|
8986
|
+
initArgs = {
|
|
8987
|
+
dir: {
|
|
8988
|
+
type: "string",
|
|
8989
|
+
description: "where to scaffold (default: ./my-servers)"
|
|
8990
|
+
},
|
|
8991
|
+
name: {
|
|
8992
|
+
type: "string",
|
|
8993
|
+
description: "package name (default: the directory name)"
|
|
8994
|
+
},
|
|
8995
|
+
pm: {
|
|
8996
|
+
type: "string",
|
|
8997
|
+
description: "pnpm | npm | yarn | bun (default: the first one installed)"
|
|
8998
|
+
},
|
|
8999
|
+
install: {
|
|
9000
|
+
type: "boolean",
|
|
9001
|
+
default: true,
|
|
9002
|
+
negativeDescription: "write the files, install nothing"
|
|
9003
|
+
},
|
|
9004
|
+
yes: {
|
|
9005
|
+
type: "boolean",
|
|
9006
|
+
alias: "y",
|
|
9007
|
+
description: "take every default, ask nothing"
|
|
9008
|
+
}
|
|
9009
|
+
};
|
|
9010
|
+
initCommand = defineCommand({
|
|
9011
|
+
meta: {
|
|
9012
|
+
name: "init",
|
|
9013
|
+
description: "scaffold a project that keeps its state in the repo"
|
|
9014
|
+
},
|
|
9015
|
+
args: initArgs,
|
|
9016
|
+
run: async ({ args }) => {
|
|
9017
|
+
await runInit({
|
|
9018
|
+
dir: args.dir,
|
|
9019
|
+
name: args.name,
|
|
9020
|
+
pm: args.pm,
|
|
9021
|
+
noInstall: args.install === false,
|
|
9022
|
+
yes: args.yes === true
|
|
9023
|
+
});
|
|
9024
|
+
}
|
|
9025
|
+
});
|
|
9026
|
+
}));
|
|
9027
|
+
//#endregion
|
|
9028
|
+
//#region src/cli/ui-switch.ts
|
|
9029
|
+
var ui_switch_exports = /* @__PURE__ */ __exportAll({
|
|
9030
|
+
DEFAULT_REPO: () => DEFAULT_REPO,
|
|
9031
|
+
MAX_DOWNLOAD_BYTES: () => MAX_DOWNLOAD_BYTES,
|
|
9032
|
+
defaultReleaseTag: () => defaultReleaseTag,
|
|
9033
|
+
fallbackUiName: () => fallbackUiName,
|
|
9034
|
+
isGithubHost: () => isGithubHost,
|
|
9035
|
+
isOwnRepo: () => isOwnRepo,
|
|
9036
|
+
isUiAsset: () => isUiAsset,
|
|
9037
|
+
matchAsset: () => matchAsset,
|
|
9038
|
+
parseFileSource: () => parseFileSource,
|
|
9039
|
+
parseRepoSlug: () => parseRepoSlug,
|
|
9040
|
+
releaseApiUrl: () => releaseApiUrl,
|
|
9041
|
+
uiSwitch: () => uiSwitch,
|
|
9042
|
+
uiSwitchCommand: () => uiSwitchCommand
|
|
9043
|
+
});
|
|
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;
|
|
8123
9136
|
}
|
|
8124
|
-
ui.revert();
|
|
8125
|
-
process.stdout.write(`${green("custom UI removed")} — the stock panel is back; refresh the browser\n`);
|
|
8126
9137
|
}
|
|
8127
|
-
|
|
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
|
+
async function uiSwitch(argv, io) {
|
|
8128
9144
|
const { values } = parseArgs({
|
|
8129
9145
|
args: argv,
|
|
8130
|
-
options: {
|
|
9146
|
+
options: {
|
|
9147
|
+
repo: { type: "string" },
|
|
9148
|
+
tag: { type: "string" },
|
|
9149
|
+
asset: { type: "string" },
|
|
9150
|
+
file: { type: "string" },
|
|
9151
|
+
list: { type: "boolean" },
|
|
9152
|
+
token: { type: "string" },
|
|
9153
|
+
yes: {
|
|
9154
|
+
type: "boolean",
|
|
9155
|
+
short: "y"
|
|
9156
|
+
}
|
|
9157
|
+
},
|
|
8131
9158
|
allowPositionals: false
|
|
8132
9159
|
});
|
|
8133
|
-
const {
|
|
8134
|
-
const
|
|
8135
|
-
|
|
8136
|
-
|
|
8137
|
-
|
|
8138
|
-
|
|
8139
|
-
|
|
9160
|
+
const { appVersion } = await Promise.resolve().then(() => (init_version(), version_exports));
|
|
9161
|
+
const context = {
|
|
9162
|
+
io,
|
|
9163
|
+
version: appVersion(),
|
|
9164
|
+
token: values.token ?? process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? null
|
|
9165
|
+
};
|
|
9166
|
+
if (values.file !== void 0) {
|
|
9167
|
+
if (values.repo !== void 0 || values.tag !== void 0 || values.asset !== void 0 || values.list === true) throw new Error("--file installs a zip directly; it cannot be combined with --repo, --tag, --asset or --list");
|
|
9168
|
+
await installFromFile(values.file, context);
|
|
8140
9169
|
return;
|
|
8141
9170
|
}
|
|
8142
|
-
const
|
|
8143
|
-
|
|
8144
|
-
|
|
8145
|
-
|
|
8146
|
-
|
|
8147
|
-
|
|
9171
|
+
const repo = parseRepoSlug(values.repo ?? "NamesMT/home-hosted");
|
|
9172
|
+
if (repo === null) throw new Error(`invalid --repo "${values.repo}" — expected an "owner/name" slug, e.g. ${DEFAULT_REPO}`);
|
|
9173
|
+
const requestedTag = values.tag ?? defaultReleaseTag(repo, context.version);
|
|
9174
|
+
const release = await fetchRelease(repo, requestedTag, context);
|
|
9175
|
+
const skipped = release.assets.filter((asset) => !isUiAsset(asset.name ?? "")).map((asset) => asset.name ?? "?");
|
|
9176
|
+
const usable = release.assets.filter((asset) => isUiAsset(asset.name ?? ""));
|
|
9177
|
+
const where = `${repo.owner}/${repo.name}@${release.tag}`;
|
|
9178
|
+
if (values.list === true) {
|
|
9179
|
+
if (usable.length === 0) throw new Error(noAssetsMessage(where, skipped, requestedTag));
|
|
9180
|
+
const lines = [`${io.style.bold(where)} — ${usable.length} usable UI asset(s)`];
|
|
9181
|
+
for (const asset of usable) lines.push(` ${asset.name}`);
|
|
9182
|
+
if (skipped.length > 0) lines.push(io.style.dim(` skipped (not a .zip): ${skipped.join(", ")}`));
|
|
9183
|
+
io.write(`${lines.join("\n")}\n`);
|
|
9184
|
+
return;
|
|
8148
9185
|
}
|
|
8149
|
-
if (
|
|
8150
|
-
|
|
8151
|
-
|
|
8152
|
-
|
|
8153
|
-
|
|
9186
|
+
if (usable.length === 0) throw new Error(noAssetsMessage(where, skipped, requestedTag));
|
|
9187
|
+
const chosen = await chooseAsset(usable, values, context);
|
|
9188
|
+
if (chosen === null) {
|
|
9189
|
+
io.write("cancelled — nothing was installed\n");
|
|
9190
|
+
return;
|
|
9191
|
+
}
|
|
9192
|
+
await installFromUrl(assetDownloadUrl(chosen), fallbackUiName(chosen.name ?? ""), context);
|
|
8154
9193
|
}
|
|
8155
|
-
|
|
8156
|
-
|
|
8157
|
-
|
|
8158
|
-
|
|
8159
|
-
|
|
8160
|
-
|
|
8161
|
-
|
|
8162
|
-
|
|
8163
|
-
|
|
8164
|
-
|
|
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
|
+
async function chooseAsset(assets, values, context) {
|
|
9210
|
+
const { io } = context;
|
|
9211
|
+
if (values.asset !== void 0) {
|
|
9212
|
+
const matched = matchAsset(assets.map((asset) => asset.name ?? ""), values.asset);
|
|
9213
|
+
if (!matched.ok) throw new Error(matched.error);
|
|
9214
|
+
return assets.find((asset) => asset.name === matched.name);
|
|
9215
|
+
}
|
|
9216
|
+
if (!(process.stdin.isTTY === true && values.yes !== true)) {
|
|
9217
|
+
if (assets.length === 1) return assets[0];
|
|
9218
|
+
throw new Error([
|
|
9219
|
+
`${assets.length} UI assets are available and this session cannot ask which one:`,
|
|
9220
|
+
...assets.map((asset) => ` ${asset.name}`),
|
|
9221
|
+
" pick one with --asset <name>, or list them with --list"
|
|
9222
|
+
].join("\n"));
|
|
9223
|
+
}
|
|
9224
|
+
io.write(`${[io.style.bold("Available UI assets"), ...assets.map((asset, index) => ` ${index + 1}) ${asset.name}`)].join("\n")}\n`);
|
|
9225
|
+
for (;;) {
|
|
9226
|
+
const answer = (await io.prompt(`Select an asset [1-${assets.length}] (empty to cancel) `)).trim();
|
|
9227
|
+
if (answer.length === 0) return null;
|
|
9228
|
+
if (/^\d+$/.test(answer)) {
|
|
9229
|
+
const index = Number.parseInt(answer, 10);
|
|
9230
|
+
if (index >= 1 && index <= assets.length) return assets[index - 1];
|
|
9231
|
+
}
|
|
9232
|
+
const matched = matchAsset(assets.map((asset) => asset.name ?? ""), answer);
|
|
9233
|
+
if (matched.ok) return assets.find((asset) => asset.name === matched.name);
|
|
9234
|
+
io.write(` ${io.style.dim(matched.error)}\n`);
|
|
9235
|
+
}
|
|
9236
|
+
}
|
|
9237
|
+
async function installFromFile(value, context) {
|
|
9238
|
+
const source = parseFileSource(value);
|
|
9239
|
+
if (source.kind === "path") {
|
|
9240
|
+
if (!fs.existsSync(source.path)) throw new Error(`no file at ${source.path}`);
|
|
9241
|
+
if (!fs.statSync(source.path).isFile()) throw new Error(`${source.path} is not a file`);
|
|
9242
|
+
await installArchive(source.path, fallbackUiName(source.path), context);
|
|
9243
|
+
return;
|
|
9244
|
+
}
|
|
9245
|
+
await installFromUrl(source.url, fallbackUiName(new URL(source.url).pathname), context);
|
|
9246
|
+
}
|
|
9247
|
+
async function installFromUrl(url, fallbackName, context) {
|
|
9248
|
+
const headers = {
|
|
9249
|
+
"accept": "application/octet-stream",
|
|
9250
|
+
"user-agent": `home-hosted/${context.version}`
|
|
9251
|
+
};
|
|
9252
|
+
if (context.token !== null && context.token.length > 0 && isGithubHost(url)) headers.authorization = `Bearer ${context.token}`;
|
|
9253
|
+
const download = await downloadToTemp(url, headers, context);
|
|
9254
|
+
try {
|
|
9255
|
+
await installArchive(download.file, fallbackName, context);
|
|
9256
|
+
} finally {
|
|
9257
|
+
fs.rmSync(download.dir, {
|
|
9258
|
+
recursive: true,
|
|
9259
|
+
force: true
|
|
9260
|
+
});
|
|
9261
|
+
}
|
|
9262
|
+
}
|
|
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
|
+
async function installArchive(archivePath, fallbackName, context) {
|
|
9305
|
+
const { UiService } = await Promise.resolve().then(() => (init_ui(), ui_exports));
|
|
9306
|
+
const { dataRoot } = await Promise.resolve().then(() => (init_paths(), paths_exports));
|
|
9307
|
+
const ui = new UiService({ dataRoot });
|
|
9308
|
+
const result = await ui.install(archivePath, fallbackName);
|
|
9309
|
+
if (!result.ok) throw new Error(`nothing was installed: ${result.error}`);
|
|
9310
|
+
const { meta } = result;
|
|
9311
|
+
const label = meta.version === null ? meta.name : `${meta.name} ${meta.version}`;
|
|
9312
|
+
context.io.write(`${context.io.style.green("UI installed")} — ${label}\n`);
|
|
9313
|
+
context.io.write(` ${context.io.style.dim("state")} ${ui.directory}\n`);
|
|
9314
|
+
context.io.write(` ${context.io.style.dim("files")} ${meta.files}\n`);
|
|
9315
|
+
context.io.write(`refresh the browser to see it\n`);
|
|
9316
|
+
}
|
|
9317
|
+
function apiHeaders(context) {
|
|
9318
|
+
const headers = {
|
|
9319
|
+
"accept": "application/vnd.github+json",
|
|
9320
|
+
"user-agent": `home-hosted/${context.version}`,
|
|
9321
|
+
"x-github-api-version": "2022-11-28"
|
|
9322
|
+
};
|
|
9323
|
+
if (context.token !== null && context.token.length > 0) headers.authorization = `Bearer ${context.token}`;
|
|
9324
|
+
return headers;
|
|
9325
|
+
}
|
|
9326
|
+
function assetDownloadUrl(asset) {
|
|
9327
|
+
const url = asset.url ?? asset.browser_download_url;
|
|
9328
|
+
if (url === void 0 || url.length === 0) throw new Error(`the release metadata for "${asset.name ?? "an asset"}" carries no download URL`);
|
|
9329
|
+
return url;
|
|
9330
|
+
}
|
|
9331
|
+
function noAssetsMessage(where, skipped, tag) {
|
|
9332
|
+
const lines = [`no usable UI assets in ${where} (expected a .zip)`];
|
|
9333
|
+
lines.push(skipped.length > 0 ? ` skipped: ${skipped.join(", ")}` : " the release has no assets at all");
|
|
9334
|
+
if (tag !== null && tag !== "latest") lines.push(" installing a different version silently is worse than failing: try the newest release with --tag latest");
|
|
9335
|
+
return lines.join("\n");
|
|
9336
|
+
}
|
|
9337
|
+
function describeReleaseFailure(status, repo, tag) {
|
|
9338
|
+
const slug = `${repo.owner}/${repo.name}`;
|
|
9339
|
+
if (status === 404) {
|
|
9340
|
+
if (tag !== null && tag !== "latest") return `no release tagged "${tag}" in ${slug}\n list what exists with: home-hosted ui-switch --repo ${slug} --tag latest --list`;
|
|
9341
|
+
return `no repository or published release at ${slug}\n check the --repo slug (owner/name), and that the repository is public`;
|
|
9342
|
+
}
|
|
9343
|
+
if (status === 403 || status === 429) return `GitHub refused the request (HTTP ${status}) — the unauthenticated API rate limit is per address.\n set a token to raise it: --token <token>, or GITHUB_TOKEN / GH_TOKEN`;
|
|
9344
|
+
if (status === 401) return "GitHub rejected the token (HTTP 401) — check --token, GITHUB_TOKEN or GH_TOKEN";
|
|
9345
|
+
return `GitHub answered HTTP ${status} while reading the release of ${slug}`;
|
|
9346
|
+
}
|
|
9347
|
+
function describeDownloadFailure(status, url) {
|
|
9348
|
+
const github = isGithubHost(url);
|
|
9349
|
+
if (status === 404) return github ? `the asset is gone (HTTP 404) — ${url}\n the release may have been rebuilt since it was listed; run the command again` : `nothing is served at that URL (HTTP 404) — ${url}`;
|
|
9350
|
+
if (status === 403 || status === 429) return github ? `GitHub refused the download (HTTP ${status}) — a token raises the rate limit: --token <token>, or GITHUB_TOKEN / GH_TOKEN` : `the host refused the download (HTTP ${status}) — ${url}`;
|
|
9351
|
+
if (status === 401 && github) return "GitHub rejected the token on the download (HTTP 401) — check --token, GITHUB_TOKEN or GH_TOKEN";
|
|
9352
|
+
return `the download failed (HTTP ${status}) — ${url}`;
|
|
9353
|
+
}
|
|
9354
|
+
function tooLargeMessage(bytes) {
|
|
9355
|
+
return `the download is ${formatBytes(bytes)}, larger than the ${MAX_DOWNLOAD_BYTES / 1024 / 1024}MB a UI may be`;
|
|
9356
|
+
}
|
|
9357
|
+
function formatBytes(bytes) {
|
|
9358
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
9359
|
+
if (bytes < 1048576) return `${Math.round(bytes / 1024)} KB`;
|
|
9360
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
9361
|
+
}
|
|
9362
|
+
function describeError(error) {
|
|
9363
|
+
return error instanceof Error ? error.message : String(error);
|
|
9364
|
+
}
|
|
9365
|
+
var DEFAULT_REPO, MAX_DOWNLOAD_BYTES, uiSwitchCommand;
|
|
9366
|
+
var init_ui_switch = __esmMin((() => {
|
|
9367
|
+
init_io();
|
|
9368
|
+
DEFAULT_REPO = "NamesMT/home-hosted";
|
|
9369
|
+
MAX_DOWNLOAD_BYTES = 536870912;
|
|
9370
|
+
uiSwitchCommand = defineCommand({
|
|
9371
|
+
meta: {
|
|
9372
|
+
name: "ui-switch",
|
|
9373
|
+
description: "install a UI from a release asset, a zip file or a URL"
|
|
8165
9374
|
},
|
|
8166
|
-
|
|
8167
|
-
|
|
8168
|
-
|
|
8169
|
-
|
|
8170
|
-
|
|
8171
|
-
|
|
8172
|
-
if (values.clear === true) {
|
|
8173
|
-
if (!store.apiTokenSet) {
|
|
8174
|
-
process.stdout.write("no API token is set — nothing to clear\n");
|
|
8175
|
-
return;
|
|
9375
|
+
run: async ({ rawArgs }) => {
|
|
9376
|
+
await uiSwitch(rawArgs, {
|
|
9377
|
+
write: (text) => process.stdout.write(text),
|
|
9378
|
+
prompt,
|
|
9379
|
+
style
|
|
9380
|
+
});
|
|
8176
9381
|
}
|
|
8177
|
-
|
|
8178
|
-
|
|
9382
|
+
});
|
|
9383
|
+
}));
|
|
9384
|
+
//#endregion
|
|
9385
|
+
//#region src/cli/ui-revert.ts
|
|
9386
|
+
var ui_revert_exports = /* @__PURE__ */ __exportAll({
|
|
9387
|
+
runUiRevert: () => runUiRevert,
|
|
9388
|
+
uiRevertCommand: () => uiRevertCommand
|
|
9389
|
+
});
|
|
9390
|
+
/** `ui-revert` drops a user-installed UI so the stock panel serves again. */
|
|
9391
|
+
async function runUiRevert() {
|
|
9392
|
+
const ui = new UiService({ dataRoot });
|
|
9393
|
+
if (!ui.custom) {
|
|
9394
|
+
process.stdout.write("no custom UI is installed — the stock panel is already in use\n");
|
|
8179
9395
|
return;
|
|
8180
9396
|
}
|
|
8181
|
-
|
|
8182
|
-
|
|
8183
|
-
else if (token === null && process.stdin.isTTY === true) token = await promptHidden("API token: ");
|
|
8184
|
-
if (token !== null) token = token.trim();
|
|
8185
|
-
if (token === null || token.length === 0) fail("no token given: run `home-hosted set-token --generate`, set HHOSTED_TOKEN, or paste one interactively");
|
|
8186
|
-
store.setApiToken(token);
|
|
8187
|
-
const generated = values.generate === true;
|
|
8188
|
-
process.stdout.write(`${green(generated ? "token generated" : "token stored")} in ${defaultSecretsPath} (mode 0600)\n`);
|
|
8189
|
-
if (generated) {
|
|
8190
|
-
process.stdout.write(` ${bold(token)}\n`);
|
|
8191
|
-
process.stdout.write(`${dim(" shown once — only its SHA-256 is kept on disk, so copy it now")}\n`);
|
|
8192
|
-
} else process.stdout.write(`${dim(` stored as ${token.slice(0, 8)}… — the file keeps only its hash`)}\n`);
|
|
8193
|
-
const { readRuntime } = await Promise.resolve().then(() => (init_daemon(), daemon_exports));
|
|
8194
|
-
const runtime = readRuntime();
|
|
8195
|
-
const base = (runtime?.url ?? `http://127.0.0.1:${DEFAULT_PORT}`).replace(/\/+$/, "");
|
|
8196
|
-
if (runtime !== null) {
|
|
8197
|
-
const verified = await verifyToken(base, token);
|
|
8198
|
-
if (verified === true) process.stdout.write(`${dim(`verified: ${base}/api/auth/session accepted it`)}\n`);
|
|
8199
|
-
else if (verified === false) process.stdout.write(`${dim(`the panel at ${base} did not accept it — is it running with this state directory?`)}\n`);
|
|
8200
|
-
}
|
|
8201
|
-
process.stdout.write(`Use it from a script or an agent:\n`);
|
|
8202
|
-
process.stdout.write(` ${bold(`curl -H "Authorization: Bearer ${generated ? token : "<token>"}" ${base}/api/state`)}\n`);
|
|
8203
|
-
process.stdout.write(`${dim("It needs no restart, outlives sessions, and holds the same access as a signed-in browser.")}\n`);
|
|
8204
|
-
process.stdout.write(`${dim("Remove it any time with: home-hosted set-token --clear")}\n`);
|
|
8205
|
-
if (store.usingDefaultPassword) process.stdout.write(`${dim("The panel password is still the default — change it before the panel is reachable beyond loopback.")}\n`);
|
|
9397
|
+
ui.revert();
|
|
9398
|
+
process.stdout.write(`${green("custom UI removed")} — the stock panel is back; refresh the browser\n`);
|
|
8206
9399
|
}
|
|
8207
|
-
|
|
8208
|
-
|
|
8209
|
-
|
|
9400
|
+
var uiRevertCommand;
|
|
9401
|
+
var init_ui_revert = __esmMin((() => {
|
|
9402
|
+
init_io();
|
|
9403
|
+
init_paths();
|
|
9404
|
+
init_ui();
|
|
9405
|
+
uiRevertCommand = defineCommand({
|
|
9406
|
+
meta: {
|
|
9407
|
+
name: "ui-revert",
|
|
9408
|
+
description: "go back to the stock control panel UI"
|
|
9409
|
+
},
|
|
9410
|
+
run: async () => {
|
|
9411
|
+
await runUiRevert();
|
|
9412
|
+
}
|
|
9413
|
+
});
|
|
9414
|
+
}));
|
|
9415
|
+
//#endregion
|
|
9416
|
+
//#region src/cli.ts
|
|
9417
|
+
init_args();
|
|
9418
|
+
init_io();
|
|
9419
|
+
/**
|
|
9420
|
+
* The command line, and nothing else. Two things happen before citty is asked
|
|
9421
|
+
* anything: `--home`/`--project` are peeled off and applied (because
|
|
9422
|
+
* `#src/helpers/paths.ts` resolves at import time), and the curated surface —
|
|
9423
|
+
* `help`, `version`, `unknown command`, and the `-p 4000` shorthand for `up` —
|
|
9424
|
+
* is decided. The command modules are then resolved lazily by citty, so their
|
|
9425
|
+
* own `#src` imports are safe: by the time one is imported, the directories are
|
|
9426
|
+
* already in the environment.
|
|
9427
|
+
*
|
|
9428
|
+
* The only static imports here are node builtins, citty, and the two path-free
|
|
9429
|
+
* local modules the pre-pass needs; every command (and so every state-reading
|
|
9430
|
+
* module) is a dynamic import behind `subCommands`.
|
|
9431
|
+
*/
|
|
9432
|
+
var CLI_ENTRY = fileURLToPath(import.meta.url);
|
|
9433
|
+
/**
|
|
9434
|
+
* The curated prose, kept here because citty cannot generate it. One named
|
|
9435
|
+
* piece per command, so the full reference and a single command's help are
|
|
9436
|
+
* composed from the same lines and can never drift apart. Every section lays
|
|
9437
|
+
* its left column out at the same width, so the two views read alike.
|
|
9438
|
+
*/
|
|
9439
|
+
var HEADER = "home-hosted — a control panel for the processes on your home server";
|
|
9440
|
+
/** The one line the full reference lists for a command. */
|
|
9441
|
+
var SYNOPSIS = {
|
|
9442
|
+
"up": "home-hosted up [options]",
|
|
9443
|
+
"down": "home-hosted down",
|
|
9444
|
+
"restart": "home-hosted restart [options]",
|
|
9445
|
+
"status": "home-hosted status [--json]",
|
|
9446
|
+
"set-password": "home-hosted set-password",
|
|
9447
|
+
"set-token": "home-hosted set-token",
|
|
9448
|
+
"migrate": "home-hosted migrate",
|
|
9449
|
+
"init": "home-hosted init",
|
|
9450
|
+
"ui-switch": "home-hosted ui-switch",
|
|
9451
|
+
"ui-revert": "home-hosted ui-revert"
|
|
9452
|
+
};
|
|
9453
|
+
var SUMMARIES = {
|
|
9454
|
+
"up": "start it in the background (detached)",
|
|
9455
|
+
"down": "stop it, and everything it supervises",
|
|
9456
|
+
"restart": "down, then up",
|
|
9457
|
+
"status": "is it running, where, and how to reach it",
|
|
9458
|
+
"set-password": "set the panel password without the API",
|
|
9459
|
+
"set-token": "set the API token that scripts and agents use",
|
|
9460
|
+
"migrate": "bring the config up to this release's schema",
|
|
9461
|
+
"init": "scaffold a project that keeps its state in the repo",
|
|
9462
|
+
"ui-switch": "install a UI from a release asset, a zip file or a URL",
|
|
9463
|
+
"ui-revert": "go back to the stock control panel UI"
|
|
9464
|
+
};
|
|
9465
|
+
/** Every section lays its left column out at this width, so the two views align. */
|
|
9466
|
+
var OPTION_WIDTH = 19;
|
|
9467
|
+
var UP_SECTION = {
|
|
9468
|
+
heading: "Options for up/restart",
|
|
9469
|
+
lines: [
|
|
9470
|
+
["-c, --config <file>", "servers config (default: <state>/servers.config.json)"],
|
|
9471
|
+
["-p, --port <port>", "control panel port (default: 3999)"],
|
|
9472
|
+
["--host <bind>", "local | lan | an ipv4 address (default: local)"],
|
|
9473
|
+
["--open", "open the panel in a browser once it is up"],
|
|
9474
|
+
["--no-autostart", "do not start the entries marked autostart"],
|
|
9475
|
+
["--foreground", "run in this process instead of detaching (systemd/docker)"],
|
|
9476
|
+
["--print-config", "print the effective config and exit"]
|
|
9477
|
+
]
|
|
9478
|
+
};
|
|
9479
|
+
var STATUS_SECTION = {
|
|
9480
|
+
heading: "Options for status",
|
|
9481
|
+
lines: [["--json", "print machine-readable JSON"]]
|
|
9482
|
+
};
|
|
9483
|
+
var SET_PASSWORD_SECTION = {
|
|
9484
|
+
heading: "Options for set-password",
|
|
9485
|
+
lines: [["--clear", "remove the password, which disables authentication"]]
|
|
9486
|
+
};
|
|
9487
|
+
var SET_TOKEN_SECTION = {
|
|
9488
|
+
heading: "Options for set-token",
|
|
9489
|
+
lines: [["--generate", "create a strong token and print it once"], ["--clear", "remove the token, so it stops working"]]
|
|
9490
|
+
};
|
|
9491
|
+
var MIGRATE_SECTION = {
|
|
9492
|
+
heading: "Options for migrate",
|
|
9493
|
+
lines: [["--dry-run", "print what would change, write nothing"], ["-y, --yes", "apply without asking (or set HHOSTED_MIGRATE=allow)"]]
|
|
9494
|
+
};
|
|
9495
|
+
var INIT_SECTION = {
|
|
9496
|
+
heading: "Options for init",
|
|
9497
|
+
lines: [
|
|
9498
|
+
["--dir <dir>", "where to scaffold (default: ./my-servers)"],
|
|
9499
|
+
["--name <name>", "package name (default: the directory name)"],
|
|
9500
|
+
["--pm <manager>", "pnpm | npm | yarn | bun (default: the first one installed)"],
|
|
9501
|
+
["--no-install", "write the files, install nothing"],
|
|
9502
|
+
["-y, --yes", "take every default, ask nothing"]
|
|
9503
|
+
]
|
|
9504
|
+
};
|
|
9505
|
+
var UI_SWITCH_SECTION = {
|
|
9506
|
+
heading: "Options for ui-switch",
|
|
9507
|
+
lines: [
|
|
9508
|
+
["--repo <owner/name>", "release repo (default: NamesMT/home-hosted)"],
|
|
9509
|
+
["--tag <tag>", "release tag (default: this release's tag, or latest for another repo)"],
|
|
9510
|
+
["--asset <name>", "asset to install (exact or unambiguous match)"],
|
|
9511
|
+
["--file <path|url>", "install a zip from a local path or an http(s) URL"],
|
|
9512
|
+
["--list", "list the usable assets and install nothing"],
|
|
9513
|
+
["--token <token>", "GitHub token (or GITHUB_TOKEN / GH_TOKEN)"],
|
|
9514
|
+
["-y, --yes", "take the only asset instead of asking"]
|
|
9515
|
+
]
|
|
9516
|
+
};
|
|
9517
|
+
var EVERYWHERE_SECTION = {
|
|
9518
|
+
heading: "Everywhere",
|
|
9519
|
+
lines: [
|
|
9520
|
+
["--home <dir>", "state directory (default: $HHOSTED_HOME or ~/.home-hosted)"],
|
|
9521
|
+
["--project <dir>", "base for relative entry paths (default: the current directory)"],
|
|
9522
|
+
["-h, --help", "this text"],
|
|
9523
|
+
["-v, --version", "the version"]
|
|
9524
|
+
]
|
|
9525
|
+
};
|
|
9526
|
+
var ALIAS_SECTION = {
|
|
9527
|
+
heading: "Alias",
|
|
9528
|
+
lines: [["hh", "the same CLI, on a machine where home-hosted is installed"]]
|
|
9529
|
+
};
|
|
9530
|
+
var ENVIRONMENT_SECTION = {
|
|
9531
|
+
heading: "Environment",
|
|
9532
|
+
lines: [
|
|
9533
|
+
["HHOSTED_HOME", "where config, secrets, logs, TLS and backups live"],
|
|
9534
|
+
["HHOSTED_PROJECT", "base for relative entry paths"],
|
|
9535
|
+
["HHOSTED_PASSWORD", "the password for a non-interactive set-password"],
|
|
9536
|
+
["HHOSTED_TOKEN", "the token for a non-interactive set-token"],
|
|
9537
|
+
["GITHUB_TOKEN", "a GitHub token for ui-switch (GH_TOKEN also works)"]
|
|
9538
|
+
]
|
|
9539
|
+
};
|
|
9540
|
+
/** The command list of the full reference, aligned as it always was. */
|
|
9541
|
+
function renderCommandList() {
|
|
9542
|
+
const width = Math.max(...Object.values(SYNOPSIS).map((synopsis) => synopsis.length));
|
|
9543
|
+
return Object.keys(SYNOPSIS).map((name) => {
|
|
9544
|
+
const synopsis = SYNOPSIS[name];
|
|
9545
|
+
return ` ${cyan(synopsis)}${" ".repeat(width + 1 - synopsis.length)} ${SUMMARIES[name]}`;
|
|
9546
|
+
}).join("\n");
|
|
9547
|
+
}
|
|
9548
|
+
function renderSection(section) {
|
|
9549
|
+
const lines = section.lines.map(([left, right]) => ` ${cyan(left)}${" ".repeat(Math.max(0, OPTION_WIDTH - left.length))} ${right}`).join("\n");
|
|
9550
|
+
return `${heading(section.heading)}\n${lines}`;
|
|
9551
|
+
}
|
|
9552
|
+
/** The trailer every command shares: the global flags, the alias and the environment. */
|
|
9553
|
+
var SHARED_TRAILER = [
|
|
9554
|
+
renderSection(EVERYWHERE_SECTION),
|
|
9555
|
+
"",
|
|
9556
|
+
renderSection(ALIAS_SECTION),
|
|
9557
|
+
"",
|
|
9558
|
+
renderSection(ENVIRONMENT_SECTION),
|
|
9559
|
+
""
|
|
9560
|
+
].join("\n");
|
|
9561
|
+
/** The full reference: every command, every option, the trailer. */
|
|
9562
|
+
var USAGE = [
|
|
9563
|
+
dim(HEADER),
|
|
9564
|
+
"",
|
|
9565
|
+
heading("Usage"),
|
|
9566
|
+
renderCommandList(),
|
|
9567
|
+
"",
|
|
9568
|
+
renderSection(UP_SECTION),
|
|
9569
|
+
"",
|
|
9570
|
+
renderSection(SET_PASSWORD_SECTION),
|
|
9571
|
+
"",
|
|
9572
|
+
renderSection(SET_TOKEN_SECTION),
|
|
9573
|
+
"",
|
|
9574
|
+
renderSection(MIGRATE_SECTION),
|
|
9575
|
+
"",
|
|
9576
|
+
renderSection(INIT_SECTION),
|
|
9577
|
+
"",
|
|
9578
|
+
renderSection(UI_SWITCH_SECTION),
|
|
9579
|
+
"",
|
|
9580
|
+
renderSection(STATUS_SECTION),
|
|
9581
|
+
"",
|
|
9582
|
+
SHARED_TRAILER
|
|
9583
|
+
].join("\n");
|
|
9584
|
+
var UP_COMMANDS = /* @__PURE__ */ new Set(["up", "restart"]);
|
|
9585
|
+
var SECTIONS = {
|
|
9586
|
+
"status": STATUS_SECTION,
|
|
9587
|
+
"set-password": SET_PASSWORD_SECTION,
|
|
9588
|
+
"set-token": SET_TOKEN_SECTION,
|
|
9589
|
+
"migrate": MIGRATE_SECTION,
|
|
9590
|
+
"init": INIT_SECTION,
|
|
9591
|
+
"ui-switch": UI_SWITCH_SECTION
|
|
9592
|
+
};
|
|
9593
|
+
/**
|
|
9594
|
+
* `home-hosted <command> --help`: the usage line, that command's own options,
|
|
9595
|
+
* and the shared trailer. A command with no options says so instead of printing
|
|
9596
|
+
* an empty heading.
|
|
9597
|
+
*/
|
|
9598
|
+
function commandHelp(command) {
|
|
9599
|
+
const synopsis = SYNOPSIS[command];
|
|
9600
|
+
if (synopsis === void 0) return USAGE;
|
|
9601
|
+
const section = UP_COMMANDS.has(command) ? UP_SECTION : SECTIONS[command];
|
|
9602
|
+
const options = section === void 0 ? dim("no options") : renderSection(section);
|
|
9603
|
+
return [
|
|
9604
|
+
dim(HEADER),
|
|
9605
|
+
"",
|
|
9606
|
+
cyan(synopsis),
|
|
9607
|
+
"",
|
|
9608
|
+
options,
|
|
9609
|
+
"",
|
|
9610
|
+
SHARED_TRAILER
|
|
9611
|
+
].join("\n");
|
|
9612
|
+
}
|
|
9613
|
+
function manifestVersion() {
|
|
8210
9614
|
try {
|
|
8211
|
-
|
|
8212
|
-
if (!response.ok) return false;
|
|
8213
|
-
return (await response.json()).authenticated === true;
|
|
9615
|
+
return JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "0.0.0";
|
|
8214
9616
|
} catch {
|
|
8215
|
-
return
|
|
9617
|
+
return "0.0.0";
|
|
8216
9618
|
}
|
|
8217
9619
|
}
|
|
8218
9620
|
function version() {
|
|
8219
|
-
|
|
8220
|
-
process.stdout.write(`${manifest.version ?? "0.0.0"}\n`);
|
|
9621
|
+
process.stdout.write(`${manifestVersion()}\n`);
|
|
8221
9622
|
}
|
|
9623
|
+
/**
|
|
9624
|
+
* citty owns dispatch and argument parsing. Each entry is a lazy import, so a
|
|
9625
|
+
* command's `#src` modules are only evaluated once `applyDirFlags()` has run.
|
|
9626
|
+
*/
|
|
9627
|
+
var COMMANDS = {
|
|
9628
|
+
"up": () => Promise.resolve().then(() => (init_up(), up_exports)).then((module) => module.upCommand(CLI_ENTRY)),
|
|
9629
|
+
"down": () => Promise.resolve().then(() => (init_down(), down_exports)).then((module) => module.downCommand),
|
|
9630
|
+
"restart": () => Promise.resolve().then(() => (init_restart(), restart_exports)).then((module) => module.restartCommand(CLI_ENTRY)),
|
|
9631
|
+
"status": () => Promise.resolve().then(() => (init_status(), status_exports)).then((module) => module.statusCommand),
|
|
9632
|
+
"set-password": () => Promise.resolve().then(() => (init_set_password(), set_password_exports)).then((module) => module.setPasswordCommand),
|
|
9633
|
+
"set-token": () => Promise.resolve().then(() => (init_set_token(), set_token_exports)).then((module) => module.setTokenCommand),
|
|
9634
|
+
"migrate": () => Promise.resolve().then(() => (init_migrate(), migrate_exports)).then((module) => module.migrateCommand),
|
|
9635
|
+
"init": () => Promise.resolve().then(() => (init_init(), init_exports)).then((module) => module.initCommand),
|
|
9636
|
+
"ui-switch": () => Promise.resolve().then(() => (init_ui_switch(), ui_switch_exports)).then((module) => module.uiSwitchCommand),
|
|
9637
|
+
"ui-revert": () => Promise.resolve().then(() => (init_ui_revert(), ui_revert_exports)).then((module) => module.uiRevertCommand)
|
|
9638
|
+
};
|
|
9639
|
+
var rootCommand = defineCommand({
|
|
9640
|
+
meta: {
|
|
9641
|
+
name: "home-hosted",
|
|
9642
|
+
version: manifestVersion(),
|
|
9643
|
+
description: "a control panel for the processes on your home server"
|
|
9644
|
+
},
|
|
9645
|
+
subCommands: COMMANDS
|
|
9646
|
+
});
|
|
9647
|
+
var commandNames = Object.keys(COMMANDS);
|
|
8222
9648
|
async function main() {
|
|
8223
|
-
const
|
|
8224
|
-
|
|
8225
|
-
|
|
8226
|
-
|
|
8227
|
-
|
|
9649
|
+
const dirFlags = extractDirFlags(process.argv.slice(2));
|
|
9650
|
+
if (dirFlags.error !== void 0) fail(dirFlags.error);
|
|
9651
|
+
applyDirFlags(dirFlags);
|
|
9652
|
+
const invocation = resolveInvocation(dirFlags.rest, commandNames);
|
|
9653
|
+
if (invocation.kind === "help") {
|
|
9654
|
+
process.stdout.write(invocation.command === void 0 ? USAGE : commandHelp(invocation.command));
|
|
8228
9655
|
return;
|
|
8229
9656
|
}
|
|
8230
|
-
|
|
8231
|
-
|
|
8232
|
-
|
|
8233
|
-
|
|
8234
|
-
|
|
8235
|
-
|
|
8236
|
-
|
|
8237
|
-
|
|
8238
|
-
|
|
8239
|
-
|
|
8240
|
-
|
|
8241
|
-
|
|
8242
|
-
|
|
8243
|
-
|
|
8244
|
-
|
|
8245
|
-
|
|
8246
|
-
return;
|
|
8247
|
-
case "set-password":
|
|
8248
|
-
applyDirFlags(dirFlags);
|
|
8249
|
-
await setPassword(args);
|
|
8250
|
-
return;
|
|
8251
|
-
case "set-token":
|
|
8252
|
-
applyDirFlags(dirFlags);
|
|
8253
|
-
await setToken(args);
|
|
8254
|
-
return;
|
|
8255
|
-
case "migrate":
|
|
8256
|
-
applyDirFlags(dirFlags);
|
|
8257
|
-
await migrateConfig(args);
|
|
8258
|
-
return;
|
|
8259
|
-
case "init":
|
|
8260
|
-
applyDirFlags(dirFlags);
|
|
8261
|
-
await initProject(args);
|
|
8262
|
-
return;
|
|
8263
|
-
case "ui-revert":
|
|
8264
|
-
applyDirFlags(dirFlags);
|
|
8265
|
-
await uiRevert();
|
|
8266
|
-
return;
|
|
8267
|
-
case "help":
|
|
8268
|
-
case "--help":
|
|
8269
|
-
case "-h":
|
|
8270
|
-
process.stdout.write(USAGE);
|
|
8271
|
-
return;
|
|
8272
|
-
case "version":
|
|
8273
|
-
case "--version":
|
|
8274
|
-
case "-v":
|
|
8275
|
-
version();
|
|
8276
|
-
return;
|
|
8277
|
-
default:
|
|
8278
|
-
if (command.startsWith("-")) {
|
|
8279
|
-
applyDirFlags(dirFlags);
|
|
8280
|
-
await up(rest);
|
|
8281
|
-
return;
|
|
8282
|
-
}
|
|
8283
|
-
process.stderr.write(`unknown command: ${command}\n\n${USAGE}`);
|
|
8284
|
-
process.exit(1);
|
|
9657
|
+
if (invocation.kind === "version") {
|
|
9658
|
+
version();
|
|
9659
|
+
return;
|
|
9660
|
+
}
|
|
9661
|
+
if (invocation.kind === "unknown") {
|
|
9662
|
+
process.stderr.write(`unknown command: ${invocation.command}\n\n${USAGE}`);
|
|
9663
|
+
process.exit(1);
|
|
9664
|
+
}
|
|
9665
|
+
const sub = COMMANDS[invocation.argv[0]];
|
|
9666
|
+
const command = typeof sub === "function" ? await sub() : sub;
|
|
9667
|
+
const problem = rejectUnknownFlags(invocation.argv.slice(1), command?.args);
|
|
9668
|
+
if (problem !== null) fail(problem);
|
|
9669
|
+
try {
|
|
9670
|
+
await runCommand(rootCommand, { rawArgs: invocation.argv });
|
|
9671
|
+
} catch (error) {
|
|
9672
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
8285
9673
|
}
|
|
8286
9674
|
}
|
|
8287
9675
|
main().catch((error) => {
|
|
8288
9676
|
fail(error instanceof Error ? error.message : String(error));
|
|
8289
9677
|
});
|
|
8290
9678
|
//#endregion
|
|
8291
|
-
export {};
|
|
9679
|
+
export { commandHelp };
|
|
8292
9680
|
|
|
8293
9681
|
//# sourceMappingURL=cli.js.map
|