home-hosted 0.4.0 → 0.5.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 +81 -15
- package/README.md +65 -18
- package/dist/cli.js +1586 -616
- package/dist/cli.js.map +1 -1
- package/{SERVERS.md → docs/SERVERS.md} +16 -5
- package/{UI_CREATION.md → docs/UI_CREATION.md} +34 -2
- package/package.json +9 -4
- package/uis/stock/dist/assets/index-C7Rc5zP8.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-x_wy1ycR.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,214 @@ 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 first token that starts with `-` is the one-shot form
|
|
92
|
+
* (`home-hosted -p 4000`), so `up` is prepended. Anything else has to name a
|
|
93
|
+
* command, which keeps the old `unknown command:` text exact.
|
|
94
|
+
*/
|
|
95
|
+
function resolveInvocation(argv, commands) {
|
|
96
|
+
if (argv.length === 0) return {
|
|
97
|
+
kind: "command",
|
|
98
|
+
argv: ["up"]
|
|
99
|
+
};
|
|
100
|
+
const first = argv[0];
|
|
101
|
+
if (HELP_TOKENS.has(first)) return { kind: "help" };
|
|
102
|
+
if (VERSION_TOKENS.has(first)) return { kind: "version" };
|
|
103
|
+
if (first.startsWith("-")) return {
|
|
104
|
+
kind: "command",
|
|
105
|
+
argv: ["up", ...argv]
|
|
106
|
+
};
|
|
107
|
+
if (!commands.includes(first)) return {
|
|
108
|
+
kind: "unknown",
|
|
109
|
+
command: first
|
|
110
|
+
};
|
|
111
|
+
for (const arg of argv.slice(1)) {
|
|
112
|
+
if (HELP_FLAGS.has(arg)) return { kind: "help" };
|
|
113
|
+
if (VERSION_FLAGS.has(arg)) return { kind: "version" };
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
kind: "command",
|
|
117
|
+
argv
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/** citty takes camelCase definitions; the flag a person types is kebab-case. */
|
|
121
|
+
function kebab(name) {
|
|
122
|
+
return name.replace(CAMEL, (match) => `-${match.toLowerCase()}`);
|
|
123
|
+
}
|
|
124
|
+
function aliasesOf(def) {
|
|
125
|
+
if (def === void 0 || !("alias" in def) || def.alias === void 0) return [];
|
|
126
|
+
return Array.isArray(def.alias) ? def.alias : [def.alias];
|
|
127
|
+
}
|
|
128
|
+
function findArg(argsDef, name) {
|
|
129
|
+
for (const [key, def] of Object.entries(argsDef)) {
|
|
130
|
+
if (def === void 0) continue;
|
|
131
|
+
if ((/* @__PURE__ */ new Set([
|
|
132
|
+
key,
|
|
133
|
+
kebab(key),
|
|
134
|
+
...aliasesOf(def)
|
|
135
|
+
])).has(name)) return def;
|
|
136
|
+
if (def.type === "boolean" && (name === `no-${key}` || name === `no-${kebab(key)}`)) return def;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* citty parses permissively (`strict: false`), so a mistyped flag would quietly do
|
|
141
|
+
* nothing — `--autostart` where `--no-autostart` was meant would start the panel
|
|
142
|
+
* with the wrong policy. This keeps the refusal the command line always had, from
|
|
143
|
+
* citty's own definitions rather than a second list.
|
|
144
|
+
*
|
|
145
|
+
* A command that declares no arguments parses its own argv (and rejects its own
|
|
146
|
+
* unknown flags), so it is left alone. Returns the message to print, or null.
|
|
147
|
+
*/
|
|
148
|
+
function rejectUnknownFlags(argv, argsDef) {
|
|
149
|
+
if (argsDef === void 0 || Object.keys(argsDef).length === 0) return null;
|
|
150
|
+
for (let index = 0; index < argv.length; index++) {
|
|
151
|
+
const token = argv[index];
|
|
152
|
+
if (token === "--") return null;
|
|
153
|
+
if (!token.startsWith("-") || token.length === 1) return `Unexpected argument '${token}'`;
|
|
154
|
+
const body = token.startsWith("--") ? token.slice(2) : token.slice(1);
|
|
155
|
+
const equals = body.indexOf("=");
|
|
156
|
+
const name = equals === -1 ? body : body.slice(0, equals);
|
|
157
|
+
const def = name.length === 0 ? void 0 : findArg(argsDef, name);
|
|
158
|
+
if (def === void 0) return `Unknown option '${token}'`;
|
|
159
|
+
if (def.type === "string" && equals === -1 && argv[index + 1] === void 0) return `Option '${token}' needs a value`;
|
|
160
|
+
if (def.type === "string" && equals === -1) index += 1;
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* The daemon gets the same instructions, but never `--foreground` (that is what
|
|
166
|
+
* makes it the daemon) and never `--print-config` (that one is answered in the
|
|
167
|
+
* calling process).
|
|
168
|
+
*/
|
|
169
|
+
function buildDaemonArgv(flags) {
|
|
170
|
+
const args = ["up", "--foreground"];
|
|
171
|
+
if (flags.config !== void 0) args.push("--config", flags.config);
|
|
172
|
+
if (flags.port !== void 0) args.push("--port", String(flags.port));
|
|
173
|
+
if (flags.host !== void 0) args.push("--host", flags.host);
|
|
174
|
+
if (!flags.autostart) args.push("--no-autostart");
|
|
175
|
+
if (flags.open) args.push("--open");
|
|
176
|
+
return args;
|
|
177
|
+
}
|
|
178
|
+
var HELP_TOKENS, VERSION_TOKENS, HELP_FLAGS, VERSION_FLAGS, CAMEL;
|
|
179
|
+
var init_args = __esmMin((() => {
|
|
180
|
+
HELP_TOKENS = /* @__PURE__ */ new Set([
|
|
181
|
+
"help",
|
|
182
|
+
"--help",
|
|
183
|
+
"-h"
|
|
184
|
+
]);
|
|
185
|
+
VERSION_TOKENS = /* @__PURE__ */ new Set([
|
|
186
|
+
"version",
|
|
187
|
+
"--version",
|
|
188
|
+
"-v"
|
|
189
|
+
]);
|
|
190
|
+
HELP_FLAGS = /* @__PURE__ */ new Set(["--help", "-h"]);
|
|
191
|
+
VERSION_FLAGS = /* @__PURE__ */ new Set(["--version", "-v"]);
|
|
192
|
+
CAMEL = /[A-Z]/g;
|
|
193
|
+
}));
|
|
194
|
+
//#endregion
|
|
195
|
+
//#region src/cli/io.ts
|
|
196
|
+
/** One shape for every failure: a red `error <message>` on stderr and exit 1. */
|
|
197
|
+
function fail(message) {
|
|
198
|
+
process.stderr.write(`${paint("31", "error")} ${message}\n`);
|
|
199
|
+
process.exit(1);
|
|
200
|
+
}
|
|
201
|
+
function delay$3(ms) {
|
|
202
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
203
|
+
}
|
|
204
|
+
/** A plain y/N question, for decisions that are not secrets. */
|
|
205
|
+
function prompt(question) {
|
|
206
|
+
return new Promise((resolve) => {
|
|
207
|
+
const rl = readline.createInterface({
|
|
208
|
+
input: process.stdin,
|
|
209
|
+
output: process.stdout
|
|
210
|
+
});
|
|
211
|
+
rl.question(question, (answer) => {
|
|
212
|
+
rl.close();
|
|
213
|
+
resolve(answer);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
/** Reads a line with echo suppressed, so the password never lands in scrollback. */
|
|
218
|
+
function promptHidden(question) {
|
|
219
|
+
return new Promise((resolve) => {
|
|
220
|
+
const rl = readline.createInterface({
|
|
221
|
+
input: process.stdin,
|
|
222
|
+
output: process.stdout,
|
|
223
|
+
terminal: true
|
|
224
|
+
});
|
|
225
|
+
const onData = () => {
|
|
226
|
+
readline.clearLine(process.stdout, 0);
|
|
227
|
+
readline.cursorTo(process.stdout, 0);
|
|
228
|
+
process.stdout.write(question);
|
|
229
|
+
};
|
|
230
|
+
process.stdin.on("data", onData);
|
|
231
|
+
rl.question(question, (answer) => {
|
|
232
|
+
process.stdin.off("data", onData);
|
|
233
|
+
rl.close();
|
|
234
|
+
process.stdout.write("\n");
|
|
235
|
+
resolve(answer);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
/** Asks a yes/no question with a default, so an empty answer is a real answer. */
|
|
240
|
+
async function confirm(question, fallback) {
|
|
241
|
+
const answer = (await prompt(`${question} ${fallback ? "[Y/n]" : "[y/N]"} `)).trim().toLowerCase();
|
|
242
|
+
if (answer.length === 0) return fallback;
|
|
243
|
+
return answer === "y" || answer === "yes";
|
|
244
|
+
}
|
|
245
|
+
var isTty, paint, dim, bold, green, style;
|
|
246
|
+
var init_io = __esmMin((() => {
|
|
247
|
+
isTty = () => process.stdout.isTTY === true;
|
|
248
|
+
paint = (code, text) => isTty() ? `\x1B[${code}m${text}\x1B[0m` : text;
|
|
249
|
+
dim = (text) => paint("2", text);
|
|
250
|
+
bold = (text) => paint("1", text);
|
|
251
|
+
green = (text) => paint("32", text);
|
|
252
|
+
style = {
|
|
253
|
+
bold,
|
|
254
|
+
dim,
|
|
255
|
+
green
|
|
256
|
+
};
|
|
257
|
+
}));
|
|
258
|
+
//#endregion
|
|
50
259
|
//#region src/helpers/cookies.ts
|
|
51
260
|
function parseCookies(header) {
|
|
52
261
|
const cookies = {};
|
|
@@ -88,7 +297,7 @@ function parseBind(value) {
|
|
|
88
297
|
const parsed = bindSchema(value);
|
|
89
298
|
return parsed instanceof type.errors ? null : parsed;
|
|
90
299
|
}
|
|
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;
|
|
300
|
+
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, uiStatusSchema, tlsUploadSchema, settingsViewSchema, settingsSavedSchema;
|
|
92
301
|
var init_contracts = __esmMin((() => {
|
|
93
302
|
bindSchema = type("\"local\" | \"lan\" | /^\\d{1,3}(?:\\.\\d{1,3}){3}$/");
|
|
94
303
|
portSchema = type("1 <= number.integer <= 65535 | null");
|
|
@@ -173,7 +382,12 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
173
382
|
envFile: "string = \"\"",
|
|
174
383
|
resources: resourcesSchema.default(() => ({})),
|
|
175
384
|
/** Paths included in backups for this server (templates allowed). */
|
|
176
|
-
backupPaths: type("string[]").default(() => [])
|
|
385
|
+
backupPaths: type("string[]").default(() => []),
|
|
386
|
+
/**
|
|
387
|
+
* Skip well-known build output and dependency directories (`node_modules`,
|
|
388
|
+
* `dist`, `.next`, caches, …) inside the paths this entry declares.
|
|
389
|
+
*/
|
|
390
|
+
backupIgnoreGenerated: "boolean = true"
|
|
177
391
|
}).onUndeclaredKey("reject");
|
|
178
392
|
authSchema = type({
|
|
179
393
|
enabled: "boolean = true",
|
|
@@ -355,7 +569,8 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
355
569
|
dependsOn: type("string[]").optional(),
|
|
356
570
|
envFile: "string?",
|
|
357
571
|
resources: resourcesPatchSchema.optional(),
|
|
358
|
-
backupPaths: type("string[]").optional()
|
|
572
|
+
backupPaths: type("string[]").optional(),
|
|
573
|
+
backupIgnoreGenerated: "boolean?"
|
|
359
574
|
};
|
|
360
575
|
serverPatchSchema = type(editableFields).onUndeclaredKey("reject");
|
|
361
576
|
serverCreateSchema = type({
|
|
@@ -507,7 +722,9 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
507
722
|
origin: "string",
|
|
508
723
|
/** false when a parent path already covers it, or it would swallow the archive dir. */
|
|
509
724
|
included: "boolean",
|
|
510
|
-
note: "string | null"
|
|
725
|
+
note: "string | null",
|
|
726
|
+
/** The declaring entry asked for generated directories to be skipped. */
|
|
727
|
+
ignoreGenerated: "boolean?"
|
|
511
728
|
});
|
|
512
729
|
backupsViewSchema = type({
|
|
513
730
|
enabled: "boolean",
|
|
@@ -676,7 +893,7 @@ password: passwordValueSchema.optional() }).onUndeclaredKey("reject");
|
|
|
676
893
|
uploadedAt: "number",
|
|
677
894
|
files: "number.integer >= 1"
|
|
678
895
|
});
|
|
679
|
-
type({
|
|
896
|
+
uiStatusSchema = type({
|
|
680
897
|
/** A user-supplied UI is being served instead of the stock one. */
|
|
681
898
|
custom: "boolean",
|
|
682
899
|
/** Where that UI lives, whether or not it exists yet. */
|
|
@@ -687,6 +904,20 @@ password: passwordValueSchema.optional() }).onUndeclaredKey("reject");
|
|
|
687
904
|
certificate: "string >= 1",
|
|
688
905
|
privateKey: "string >= 1"
|
|
689
906
|
}).onUndeclaredKey("reject");
|
|
907
|
+
settingsViewSchema = type({
|
|
908
|
+
control: controlViewSchema,
|
|
909
|
+
defaults: defaultsSchema,
|
|
910
|
+
logs: logsSchema,
|
|
911
|
+
notifications: notificationViewSchema,
|
|
912
|
+
host: hostSchema,
|
|
913
|
+
backups: backupsViewSchema,
|
|
914
|
+
ui: uiStatusSchema
|
|
915
|
+
});
|
|
916
|
+
settingsSavedSchema = settingsViewSchema.and(type({
|
|
917
|
+
/** The listener is moving; reconnect at `targetUrl` when it stops being null. */
|
|
918
|
+
rebinding: "boolean",
|
|
919
|
+
targetUrl: "string | null"
|
|
920
|
+
}));
|
|
690
921
|
}));
|
|
691
922
|
//#endregion
|
|
692
923
|
//#region src/helpers/openapi.ts
|
|
@@ -757,7 +988,6 @@ function isLoopbackRequest(c) {
|
|
|
757
988
|
var init_loopback = __esmMin((() => {}));
|
|
758
989
|
//#endregion
|
|
759
990
|
//#region src/helpers/atomic.ts
|
|
760
|
-
var atomic_exports = /* @__PURE__ */ __exportAll({ writeFileAtomic: () => writeFileAtomic });
|
|
761
991
|
/**
|
|
762
992
|
* Write via a temp file in the same directory, then rename: readers never see a
|
|
763
993
|
* half-written file, and a crash cannot truncate the previous config.
|
|
@@ -772,17 +1002,6 @@ function writeFileAtomic(file, content, options = {}) {
|
|
|
772
1002
|
var init_atomic = __esmMin((() => {}));
|
|
773
1003
|
//#endregion
|
|
774
1004
|
//#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
1005
|
function deriveKey(password, salt, cost, keylen) {
|
|
787
1006
|
return crypto.scryptSync(password.normalize("NFKC"), salt, keylen, { ...cost });
|
|
788
1007
|
}
|
|
@@ -819,6 +1038,7 @@ function verifyApiToken(token, record) {
|
|
|
819
1038
|
if (token.length === 0) return false;
|
|
820
1039
|
const expected = Buffer$1.from(record.hash, "base64");
|
|
821
1040
|
const actual = Buffer$1.from(hashApiToken(token), "base64");
|
|
1041
|
+
if (actual.length !== expected.length) return false;
|
|
822
1042
|
return crypto.timingSafeEqual(actual, expected);
|
|
823
1043
|
}
|
|
824
1044
|
function apiTokenRecord(token, now = Date.now()) {
|
|
@@ -1539,7 +1759,7 @@ function resolveProjectDir() {
|
|
|
1539
1759
|
function resolveUserPath(target, base = projectDir) {
|
|
1540
1760
|
let value = target;
|
|
1541
1761
|
if (value === "~") value = os.homedir();
|
|
1542
|
-
else if (value.startsWith("~/")) value = path.join(os.homedir(), value.slice(2));
|
|
1762
|
+
else if (value.startsWith("~/") || value.startsWith("~\\")) value = path.join(os.homedir(), value.slice(2));
|
|
1543
1763
|
return path.isAbsolute(value) ? value : path.resolve(base, value);
|
|
1544
1764
|
}
|
|
1545
1765
|
var dataRoot, projectDir, defaultConfigPath, configSchemaPath, defaultSecretsPath, defaultLogsDir, defaultHistoryPath, defaultTlsDir, runtimePath, daemonLogPath;
|
|
@@ -1667,8 +1887,8 @@ function createBackupsRoute(deps) {
|
|
|
1667
1887
|
200: { description: "the archive (application/zip)" },
|
|
1668
1888
|
404: ERROR_RESPONSES[404]
|
|
1669
1889
|
}
|
|
1670
|
-
}), (c) => {
|
|
1671
|
-
const file = deps.backups.resolve(c.req.
|
|
1890
|
+
}), validate("param", nameParam), (c) => {
|
|
1891
|
+
const file = deps.backups.resolve(c.req.valid("param").name);
|
|
1672
1892
|
if (file === null) throw new DetailedError("unknown backup", {
|
|
1673
1893
|
statusCode: 404,
|
|
1674
1894
|
code: "UNKNOWN_BACKUP"
|
|
@@ -1686,8 +1906,8 @@ function createBackupsRoute(deps) {
|
|
|
1686
1906
|
200: { description: "Removed" },
|
|
1687
1907
|
404: ERROR_RESPONSES[404]
|
|
1688
1908
|
}
|
|
1689
|
-
}), (c) => {
|
|
1690
|
-
if (!deps.backups.remove(c.req.
|
|
1909
|
+
}), validate("param", nameParam), (c) => {
|
|
1910
|
+
if (!deps.backups.remove(c.req.valid("param").name)) throw new DetailedError("unknown backup", {
|
|
1691
1911
|
statusCode: 404,
|
|
1692
1912
|
code: "UNKNOWN_BACKUP"
|
|
1693
1913
|
});
|
|
@@ -1731,8 +1951,8 @@ function createBackupsRoute(deps) {
|
|
|
1731
1951
|
});
|
|
1732
1952
|
const uploads = path.join(deps.backups.directory, "uploads");
|
|
1733
1953
|
fs.mkdirSync(uploads, { recursive: true });
|
|
1734
|
-
uploadedTo = path.join(uploads, `upload-${
|
|
1735
|
-
fs.writeFileSync(uploadedTo, Buffer$1.from(await file.arrayBuffer()));
|
|
1954
|
+
uploadedTo = path.join(uploads, `upload-${crypto.randomUUID()}.zip`);
|
|
1955
|
+
fs.writeFileSync(uploadedTo, Buffer$1.from(await file.arrayBuffer()), { mode: 384 });
|
|
1736
1956
|
archive = uploadedTo;
|
|
1737
1957
|
const rawInclude = typeof body.include === "string" && body.include.length > 0 ? body.include : null;
|
|
1738
1958
|
let include;
|
|
@@ -1782,7 +2002,7 @@ function createBackupsRoute(deps) {
|
|
|
1782
2002
|
}
|
|
1783
2003
|
});
|
|
1784
2004
|
}
|
|
1785
|
-
var MAX_UPLOAD_BYTES;
|
|
2005
|
+
var MAX_UPLOAD_BYTES, nameParam;
|
|
1786
2006
|
var init_backups$1 = __esmMin((() => {
|
|
1787
2007
|
init_factory();
|
|
1788
2008
|
init_logger();
|
|
@@ -1792,6 +2012,7 @@ var init_backups$1 = __esmMin((() => {
|
|
|
1792
2012
|
init_state$1();
|
|
1793
2013
|
init_contracts();
|
|
1794
2014
|
MAX_UPLOAD_BYTES = 268435456;
|
|
2015
|
+
nameParam = type({ name: "string >= 1" });
|
|
1795
2016
|
}));
|
|
1796
2017
|
//#endregion
|
|
1797
2018
|
//#region src/helpers/deferred.ts
|
|
@@ -1867,7 +2088,7 @@ function createEventsRoute(deps) {
|
|
|
1867
2088
|
let closed = false;
|
|
1868
2089
|
const send = (message) => {
|
|
1869
2090
|
if (closed) return queue;
|
|
1870
|
-
if (message.type === "log" && pending > MAX_PENDING_WRITES) return queue;
|
|
2091
|
+
if (message.type === "log" && pending > MAX_PENDING_WRITES$1) return queue;
|
|
1871
2092
|
pending += 1;
|
|
1872
2093
|
queue = queue.then(() => stream.writeSSE({
|
|
1873
2094
|
event: message.type,
|
|
@@ -1903,11 +2124,11 @@ function createEventsRoute(deps) {
|
|
|
1903
2124
|
});
|
|
1904
2125
|
});
|
|
1905
2126
|
}
|
|
1906
|
-
var MAX_PENDING_WRITES, PING_INTERVAL_MS, eventsQuery;
|
|
2127
|
+
var MAX_PENDING_WRITES$1, PING_INTERVAL_MS, eventsQuery;
|
|
1907
2128
|
var init_events$1 = __esmMin((() => {
|
|
1908
2129
|
init_factory();
|
|
1909
2130
|
init_validator();
|
|
1910
|
-
MAX_PENDING_WRITES = 200;
|
|
2131
|
+
MAX_PENDING_WRITES$1 = 200;
|
|
1911
2132
|
PING_INTERVAL_MS = 15e3;
|
|
1912
2133
|
eventsQuery = type({
|
|
1913
2134
|
/** Only this server's frames. */
|
|
@@ -2287,12 +2508,6 @@ var init_notifications$1 = __esmMin((() => {
|
|
|
2287
2508
|
}));
|
|
2288
2509
|
//#endregion
|
|
2289
2510
|
//#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
2511
|
/** What would have to run to bring `from` up to `to`. */
|
|
2297
2512
|
function planConfigMigrations(from, options = {}) {
|
|
2298
2513
|
const to = options.to ?? 1;
|
|
@@ -2319,6 +2534,24 @@ var init_migrations = __esmMin((() => {
|
|
|
2319
2534
|
}));
|
|
2320
2535
|
//#endregion
|
|
2321
2536
|
//#region src/config/schema.ts
|
|
2537
|
+
function isRecord$2(value) {
|
|
2538
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2539
|
+
}
|
|
2540
|
+
/**
|
|
2541
|
+
* Resolves one entry against the panel's server defaults. A group (`restart`,
|
|
2542
|
+
* `health`, `health.http`, `stop`) merges key by key, so an entry that decides one
|
|
2543
|
+
* member does not silently fall back to the *schema* default for the others — which
|
|
2544
|
+
* is the whole point of the panel having defaults at all.
|
|
2545
|
+
*/
|
|
2546
|
+
function mergeDefaults(defaults, entry) {
|
|
2547
|
+
const merged = { ...entry };
|
|
2548
|
+
for (const [key, value] of Object.entries(defaults)) {
|
|
2549
|
+
const current = merged[key];
|
|
2550
|
+
if (current === void 0) merged[key] = value;
|
|
2551
|
+
else if (isRecord$2(value) && isRecord$2(current)) merged[key] = mergeDefaults(value, current);
|
|
2552
|
+
}
|
|
2553
|
+
return merged;
|
|
2554
|
+
}
|
|
2322
2555
|
var metaSchema, configSchema, CONFIG_KEYS;
|
|
2323
2556
|
var init_schema = __esmMin((() => {
|
|
2324
2557
|
init_contracts();
|
|
@@ -2383,10 +2616,6 @@ var init_version = __esmMin((() => {
|
|
|
2383
2616
|
}));
|
|
2384
2617
|
//#endregion
|
|
2385
2618
|
//#region src/config/parse.ts
|
|
2386
|
-
var parse_exports = /* @__PURE__ */ __exportAll({
|
|
2387
|
-
parseConfig: () => parseConfig,
|
|
2388
|
-
stampConfig: () => stampConfig
|
|
2389
|
-
});
|
|
2390
2619
|
function isRecord$1(value) {
|
|
2391
2620
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2392
2621
|
}
|
|
@@ -2485,10 +2714,7 @@ function parseConfig(raw, options = {}) {
|
|
|
2485
2714
|
const seen = /* @__PURE__ */ new Set();
|
|
2486
2715
|
(Array.isArray(raw.servers) ? raw.servers : []).forEach((entry, index) => {
|
|
2487
2716
|
const label = `servers[${index}]`;
|
|
2488
|
-
const parsed = parseTolerant(isRecord$1(entry) ?
|
|
2489
|
-
...defaults,
|
|
2490
|
-
...entry
|
|
2491
|
-
} : entry, serverSchema, label, unknownKeys);
|
|
2717
|
+
const parsed = parseTolerant(isRecord$1(entry) ? mergeDefaults(defaults, entry) : entry, serverSchema, label, unknownKeys);
|
|
2492
2718
|
if (parsed.error !== null) {
|
|
2493
2719
|
const id = isRecord$1(entry) ? entry.id : void 0;
|
|
2494
2720
|
errors.push(`${label}${typeof id === "string" ? ` ("${id}")` : ""}: ${parsed.error}`);
|
|
@@ -2655,6 +2881,8 @@ var init_store = __esmMin((() => {
|
|
|
2655
2881
|
file;
|
|
2656
2882
|
seed;
|
|
2657
2883
|
raw = {};
|
|
2884
|
+
/** The bytes behind the live config, so a watcher can tell a real edit from our own write. */
|
|
2885
|
+
lastText = null;
|
|
2658
2886
|
resolvedConfig;
|
|
2659
2887
|
error = null;
|
|
2660
2888
|
warnings = [];
|
|
@@ -2703,29 +2931,75 @@ var init_store = __esmMin((() => {
|
|
|
2703
2931
|
}
|
|
2704
2932
|
/**
|
|
2705
2933
|
* Reads the file and tells the listeners, so whatever wrote it — the settings
|
|
2706
|
-
* page,
|
|
2934
|
+
* page, a restored backup, or the file watcher — becomes the live config.
|
|
2707
2935
|
*/
|
|
2708
2936
|
load() {
|
|
2709
2937
|
this.read();
|
|
2710
2938
|
this.notify();
|
|
2711
2939
|
}
|
|
2940
|
+
/**
|
|
2941
|
+
* The watcher's entry point: re-reads the file only when its bytes changed.
|
|
2942
|
+
*
|
|
2943
|
+
* `changed` means the file on disk is different from what the live config was
|
|
2944
|
+
* read from, `applied` means that difference was accepted — a file this release
|
|
2945
|
+
* cannot read is reported and the config already running is left alone, which is
|
|
2946
|
+
* what keeps an editor's typo from stopping every server.
|
|
2947
|
+
*/
|
|
2948
|
+
reloadFromDisk() {
|
|
2949
|
+
let text;
|
|
2950
|
+
try {
|
|
2951
|
+
text = fs.readFileSync(this.file, "utf8");
|
|
2952
|
+
} catch {
|
|
2953
|
+
this.error = `${path.basename(this.file)} is gone`;
|
|
2954
|
+
return {
|
|
2955
|
+
changed: false,
|
|
2956
|
+
applied: false,
|
|
2957
|
+
error: this.error
|
|
2958
|
+
};
|
|
2959
|
+
}
|
|
2960
|
+
if (text === this.lastText) {
|
|
2961
|
+
if (this.error !== null) {
|
|
2962
|
+
this.error = null;
|
|
2963
|
+
this.load();
|
|
2964
|
+
}
|
|
2965
|
+
return {
|
|
2966
|
+
changed: false,
|
|
2967
|
+
applied: false,
|
|
2968
|
+
error: null
|
|
2969
|
+
};
|
|
2970
|
+
}
|
|
2971
|
+
const before = this.resolvedConfig;
|
|
2972
|
+
this.load();
|
|
2973
|
+
return {
|
|
2974
|
+
changed: true,
|
|
2975
|
+
applied: this.resolvedConfig !== before,
|
|
2976
|
+
error: this.error
|
|
2977
|
+
};
|
|
2978
|
+
}
|
|
2712
2979
|
read() {
|
|
2713
2980
|
if (!fs.existsSync(this.file)) {
|
|
2714
2981
|
const seed = stampConfig(structuredClone(this.seed));
|
|
2715
|
-
|
|
2982
|
+
const text = `${JSON.stringify(seed, null, 2)}\n`;
|
|
2983
|
+
writeFileAtomic(this.file, text);
|
|
2984
|
+
this.lastText = text;
|
|
2716
2985
|
this.raw = seed;
|
|
2717
2986
|
this.apply(seed);
|
|
2718
2987
|
return;
|
|
2719
2988
|
}
|
|
2989
|
+
let text;
|
|
2720
2990
|
let parsed;
|
|
2721
2991
|
try {
|
|
2722
|
-
|
|
2992
|
+
text = fs.readFileSync(this.file, "utf8");
|
|
2993
|
+
parsed = JSON.parse(text);
|
|
2723
2994
|
} catch (error) {
|
|
2724
2995
|
this.error = `cannot parse ${path.basename(this.file)}: ${error.message}`;
|
|
2725
|
-
this.
|
|
2726
|
-
|
|
2996
|
+
if (this.resolvedConfig === void 0) {
|
|
2997
|
+
this.raw = {};
|
|
2998
|
+
this.resolvedConfig = this.resolveFallback();
|
|
2999
|
+
}
|
|
2727
3000
|
return;
|
|
2728
3001
|
}
|
|
3002
|
+
this.lastText = text;
|
|
2729
3003
|
this.apply(parsed);
|
|
2730
3004
|
}
|
|
2731
3005
|
notify() {
|
|
@@ -2818,10 +3092,7 @@ var init_store = __esmMin((() => {
|
|
|
2818
3092
|
if ((fs.existsSync(configSchemaPath) ? fs.readFileSync(configSchemaPath, "utf8") : null) !== schema) writeFileAtomic(configSchemaPath, schema);
|
|
2819
3093
|
}
|
|
2820
3094
|
validateServer(entry, label) {
|
|
2821
|
-
const parsed = serverSchema(
|
|
2822
|
-
...this.defaults,
|
|
2823
|
-
...entry
|
|
2824
|
-
});
|
|
3095
|
+
const parsed = serverSchema(mergeDefaults(this.defaults, entry));
|
|
2825
3096
|
if (parsed instanceof type.errors) throw new ConfigError(`${label}: ${formatErrors(parsed)}`);
|
|
2826
3097
|
return {
|
|
2827
3098
|
...parsed,
|
|
@@ -2830,7 +3101,9 @@ var init_store = __esmMin((() => {
|
|
|
2830
3101
|
}
|
|
2831
3102
|
commit(draft) {
|
|
2832
3103
|
const stamped = stampConfig(draft);
|
|
2833
|
-
|
|
3104
|
+
const text = `${JSON.stringify(stamped, null, 2)}\n`;
|
|
3105
|
+
writeFileAtomic(this.file, text);
|
|
3106
|
+
this.lastText = text;
|
|
2834
3107
|
this.raw = stamped;
|
|
2835
3108
|
this.apply(stamped);
|
|
2836
3109
|
this.notify();
|
|
@@ -2968,14 +3241,19 @@ function createServersRoute(deps) {
|
|
|
2968
3241
|
if (!deps.store.getServer(id)) throw unknownServer(id);
|
|
2969
3242
|
return streamSSE(c, async (stream) => {
|
|
2970
3243
|
let closed = false;
|
|
3244
|
+
let pending = 0;
|
|
2971
3245
|
let queue = Promise.resolve();
|
|
2972
3246
|
const send = (data, event) => {
|
|
2973
3247
|
if (closed) return;
|
|
3248
|
+
if (event === "log" && pending > MAX_PENDING_WRITES) return;
|
|
3249
|
+
pending += 1;
|
|
2974
3250
|
queue = queue.then(() => stream.writeSSE({
|
|
2975
3251
|
event,
|
|
2976
3252
|
data
|
|
2977
3253
|
})).catch(() => {
|
|
2978
3254
|
closed = true;
|
|
3255
|
+
}).finally(() => {
|
|
3256
|
+
pending -= 1;
|
|
2979
3257
|
});
|
|
2980
3258
|
};
|
|
2981
3259
|
const unsubscribe = deps.hub.subscribe(id, (message) => {
|
|
@@ -3112,7 +3390,7 @@ function createServersRoute(deps) {
|
|
|
3112
3390
|
}
|
|
3113
3391
|
});
|
|
3114
3392
|
}
|
|
3115
|
-
var idParam, serverResponse, serversResponse, okResponse;
|
|
3393
|
+
var idParam, MAX_PENDING_WRITES, serverResponse, serversResponse, okResponse;
|
|
3116
3394
|
var init_$_routes = __esmMin((() => {
|
|
3117
3395
|
init_store();
|
|
3118
3396
|
init_factory();
|
|
@@ -3120,6 +3398,7 @@ var init_$_routes = __esmMin((() => {
|
|
|
3120
3398
|
init_validator();
|
|
3121
3399
|
init_contracts();
|
|
3122
3400
|
idParam = type({ id: "string >= 1" });
|
|
3401
|
+
MAX_PENDING_WRITES = 200;
|
|
3123
3402
|
serverResponse = type({ server: serverViewSchema });
|
|
3124
3403
|
serversResponse = type({ servers: serverViewSchema.array() });
|
|
3125
3404
|
okResponse = type({ ok: "boolean" });
|
|
@@ -3132,7 +3411,14 @@ function sanitizeName(name) {
|
|
|
3132
3411
|
return cleaned.length > 0 ? cleaned.slice(0, 60) : "custom-ui";
|
|
3133
3412
|
}
|
|
3134
3413
|
function createSettingsRoute(deps) {
|
|
3135
|
-
return appFactory.createApp().get("/settings", (
|
|
3414
|
+
return appFactory.createApp().get("/settings", describeRoute({
|
|
3415
|
+
tags: ["panel"],
|
|
3416
|
+
summary: "The panel, server defaults, logs, notifications, host and backups",
|
|
3417
|
+
responses: { 200: {
|
|
3418
|
+
description: "The settings",
|
|
3419
|
+
content: jsonBody(settingsViewSchema)
|
|
3420
|
+
} }
|
|
3421
|
+
}), (c) => c.json({
|
|
3136
3422
|
control: buildControlView(deps.store, deps.auth, deps.controlServer.endpoint, deps.tls),
|
|
3137
3423
|
defaults: deps.store.defaults,
|
|
3138
3424
|
logs: deps.store.config.logs,
|
|
@@ -3144,7 +3430,10 @@ function createSettingsRoute(deps) {
|
|
|
3144
3430
|
tags: ["panel"],
|
|
3145
3431
|
summary: "Edit the panel, server defaults, logs, notifications, host and backups",
|
|
3146
3432
|
responses: {
|
|
3147
|
-
200: {
|
|
3433
|
+
200: {
|
|
3434
|
+
description: "Saved; the listener may be moving",
|
|
3435
|
+
content: jsonBody(settingsSavedSchema)
|
|
3436
|
+
},
|
|
3148
3437
|
400: ERROR_RESPONSES[400]
|
|
3149
3438
|
}
|
|
3150
3439
|
}), validate("json", settingsPatchSchema), async (c) => {
|
|
@@ -3736,11 +4025,12 @@ async function isPortFree(port, host = "127.0.0.1", timeoutMs = 1e3) {
|
|
|
3736
4025
|
return !await probePort(host, port, timeoutMs);
|
|
3737
4026
|
}
|
|
3738
4027
|
/**
|
|
3739
|
-
* Kills whatever holds the port
|
|
3740
|
-
*
|
|
4028
|
+
* Kills whatever holds the port, minus `exclude` (the panel's own process tree).
|
|
4029
|
+
* Only used as a last resort for wrappers that spawn their real server detached,
|
|
4030
|
+
* where a process-group signal cannot reach it.
|
|
3741
4031
|
*/
|
|
3742
|
-
async function killPortHolders(port) {
|
|
3743
|
-
const pids = await listPortHolders(port);
|
|
4032
|
+
async function killPortHolders(port, exclude) {
|
|
4033
|
+
const pids = (await listPortHolders(port)).filter((pid) => !exclude?.has(pid));
|
|
3744
4034
|
for (const pid of pids) try {
|
|
3745
4035
|
process.kill(pid, "SIGKILL");
|
|
3746
4036
|
} catch {}
|
|
@@ -3760,7 +4050,7 @@ function signalPid$1(pid, name) {
|
|
|
3760
4050
|
process.kill(pid, name);
|
|
3761
4051
|
} catch {}
|
|
3762
4052
|
}
|
|
3763
|
-
function delay$
|
|
4053
|
+
function delay$2(ms) {
|
|
3764
4054
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3765
4055
|
}
|
|
3766
4056
|
/**
|
|
@@ -3775,12 +4065,12 @@ async function terminatePids(pids, options = {}) {
|
|
|
3775
4065
|
const deadline = Date.now() + graceMs;
|
|
3776
4066
|
let alive = aliveBefore.filter(isProcessAlive);
|
|
3777
4067
|
while (alive.length > 0 && Date.now() < deadline) {
|
|
3778
|
-
await delay$
|
|
4068
|
+
await delay$2(100);
|
|
3779
4069
|
alive = alive.filter(isProcessAlive);
|
|
3780
4070
|
}
|
|
3781
4071
|
const stopped = aliveBefore.filter((pid) => !alive.includes(pid));
|
|
3782
4072
|
for (const pid of alive) signalPid$1(pid, "SIGKILL");
|
|
3783
|
-
if (alive.length > 0 && graceMs > 0) await delay$
|
|
4073
|
+
if (alive.length > 0 && graceMs > 0) await delay$2(150);
|
|
3784
4074
|
return {
|
|
3785
4075
|
stopped,
|
|
3786
4076
|
forced: alive
|
|
@@ -4421,8 +4711,9 @@ var init_proc = __esmMin((() => {
|
|
|
4421
4711
|
*/
|
|
4422
4712
|
function resolveCommand(command, ...searchDirs) {
|
|
4423
4713
|
if (command.includes("/") || command.includes("\\")) return command;
|
|
4424
|
-
|
|
4425
|
-
|
|
4714
|
+
const candidates = process.platform === "win32" && path.extname(command) === "" ? [command, ...WINDOWS_SHIM_EXTENSIONS.map((extension) => `${command}${extension}`)] : [command];
|
|
4715
|
+
for (const dir of searchDirs) for (const candidate of candidates) {
|
|
4716
|
+
const local = path.join(dir, "node_modules", ".bin", candidate);
|
|
4426
4717
|
if (fs.existsSync(local)) return local;
|
|
4427
4718
|
}
|
|
4428
4719
|
return command;
|
|
@@ -4431,6 +4722,10 @@ function resolveCommand(command, ...searchDirs) {
|
|
|
4431
4722
|
function resolveCwd(cwd, base = projectDir) {
|
|
4432
4723
|
return path.resolve(base, cwd);
|
|
4433
4724
|
}
|
|
4725
|
+
/** Node cannot spawn a Windows `.cmd`/`.bat` shim without a shell (EINVAL). */
|
|
4726
|
+
function needsShell(command) {
|
|
4727
|
+
return process.platform === "win32" && /\.(?:cmd|bat)$/i.test(command);
|
|
4728
|
+
}
|
|
4434
4729
|
function spawnManaged(spec) {
|
|
4435
4730
|
return spawn(spec.command, spec.args, {
|
|
4436
4731
|
cwd: spec.cwd,
|
|
@@ -4444,6 +4739,7 @@ function spawnManaged(spec) {
|
|
|
4444
4739
|
"pipe",
|
|
4445
4740
|
"pipe"
|
|
4446
4741
|
],
|
|
4742
|
+
shell: needsShell(spec.command),
|
|
4447
4743
|
windowsHide: true
|
|
4448
4744
|
});
|
|
4449
4745
|
}
|
|
@@ -4509,14 +4805,14 @@ async function terminatePid(pid, options) {
|
|
|
4509
4805
|
if (!alive(pid)) return "exited";
|
|
4510
4806
|
signalPid(pid, options.signal, options.killGroup);
|
|
4511
4807
|
const deadline = Date.now() + Math.max(0, options.graceMs);
|
|
4512
|
-
while (Date.now() < deadline && alive(pid)) await delay$
|
|
4808
|
+
while (Date.now() < deadline && alive(pid)) await delay$1(50);
|
|
4513
4809
|
if (!alive(pid)) return "exited";
|
|
4514
4810
|
signalPid(pid, "SIGKILL", options.killGroup);
|
|
4515
4811
|
const hardDeadline = Date.now() + 2e3;
|
|
4516
|
-
while (Date.now() < hardDeadline && alive(pid)) await delay$
|
|
4812
|
+
while (Date.now() < hardDeadline && alive(pid)) await delay$1(50);
|
|
4517
4813
|
return "force-killed";
|
|
4518
4814
|
}
|
|
4519
|
-
function delay$
|
|
4815
|
+
function delay$1(ms) {
|
|
4520
4816
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
4521
4817
|
}
|
|
4522
4818
|
function waitForExit$1(child, timeoutMs) {
|
|
@@ -4534,10 +4830,16 @@ function waitForExit$1(child, timeoutMs) {
|
|
|
4534
4830
|
child.once("exit", onExit);
|
|
4535
4831
|
});
|
|
4536
4832
|
}
|
|
4537
|
-
var execFileAsync$1;
|
|
4833
|
+
var execFileAsync$1, WINDOWS_SHIM_EXTENSIONS;
|
|
4538
4834
|
var init_process = __esmMin((() => {
|
|
4539
4835
|
init_paths();
|
|
4540
4836
|
execFileAsync$1 = promisify(execFile);
|
|
4837
|
+
WINDOWS_SHIM_EXTENSIONS = [
|
|
4838
|
+
".cmd",
|
|
4839
|
+
".exe",
|
|
4840
|
+
".bat",
|
|
4841
|
+
".ps1"
|
|
4842
|
+
];
|
|
4541
4843
|
}));
|
|
4542
4844
|
//#endregion
|
|
4543
4845
|
//#region src/services/dependencies.ts
|
|
@@ -4584,8 +4886,9 @@ function dependenciesOf(server, servers) {
|
|
|
4584
4886
|
var init_dependencies = __esmMin((() => {}));
|
|
4585
4887
|
//#endregion
|
|
4586
4888
|
//#region src/services/log-buffer.ts
|
|
4587
|
-
var LogBuffer, LineSplitter;
|
|
4889
|
+
var MAX_PENDING_CHARS, LogBuffer, LineSplitter;
|
|
4588
4890
|
var init_log_buffer = __esmMin((() => {
|
|
4891
|
+
MAX_PENDING_CHARS = 65536;
|
|
4589
4892
|
LogBuffer = class {
|
|
4590
4893
|
capacity;
|
|
4591
4894
|
lines = [];
|
|
@@ -4621,17 +4924,21 @@ var init_log_buffer = __esmMin((() => {
|
|
|
4621
4924
|
const parts = this.pending.split("\n");
|
|
4622
4925
|
this.pending = parts.pop() ?? "";
|
|
4623
4926
|
for (const part of parts) this.emit(stream, part.replace(/\r$/, ""));
|
|
4927
|
+
while (this.pending.length > MAX_PENDING_CHARS) {
|
|
4928
|
+
this.emit(stream, this.pending.slice(0, MAX_PENDING_CHARS));
|
|
4929
|
+
this.pending = this.pending.slice(MAX_PENDING_CHARS);
|
|
4930
|
+
}
|
|
4624
4931
|
}
|
|
4625
4932
|
flush(stream) {
|
|
4626
4933
|
if (this.pending.length === 0) return;
|
|
4627
|
-
this.emit(stream, this.pending);
|
|
4934
|
+
this.emit(stream, this.pending.replace(/\r$/, ""));
|
|
4628
4935
|
this.pending = "";
|
|
4629
4936
|
}
|
|
4630
4937
|
};
|
|
4631
4938
|
}));
|
|
4632
4939
|
//#endregion
|
|
4633
4940
|
//#region src/services/supervisor.ts
|
|
4634
|
-
function delay
|
|
4941
|
+
function delay(ms) {
|
|
4635
4942
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
4636
4943
|
}
|
|
4637
4944
|
/** The placeholder set every server entry can use; shared with path resolvers. */
|
|
@@ -4685,7 +4992,9 @@ var init_supervisor = __esmMin((() => {
|
|
|
4685
4992
|
this.options = options;
|
|
4686
4993
|
this.sync();
|
|
4687
4994
|
this.store.onChange(() => this.sync());
|
|
4688
|
-
this.tickTimer = setInterval(() =>
|
|
4995
|
+
this.tickTimer = setInterval(() => {
|
|
4996
|
+
this.tick().catch((error) => logger.error("the supervisor tick failed", error));
|
|
4997
|
+
}, TICK_INTERVAL_MS);
|
|
4689
4998
|
this.tickTimer.unref();
|
|
4690
4999
|
}
|
|
4691
5000
|
getState() {
|
|
@@ -4857,7 +5166,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
4857
5166
|
return (await Promise.all(this.occupancyHosts(entry).map((host) => isPortFree(port, host)))).every(Boolean);
|
|
4858
5167
|
};
|
|
4859
5168
|
if (await freeOnAll()) return true;
|
|
4860
|
-
await delay
|
|
5169
|
+
await delay(PORT_RELEASE_RECHECK_MS);
|
|
4861
5170
|
return freeOnAll();
|
|
4862
5171
|
}
|
|
4863
5172
|
clearLogs(id) {
|
|
@@ -4890,7 +5199,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
4890
5199
|
if (this.statusOf(target) !== "running") return false;
|
|
4891
5200
|
return target.health !== "unhealthy";
|
|
4892
5201
|
};
|
|
4893
|
-
while (!isReady() && Date.now() < deadline) await delay
|
|
5202
|
+
while (!isReady() && Date.now() < deadline) await delay(250);
|
|
4894
5203
|
if (!isReady()) {
|
|
4895
5204
|
const status = this.statusOf(target);
|
|
4896
5205
|
this.log(entry, "system", `dependency "${dependency.id}" is ${status}/${target.health} — starting anyway`);
|
|
@@ -4932,6 +5241,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
4932
5241
|
entry.nextRetryAt = null;
|
|
4933
5242
|
entry.stopping = true;
|
|
4934
5243
|
if (entry.child === null && entry.pid === null) {
|
|
5244
|
+
entry.stopping = false;
|
|
4935
5245
|
entry.status = "stopped";
|
|
4936
5246
|
this.publishServer(entry);
|
|
4937
5247
|
return { ok: true };
|
|
@@ -4941,11 +5251,8 @@ var init_supervisor = __esmMin((() => {
|
|
|
4941
5251
|
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
5252
|
const { port, stop } = entry.config;
|
|
4943
5253
|
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
|
-
}
|
|
5254
|
+
const leftover = await killPortHolders(port, this.supervisedPids());
|
|
5255
|
+
if (leftover.length > 0) this.log(entry, "system", `port ${port} was still held by pid ${leftover.join(", ")} — killed`);
|
|
4949
5256
|
}
|
|
4950
5257
|
entry.stopping = false;
|
|
4951
5258
|
entry.child = null;
|
|
@@ -4993,7 +5300,9 @@ var init_supervisor = __esmMin((() => {
|
|
|
4993
5300
|
const config = wanted.get(id);
|
|
4994
5301
|
if (!config) {
|
|
4995
5302
|
this.entries.delete(id);
|
|
4996
|
-
this.stopEntry(entry)
|
|
5303
|
+
this.stopEntry(entry).catch((error) => {
|
|
5304
|
+
logger.error(`could not stop the removed server ${id}`, error);
|
|
5305
|
+
});
|
|
4997
5306
|
continue;
|
|
4998
5307
|
}
|
|
4999
5308
|
const bufferChanged = entry.config.logBufferLines !== config.logBufferLines;
|
|
@@ -5003,7 +5312,9 @@ var init_supervisor = __esmMin((() => {
|
|
|
5003
5312
|
entry.logs = new LogBuffer(config.logBufferLines);
|
|
5004
5313
|
entry.logs.extend(kept);
|
|
5005
5314
|
}
|
|
5006
|
-
if (!config.enabled && this.isActive(entry)) this.stopEntry(entry)
|
|
5315
|
+
if (!config.enabled && this.isActive(entry)) this.stopEntry(entry).catch((error) => {
|
|
5316
|
+
logger.error(`could not stop the disabled server ${id}`, error);
|
|
5317
|
+
});
|
|
5007
5318
|
}
|
|
5008
5319
|
for (const [id, config] of wanted) if (!this.entries.has(id)) this.entries.set(id, this.createEntry(config));
|
|
5009
5320
|
this.publishState();
|
|
@@ -5051,7 +5362,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
5051
5362
|
};
|
|
5052
5363
|
let free = await freeOnAll();
|
|
5053
5364
|
if (!free) {
|
|
5054
|
-
await delay
|
|
5365
|
+
await delay(PORT_RELEASE_RECHECK_MS);
|
|
5055
5366
|
free = await freeOnAll();
|
|
5056
5367
|
}
|
|
5057
5368
|
entry.portState = free ? "free" : "in-use";
|
|
@@ -5067,7 +5378,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
5067
5378
|
this.log(entry, "system", `port ${port} is held by pid ${own.join(", ")}, a detached restart of this entry — replacing it with a supervised process`);
|
|
5068
5379
|
const { forced } = await terminatePids(own);
|
|
5069
5380
|
if (forced.length > 0) this.log(entry, "system", `pid ${forced.join(", ")} ignored SIGTERM and was killed`);
|
|
5070
|
-
await delay
|
|
5381
|
+
await delay(PORT_RELEASE_RECHECK_MS);
|
|
5071
5382
|
if (await freeOnAll()) {
|
|
5072
5383
|
entry.portState = "free";
|
|
5073
5384
|
return { kind: "free" };
|
|
@@ -5289,7 +5600,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
5289
5600
|
this.publishServer(entry);
|
|
5290
5601
|
return;
|
|
5291
5602
|
}
|
|
5292
|
-
await delay
|
|
5603
|
+
await delay(300);
|
|
5293
5604
|
}
|
|
5294
5605
|
if (entry.child === child && entry.status === "starting") {
|
|
5295
5606
|
entry.status = "running";
|
|
@@ -5342,7 +5653,9 @@ var init_supervisor = __esmMin((() => {
|
|
|
5342
5653
|
this.log(entry, "system", `restart ${entry.restarts}/${restart.maxRetries} in ${backoffMs}ms`);
|
|
5343
5654
|
entry.retryTimer = setTimeout(() => {
|
|
5344
5655
|
entry.retryTimer = null;
|
|
5345
|
-
this.start(entry.config.id, { retry: true })
|
|
5656
|
+
this.start(entry.config.id, { retry: true }).catch((error) => {
|
|
5657
|
+
logger.error(`could not restart ${entry.config.id}`, error);
|
|
5658
|
+
});
|
|
5346
5659
|
}, backoffMs);
|
|
5347
5660
|
entry.retryTimer.unref();
|
|
5348
5661
|
} else {
|
|
@@ -5375,7 +5688,9 @@ var init_supervisor = __esmMin((() => {
|
|
|
5375
5688
|
await this.restart(entry.config.id);
|
|
5376
5689
|
continue;
|
|
5377
5690
|
}
|
|
5378
|
-
if (entry.status === "backoff" && entry.nextRetryAt !== null && now >= entry.nextRetryAt && entry.retryTimer === null) this.start(entry.config.id, { retry: true })
|
|
5691
|
+
if (entry.status === "backoff" && entry.nextRetryAt !== null && now >= entry.nextRetryAt && entry.retryTimer === null) this.start(entry.config.id, { retry: true }).catch((error) => {
|
|
5692
|
+
logger.error(`could not restart ${entry.config.id}`, error);
|
|
5693
|
+
});
|
|
5379
5694
|
}
|
|
5380
5695
|
this.publishState();
|
|
5381
5696
|
}
|
|
@@ -5549,6 +5864,69 @@ var init_supervisor = __esmMin((() => {
|
|
|
5549
5864
|
};
|
|
5550
5865
|
}));
|
|
5551
5866
|
//#endregion
|
|
5867
|
+
//#region src/shared/generated.ts
|
|
5868
|
+
/**
|
|
5869
|
+
* True when a path *relative to the declared root* looks generated. Accepts
|
|
5870
|
+
* either separator, so a `path.relative` result can be passed straight in.
|
|
5871
|
+
*/
|
|
5872
|
+
function isGeneratedPath(relative) {
|
|
5873
|
+
const segments = relative.split(/[\\/]+/).filter((segment) => segment.length > 0 && segment !== ".");
|
|
5874
|
+
if (segments.length === 0) return false;
|
|
5875
|
+
if (FILE_NAME_SET.has(segments[segments.length - 1])) return true;
|
|
5876
|
+
return segments.some((segment) => DIR_NAME_SET.has(segment));
|
|
5877
|
+
}
|
|
5878
|
+
var GENERATED_DIRS, GENERATED_FILES, DIR_NAME_SET, FILE_NAME_SET;
|
|
5879
|
+
var init_generated = __esmMin((() => {
|
|
5880
|
+
GENERATED_DIRS = [
|
|
5881
|
+
"node_modules",
|
|
5882
|
+
"bower_components",
|
|
5883
|
+
"jspm_packages",
|
|
5884
|
+
".yarn",
|
|
5885
|
+
".pnpm",
|
|
5886
|
+
".pnpm-store",
|
|
5887
|
+
".npm",
|
|
5888
|
+
".next",
|
|
5889
|
+
".nuxt",
|
|
5890
|
+
".svelte-kit",
|
|
5891
|
+
".astro",
|
|
5892
|
+
".output",
|
|
5893
|
+
".vercel",
|
|
5894
|
+
".netlify",
|
|
5895
|
+
".angular",
|
|
5896
|
+
"dist",
|
|
5897
|
+
"build",
|
|
5898
|
+
"out",
|
|
5899
|
+
".cache",
|
|
5900
|
+
".parcel-cache",
|
|
5901
|
+
".turbo",
|
|
5902
|
+
".vite",
|
|
5903
|
+
".rollup.cache",
|
|
5904
|
+
".swc",
|
|
5905
|
+
"coverage",
|
|
5906
|
+
".nyc_output",
|
|
5907
|
+
"__pycache__",
|
|
5908
|
+
".pytest_cache",
|
|
5909
|
+
".mypy_cache",
|
|
5910
|
+
".ruff_cache",
|
|
5911
|
+
".tox",
|
|
5912
|
+
"target",
|
|
5913
|
+
".gradle",
|
|
5914
|
+
".dart_tool"
|
|
5915
|
+
];
|
|
5916
|
+
GENERATED_FILES = [
|
|
5917
|
+
".DS_Store",
|
|
5918
|
+
"Thumbs.db",
|
|
5919
|
+
"desktop.ini",
|
|
5920
|
+
".eslintcache",
|
|
5921
|
+
".stylelintcache",
|
|
5922
|
+
"npm-debug.log",
|
|
5923
|
+
"yarn-error.log",
|
|
5924
|
+
"pnpm-debug.log"
|
|
5925
|
+
];
|
|
5926
|
+
DIR_NAME_SET = new Set(GENERATED_DIRS);
|
|
5927
|
+
FILE_NAME_SET = new Set(GENERATED_FILES);
|
|
5928
|
+
}));
|
|
5929
|
+
//#endregion
|
|
5552
5930
|
//#region src/services/backups.ts
|
|
5553
5931
|
/**
|
|
5554
5932
|
* Structural allowlist for archive entries. A regex alone is not enough: `..`
|
|
@@ -5584,21 +5962,23 @@ function resolveBackupPaths(servers, includePaths = []) {
|
|
|
5584
5962
|
dataRoot,
|
|
5585
5963
|
home: os.homedir()
|
|
5586
5964
|
};
|
|
5587
|
-
const add = (value, origin, vars) => {
|
|
5965
|
+
const add = (value, origin, vars, ignoreGenerated) => {
|
|
5588
5966
|
if (value.trim().length === 0) return;
|
|
5589
5967
|
const resolved = path.normalize(resolveUserPath(expandEnv(resolveTemplate(value, vars), process.env)));
|
|
5590
5968
|
declared.push({
|
|
5591
5969
|
path: resolved,
|
|
5592
5970
|
origin,
|
|
5593
5971
|
depth: path.normalize(resolved).split(path.sep).filter(Boolean).length,
|
|
5594
|
-
order: declared.length
|
|
5972
|
+
order: declared.length,
|
|
5973
|
+
ignoreGenerated
|
|
5595
5974
|
});
|
|
5596
5975
|
};
|
|
5597
|
-
for (const value of includePaths) add(value, "global", globalVars);
|
|
5976
|
+
for (const value of includePaths) add(value, "global", globalVars, false);
|
|
5598
5977
|
for (const config of servers) {
|
|
5599
5978
|
const vars = serverTemplateVars(config);
|
|
5600
|
-
|
|
5601
|
-
for (const value of config.
|
|
5979
|
+
const ignoreGenerated = config.backupIgnoreGenerated !== false;
|
|
5980
|
+
for (const [name, value] of Object.entries(config.dataEnvs)) add(value, `${config.id}:${name}`, vars, ignoreGenerated);
|
|
5981
|
+
for (const value of config.backupPaths) add(value, `${config.id}:backupPaths`, vars, ignoreGenerated);
|
|
5602
5982
|
}
|
|
5603
5983
|
const sorted = [...declared].sort((a, b) => a.depth - b.depth || a.order - b.order);
|
|
5604
5984
|
return sorted.map((entry, index) => {
|
|
@@ -5607,14 +5987,16 @@ function resolveBackupPaths(servers, includePaths = []) {
|
|
|
5607
5987
|
path: entry.path,
|
|
5608
5988
|
origin: entry.origin,
|
|
5609
5989
|
included: true,
|
|
5610
|
-
note: null
|
|
5990
|
+
note: null,
|
|
5991
|
+
ignoreGenerated: entry.ignoreGenerated
|
|
5611
5992
|
};
|
|
5612
5993
|
const note = parent.path === entry.path ? `already declared by ${parent.origin}` : `covered by ${parent.path}`;
|
|
5613
5994
|
return {
|
|
5614
5995
|
path: entry.path,
|
|
5615
5996
|
origin: entry.origin,
|
|
5616
5997
|
included: false,
|
|
5617
|
-
note
|
|
5998
|
+
note,
|
|
5999
|
+
ignoreGenerated: entry.ignoreGenerated
|
|
5618
6000
|
};
|
|
5619
6001
|
});
|
|
5620
6002
|
}
|
|
@@ -5684,6 +6066,7 @@ var init_backups = __esmMin((() => {
|
|
|
5684
6066
|
init_template();
|
|
5685
6067
|
init_archive();
|
|
5686
6068
|
init_supervisor();
|
|
6069
|
+
init_generated();
|
|
5687
6070
|
MANIFEST = "manifest.json";
|
|
5688
6071
|
ALLOWED_ROOTS = /* @__PURE__ */ new Set([
|
|
5689
6072
|
"config",
|
|
@@ -5770,7 +6153,7 @@ var init_backups = __esmMin((() => {
|
|
|
5770
6153
|
if (!declared.included || !fs.existsSync(declared.path)) continue;
|
|
5771
6154
|
const slug = slugifyPath(declared.path);
|
|
5772
6155
|
if (data.some((entry) => entry.slug === slug)) continue;
|
|
5773
|
-
this.copyInto(staging, path.join("data", slug), declared.path);
|
|
6156
|
+
this.copyInto(staging, path.join("data", slug), declared.path, declared.ignoreGenerated === true);
|
|
5774
6157
|
data.push({
|
|
5775
6158
|
slug,
|
|
5776
6159
|
path: declared.path,
|
|
@@ -6080,15 +6463,21 @@ var init_backups = __esmMin((() => {
|
|
|
6080
6463
|
const present = new Set(listed.map((file) => file.name));
|
|
6081
6464
|
for (const name of [...this.flags.keys()]) if (!present.has(name)) this.flags.delete(name);
|
|
6082
6465
|
}
|
|
6083
|
-
copyInto(staging, relative, source) {
|
|
6466
|
+
copyInto(staging, relative, source, ignoreGenerated = false) {
|
|
6084
6467
|
if (!fs.existsSync(source)) return;
|
|
6085
6468
|
const target = path.join(staging, relative);
|
|
6086
6469
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
6087
6470
|
const archiveDir = path.resolve(this.resolveDir());
|
|
6471
|
+
const root = path.resolve(source);
|
|
6088
6472
|
fs.cpSync(source, target, {
|
|
6089
6473
|
recursive: true,
|
|
6090
6474
|
force: true,
|
|
6091
|
-
filter: (from) =>
|
|
6475
|
+
filter: (from) => {
|
|
6476
|
+
const resolved = path.resolve(from);
|
|
6477
|
+
if (isInside(archiveDir, resolved)) return false;
|
|
6478
|
+
if (!ignoreGenerated || resolved === root) return true;
|
|
6479
|
+
return !isGeneratedPath(path.relative(root, resolved));
|
|
6480
|
+
}
|
|
6092
6481
|
});
|
|
6093
6482
|
}
|
|
6094
6483
|
prune() {
|
|
@@ -6102,6 +6491,76 @@ var init_backups = __esmMin((() => {
|
|
|
6102
6491
|
};
|
|
6103
6492
|
}));
|
|
6104
6493
|
//#endregion
|
|
6494
|
+
//#region src/services/config-watch.ts
|
|
6495
|
+
var DEFAULT_DEBOUNCE_MS, DEFAULT_POLL_MS, ConfigWatch;
|
|
6496
|
+
var init_config_watch = __esmMin((() => {
|
|
6497
|
+
DEFAULT_DEBOUNCE_MS = 150;
|
|
6498
|
+
DEFAULT_POLL_MS = 2e3;
|
|
6499
|
+
ConfigWatch = class {
|
|
6500
|
+
options;
|
|
6501
|
+
debounceMs;
|
|
6502
|
+
pollMs;
|
|
6503
|
+
watcher = null;
|
|
6504
|
+
debounceTimer = null;
|
|
6505
|
+
pollTimer = null;
|
|
6506
|
+
disposed = false;
|
|
6507
|
+
constructor(options) {
|
|
6508
|
+
this.options = options;
|
|
6509
|
+
this.debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
|
|
6510
|
+
this.pollMs = options.pollMs ?? DEFAULT_POLL_MS;
|
|
6511
|
+
}
|
|
6512
|
+
start() {
|
|
6513
|
+
if (this.disposed || this.watcher !== null) return;
|
|
6514
|
+
try {
|
|
6515
|
+
this.watcher = fs.watch(path.dirname(this.options.file), (_event, filename) => {
|
|
6516
|
+
if (filename !== null && filename !== path.basename(this.options.file)) return;
|
|
6517
|
+
this.schedule();
|
|
6518
|
+
});
|
|
6519
|
+
this.watcher.on("error", (error) => {
|
|
6520
|
+
this.options.onError?.(error);
|
|
6521
|
+
this.closeWatcher();
|
|
6522
|
+
});
|
|
6523
|
+
this.watcher.unref();
|
|
6524
|
+
} catch (error) {
|
|
6525
|
+
this.options.onError?.(error);
|
|
6526
|
+
}
|
|
6527
|
+
if (this.pollMs > 0) {
|
|
6528
|
+
this.pollTimer = setInterval(() => this.check(), this.pollMs);
|
|
6529
|
+
this.pollTimer.unref();
|
|
6530
|
+
}
|
|
6531
|
+
}
|
|
6532
|
+
/** One check, exactly what the poll does — tests drive this instead of waiting. */
|
|
6533
|
+
check() {
|
|
6534
|
+
this.schedule();
|
|
6535
|
+
}
|
|
6536
|
+
dispose() {
|
|
6537
|
+
this.disposed = true;
|
|
6538
|
+
if (this.debounceTimer !== null) {
|
|
6539
|
+
clearTimeout(this.debounceTimer);
|
|
6540
|
+
this.debounceTimer = null;
|
|
6541
|
+
}
|
|
6542
|
+
if (this.pollTimer !== null) {
|
|
6543
|
+
clearInterval(this.pollTimer);
|
|
6544
|
+
this.pollTimer = null;
|
|
6545
|
+
}
|
|
6546
|
+
this.closeWatcher();
|
|
6547
|
+
}
|
|
6548
|
+
/** One reload per burst: an editor writing in pieces is still one edit. */
|
|
6549
|
+
schedule() {
|
|
6550
|
+
if (this.disposed || this.debounceTimer !== null) return;
|
|
6551
|
+
this.debounceTimer = setTimeout(() => {
|
|
6552
|
+
this.debounceTimer = null;
|
|
6553
|
+
if (!this.disposed) this.options.onChange();
|
|
6554
|
+
}, this.debounceMs);
|
|
6555
|
+
this.debounceTimer.unref();
|
|
6556
|
+
}
|
|
6557
|
+
closeWatcher() {
|
|
6558
|
+
this.watcher?.close();
|
|
6559
|
+
this.watcher = null;
|
|
6560
|
+
}
|
|
6561
|
+
};
|
|
6562
|
+
}));
|
|
6563
|
+
//#endregion
|
|
6105
6564
|
//#region src/services/control-server.ts
|
|
6106
6565
|
var ControlServer;
|
|
6107
6566
|
var init_control_server = __esmMin((() => {
|
|
@@ -6804,7 +7263,7 @@ var init_notifications = __esmMin((() => {
|
|
|
6804
7263
|
ok: false,
|
|
6805
7264
|
error: "no bot token configured"
|
|
6806
7265
|
};
|
|
6807
|
-
const result = await sendTelegramMessage(token, chatId, formatTelegramMessage("✅ home-hosted
|
|
7266
|
+
const result = await sendTelegramMessage(token, chatId, formatTelegramMessage("✅ home-hosted test", ["notifications are wired up correctly"]));
|
|
6808
7267
|
this.remember(result.ok ? "test message sent" : result.error ?? "test failed");
|
|
6809
7268
|
return result;
|
|
6810
7269
|
}
|
|
@@ -7341,12 +7800,50 @@ async function runControlPlane(options) {
|
|
|
7341
7800
|
notifications,
|
|
7342
7801
|
hostMonitor
|
|
7343
7802
|
});
|
|
7803
|
+
/**
|
|
7804
|
+
* A config edited by hand — a text editor, a `git checkout`, a config-management
|
|
7805
|
+
* tool — is picked up without a restart. A revision this release cannot read is
|
|
7806
|
+
* reported in the state frame and the panel keeps running what it had, so a typo
|
|
7807
|
+
* never stops a server. A definition that *did* change takes effect on that
|
|
7808
|
+
* entry's next start; a newly added entry with `autostart` starts now, the way it
|
|
7809
|
+
* would after a restart, and a removed one is stopped and forgotten.
|
|
7810
|
+
*/
|
|
7811
|
+
let lastConfigError = store.configError;
|
|
7812
|
+
const configWatch = new ConfigWatch({
|
|
7813
|
+
file: store.path,
|
|
7814
|
+
onChange: () => {
|
|
7815
|
+
const before = new Set(store.servers.map((server) => server.id));
|
|
7816
|
+
const result = store.reloadFromDisk();
|
|
7817
|
+
if (store.configError !== lastConfigError) {
|
|
7818
|
+
if (store.configError === null) logger.info("the config file is readable again");
|
|
7819
|
+
else logger.error(`${store.configError} — keeping the config already running`);
|
|
7820
|
+
lastConfigError = store.configError;
|
|
7821
|
+
}
|
|
7822
|
+
if (!result.applied) {
|
|
7823
|
+
if (result.changed && result.error === null) logger.info("the config file changed, but not in a way that changes the config");
|
|
7824
|
+
return;
|
|
7825
|
+
}
|
|
7826
|
+
const added = store.servers.filter((server) => !before.has(server.id));
|
|
7827
|
+
const removed = [...before].filter((id) => !store.getServer(id));
|
|
7828
|
+
logger.info(`config reloaded from disk — ${store.servers.length} server(s)${added.length === 0 ? "" : `, ${added.length} added`}${removed.length === 0 ? "" : `, ${removed.length} removed`}`);
|
|
7829
|
+
if (!options.autostart) return;
|
|
7830
|
+
for (const server of added) {
|
|
7831
|
+
if (!server.enabled || !server.autostart) continue;
|
|
7832
|
+
supervisor.start(server.id).catch((error) => {
|
|
7833
|
+
logger.error(`could not start the added server ${server.id}`, error);
|
|
7834
|
+
});
|
|
7835
|
+
}
|
|
7836
|
+
},
|
|
7837
|
+
onError: (error) => logger.warn(`cannot watch ${path.basename(store.path)} for changes: ${error instanceof Error ? error.message : String(error)}`)
|
|
7838
|
+
});
|
|
7839
|
+
configWatch.start();
|
|
7344
7840
|
let shuttingDown = false;
|
|
7345
7841
|
const shutdown = async (reason) => {
|
|
7346
7842
|
if (shuttingDown) return;
|
|
7347
7843
|
shuttingDown = true;
|
|
7348
7844
|
logger.info(`${reason} — stopping ${supervisor.views().length} server(s)`);
|
|
7349
7845
|
clearRuntime();
|
|
7846
|
+
configWatch.dispose();
|
|
7350
7847
|
auth.dispose();
|
|
7351
7848
|
await supervisor.dispose();
|
|
7352
7849
|
logFiles.dispose();
|
|
@@ -7417,8 +7914,14 @@ async function runControlPlane(options) {
|
|
|
7417
7914
|
logger.error("could not start the restored servers", error);
|
|
7418
7915
|
});
|
|
7419
7916
|
};
|
|
7420
|
-
|
|
7421
|
-
|
|
7917
|
+
const onSignal = (signal) => {
|
|
7918
|
+
shutdown(signal).catch((error) => {
|
|
7919
|
+
logger.error(`shutdown after ${signal} failed`, error);
|
|
7920
|
+
process.exit(1);
|
|
7921
|
+
});
|
|
7922
|
+
};
|
|
7923
|
+
process.on("SIGINT", () => onSignal("SIGINT"));
|
|
7924
|
+
process.on("SIGTERM", () => onSignal("SIGTERM"));
|
|
7422
7925
|
}
|
|
7423
7926
|
var packageRoot;
|
|
7424
7927
|
var init_src = __esmMin((() => {
|
|
@@ -7435,6 +7938,7 @@ var init_src = __esmMin((() => {
|
|
|
7435
7938
|
init_port();
|
|
7436
7939
|
init_auth$1();
|
|
7437
7940
|
init_backups();
|
|
7941
|
+
init_config_watch();
|
|
7438
7942
|
init_control_server();
|
|
7439
7943
|
init_events();
|
|
7440
7944
|
init_exposure();
|
|
@@ -7450,257 +7954,30 @@ var init_src = __esmMin((() => {
|
|
|
7450
7954
|
packageRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
7451
7955
|
}));
|
|
7452
7956
|
//#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
|
|
7957
|
+
//#region src/cli/up.ts
|
|
7958
|
+
var up_exports = /* @__PURE__ */ __exportAll({
|
|
7959
|
+
runUp: () => runUp,
|
|
7960
|
+
toUpFlags: () => toUpFlags,
|
|
7961
|
+
upArgs: () => upArgs,
|
|
7962
|
+
upCommand: () => upCommand
|
|
7464
7963
|
});
|
|
7465
|
-
/**
|
|
7466
|
-
function
|
|
7467
|
-
|
|
7964
|
+
/** citty only parses; the port's range and the boolean defaults are decided here. */
|
|
7965
|
+
function toUpFlags(args) {
|
|
7966
|
+
let port;
|
|
7967
|
+
if (args.port !== void 0) {
|
|
7968
|
+
port = Number.parseInt(args.port, 10);
|
|
7969
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) fail(`invalid port: ${args.port}`);
|
|
7970
|
+
}
|
|
7468
7971
|
return {
|
|
7469
|
-
|
|
7470
|
-
|
|
7471
|
-
|
|
7472
|
-
|
|
7473
|
-
|
|
7474
|
-
|
|
7475
|
-
|
|
7972
|
+
config: args.config,
|
|
7973
|
+
port,
|
|
7974
|
+
host: args.host,
|
|
7975
|
+
autostart: args.autostart !== false,
|
|
7976
|
+
open: args.open === true,
|
|
7977
|
+
foreground: args.foreground === true,
|
|
7978
|
+
printConfig: args.printConfig === true
|
|
7476
7979
|
};
|
|
7477
7980
|
}
|
|
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
|
-
/**
|
|
7492
|
-
* State is generated, so it stays out — except the file that declares the servers,
|
|
7493
|
-
* which is the one thing worth committing.
|
|
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
7981
|
/**
|
|
7705
7982
|
* How to run this CLI again in the same runtime. Under tsx that means passing the
|
|
7706
7983
|
* resolved loader too, because the daemon's working directory is the project's,
|
|
@@ -7730,10 +8007,9 @@ function tailLog(file, lines = 15) {
|
|
|
7730
8007
|
return "";
|
|
7731
8008
|
}
|
|
7732
8009
|
}
|
|
7733
|
-
async function
|
|
7734
|
-
const flags = parseUpFlags(argv);
|
|
8010
|
+
async function runUp(flags, entry) {
|
|
7735
8011
|
const { runControlPlane } = await Promise.resolve().then(() => (init_src(), src_exports));
|
|
7736
|
-
if (flags.foreground) {
|
|
8012
|
+
if (flags.foreground || flags.printConfig) {
|
|
7737
8013
|
await runControlPlane({
|
|
7738
8014
|
configPath: flags.config,
|
|
7739
8015
|
port: flags.port,
|
|
@@ -7758,10 +8034,8 @@ async function up(argv) {
|
|
|
7758
8034
|
const log = fs.openSync(daemonLogPath, "a");
|
|
7759
8035
|
const child = spawn(process.execPath, [
|
|
7760
8036
|
...runtimeArgs(),
|
|
7761
|
-
|
|
7762
|
-
|
|
7763
|
-
"--foreground",
|
|
7764
|
-
...daemonFlags(flags)
|
|
8037
|
+
entry,
|
|
8038
|
+
...buildDaemonArgv(flags)
|
|
7765
8039
|
], {
|
|
7766
8040
|
detached: true,
|
|
7767
8041
|
cwd: projectDir,
|
|
@@ -7800,10 +8074,69 @@ async function waitForStartup(child, timeoutMs = 2e4) {
|
|
|
7800
8074
|
const runtime = readRuntime();
|
|
7801
8075
|
if (runtime !== null && runtime.pid === child.pid) return runtime;
|
|
7802
8076
|
if (Date.now() > deadline) return null;
|
|
7803
|
-
await delay(150);
|
|
8077
|
+
await delay$3(150);
|
|
7804
8078
|
}
|
|
7805
8079
|
}
|
|
7806
|
-
|
|
8080
|
+
function upCommand(entry) {
|
|
8081
|
+
return defineCommand({
|
|
8082
|
+
meta: {
|
|
8083
|
+
name: "up",
|
|
8084
|
+
description: "start the panel in the background (detached)"
|
|
8085
|
+
},
|
|
8086
|
+
args: upArgs,
|
|
8087
|
+
run: async ({ args }) => {
|
|
8088
|
+
await runUp(toUpFlags(args), entry);
|
|
8089
|
+
}
|
|
8090
|
+
});
|
|
8091
|
+
}
|
|
8092
|
+
var DEFAULT_PORT$1, LOG_ROTATE_BYTES, upArgs;
|
|
8093
|
+
var init_up = __esmMin((() => {
|
|
8094
|
+
init_args();
|
|
8095
|
+
init_io();
|
|
8096
|
+
DEFAULT_PORT$1 = 3999;
|
|
8097
|
+
LOG_ROTATE_BYTES = 5242880;
|
|
8098
|
+
upArgs = {
|
|
8099
|
+
config: {
|
|
8100
|
+
type: "string",
|
|
8101
|
+
alias: "c",
|
|
8102
|
+
description: "servers config (default: <state>/servers.config.json)"
|
|
8103
|
+
},
|
|
8104
|
+
port: {
|
|
8105
|
+
type: "string",
|
|
8106
|
+
alias: "p",
|
|
8107
|
+
description: `control panel port (default: ${DEFAULT_PORT$1})`
|
|
8108
|
+
},
|
|
8109
|
+
host: {
|
|
8110
|
+
type: "string",
|
|
8111
|
+
description: "local | lan | an ipv4 address (default: local)"
|
|
8112
|
+
},
|
|
8113
|
+
autostart: {
|
|
8114
|
+
type: "boolean",
|
|
8115
|
+
default: true,
|
|
8116
|
+
negativeDescription: "do not start the entries marked autostart"
|
|
8117
|
+
},
|
|
8118
|
+
open: {
|
|
8119
|
+
type: "boolean",
|
|
8120
|
+
description: "open the panel in a browser once it is up"
|
|
8121
|
+
},
|
|
8122
|
+
foreground: {
|
|
8123
|
+
type: "boolean",
|
|
8124
|
+
description: "run in this process instead of detaching (systemd/docker)"
|
|
8125
|
+
},
|
|
8126
|
+
printConfig: {
|
|
8127
|
+
type: "boolean",
|
|
8128
|
+
description: "print the effective config and exit"
|
|
8129
|
+
}
|
|
8130
|
+
};
|
|
8131
|
+
}));
|
|
8132
|
+
//#endregion
|
|
8133
|
+
//#region src/cli/down.ts
|
|
8134
|
+
var down_exports = /* @__PURE__ */ __exportAll({
|
|
8135
|
+
downCommand: () => downCommand,
|
|
8136
|
+
runDown: () => runDown
|
|
8137
|
+
});
|
|
8138
|
+
/** `down` stops the panel and everything it supervises. */
|
|
8139
|
+
async function runDown() {
|
|
7807
8140
|
const { clearRuntime, isProcessAlive, readRuntime, requestShutdown } = await Promise.resolve().then(() => (init_daemon(), daemon_exports));
|
|
7808
8141
|
const runtime = readRuntime();
|
|
7809
8142
|
if (runtime === null) {
|
|
@@ -7833,7 +8166,7 @@ async function waitForExit(pid, timeoutMs) {
|
|
|
7833
8166
|
const deadline = Date.now() + timeoutMs;
|
|
7834
8167
|
while (Date.now() < deadline) {
|
|
7835
8168
|
if (!isProcessAlive(pid)) return true;
|
|
7836
|
-
await delay(200);
|
|
8169
|
+
await delay$3(200);
|
|
7837
8170
|
}
|
|
7838
8171
|
return !isProcessAlive(pid);
|
|
7839
8172
|
}
|
|
@@ -7855,22 +8188,54 @@ function forceStop(pid) {
|
|
|
7855
8188
|
}
|
|
7856
8189
|
signal(pid, "SIGKILL");
|
|
7857
8190
|
}
|
|
7858
|
-
|
|
7859
|
-
|
|
7860
|
-
|
|
7861
|
-
|
|
7862
|
-
|
|
7863
|
-
|
|
7864
|
-
|
|
7865
|
-
|
|
7866
|
-
|
|
8191
|
+
var downCommand;
|
|
8192
|
+
var init_down = __esmMin((() => {
|
|
8193
|
+
init_io();
|
|
8194
|
+
downCommand = defineCommand({
|
|
8195
|
+
meta: {
|
|
8196
|
+
name: "down",
|
|
8197
|
+
description: "stop it, and everything it supervises"
|
|
8198
|
+
},
|
|
8199
|
+
run: async () => {
|
|
8200
|
+
await runDown();
|
|
8201
|
+
}
|
|
7867
8202
|
});
|
|
8203
|
+
}));
|
|
8204
|
+
//#endregion
|
|
8205
|
+
//#region src/cli/restart.ts
|
|
8206
|
+
var restart_exports = /* @__PURE__ */ __exportAll({ restartCommand: () => restartCommand });
|
|
8207
|
+
/** `restart` is `down`, then `up` with exactly the flags it was given. */
|
|
8208
|
+
function restartCommand(entry) {
|
|
8209
|
+
return defineCommand({
|
|
8210
|
+
meta: {
|
|
8211
|
+
name: "restart",
|
|
8212
|
+
description: "down, then up"
|
|
8213
|
+
},
|
|
8214
|
+
args: upArgs,
|
|
8215
|
+
run: async ({ args }) => {
|
|
8216
|
+
await runDown();
|
|
8217
|
+
await runUp(toUpFlags(args), entry);
|
|
8218
|
+
}
|
|
8219
|
+
});
|
|
8220
|
+
}
|
|
8221
|
+
var init_restart = __esmMin((() => {
|
|
8222
|
+
init_down();
|
|
8223
|
+
init_up();
|
|
8224
|
+
}));
|
|
8225
|
+
//#endregion
|
|
8226
|
+
//#region src/cli/status.ts
|
|
8227
|
+
var status_exports = /* @__PURE__ */ __exportAll({
|
|
8228
|
+
runStatus: () => runStatus,
|
|
8229
|
+
statusArgs: () => statusArgs,
|
|
8230
|
+
statusCommand: () => statusCommand
|
|
8231
|
+
});
|
|
8232
|
+
async function runStatus(json) {
|
|
7868
8233
|
const { isProcessAlive, probeRuntime, readRuntime } = await Promise.resolve().then(() => (init_daemon(), daemon_exports));
|
|
7869
8234
|
const { UiService } = await Promise.resolve().then(() => (init_ui(), ui_exports));
|
|
7870
8235
|
const { dataRoot } = await Promise.resolve().then(() => (init_paths(), paths_exports));
|
|
7871
8236
|
const runtime = readRuntime();
|
|
7872
8237
|
if (runtime === null) {
|
|
7873
|
-
if (
|
|
8238
|
+
if (json) process.stdout.write(`${JSON.stringify({ running: false }, null, 2)}\n`);
|
|
7874
8239
|
else process.stdout.write("home-hosted is not running\n");
|
|
7875
8240
|
process.exitCode = 1;
|
|
7876
8241
|
return;
|
|
@@ -7880,7 +8245,7 @@ async function status(argv) {
|
|
|
7880
8245
|
reachable: false,
|
|
7881
8246
|
degraded: false
|
|
7882
8247
|
};
|
|
7883
|
-
if (
|
|
8248
|
+
if (json) {
|
|
7884
8249
|
const { token: _token, ...safe } = runtime;
|
|
7885
8250
|
process.stdout.write(`${JSON.stringify({
|
|
7886
8251
|
running,
|
|
@@ -7918,71 +8283,163 @@ function formatDuration(ms) {
|
|
|
7918
8283
|
if (hours < 24) return `${hours}h ${minutes % 60}m`;
|
|
7919
8284
|
return `${Math.floor(hours / 24)}d ${hours % 24}h`;
|
|
7920
8285
|
}
|
|
7921
|
-
|
|
7922
|
-
|
|
7923
|
-
|
|
7924
|
-
|
|
7925
|
-
|
|
7926
|
-
|
|
7927
|
-
|
|
7928
|
-
|
|
7929
|
-
|
|
7930
|
-
|
|
7931
|
-
|
|
8286
|
+
var statusArgs, statusCommand;
|
|
8287
|
+
var init_status = __esmMin((() => {
|
|
8288
|
+
init_io();
|
|
8289
|
+
statusArgs = { json: {
|
|
8290
|
+
type: "boolean",
|
|
8291
|
+
description: "print machine-readable JSON"
|
|
8292
|
+
} };
|
|
8293
|
+
statusCommand = defineCommand({
|
|
8294
|
+
meta: {
|
|
8295
|
+
name: "status",
|
|
8296
|
+
description: "is it running, where, and how to reach it"
|
|
8297
|
+
},
|
|
8298
|
+
args: statusArgs,
|
|
8299
|
+
run: async ({ args }) => {
|
|
8300
|
+
await runStatus(args.json === true);
|
|
8301
|
+
}
|
|
7932
8302
|
});
|
|
8303
|
+
}));
|
|
8304
|
+
//#endregion
|
|
8305
|
+
//#region src/cli/set-password.ts
|
|
8306
|
+
var set_password_exports = /* @__PURE__ */ __exportAll({
|
|
8307
|
+
runSetPassword: () => runSetPassword,
|
|
8308
|
+
setPasswordArgs: () => setPasswordArgs,
|
|
8309
|
+
setPasswordCommand: () => setPasswordCommand
|
|
8310
|
+
});
|
|
8311
|
+
async function runSetPassword(clear) {
|
|
8312
|
+
const store = new SecretsStore(defaultSecretsPath);
|
|
8313
|
+
if (clear) {
|
|
8314
|
+
store.clearPassword();
|
|
8315
|
+
process.stdout.write(`cleared the control panel password in ${defaultSecretsPath}\n`);
|
|
8316
|
+
process.stdout.write(`${dim("authentication stays disabled until you enable it again in the settings page")}\n`);
|
|
8317
|
+
return;
|
|
8318
|
+
}
|
|
8319
|
+
const interactive = process.stdin.isTTY === true;
|
|
8320
|
+
let password = process.env.HHOSTED_PASSWORD;
|
|
8321
|
+
if (password === void 0 && interactive) {
|
|
8322
|
+
password = await promptHidden("New control panel password: ");
|
|
8323
|
+
const again = await promptHidden("Repeat it: ");
|
|
8324
|
+
if (password !== again) fail("the passwords do not match");
|
|
8325
|
+
}
|
|
8326
|
+
if (password === void 0 || password.length === 0) fail("no password given: run interactively, or set HHOSTED_PASSWORD for a non-interactive run");
|
|
8327
|
+
store.setPassword(password);
|
|
8328
|
+
process.stdout.write(`${green("password stored")} in ${defaultSecretsPath} (mode 0600)\n`);
|
|
8329
|
+
if (password.length < 8) process.stdout.write(`${dim(`"${password}" is short — easy to guess if the panel is reachable beyond loopback`)}\n`);
|
|
8330
|
+
process.stdout.write(`${dim("restart the panel for it to take effect: home-hosted restart")}\n`);
|
|
7933
8331
|
}
|
|
7934
|
-
|
|
7935
|
-
|
|
7936
|
-
|
|
7937
|
-
|
|
7938
|
-
|
|
7939
|
-
|
|
7940
|
-
|
|
7941
|
-
|
|
7942
|
-
|
|
7943
|
-
|
|
7944
|
-
|
|
7945
|
-
|
|
7946
|
-
|
|
7947
|
-
|
|
7948
|
-
|
|
7949
|
-
|
|
7950
|
-
|
|
7951
|
-
|
|
7952
|
-
resolve(answer);
|
|
7953
|
-
});
|
|
8332
|
+
var setPasswordArgs, setPasswordCommand;
|
|
8333
|
+
var init_set_password = __esmMin((() => {
|
|
8334
|
+
init_io();
|
|
8335
|
+
init_secrets();
|
|
8336
|
+
init_paths();
|
|
8337
|
+
setPasswordArgs = { clear: {
|
|
8338
|
+
type: "boolean",
|
|
8339
|
+
description: "remove the password, which disables authentication"
|
|
8340
|
+
} };
|
|
8341
|
+
setPasswordCommand = defineCommand({
|
|
8342
|
+
meta: {
|
|
8343
|
+
name: "set-password",
|
|
8344
|
+
description: "set the panel password without the API"
|
|
8345
|
+
},
|
|
8346
|
+
args: setPasswordArgs,
|
|
8347
|
+
run: async ({ args }) => {
|
|
8348
|
+
await runSetPassword(args.clear === true);
|
|
8349
|
+
}
|
|
7954
8350
|
});
|
|
8351
|
+
}));
|
|
8352
|
+
//#endregion
|
|
8353
|
+
//#region src/cli/set-token.ts
|
|
8354
|
+
var set_token_exports = /* @__PURE__ */ __exportAll({
|
|
8355
|
+
runSetToken: () => runSetToken,
|
|
8356
|
+
setTokenArgs: () => setTokenArgs,
|
|
8357
|
+
setTokenCommand: () => setTokenCommand
|
|
8358
|
+
});
|
|
8359
|
+
async function runSetToken(generate, clear) {
|
|
8360
|
+
if (generate && clear) fail("use either --generate or --clear, not both");
|
|
8361
|
+
const store = new SecretsStore(defaultSecretsPath);
|
|
8362
|
+
if (clear) {
|
|
8363
|
+
if (!store.apiTokenSet) {
|
|
8364
|
+
process.stdout.write("no API token is set — nothing to clear\n");
|
|
8365
|
+
return;
|
|
8366
|
+
}
|
|
8367
|
+
store.clearApiToken();
|
|
8368
|
+
process.stdout.write(`${green("API token cleared")} in ${defaultSecretsPath} — it stops working immediately\n`);
|
|
8369
|
+
return;
|
|
8370
|
+
}
|
|
8371
|
+
let token = process.env.HHOSTED_TOKEN ?? null;
|
|
8372
|
+
if (generate) token = generateApiToken();
|
|
8373
|
+
else if (token === null && process.stdin.isTTY === true) token = await promptHidden("API token: ");
|
|
8374
|
+
if (token !== null) token = token.trim();
|
|
8375
|
+
if (token === null || token.length === 0) fail("no token given: run `home-hosted set-token --generate`, set HHOSTED_TOKEN, or paste one interactively");
|
|
8376
|
+
store.setApiToken(token);
|
|
8377
|
+
process.stdout.write(`${green(generate ? "token generated" : "token stored")} in ${defaultSecretsPath} (mode 0600)\n`);
|
|
8378
|
+
if (generate) {
|
|
8379
|
+
process.stdout.write(` ${bold(token)}\n`);
|
|
8380
|
+
process.stdout.write(`${dim(" shown once — only its SHA-256 is kept on disk, so copy it now")}\n`);
|
|
8381
|
+
} else process.stdout.write(`${dim(` stored as ${token.slice(0, 8)}… — the file keeps only its hash`)}\n`);
|
|
8382
|
+
const { readRuntime } = await Promise.resolve().then(() => (init_daemon(), daemon_exports));
|
|
8383
|
+
const runtime = readRuntime();
|
|
8384
|
+
const base = (runtime?.url ?? `http://127.0.0.1:${DEFAULT_PORT}`).replace(/\/+$/, "");
|
|
8385
|
+
if (runtime !== null) {
|
|
8386
|
+
const verified = await verifyToken(base, token);
|
|
8387
|
+
if (verified === true) process.stdout.write(`${dim(`verified: ${base}/api/auth/session accepted it`)}\n`);
|
|
8388
|
+
else if (verified === false) process.stdout.write(`${dim(`the panel at ${base} did not accept it — is it running with this state directory?`)}\n`);
|
|
8389
|
+
}
|
|
8390
|
+
process.stdout.write(`Use it from a script or an agent:\n`);
|
|
8391
|
+
process.stdout.write(` ${bold(`curl -H "Authorization: Bearer ${generate ? token : "<token>"}" ${base}/api/state`)}\n`);
|
|
8392
|
+
process.stdout.write(`${dim("It needs no restart, outlives sessions, and holds the same access as a signed-in browser.")}\n`);
|
|
8393
|
+
process.stdout.write(`${dim("Remove it any time with: home-hosted set-token --clear")}\n`);
|
|
8394
|
+
if (store.usingDefaultPassword) process.stdout.write(`${dim("The panel password is still the default — change it before the panel is reachable beyond loopback.")}\n`);
|
|
7955
8395
|
}
|
|
7956
|
-
/**
|
|
7957
|
-
|
|
7958
|
-
|
|
7959
|
-
|
|
7960
|
-
|
|
7961
|
-
|
|
7962
|
-
|
|
7963
|
-
|
|
7964
|
-
|
|
7965
|
-
|
|
7966
|
-
|
|
7967
|
-
|
|
7968
|
-
|
|
7969
|
-
|
|
7970
|
-
|
|
7971
|
-
|
|
7972
|
-
|
|
7973
|
-
|
|
7974
|
-
|
|
7975
|
-
|
|
7976
|
-
|
|
8396
|
+
/** Asks the live panel whether it accepts the token, without failing when it cannot. */
|
|
8397
|
+
async function verifyToken(base, token) {
|
|
8398
|
+
if (!base.startsWith("http://")) return null;
|
|
8399
|
+
try {
|
|
8400
|
+
const response = await fetch(`${base}/api/auth/session`, { headers: { authorization: `Bearer ${token}` } });
|
|
8401
|
+
if (!response.ok) return false;
|
|
8402
|
+
return (await response.json()).authenticated === true;
|
|
8403
|
+
} catch {
|
|
8404
|
+
return null;
|
|
8405
|
+
}
|
|
8406
|
+
}
|
|
8407
|
+
var DEFAULT_PORT, setTokenArgs, setTokenCommand;
|
|
8408
|
+
var init_set_token = __esmMin((() => {
|
|
8409
|
+
init_io();
|
|
8410
|
+
init_secrets();
|
|
8411
|
+
init_paths();
|
|
8412
|
+
DEFAULT_PORT = 3999;
|
|
8413
|
+
setTokenArgs = {
|
|
8414
|
+
generate: {
|
|
8415
|
+
type: "boolean",
|
|
8416
|
+
description: "create a strong token and print it once"
|
|
7977
8417
|
},
|
|
7978
|
-
|
|
8418
|
+
clear: {
|
|
8419
|
+
type: "boolean",
|
|
8420
|
+
description: "remove the token, so it stops working"
|
|
8421
|
+
}
|
|
8422
|
+
};
|
|
8423
|
+
setTokenCommand = defineCommand({
|
|
8424
|
+
meta: {
|
|
8425
|
+
name: "set-token",
|
|
8426
|
+
description: "set the API token that scripts and agents use"
|
|
8427
|
+
},
|
|
8428
|
+
args: setTokenArgs,
|
|
8429
|
+
run: async ({ args }) => {
|
|
8430
|
+
await runSetToken(args.generate === true, args.clear === true);
|
|
8431
|
+
}
|
|
7979
8432
|
});
|
|
7980
|
-
|
|
7981
|
-
|
|
7982
|
-
|
|
7983
|
-
|
|
7984
|
-
|
|
7985
|
-
|
|
8433
|
+
}));
|
|
8434
|
+
//#endregion
|
|
8435
|
+
//#region src/cli/migrate.ts
|
|
8436
|
+
var migrate_exports = /* @__PURE__ */ __exportAll({
|
|
8437
|
+
migrateArgs: () => migrateArgs,
|
|
8438
|
+
migrateCommand: () => migrateCommand,
|
|
8439
|
+
runMigrate: () => runMigrate
|
|
8440
|
+
});
|
|
8441
|
+
async function runMigrate(config, dryRun, yes) {
|
|
8442
|
+
const file = config ?? defaultConfigPath;
|
|
7986
8443
|
if (!fs.existsSync(file)) fail(`no config at ${file} — nothing to migrate`);
|
|
7987
8444
|
let raw;
|
|
7988
8445
|
try {
|
|
@@ -7991,12 +8448,12 @@ async function migrateConfig(argv) {
|
|
|
7991
8448
|
fail(`cannot read ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
7992
8449
|
}
|
|
7993
8450
|
const meta = typeof raw.meta === "object" && raw.meta !== null ? raw.meta : {};
|
|
7994
|
-
const from = typeof meta.schema === "number" ? meta.schema :
|
|
8451
|
+
const from = typeof meta.schema === "number" ? meta.schema : 1;
|
|
7995
8452
|
const plan = planConfigMigrations(from);
|
|
7996
8453
|
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
8454
|
const stamped = stampConfig(raw);
|
|
7998
8455
|
const upToDate = plan.steps.length === 0;
|
|
7999
|
-
if (upToDate &&
|
|
8456
|
+
if (upToDate && !dryRun && JSON.stringify(stamped) !== JSON.stringify(raw)) {
|
|
8000
8457
|
writeFileAtomic(file, `${JSON.stringify(stamped, null, 2)}\n`);
|
|
8001
8458
|
process.stdout.write(`${green("config stamped")} in ${file} — written by home-hosted ${appVersion()}, schema ${plan.to}\n`);
|
|
8002
8459
|
return;
|
|
@@ -8007,11 +8464,11 @@ async function migrateConfig(argv) {
|
|
|
8007
8464
|
}
|
|
8008
8465
|
process.stdout.write(`migrating ${file}: config schema ${from} → ${plan.to}\n`);
|
|
8009
8466
|
for (const [index, step] of plan.steps.entries()) process.stdout.write(` ${index + 1}. ${step.describe}\n`);
|
|
8010
|
-
if (
|
|
8467
|
+
if (dryRun) {
|
|
8011
8468
|
process.stdout.write(`${dim(`nothing was written (--dry-run, ${plan.steps.length} step(s) pending)`)}\n`);
|
|
8012
8469
|
return;
|
|
8013
8470
|
}
|
|
8014
|
-
if (!(
|
|
8471
|
+
if (!(yes || (process.env.HHOSTED_MIGRATE ?? "").toLowerCase() === "allow")) {
|
|
8015
8472
|
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`);
|
|
8016
8473
|
const answer = await prompt(`Apply ${plan.steps.length} migration(s) to ${path.basename(file)}? [y/N] `);
|
|
8017
8474
|
if (!/^yes$|^y$/i.test(answer.trim())) {
|
|
@@ -8019,60 +8476,172 @@ async function migrateConfig(argv) {
|
|
|
8019
8476
|
return;
|
|
8020
8477
|
}
|
|
8021
8478
|
}
|
|
8022
|
-
const { config, applied } = applyConfigMigrations(raw, from);
|
|
8023
|
-
const parsed = parseConfig(
|
|
8479
|
+
const { config: migrated, applied } = applyConfigMigrations(raw, from);
|
|
8480
|
+
const parsed = parseConfig(migrated);
|
|
8024
8481
|
if (parsed.config === null) fail(`the migration produced a config this release cannot read:\n ${parsed.errors.join("\n ")}`);
|
|
8025
8482
|
const backup = `${file}.bak`;
|
|
8026
8483
|
fs.copyFileSync(file, backup);
|
|
8027
|
-
writeFileAtomic(file, `${JSON.stringify(stampConfig(
|
|
8484
|
+
writeFileAtomic(file, `${JSON.stringify(stampConfig(migrated), null, 2)}\n`);
|
|
8028
8485
|
process.stdout.write(`${green(`migrated to schema ${plan.to}`)} (${applied.length} step(s)) in ${file}\n`);
|
|
8029
8486
|
process.stdout.write(` ${dim(`previous file kept at ${backup}`)}\n`);
|
|
8030
8487
|
for (const key of parsed.unknownKeys) process.stdout.write(` ${dim(`still ignoring an unrecognized key: ${key}`)}\n`);
|
|
8031
8488
|
}
|
|
8032
|
-
|
|
8033
|
-
|
|
8034
|
-
|
|
8035
|
-
|
|
8036
|
-
|
|
8489
|
+
var migrateArgs, migrateCommand;
|
|
8490
|
+
var init_migrate = __esmMin((() => {
|
|
8491
|
+
init_io();
|
|
8492
|
+
init_migrations();
|
|
8493
|
+
init_parse();
|
|
8494
|
+
init_atomic();
|
|
8495
|
+
init_paths();
|
|
8496
|
+
init_version();
|
|
8497
|
+
migrateArgs = {
|
|
8498
|
+
config: {
|
|
8499
|
+
type: "string",
|
|
8500
|
+
alias: "c",
|
|
8501
|
+
description: "servers config (default: <state>/servers.config.json)"
|
|
8502
|
+
},
|
|
8503
|
+
dryRun: {
|
|
8504
|
+
type: "boolean",
|
|
8505
|
+
description: "print what would change, write nothing"
|
|
8506
|
+
},
|
|
8507
|
+
yes: {
|
|
8508
|
+
type: "boolean",
|
|
8509
|
+
alias: "y",
|
|
8510
|
+
description: "apply without asking (or set HHOSTED_MIGRATE=allow)"
|
|
8511
|
+
}
|
|
8512
|
+
};
|
|
8513
|
+
migrateCommand = defineCommand({
|
|
8514
|
+
meta: {
|
|
8515
|
+
name: "migrate",
|
|
8516
|
+
description: "bring the config up to this release's schema"
|
|
8517
|
+
},
|
|
8518
|
+
args: migrateArgs,
|
|
8519
|
+
run: async ({ args }) => {
|
|
8520
|
+
await runMigrate(args.config, args.dryRun === true, args.yes === true);
|
|
8521
|
+
}
|
|
8522
|
+
});
|
|
8523
|
+
}));
|
|
8524
|
+
//#endregion
|
|
8525
|
+
//#region src/services/init.ts
|
|
8526
|
+
/** The scripts every scaffolded project gets; all of them keep state inside the project. */
|
|
8527
|
+
function projectScripts() {
|
|
8528
|
+
const withState = (args) => `home-hosted ${args} --home ./state`;
|
|
8529
|
+
return {
|
|
8530
|
+
"up": withState("up"),
|
|
8531
|
+
"down": withState("down"),
|
|
8532
|
+
"restart": withState("restart"),
|
|
8533
|
+
"status": withState("status"),
|
|
8534
|
+
"set-password": withState("set-password"),
|
|
8535
|
+
"set-token": withState("set-token"),
|
|
8536
|
+
"migrate": withState("migrate")
|
|
8537
|
+
};
|
|
8037
8538
|
}
|
|
8539
|
+
/** `home-hosted` is pinned to the version that wrote the project, and open to newer ones. */
|
|
8540
|
+
function projectManifest(name) {
|
|
8541
|
+
return `${JSON.stringify({
|
|
8542
|
+
name,
|
|
8543
|
+
version: "0.1.0",
|
|
8544
|
+
private: true,
|
|
8545
|
+
description: "Servers supervised by home-hosted.",
|
|
8546
|
+
type: "module",
|
|
8547
|
+
engines: { node: ">=24.0.0" },
|
|
8548
|
+
scripts: projectScripts(),
|
|
8549
|
+
dependencies: { "home-hosted": `^${appVersion()}` }
|
|
8550
|
+
}, null, 2)}\n`;
|
|
8551
|
+
}
|
|
8552
|
+
/**
|
|
8553
|
+
* State is generated, so it stays out — except the file that declares the servers,
|
|
8554
|
+
* which is the one thing worth committing.
|
|
8555
|
+
*/
|
|
8556
|
+
function projectGitignore() {
|
|
8557
|
+
return `node_modules/
|
|
8558
|
+
|
|
8559
|
+
# home-hosted: state is local, the server definitions are tracked
|
|
8560
|
+
state/*
|
|
8561
|
+
!state/servers.config.json
|
|
8562
|
+
data/
|
|
8563
|
+
`;
|
|
8564
|
+
}
|
|
8565
|
+
function isEmptyDir(dir) {
|
|
8566
|
+
try {
|
|
8567
|
+
return fs.readdirSync(dir).every((entry) => entry === ".git");
|
|
8568
|
+
} catch {
|
|
8569
|
+
return true;
|
|
8570
|
+
}
|
|
8571
|
+
}
|
|
8572
|
+
function assertUsableDir(dir) {
|
|
8573
|
+
let stats = null;
|
|
8574
|
+
try {
|
|
8575
|
+
stats = fs.statSync(dir);
|
|
8576
|
+
} catch {
|
|
8577
|
+
return;
|
|
8578
|
+
}
|
|
8579
|
+
if (!stats.isDirectory()) throw new Error(`${dir} exists and is not a directory`);
|
|
8580
|
+
if (!isEmptyDir(dir)) throw new Error(`${dir} is not empty — pick another directory, or empty it first`);
|
|
8581
|
+
}
|
|
8582
|
+
/** Writes the project skeleton. Nothing is installed or initialized here. */
|
|
8583
|
+
function scaffold(options) {
|
|
8584
|
+
const dir = path.resolve(options.dir);
|
|
8585
|
+
assertUsableDir(dir);
|
|
8586
|
+
const existed = fs.existsSync(dir);
|
|
8587
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
8588
|
+
const files = [["package.json", projectManifest(options.name)], [".gitignore", projectGitignore()]];
|
|
8589
|
+
for (const [name, contents] of files) fs.writeFileSync(path.join(dir, name), contents);
|
|
8590
|
+
return {
|
|
8591
|
+
dir,
|
|
8592
|
+
created: !existed,
|
|
8593
|
+
files: files.map(([name]) => name),
|
|
8594
|
+
installed: false,
|
|
8595
|
+
git: false
|
|
8596
|
+
};
|
|
8597
|
+
}
|
|
8598
|
+
/** The package manager this machine actually has, preferring pnpm. */
|
|
8599
|
+
function detectPackageManager(exists) {
|
|
8600
|
+
return PACKAGE_MANAGERS.find((pm) => exists(pm)) ?? "npm";
|
|
8601
|
+
}
|
|
8602
|
+
/** How to run one of the project's scripts with a given package manager. */
|
|
8603
|
+
function runCommand$1(pm, script) {
|
|
8604
|
+
return pm === "npm" ? `npm run ${script}` : `${pm} run ${script}`;
|
|
8605
|
+
}
|
|
8606
|
+
/** Install arguments for a scaffolded project (the manifest already lists the dependency). */
|
|
8607
|
+
function installArgs(pm) {
|
|
8608
|
+
return pm === "yarn" ? [] : ["install"];
|
|
8609
|
+
}
|
|
8610
|
+
var PACKAGE_MANAGERS;
|
|
8611
|
+
var init_init$1 = __esmMin((() => {
|
|
8612
|
+
init_version();
|
|
8613
|
+
PACKAGE_MANAGERS = [
|
|
8614
|
+
"pnpm",
|
|
8615
|
+
"npm",
|
|
8616
|
+
"yarn",
|
|
8617
|
+
"bun"
|
|
8618
|
+
];
|
|
8619
|
+
}));
|
|
8620
|
+
//#endregion
|
|
8621
|
+
//#region src/cli/init.ts
|
|
8622
|
+
var init_exports = /* @__PURE__ */ __exportAll({
|
|
8623
|
+
initArgs: () => initArgs,
|
|
8624
|
+
initCommand: () => initCommand,
|
|
8625
|
+
runInit: () => runInit
|
|
8626
|
+
});
|
|
8038
8627
|
function which(command) {
|
|
8039
8628
|
return spawnSync(command, ["--version"], {
|
|
8040
8629
|
stdio: "ignore",
|
|
8041
8630
|
shell: process.platform === "win32"
|
|
8042
8631
|
}).status === 0;
|
|
8043
8632
|
}
|
|
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;
|
|
8633
|
+
async function runInit(options) {
|
|
8634
|
+
const assumeYes = options.yes;
|
|
8066
8635
|
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 =
|
|
8636
|
+
const defaultDir = options.dir ?? "./my-servers";
|
|
8068
8637
|
const dir = assumeYes ? defaultDir : (await prompt(`Project directory (${defaultDir}) `)).trim() || defaultDir;
|
|
8069
8638
|
const defaultName = path.basename(path.resolve(dir));
|
|
8070
|
-
const name =
|
|
8071
|
-
let pm =
|
|
8639
|
+
const name = options.name ?? (assumeYes ? defaultName : (await prompt(`Package name (${defaultName}) `)).trim() || defaultName);
|
|
8640
|
+
let pm = options.pm;
|
|
8072
8641
|
if (pm !== void 0 && !PACKAGE_MANAGERS.includes(pm)) fail(`unknown package manager: ${pm} (expected one of ${PACKAGE_MANAGERS.join(", ")})`);
|
|
8073
8642
|
const detected = detectPackageManager(which);
|
|
8074
8643
|
if (pm === void 0) pm = assumeYes ? detected : (await prompt(`Package manager (${detected}) `)).trim() || detected;
|
|
8075
|
-
const install =
|
|
8644
|
+
const install = options.noInstall ? false : assumeYes || await confirm("Install the dependencies now?", true);
|
|
8076
8645
|
const git = assumeYes ? which("git") : which("git") && await confirm("Initialize a git repository?", true);
|
|
8077
8646
|
try {
|
|
8078
8647
|
const result = scaffold({
|
|
@@ -8108,180 +8677,581 @@ async function initProject(argv) {
|
|
|
8108
8677
|
const where = relative.length === 0 || relative.startsWith("..") ? target : relative;
|
|
8109
8678
|
const cd = relative.length === 0 ? "" : `cd ${where} && `;
|
|
8110
8679
|
process.stdout.write(`\nNext:\n`);
|
|
8111
|
-
process.stdout.write(` ${bold(`${cd}${runCommand(pm, "up")}`)} start the panel (default password \`hh\`)\n`);
|
|
8680
|
+
process.stdout.write(` ${bold(`${cd}${runCommand$1(pm, "up")}`)} start the panel (default password \`hh\`)\n`);
|
|
8112
8681
|
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`);
|
|
8682
|
+
process.stdout.write(` ${dim(`${runCommand$1(pm, "set-token")} --generate for scripts and agents`)}\n`);
|
|
8114
8683
|
}
|
|
8115
|
-
|
|
8116
|
-
|
|
8117
|
-
|
|
8118
|
-
|
|
8119
|
-
|
|
8120
|
-
|
|
8121
|
-
|
|
8122
|
-
|
|
8684
|
+
var initArgs, initCommand;
|
|
8685
|
+
var init_init = __esmMin((() => {
|
|
8686
|
+
init_io();
|
|
8687
|
+
init_init$1();
|
|
8688
|
+
initArgs = {
|
|
8689
|
+
dir: {
|
|
8690
|
+
type: "string",
|
|
8691
|
+
description: "where to scaffold (default: ./my-servers)"
|
|
8692
|
+
},
|
|
8693
|
+
name: {
|
|
8694
|
+
type: "string",
|
|
8695
|
+
description: "package name (default: the directory name)"
|
|
8696
|
+
},
|
|
8697
|
+
pm: {
|
|
8698
|
+
type: "string",
|
|
8699
|
+
description: "pnpm | npm | yarn | bun (default: the first one installed)"
|
|
8700
|
+
},
|
|
8701
|
+
install: {
|
|
8702
|
+
type: "boolean",
|
|
8703
|
+
default: true,
|
|
8704
|
+
negativeDescription: "write the files, install nothing"
|
|
8705
|
+
},
|
|
8706
|
+
yes: {
|
|
8707
|
+
type: "boolean",
|
|
8708
|
+
alias: "y",
|
|
8709
|
+
description: "take every default, ask nothing"
|
|
8710
|
+
}
|
|
8711
|
+
};
|
|
8712
|
+
initCommand = defineCommand({
|
|
8713
|
+
meta: {
|
|
8714
|
+
name: "init",
|
|
8715
|
+
description: "scaffold a project that keeps its state in the repo"
|
|
8716
|
+
},
|
|
8717
|
+
args: initArgs,
|
|
8718
|
+
run: async ({ args }) => {
|
|
8719
|
+
await runInit({
|
|
8720
|
+
dir: args.dir,
|
|
8721
|
+
name: args.name,
|
|
8722
|
+
pm: args.pm,
|
|
8723
|
+
noInstall: args.install === false,
|
|
8724
|
+
yes: args.yes === true
|
|
8725
|
+
});
|
|
8726
|
+
}
|
|
8727
|
+
});
|
|
8728
|
+
}));
|
|
8729
|
+
//#endregion
|
|
8730
|
+
//#region src/cli/ui-switch.ts
|
|
8731
|
+
var ui_switch_exports = /* @__PURE__ */ __exportAll({
|
|
8732
|
+
DEFAULT_REPO: () => DEFAULT_REPO,
|
|
8733
|
+
MAX_DOWNLOAD_BYTES: () => MAX_DOWNLOAD_BYTES,
|
|
8734
|
+
defaultReleaseTag: () => defaultReleaseTag,
|
|
8735
|
+
fallbackUiName: () => fallbackUiName,
|
|
8736
|
+
isGithubHost: () => isGithubHost,
|
|
8737
|
+
isOwnRepo: () => isOwnRepo,
|
|
8738
|
+
isUiAsset: () => isUiAsset,
|
|
8739
|
+
matchAsset: () => matchAsset,
|
|
8740
|
+
parseFileSource: () => parseFileSource,
|
|
8741
|
+
parseRepoSlug: () => parseRepoSlug,
|
|
8742
|
+
releaseApiUrl: () => releaseApiUrl,
|
|
8743
|
+
uiSwitch: () => uiSwitch,
|
|
8744
|
+
uiSwitchCommand: () => uiSwitchCommand
|
|
8745
|
+
});
|
|
8746
|
+
/** `owner/name`, the only slug GitHub releases are addressed by. */
|
|
8747
|
+
function parseRepoSlug(value) {
|
|
8748
|
+
const match = /^([\w.-]+)\/([\w.-]+)$/.exec(value.trim());
|
|
8749
|
+
if (match === null) return null;
|
|
8750
|
+
return {
|
|
8751
|
+
owner: match[1],
|
|
8752
|
+
name: match[2]
|
|
8753
|
+
};
|
|
8754
|
+
}
|
|
8755
|
+
/** The repo the default tag rule is about; `DEFAULT_REPO` is its only definition. */
|
|
8756
|
+
function isOwnRepo(repo) {
|
|
8757
|
+
return `${repo.owner}/${repo.name}`.toLowerCase() === DEFAULT_REPO.toLowerCase();
|
|
8758
|
+
}
|
|
8759
|
+
/**
|
|
8760
|
+
* Our own release tag matches this CLI's version, so a UI is paired with the panel
|
|
8761
|
+
* it was built for. Another repo has no such pairing, so its latest release is used.
|
|
8762
|
+
* `null` means "latest".
|
|
8763
|
+
*/
|
|
8764
|
+
function defaultReleaseTag(repo, version) {
|
|
8765
|
+
return isOwnRepo(repo) ? `v${version}` : null;
|
|
8766
|
+
}
|
|
8767
|
+
function releaseApiUrl(repo, tag) {
|
|
8768
|
+
const base = `https://api.github.com/repos/${repo.owner}/${repo.name}/releases`;
|
|
8769
|
+
if (tag === null || tag.length === 0 || tag === "latest") return `${base}/latest`;
|
|
8770
|
+
return `${base}/tags/${encodeURIComponent(tag)}`;
|
|
8771
|
+
}
|
|
8772
|
+
/** Generous and predictable: a UI bundle is an asset whose name ends in `.zip`. */
|
|
8773
|
+
function isUiAsset(name) {
|
|
8774
|
+
return /\.zip$/i.test(name.trim());
|
|
8775
|
+
}
|
|
8776
|
+
/**
|
|
8777
|
+
* Resolves `--asset`: an exact name, a case-insensitive name, or a single
|
|
8778
|
+
* unambiguous substring (`--asset stock` for `home-hosted-ui-stock.zip`).
|
|
8779
|
+
*/
|
|
8780
|
+
function matchAsset(names, query) {
|
|
8781
|
+
const wanted = query.trim();
|
|
8782
|
+
if (wanted.length === 0) return {
|
|
8783
|
+
ok: false,
|
|
8784
|
+
error: "no asset name was given"
|
|
8785
|
+
};
|
|
8786
|
+
const exact = names.find((name) => name === wanted);
|
|
8787
|
+
if (exact !== void 0) return {
|
|
8788
|
+
ok: true,
|
|
8789
|
+
name: exact
|
|
8790
|
+
};
|
|
8791
|
+
const lower = wanted.toLowerCase();
|
|
8792
|
+
const insensitive = names.filter((name) => name.toLowerCase() === lower);
|
|
8793
|
+
if (insensitive.length === 1) return {
|
|
8794
|
+
ok: true,
|
|
8795
|
+
name: insensitive[0]
|
|
8796
|
+
};
|
|
8797
|
+
const partial = names.filter((name) => name.toLowerCase().includes(lower));
|
|
8798
|
+
if (partial.length === 0) return {
|
|
8799
|
+
ok: false,
|
|
8800
|
+
error: `no asset matches "${wanted}" (available: ${names.join(", ") || "none"})`
|
|
8801
|
+
};
|
|
8802
|
+
if (partial.length > 1) return {
|
|
8803
|
+
ok: false,
|
|
8804
|
+
error: `"${wanted}" matches more than one asset: ${partial.join(", ")} — use the full name`
|
|
8805
|
+
};
|
|
8806
|
+
return {
|
|
8807
|
+
ok: true,
|
|
8808
|
+
name: partial[0]
|
|
8809
|
+
};
|
|
8810
|
+
}
|
|
8811
|
+
/** `^https?://` means a URL; anything else is a filesystem path with `~` expanded. */
|
|
8812
|
+
function parseFileSource(value) {
|
|
8813
|
+
const trimmed = value.trim();
|
|
8814
|
+
if (/^https?:\/\//i.test(trimmed)) return {
|
|
8815
|
+
kind: "url",
|
|
8816
|
+
url: trimmed
|
|
8817
|
+
};
|
|
8818
|
+
return {
|
|
8819
|
+
kind: "path",
|
|
8820
|
+
path: expandHome(trimmed)
|
|
8821
|
+
};
|
|
8822
|
+
}
|
|
8823
|
+
function expandHome(value) {
|
|
8824
|
+
if (value === "~") return os.homedir();
|
|
8825
|
+
if (value.startsWith("~/") || value.startsWith("~\\")) return path.join(os.homedir(), value.slice(2));
|
|
8826
|
+
return value;
|
|
8827
|
+
}
|
|
8828
|
+
/**
|
|
8829
|
+
* A token is only ever sent to GitHub: `--file <url>` may point anywhere, and a
|
|
8830
|
+
* credential must not leak to a host the user did not vouch for.
|
|
8831
|
+
*/
|
|
8832
|
+
function isGithubHost(url) {
|
|
8833
|
+
try {
|
|
8834
|
+
const host = new URL(url).hostname.toLowerCase();
|
|
8835
|
+
return host === "github.com" || host.endsWith(".github.com") || host === "githubusercontent.com" || host.endsWith(".githubusercontent.com");
|
|
8836
|
+
} catch {
|
|
8837
|
+
return false;
|
|
8123
8838
|
}
|
|
8124
|
-
ui.revert();
|
|
8125
|
-
process.stdout.write(`${green("custom UI removed")} — the stock panel is back; refresh the browser\n`);
|
|
8126
8839
|
}
|
|
8127
|
-
|
|
8840
|
+
/** What `UiService` names the install when the archive carries no `ui.json`. */
|
|
8841
|
+
function fallbackUiName(source) {
|
|
8842
|
+
const stripped = path.basename(source).replace(/\.zip$/i, "").replace(/^home-hosted-ui-/i, "");
|
|
8843
|
+
return stripped.length > 0 ? stripped : "custom-ui";
|
|
8844
|
+
}
|
|
8845
|
+
async function uiSwitch(argv, io) {
|
|
8128
8846
|
const { values } = parseArgs({
|
|
8129
8847
|
args: argv,
|
|
8130
|
-
options: {
|
|
8848
|
+
options: {
|
|
8849
|
+
repo: { type: "string" },
|
|
8850
|
+
tag: { type: "string" },
|
|
8851
|
+
asset: { type: "string" },
|
|
8852
|
+
file: { type: "string" },
|
|
8853
|
+
list: { type: "boolean" },
|
|
8854
|
+
token: { type: "string" },
|
|
8855
|
+
yes: {
|
|
8856
|
+
type: "boolean",
|
|
8857
|
+
short: "y"
|
|
8858
|
+
}
|
|
8859
|
+
},
|
|
8131
8860
|
allowPositionals: false
|
|
8132
8861
|
});
|
|
8133
|
-
const {
|
|
8134
|
-
const
|
|
8135
|
-
|
|
8136
|
-
|
|
8137
|
-
|
|
8138
|
-
|
|
8139
|
-
|
|
8862
|
+
const { appVersion } = await Promise.resolve().then(() => (init_version(), version_exports));
|
|
8863
|
+
const context = {
|
|
8864
|
+
io,
|
|
8865
|
+
version: appVersion(),
|
|
8866
|
+
token: values.token ?? process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? null
|
|
8867
|
+
};
|
|
8868
|
+
if (values.file !== void 0) {
|
|
8869
|
+
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");
|
|
8870
|
+
await installFromFile(values.file, context);
|
|
8140
8871
|
return;
|
|
8141
8872
|
}
|
|
8142
|
-
const
|
|
8143
|
-
|
|
8144
|
-
|
|
8145
|
-
|
|
8146
|
-
|
|
8147
|
-
|
|
8873
|
+
const repo = parseRepoSlug(values.repo ?? "NamesMT/home-hosted");
|
|
8874
|
+
if (repo === null) throw new Error(`invalid --repo "${values.repo}" — expected an "owner/name" slug, e.g. ${DEFAULT_REPO}`);
|
|
8875
|
+
const requestedTag = values.tag ?? defaultReleaseTag(repo, context.version);
|
|
8876
|
+
const release = await fetchRelease(repo, requestedTag, context);
|
|
8877
|
+
const skipped = release.assets.filter((asset) => !isUiAsset(asset.name ?? "")).map((asset) => asset.name ?? "?");
|
|
8878
|
+
const usable = release.assets.filter((asset) => isUiAsset(asset.name ?? ""));
|
|
8879
|
+
const where = `${repo.owner}/${repo.name}@${release.tag}`;
|
|
8880
|
+
if (values.list === true) {
|
|
8881
|
+
if (usable.length === 0) throw new Error(noAssetsMessage(where, skipped, requestedTag));
|
|
8882
|
+
const lines = [`${io.style.bold(where)} — ${usable.length} usable UI asset(s)`];
|
|
8883
|
+
for (const asset of usable) lines.push(` ${asset.name}`);
|
|
8884
|
+
if (skipped.length > 0) lines.push(io.style.dim(` skipped (not a .zip): ${skipped.join(", ")}`));
|
|
8885
|
+
io.write(`${lines.join("\n")}\n`);
|
|
8886
|
+
return;
|
|
8148
8887
|
}
|
|
8149
|
-
if (
|
|
8150
|
-
|
|
8151
|
-
|
|
8152
|
-
|
|
8153
|
-
|
|
8888
|
+
if (usable.length === 0) throw new Error(noAssetsMessage(where, skipped, requestedTag));
|
|
8889
|
+
const chosen = await chooseAsset(usable, values, context);
|
|
8890
|
+
if (chosen === null) {
|
|
8891
|
+
io.write("cancelled — nothing was installed\n");
|
|
8892
|
+
return;
|
|
8893
|
+
}
|
|
8894
|
+
await installFromUrl(assetDownloadUrl(chosen), fallbackUiName(chosen.name ?? ""), context);
|
|
8154
8895
|
}
|
|
8155
|
-
|
|
8156
|
-
|
|
8157
|
-
|
|
8158
|
-
|
|
8159
|
-
|
|
8160
|
-
|
|
8161
|
-
|
|
8162
|
-
|
|
8163
|
-
|
|
8164
|
-
|
|
8896
|
+
async function fetchRelease(repo, tag, context) {
|
|
8897
|
+
const url = releaseApiUrl(repo, tag);
|
|
8898
|
+
let response;
|
|
8899
|
+
try {
|
|
8900
|
+
response = await fetch(url, { headers: apiHeaders(context) });
|
|
8901
|
+
} catch (error) {
|
|
8902
|
+
throw new Error(`could not reach GitHub: ${describeError(error)}`);
|
|
8903
|
+
}
|
|
8904
|
+
if (!response.ok) throw new Error(describeReleaseFailure(response.status, repo, tag));
|
|
8905
|
+
const body = await response.json();
|
|
8906
|
+
return {
|
|
8907
|
+
tag: body.tag_name ?? tag ?? "latest",
|
|
8908
|
+
assets: Array.isArray(body.assets) ? body.assets : []
|
|
8909
|
+
};
|
|
8910
|
+
}
|
|
8911
|
+
async function chooseAsset(assets, values, context) {
|
|
8912
|
+
const { io } = context;
|
|
8913
|
+
if (values.asset !== void 0) {
|
|
8914
|
+
const matched = matchAsset(assets.map((asset) => asset.name ?? ""), values.asset);
|
|
8915
|
+
if (!matched.ok) throw new Error(matched.error);
|
|
8916
|
+
return assets.find((asset) => asset.name === matched.name);
|
|
8917
|
+
}
|
|
8918
|
+
if (!(process.stdin.isTTY === true && values.yes !== true)) {
|
|
8919
|
+
if (assets.length === 1) return assets[0];
|
|
8920
|
+
throw new Error([
|
|
8921
|
+
`${assets.length} UI assets are available and this session cannot ask which one:`,
|
|
8922
|
+
...assets.map((asset) => ` ${asset.name}`),
|
|
8923
|
+
" pick one with --asset <name>, or list them with --list"
|
|
8924
|
+
].join("\n"));
|
|
8925
|
+
}
|
|
8926
|
+
io.write(`${[io.style.bold("Available UI assets"), ...assets.map((asset, index) => ` ${index + 1}) ${asset.name}`)].join("\n")}\n`);
|
|
8927
|
+
for (;;) {
|
|
8928
|
+
const answer = (await io.prompt(`Select an asset [1-${assets.length}] (empty to cancel) `)).trim();
|
|
8929
|
+
if (answer.length === 0) return null;
|
|
8930
|
+
if (/^\d+$/.test(answer)) {
|
|
8931
|
+
const index = Number.parseInt(answer, 10);
|
|
8932
|
+
if (index >= 1 && index <= assets.length) return assets[index - 1];
|
|
8933
|
+
}
|
|
8934
|
+
const matched = matchAsset(assets.map((asset) => asset.name ?? ""), answer);
|
|
8935
|
+
if (matched.ok) return assets.find((asset) => asset.name === matched.name);
|
|
8936
|
+
io.write(` ${io.style.dim(matched.error)}\n`);
|
|
8937
|
+
}
|
|
8938
|
+
}
|
|
8939
|
+
async function installFromFile(value, context) {
|
|
8940
|
+
const source = parseFileSource(value);
|
|
8941
|
+
if (source.kind === "path") {
|
|
8942
|
+
if (!fs.existsSync(source.path)) throw new Error(`no file at ${source.path}`);
|
|
8943
|
+
if (!fs.statSync(source.path).isFile()) throw new Error(`${source.path} is not a file`);
|
|
8944
|
+
await installArchive(source.path, fallbackUiName(source.path), context);
|
|
8945
|
+
return;
|
|
8946
|
+
}
|
|
8947
|
+
await installFromUrl(source.url, fallbackUiName(new URL(source.url).pathname), context);
|
|
8948
|
+
}
|
|
8949
|
+
async function installFromUrl(url, fallbackName, context) {
|
|
8950
|
+
const headers = {
|
|
8951
|
+
"accept": "application/octet-stream",
|
|
8952
|
+
"user-agent": `home-hosted/${context.version}`
|
|
8953
|
+
};
|
|
8954
|
+
if (context.token !== null && context.token.length > 0 && isGithubHost(url)) headers.authorization = `Bearer ${context.token}`;
|
|
8955
|
+
const download = await downloadToTemp(url, headers, context);
|
|
8956
|
+
try {
|
|
8957
|
+
await installArchive(download.file, fallbackName, context);
|
|
8958
|
+
} finally {
|
|
8959
|
+
fs.rmSync(download.dir, {
|
|
8960
|
+
recursive: true,
|
|
8961
|
+
force: true
|
|
8962
|
+
});
|
|
8963
|
+
}
|
|
8964
|
+
}
|
|
8965
|
+
/** Streams to `os.tmpdir()` so a large body is never buffered in memory. */
|
|
8966
|
+
async function downloadToTemp(url, headers, context) {
|
|
8967
|
+
let response;
|
|
8968
|
+
try {
|
|
8969
|
+
response = await fetch(url, {
|
|
8970
|
+
headers,
|
|
8971
|
+
redirect: "follow"
|
|
8972
|
+
});
|
|
8973
|
+
} catch (error) {
|
|
8974
|
+
throw new Error(`could not reach ${url}: ${describeError(error)}`);
|
|
8975
|
+
}
|
|
8976
|
+
if (!response.ok) throw new Error(describeDownloadFailure(response.status, url));
|
|
8977
|
+
const declared = Number(response.headers.get("content-length") ?? "0");
|
|
8978
|
+
if (!Number.isFinite(declared) || declared < 0) throw new Error(`the download from ${url} reported an unusable size`);
|
|
8979
|
+
if (declared > 536870912) throw new Error(tooLargeMessage(declared));
|
|
8980
|
+
if (response.body === null) throw new Error(`the download from ${url} had no body`);
|
|
8981
|
+
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "hh2-ui-"));
|
|
8982
|
+
const file = path.join(dir, "ui.zip");
|
|
8983
|
+
const handle = await fs.promises.open(file, "w");
|
|
8984
|
+
let received = 0;
|
|
8985
|
+
try {
|
|
8986
|
+
for await (const chunk of response.body) {
|
|
8987
|
+
received += chunk.length;
|
|
8988
|
+
if (received > 536870912) throw new Error(tooLargeMessage(received));
|
|
8989
|
+
await handle.write(chunk);
|
|
8990
|
+
}
|
|
8991
|
+
} catch (error) {
|
|
8992
|
+
await handle.close();
|
|
8993
|
+
fs.rmSync(dir, {
|
|
8994
|
+
recursive: true,
|
|
8995
|
+
force: true
|
|
8996
|
+
});
|
|
8997
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
8998
|
+
}
|
|
8999
|
+
await handle.close();
|
|
9000
|
+
context.io.write(`${context.io.style.dim(`downloaded ${formatBytes(received)}`)}\n`);
|
|
9001
|
+
return {
|
|
9002
|
+
dir,
|
|
9003
|
+
file
|
|
9004
|
+
};
|
|
9005
|
+
}
|
|
9006
|
+
async function installArchive(archivePath, fallbackName, context) {
|
|
9007
|
+
const { UiService } = await Promise.resolve().then(() => (init_ui(), ui_exports));
|
|
9008
|
+
const { dataRoot } = await Promise.resolve().then(() => (init_paths(), paths_exports));
|
|
9009
|
+
const ui = new UiService({ dataRoot });
|
|
9010
|
+
const result = await ui.install(archivePath, fallbackName);
|
|
9011
|
+
if (!result.ok) throw new Error(`nothing was installed: ${result.error}`);
|
|
9012
|
+
const { meta } = result;
|
|
9013
|
+
const label = meta.version === null ? meta.name : `${meta.name} ${meta.version}`;
|
|
9014
|
+
context.io.write(`${context.io.style.green("UI installed")} — ${label}\n`);
|
|
9015
|
+
context.io.write(` ${context.io.style.dim("state")} ${ui.directory}\n`);
|
|
9016
|
+
context.io.write(` ${context.io.style.dim("files")} ${meta.files}\n`);
|
|
9017
|
+
context.io.write(`refresh the browser to see it\n`);
|
|
9018
|
+
}
|
|
9019
|
+
function apiHeaders(context) {
|
|
9020
|
+
const headers = {
|
|
9021
|
+
"accept": "application/vnd.github+json",
|
|
9022
|
+
"user-agent": `home-hosted/${context.version}`,
|
|
9023
|
+
"x-github-api-version": "2022-11-28"
|
|
9024
|
+
};
|
|
9025
|
+
if (context.token !== null && context.token.length > 0) headers.authorization = `Bearer ${context.token}`;
|
|
9026
|
+
return headers;
|
|
9027
|
+
}
|
|
9028
|
+
function assetDownloadUrl(asset) {
|
|
9029
|
+
const url = asset.url ?? asset.browser_download_url;
|
|
9030
|
+
if (url === void 0 || url.length === 0) throw new Error(`the release metadata for "${asset.name ?? "an asset"}" carries no download URL`);
|
|
9031
|
+
return url;
|
|
9032
|
+
}
|
|
9033
|
+
function noAssetsMessage(where, skipped, tag) {
|
|
9034
|
+
const lines = [`no usable UI assets in ${where} (expected a .zip)`];
|
|
9035
|
+
lines.push(skipped.length > 0 ? ` skipped: ${skipped.join(", ")}` : " the release has no assets at all");
|
|
9036
|
+
if (tag !== null && tag !== "latest") lines.push(" installing a different version silently is worse than failing: try the newest release with --tag latest");
|
|
9037
|
+
return lines.join("\n");
|
|
9038
|
+
}
|
|
9039
|
+
function describeReleaseFailure(status, repo, tag) {
|
|
9040
|
+
const slug = `${repo.owner}/${repo.name}`;
|
|
9041
|
+
if (status === 404) {
|
|
9042
|
+
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`;
|
|
9043
|
+
return `no repository or published release at ${slug}\n check the --repo slug (owner/name), and that the repository is public`;
|
|
9044
|
+
}
|
|
9045
|
+
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`;
|
|
9046
|
+
if (status === 401) return "GitHub rejected the token (HTTP 401) — check --token, GITHUB_TOKEN or GH_TOKEN";
|
|
9047
|
+
return `GitHub answered HTTP ${status} while reading the release of ${slug}`;
|
|
9048
|
+
}
|
|
9049
|
+
function describeDownloadFailure(status, url) {
|
|
9050
|
+
const github = isGithubHost(url);
|
|
9051
|
+
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}`;
|
|
9052
|
+
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}`;
|
|
9053
|
+
if (status === 401 && github) return "GitHub rejected the token on the download (HTTP 401) — check --token, GITHUB_TOKEN or GH_TOKEN";
|
|
9054
|
+
return `the download failed (HTTP ${status}) — ${url}`;
|
|
9055
|
+
}
|
|
9056
|
+
function tooLargeMessage(bytes) {
|
|
9057
|
+
return `the download is ${formatBytes(bytes)}, larger than the ${MAX_DOWNLOAD_BYTES / 1024 / 1024}MB a UI may be`;
|
|
9058
|
+
}
|
|
9059
|
+
function formatBytes(bytes) {
|
|
9060
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
9061
|
+
if (bytes < 1048576) return `${Math.round(bytes / 1024)} KB`;
|
|
9062
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
9063
|
+
}
|
|
9064
|
+
function describeError(error) {
|
|
9065
|
+
return error instanceof Error ? error.message : String(error);
|
|
9066
|
+
}
|
|
9067
|
+
var DEFAULT_REPO, MAX_DOWNLOAD_BYTES, uiSwitchCommand;
|
|
9068
|
+
var init_ui_switch = __esmMin((() => {
|
|
9069
|
+
init_io();
|
|
9070
|
+
DEFAULT_REPO = "NamesMT/home-hosted";
|
|
9071
|
+
MAX_DOWNLOAD_BYTES = 536870912;
|
|
9072
|
+
uiSwitchCommand = defineCommand({
|
|
9073
|
+
meta: {
|
|
9074
|
+
name: "ui-switch",
|
|
9075
|
+
description: "install a UI from a release asset, a zip file or a URL"
|
|
8165
9076
|
},
|
|
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;
|
|
9077
|
+
run: async ({ rawArgs }) => {
|
|
9078
|
+
await uiSwitch(rawArgs, {
|
|
9079
|
+
write: (text) => process.stdout.write(text),
|
|
9080
|
+
prompt,
|
|
9081
|
+
style
|
|
9082
|
+
});
|
|
8176
9083
|
}
|
|
8177
|
-
|
|
8178
|
-
|
|
9084
|
+
});
|
|
9085
|
+
}));
|
|
9086
|
+
//#endregion
|
|
9087
|
+
//#region src/cli/ui-revert.ts
|
|
9088
|
+
var ui_revert_exports = /* @__PURE__ */ __exportAll({
|
|
9089
|
+
runUiRevert: () => runUiRevert,
|
|
9090
|
+
uiRevertCommand: () => uiRevertCommand
|
|
9091
|
+
});
|
|
9092
|
+
/** `ui-revert` drops a user-installed UI so the stock panel serves again. */
|
|
9093
|
+
async function runUiRevert() {
|
|
9094
|
+
const ui = new UiService({ dataRoot });
|
|
9095
|
+
if (!ui.custom) {
|
|
9096
|
+
process.stdout.write("no custom UI is installed — the stock panel is already in use\n");
|
|
8179
9097
|
return;
|
|
8180
9098
|
}
|
|
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`);
|
|
9099
|
+
ui.revert();
|
|
9100
|
+
process.stdout.write(`${green("custom UI removed")} — the stock panel is back; refresh the browser\n`);
|
|
8206
9101
|
}
|
|
8207
|
-
|
|
8208
|
-
|
|
8209
|
-
|
|
9102
|
+
var uiRevertCommand;
|
|
9103
|
+
var init_ui_revert = __esmMin((() => {
|
|
9104
|
+
init_io();
|
|
9105
|
+
init_paths();
|
|
9106
|
+
init_ui();
|
|
9107
|
+
uiRevertCommand = defineCommand({
|
|
9108
|
+
meta: {
|
|
9109
|
+
name: "ui-revert",
|
|
9110
|
+
description: "go back to the stock control panel UI"
|
|
9111
|
+
},
|
|
9112
|
+
run: async () => {
|
|
9113
|
+
await runUiRevert();
|
|
9114
|
+
}
|
|
9115
|
+
});
|
|
9116
|
+
}));
|
|
9117
|
+
//#endregion
|
|
9118
|
+
//#region src/cli.ts
|
|
9119
|
+
init_args();
|
|
9120
|
+
init_io();
|
|
9121
|
+
/**
|
|
9122
|
+
* The command line, and nothing else. Two things happen before citty is asked
|
|
9123
|
+
* anything: `--home`/`--project` are peeled off and applied (because
|
|
9124
|
+
* `#src/helpers/paths.ts` resolves at import time), and the curated surface —
|
|
9125
|
+
* `help`, `version`, `unknown command`, and the `-p 4000` shorthand for `up` —
|
|
9126
|
+
* is decided. The command modules are then resolved lazily by citty, so their
|
|
9127
|
+
* own `#src` imports are safe: by the time one is imported, the directories are
|
|
9128
|
+
* already in the environment.
|
|
9129
|
+
*
|
|
9130
|
+
* The only static imports here are node builtins, citty, and the two path-free
|
|
9131
|
+
* local modules the pre-pass needs; every command (and so every state-reading
|
|
9132
|
+
* module) is a dynamic import behind `subCommands`.
|
|
9133
|
+
*/
|
|
9134
|
+
var CLI_ENTRY = fileURLToPath(import.meta.url);
|
|
9135
|
+
var USAGE = `home-hosted — a control panel for the processes on your home server
|
|
9136
|
+
|
|
9137
|
+
Usage
|
|
9138
|
+
home-hosted up [options] start it in the background (detached)
|
|
9139
|
+
home-hosted down stop it, and everything it supervises
|
|
9140
|
+
home-hosted restart [options] down, then up
|
|
9141
|
+
home-hosted status [--json] is it running, where, and how to reach it
|
|
9142
|
+
home-hosted set-password set the panel password without the API
|
|
9143
|
+
home-hosted set-token set the API token that scripts and agents use
|
|
9144
|
+
home-hosted migrate bring the config up to this release's schema
|
|
9145
|
+
home-hosted init scaffold a project that keeps its state in the repo
|
|
9146
|
+
home-hosted ui-switch install a UI from a release asset, a zip file or a URL
|
|
9147
|
+
home-hosted ui-revert go back to the stock control panel UI
|
|
9148
|
+
|
|
9149
|
+
Options for up/restart
|
|
9150
|
+
-c, --config <file> servers config (default: <state>/servers.config.json)
|
|
9151
|
+
-p, --port <port> control panel port (default: 3999)
|
|
9152
|
+
--host <bind> local | lan | an ipv4 address (default: local)
|
|
9153
|
+
--open open the panel in a browser once it is up
|
|
9154
|
+
--no-autostart do not start the entries marked autostart
|
|
9155
|
+
--foreground run in this process instead of detaching (systemd/docker)
|
|
9156
|
+
--print-config print the effective config and exit
|
|
9157
|
+
|
|
9158
|
+
Options for set-token
|
|
9159
|
+
--generate create a strong token and print it once
|
|
9160
|
+
--clear remove the token, so it stops working
|
|
9161
|
+
|
|
9162
|
+
Options for migrate
|
|
9163
|
+
--dry-run print what would change, write nothing
|
|
9164
|
+
-y, --yes apply without asking (or set HHOSTED_MIGRATE=allow)
|
|
9165
|
+
|
|
9166
|
+
Options for init
|
|
9167
|
+
--dir <dir> where to scaffold (default: ./my-servers)
|
|
9168
|
+
--name <name> package name (default: the directory name)
|
|
9169
|
+
--pm <manager> pnpm | npm | yarn | bun (default: the first one installed)
|
|
9170
|
+
--no-install write the files, install nothing
|
|
9171
|
+
-y, --yes take every default, ask nothing
|
|
9172
|
+
|
|
9173
|
+
Options for ui-switch
|
|
9174
|
+
--repo <owner/name> release repo (default: NamesMT/home-hosted)
|
|
9175
|
+
--tag <tag> release tag (default: this release's tag, or latest for another repo)
|
|
9176
|
+
--asset <name> asset to install (exact or unambiguous match)
|
|
9177
|
+
--file <path|url> install a zip from a local path or an http(s) URL
|
|
9178
|
+
--list list the usable assets and install nothing
|
|
9179
|
+
--token <token> GitHub token (or GITHUB_TOKEN / GH_TOKEN)
|
|
9180
|
+
-y, --yes take the only asset instead of asking
|
|
9181
|
+
|
|
9182
|
+
Everywhere
|
|
9183
|
+
--home <dir> state directory (default: $HHOSTED_HOME or ~/.home-hosted)
|
|
9184
|
+
--project <dir> base for relative entry paths (default: the current directory)
|
|
9185
|
+
-h, --help this text
|
|
9186
|
+
-v, --version the version
|
|
9187
|
+
|
|
9188
|
+
Environment
|
|
9189
|
+
HHOSTED_HOME where config, secrets, logs, TLS and backups live
|
|
9190
|
+
HHOSTED_PROJECT base for relative entry paths
|
|
9191
|
+
HHOSTED_PASSWORD the password for a non-interactive set-password
|
|
9192
|
+
HHOSTED_TOKEN the token for a non-interactive set-token
|
|
9193
|
+
GITHUB_TOKEN a GitHub token for ui-switch (GH_TOKEN also works)
|
|
9194
|
+
`;
|
|
9195
|
+
function manifestVersion() {
|
|
8210
9196
|
try {
|
|
8211
|
-
|
|
8212
|
-
if (!response.ok) return false;
|
|
8213
|
-
return (await response.json()).authenticated === true;
|
|
9197
|
+
return JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "0.0.0";
|
|
8214
9198
|
} catch {
|
|
8215
|
-
return
|
|
9199
|
+
return "0.0.0";
|
|
8216
9200
|
}
|
|
8217
9201
|
}
|
|
8218
9202
|
function version() {
|
|
8219
|
-
|
|
8220
|
-
process.stdout.write(`${manifest.version ?? "0.0.0"}\n`);
|
|
9203
|
+
process.stdout.write(`${manifestVersion()}\n`);
|
|
8221
9204
|
}
|
|
9205
|
+
/**
|
|
9206
|
+
* citty owns dispatch and argument parsing. Each entry is a lazy import, so a
|
|
9207
|
+
* command's `#src` modules are only evaluated once `applyDirFlags()` has run.
|
|
9208
|
+
*/
|
|
9209
|
+
var COMMANDS = {
|
|
9210
|
+
"up": () => Promise.resolve().then(() => (init_up(), up_exports)).then((module) => module.upCommand(CLI_ENTRY)),
|
|
9211
|
+
"down": () => Promise.resolve().then(() => (init_down(), down_exports)).then((module) => module.downCommand),
|
|
9212
|
+
"restart": () => Promise.resolve().then(() => (init_restart(), restart_exports)).then((module) => module.restartCommand(CLI_ENTRY)),
|
|
9213
|
+
"status": () => Promise.resolve().then(() => (init_status(), status_exports)).then((module) => module.statusCommand),
|
|
9214
|
+
"set-password": () => Promise.resolve().then(() => (init_set_password(), set_password_exports)).then((module) => module.setPasswordCommand),
|
|
9215
|
+
"set-token": () => Promise.resolve().then(() => (init_set_token(), set_token_exports)).then((module) => module.setTokenCommand),
|
|
9216
|
+
"migrate": () => Promise.resolve().then(() => (init_migrate(), migrate_exports)).then((module) => module.migrateCommand),
|
|
9217
|
+
"init": () => Promise.resolve().then(() => (init_init(), init_exports)).then((module) => module.initCommand),
|
|
9218
|
+
"ui-switch": () => Promise.resolve().then(() => (init_ui_switch(), ui_switch_exports)).then((module) => module.uiSwitchCommand),
|
|
9219
|
+
"ui-revert": () => Promise.resolve().then(() => (init_ui_revert(), ui_revert_exports)).then((module) => module.uiRevertCommand)
|
|
9220
|
+
};
|
|
9221
|
+
var rootCommand = defineCommand({
|
|
9222
|
+
meta: {
|
|
9223
|
+
name: "home-hosted",
|
|
9224
|
+
version: manifestVersion(),
|
|
9225
|
+
description: "a control panel for the processes on your home server"
|
|
9226
|
+
},
|
|
9227
|
+
subCommands: COMMANDS
|
|
9228
|
+
});
|
|
9229
|
+
var commandNames = Object.keys(COMMANDS);
|
|
8222
9230
|
async function main() {
|
|
8223
|
-
const
|
|
8224
|
-
|
|
8225
|
-
|
|
8226
|
-
|
|
8227
|
-
|
|
9231
|
+
const dirFlags = extractDirFlags(process.argv.slice(2));
|
|
9232
|
+
if (dirFlags.error !== void 0) fail(dirFlags.error);
|
|
9233
|
+
applyDirFlags(dirFlags);
|
|
9234
|
+
const invocation = resolveInvocation(dirFlags.rest, commandNames);
|
|
9235
|
+
if (invocation.kind === "help") {
|
|
9236
|
+
process.stdout.write(USAGE);
|
|
8228
9237
|
return;
|
|
8229
9238
|
}
|
|
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);
|
|
9239
|
+
if (invocation.kind === "version") {
|
|
9240
|
+
version();
|
|
9241
|
+
return;
|
|
9242
|
+
}
|
|
9243
|
+
if (invocation.kind === "unknown") {
|
|
9244
|
+
process.stderr.write(`unknown command: ${invocation.command}\n\n${USAGE}`);
|
|
9245
|
+
process.exit(1);
|
|
9246
|
+
}
|
|
9247
|
+
const sub = COMMANDS[invocation.argv[0]];
|
|
9248
|
+
const command = typeof sub === "function" ? await sub() : sub;
|
|
9249
|
+
const problem = rejectUnknownFlags(invocation.argv.slice(1), command?.args);
|
|
9250
|
+
if (problem !== null) fail(problem);
|
|
9251
|
+
try {
|
|
9252
|
+
await runCommand(rootCommand, { rawArgs: invocation.argv });
|
|
9253
|
+
} catch (error) {
|
|
9254
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
8285
9255
|
}
|
|
8286
9256
|
}
|
|
8287
9257
|
main().catch((error) => {
|