home-hosted 0.4.0 → 0.5.0

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