home-hosted 0.4.1 → 0.6.0

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