home-hosted 0.3.0 → 0.4.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
@@ -88,7 +88,7 @@ function parseBind(value) {
88
88
  const parsed = bindSchema(value);
89
89
  return parsed instanceof type.errors ? null : parsed;
90
90
  }
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, logHistoryQuerySchema, logFileInfoSchema, logServerViewSchema, logServersViewSchema, notificationActionSchema, telegramTokenSchema, apiErrorSchema, uiMetaSchema, tlsUploadSchema;
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;
92
92
  var init_contracts = __esmMin((() => {
93
93
  bindSchema = type("\"local\" | \"lan\" | /^\\d{1,3}(?:\\.\\d{1,3}){3}$/");
94
94
  portSchema = type("1 <= number.integer <= 65535 | null");
@@ -162,7 +162,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
162
162
  bootstrap: bootstrapOrNullSchema.optional(),
163
163
  port: portSchema.optional(),
164
164
  bind: bindSchema.default(() => "local"),
165
- onPortConflict: "\"block\" | \"warn\" = \"block\"",
165
+ onPortConflict: "\"block\" | \"warn\" | \"follow\" | \"reclaim\" = \"block\"",
166
166
  restart: restartSchema.default(() => ({})),
167
167
  health: healthSchema.default(() => ({})),
168
168
  stop: stopSchema.default(() => ({})),
@@ -226,7 +226,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
226
226
  }).onUndeclaredKey("reject");
227
227
  controlSchema = type({
228
228
  /** What the panel calls itself; the stock UI shows it in the sidebar. */
229
- label: "1 <= string <= 60 = \"Stock UI\"",
229
+ label: "1 <= string <= 60 = \"home-hosted\"",
230
230
  port: "1 <= number.integer <= 65535 = 3999",
231
231
  /** Where the control panel itself listens; keep it `local` unless you mean it. */
232
232
  host: bindSchema.default(() => "local"),
@@ -238,7 +238,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
238
238
  enabled: "boolean = true",
239
239
  autostart: "boolean = false",
240
240
  bind: bindSchema.default(() => "local"),
241
- onPortConflict: "\"block\" | \"warn\" = \"block\"",
241
+ onPortConflict: "\"block\" | \"warn\" | \"follow\" | \"reclaim\" = \"block\"",
242
242
  restart: restartSchema.default(() => ({})),
243
243
  health: healthSchema.default(() => ({})),
244
244
  stop: stopSchema.default(() => ({})),
@@ -329,7 +329,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
329
329
  enabled: "boolean?",
330
330
  autostart: "boolean?",
331
331
  bind: bindSchema.optional(),
332
- onPortConflict: "\"block\" | \"warn\"?",
332
+ onPortConflict: "\"block\" | \"warn\" | \"follow\" | \"reclaim\"?",
333
333
  restart: restartPatchSchema.optional(),
334
334
  health: healthPatchSchema.optional(),
335
335
  stop: stopPatchSchema.optional(),
@@ -347,7 +347,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
347
347
  bootstrap: bootstrapOrNullSchema.optional(),
348
348
  port: portSchema.optional(),
349
349
  bind: bindSchema.optional(),
350
- onPortConflict: "\"block\" | \"warn\"?",
350
+ onPortConflict: "\"block\" | \"warn\" | \"follow\" | \"reclaim\"?",
351
351
  restart: restartPatchSchema.optional(),
352
352
  health: healthPatchSchema.optional(),
353
353
  stop: stopPatchSchema.optional(),
@@ -375,6 +375,13 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
375
375
  enabled: "boolean",
376
376
  passwordSet: "boolean",
377
377
  passwordUpdatedAt: "number | null",
378
+ /**
379
+ * An API token is set; it is shown here as a flag, never as a value. Optional so
380
+ * a freshly built UI still parses the state of a panel process that predates it:
381
+ * an upgrade replaces `uis/stock/dist` on disk while the old process keeps
382
+ * serving, and a missing key must not blank the whole app.
383
+ */
384
+ apiTokenSet: "boolean?",
378
385
  /** Still the boot-time default; the login page says so and exposure stays blocked. */
379
386
  usingDefaultPassword: "boolean",
380
387
  /** The panel currently listens beyond loopback. */
@@ -419,6 +426,8 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
419
426
  authenticated: "boolean",
420
427
  authRequired: "boolean",
421
428
  passwordSet: "boolean",
429
+ /** A long-lived API token is configured; optional for the same reason as above. */
430
+ apiTokenSet: "boolean?",
422
431
  usingDefaultPassword: "boolean",
423
432
  /** The boot-time password, exposed only while it is still in use. */
424
433
  defaultPassword: "string | null",
@@ -553,6 +562,8 @@ password: passwordValueSchema.optional() }).onUndeclaredKey("reject");
553
562
  health: healthStateSchema,
554
563
  portState: portStateSchema,
555
564
  pid: "number | null",
565
+ /** True when the process serving this entry was adopted, not spawned by the panel. */
566
+ adopted: "boolean?",
556
567
  startedAt: "number | null",
557
568
  exitCode: "number | null",
558
569
  exitSignal: "string | null",
@@ -608,6 +619,17 @@ password: passwordValueSchema.optional() }).onUndeclaredKey("reject");
608
619
  lines: logLineSchema.array().optional()
609
620
  });
610
621
  logQuerySchema = type({ limit: "string?" });
622
+ freePortResultSchema = type({
623
+ ok: "boolean",
624
+ port: "number | null",
625
+ /** Asked to leave with SIGTERM, and the ones that ignored it. */
626
+ terminated: "number[]",
627
+ forced: "number[]",
628
+ /** Listeners this panel supervises; never signalled. */
629
+ skipped: "number[]",
630
+ /** The port answers no more after the attempt. */
631
+ free: "boolean"
632
+ });
611
633
  logHistoryQuerySchema = type({
612
634
  tail: "string?",
613
635
  /** Case-insensitive substring filter over the tail window. */
@@ -735,6 +757,7 @@ function isLoopbackRequest(c) {
735
757
  var init_loopback = __esmMin((() => {}));
736
758
  //#endregion
737
759
  //#region src/helpers/atomic.ts
760
+ var atomic_exports = /* @__PURE__ */ __exportAll({ writeFileAtomic: () => writeFileAtomic });
738
761
  /**
739
762
  * Write via a temp file in the same directory, then rename: readers never see a
740
763
  * half-written file, and a crash cannot truncate the previous config.
@@ -750,9 +773,14 @@ var init_atomic = __esmMin((() => {}));
750
773
  //#endregion
751
774
  //#region src/config/secrets.ts
752
775
  var secrets_exports = /* @__PURE__ */ __exportAll({
776
+ API_TOKEN_PREFIX: () => "hh_",
753
777
  SecretsStore: () => SecretsStore,
778
+ apiTokenRecord: () => apiTokenRecord,
754
779
  deriveKey: () => deriveKey,
780
+ generateApiToken: () => generateApiToken,
781
+ hashApiToken: () => hashApiToken,
755
782
  hashPassword: () => hashPassword,
783
+ verifyApiToken: () => verifyApiToken,
756
784
  verifyPassword: () => verifyPassword
757
785
  });
758
786
  function deriveKey(password, salt, cost, keylen) {
@@ -781,7 +809,27 @@ function verifyPassword(password, record) {
781
809
  if (actual.length !== expected.length) return false;
782
810
  return crypto.timingSafeEqual(actual, expected);
783
811
  }
784
- var COST, KEYLEN, SALT_BYTES, SecretsStore;
812
+ function generateApiToken() {
813
+ return `hh_${crypto.randomBytes(API_TOKEN_BYTES).toString("base64url")}`;
814
+ }
815
+ function hashApiToken(token) {
816
+ return crypto.createHash("sha256").update(token, "utf8").digest("base64");
817
+ }
818
+ function verifyApiToken(token, record) {
819
+ if (token.length === 0) return false;
820
+ const expected = Buffer$1.from(record.hash, "base64");
821
+ const actual = Buffer$1.from(hashApiToken(token), "base64");
822
+ return crypto.timingSafeEqual(actual, expected);
823
+ }
824
+ function apiTokenRecord(token, now = Date.now()) {
825
+ return {
826
+ algo: "sha256",
827
+ hash: hashApiToken(token),
828
+ hint: token.slice(0, API_TOKEN_HINT_CHARS),
829
+ updatedAt: now
830
+ };
831
+ }
832
+ var COST, KEYLEN, SALT_BYTES, API_TOKEN_BYTES, API_TOKEN_HINT_CHARS, SecretsStore;
785
833
  var init_secrets = __esmMin((() => {
786
834
  init_atomic();
787
835
  COST = {
@@ -791,6 +839,8 @@ var init_secrets = __esmMin((() => {
791
839
  };
792
840
  KEYLEN = 64;
793
841
  SALT_BYTES = 16;
842
+ API_TOKEN_BYTES = 32;
843
+ API_TOKEN_HINT_CHARS = 8;
794
844
  SecretsStore = class {
795
845
  file;
796
846
  cache = null;
@@ -833,6 +883,16 @@ var init_secrets = __esmMin((() => {
833
883
  get usingDefaultPassword() {
834
884
  return this.password?.isDefault === true;
835
885
  }
886
+ get apiToken() {
887
+ return this.load().apiToken;
888
+ }
889
+ get apiTokenSet() {
890
+ return this.apiToken !== null;
891
+ }
892
+ /** The readable head of the stored token, or null when none is set. */
893
+ get apiTokenHint() {
894
+ return this.apiToken?.hint ?? null;
895
+ }
836
896
  get telegramToken() {
837
897
  return this.load().telegram?.botToken ?? null;
838
898
  }
@@ -858,6 +918,20 @@ var init_secrets = __esmMin((() => {
858
918
  password: null
859
919
  });
860
920
  }
921
+ setApiToken(token) {
922
+ const record = apiTokenRecord(token);
923
+ this.save({
924
+ ...this.load(),
925
+ apiToken: record
926
+ });
927
+ return record;
928
+ }
929
+ clearApiToken() {
930
+ this.save({
931
+ ...this.load(),
932
+ apiToken: null
933
+ });
934
+ }
861
935
  setTelegramToken(token) {
862
936
  const trimmed = token?.trim() ?? "";
863
937
  this.save({
@@ -867,21 +941,24 @@ var init_secrets = __esmMin((() => {
867
941
  }
868
942
  read() {
869
943
  if (!fs.existsSync(this.file)) return {
870
- version: 2,
944
+ version: 3,
871
945
  password: null,
946
+ apiToken: null,
872
947
  telegram: null
873
948
  };
874
949
  try {
875
950
  const parsed = JSON.parse(fs.readFileSync(this.file, "utf8"));
876
951
  return {
877
- version: 2,
952
+ version: 3,
878
953
  password: parsed?.password ?? null,
954
+ apiToken: parsed?.apiToken?.hash ? parsed.apiToken : null,
879
955
  telegram: parsed?.telegram?.botToken ? { botToken: parsed.telegram.botToken } : null
880
956
  };
881
957
  } catch {
882
958
  return {
883
- version: 2,
959
+ version: 3,
884
960
  password: null,
961
+ apiToken: null,
885
962
  telegram: null
886
963
  };
887
964
  }
@@ -891,7 +968,21 @@ var init_secrets = __esmMin((() => {
891
968
  this.cache = contents;
892
969
  }
893
970
  };
894
- })), SESSION_COOKIE, MAX_SESSIONS, MAX_LOCKOUT_MS, MAX_ATTEMPT_RECORDS, ATTEMPT_RECORD_TTL_MS, AuthService;
971
+ }));
972
+ //#endregion
973
+ //#region src/services/auth.ts
974
+ /**
975
+ * `Authorization: Bearer <token>`; the scheme is case-insensitive per RFC 7235,
976
+ * and the credential is taken whole so a stray space cannot silently shorten it.
977
+ */
978
+ function bearerToken(header) {
979
+ if (!header) return null;
980
+ const [scheme, ...rest] = header.trim().split(/\s+/);
981
+ if (scheme?.toLowerCase() !== "bearer") return null;
982
+ const token = rest.join("");
983
+ return token.length > 0 ? token : null;
984
+ }
985
+ var SESSION_COOKIE, MAX_SESSIONS, MAX_LOCKOUT_MS, MAX_ATTEMPT_RECORDS, ATTEMPT_RECORD_TTL_MS, ANONYMOUS, AuthService;
895
986
  var init_auth$1 = __esmMin((() => {
896
987
  init_secrets();
897
988
  init_cookies();
@@ -900,6 +991,11 @@ var init_auth$1 = __esmMin((() => {
900
991
  MAX_LOCKOUT_MS = 9e5;
901
992
  MAX_ATTEMPT_RECORDS = 1e4;
902
993
  ATTEMPT_RECORD_TTL_MS = 36e5;
994
+ ANONYMOUS = {
995
+ authenticated: false,
996
+ method: null,
997
+ session: null
998
+ };
903
999
  AuthService = class {
904
1000
  secrets;
905
1001
  getConfig;
@@ -926,7 +1022,7 @@ var init_auth$1 = __esmMin((() => {
926
1022
  isEnabled() {
927
1023
  return this.getConfig().enabled;
928
1024
  }
929
- /** On *and* usable: there is a password to check against. */
1025
+ /** A password is set, so a browser has something to sign in with. */
930
1026
  isArmed() {
931
1027
  return this.getConfig().enabled && this.secrets.passwordSet;
932
1028
  }
@@ -934,11 +1030,44 @@ var init_auth$1 = __esmMin((() => {
934
1030
  isRequired() {
935
1031
  return this.isArmed();
936
1032
  }
937
- sessionView(token) {
1033
+ get apiTokenSet() {
1034
+ return this.secrets.apiTokenSet;
1035
+ }
1036
+ /** The readable head of the stored token, never the token itself. */
1037
+ get apiTokenHint() {
1038
+ return this.secrets.apiTokenHint;
1039
+ }
1040
+ /** Compared against the stored SHA-256, in constant time. */
1041
+ validateApiToken(token) {
1042
+ if (token === null) return false;
1043
+ const record = this.secrets.apiToken;
1044
+ if (record === null) return false;
1045
+ return verifyApiToken(token, record);
1046
+ }
1047
+ /**
1048
+ * Resolves whichever credential the request carried. The token is only
1049
+ * consulted when no session matched, so a stale cookie cannot mask it.
1050
+ */
1051
+ authenticate(credentials) {
1052
+ const session = this.validate(credentials.cookieToken);
1053
+ if (session !== null) return {
1054
+ authenticated: true,
1055
+ method: "cookie",
1056
+ session
1057
+ };
1058
+ if (this.validateApiToken(credentials.bearerToken)) return {
1059
+ authenticated: true,
1060
+ method: "token",
1061
+ session: null
1062
+ };
1063
+ return ANONYMOUS;
1064
+ }
1065
+ sessionView(authenticated) {
938
1066
  return {
939
- authenticated: this.validate(token) !== null,
1067
+ authenticated,
940
1068
  authRequired: this.isRequired(),
941
1069
  passwordSet: this.secrets.passwordSet,
1070
+ apiTokenSet: this.secrets.apiTokenSet,
942
1071
  usingDefaultPassword: this.secrets.usingDefaultPassword,
943
1072
  defaultPassword: this.secrets.usingDefaultPassword ? "hh" : null,
944
1073
  sessionTtlMs: this.getConfig().sessionTtlMs
@@ -1083,9 +1212,24 @@ var init_auth$1 = __esmMin((() => {
1083
1212
  //#endregion
1084
1213
  //#region src/middleware/auth.ts
1085
1214
  /**
1215
+ * The one place that reads a request's credentials, so the guard, the session
1216
+ * route and `/healthz` can never disagree about who is calling.
1217
+ */
1218
+ function requestIdentity(c, auth) {
1219
+ return auth.authenticate({
1220
+ cookieToken: auth.tokenFromCookie(c.req.header("cookie")),
1221
+ bearerToken: bearerToken(c.req.header("authorization"))
1222
+ });
1223
+ }
1224
+ /**
1086
1225
  * Guards every `/api/*` route. The SPA shell stays public (it holds no data),
1087
1226
  * so the browser can load the app and show the login screen.
1088
1227
  *
1228
+ * A request may prove itself with the session cookie or with an API token
1229
+ * (`Authorization: Bearer …`), which is what lets a script or an agent drive the
1230
+ * panel without a browser. The token also works while auth is enabled but no
1231
+ * password is set — that state otherwise only trusts this machine.
1232
+ *
1089
1233
  * State-changing requests that carry an `Origin` must come from this same host:
1090
1234
  * with `SameSite=Strict` cookies that closes the cross-site CSRF path.
1091
1235
  */
@@ -1111,14 +1255,17 @@ function createAuthGuard(deps) {
1111
1255
  }
1112
1256
  }
1113
1257
  if (!PUBLIC_PATHS.has(path)) {
1258
+ const identity = requestIdentity(c, deps.auth);
1114
1259
  if (deps.auth.isArmed()) {
1115
- const token = deps.auth.tokenFromCookie(c.req.header("cookie"));
1116
- if (deps.auth.validate(token) === null) throw new DetailedError("authentication required", {
1117
- statusCode: 401,
1118
- code: AUTH_REQUIRED_CODE
1119
- });
1260
+ if (!identity.authenticated) {
1261
+ if (deps.auth.apiTokenSet) c.header("WWW-Authenticate", "Bearer realm=\"home-hosted\"");
1262
+ throw new DetailedError("authentication required", {
1263
+ statusCode: 401,
1264
+ code: AUTH_REQUIRED_CODE
1265
+ });
1266
+ }
1120
1267
  } else if (deps.auth.isEnabled()) {
1121
- if (!isLoopbackRequest(c)) throw new DetailedError("authentication is enabled but no password is set — set one from the machine running the panel", {
1268
+ if (!identity.authenticated && !isLoopbackRequest(c)) throw new DetailedError("authentication is enabled but no password is set — set one from the machine running the panel", {
1122
1269
  statusCode: 401,
1123
1270
  code: "AUTH_UNARMED"
1124
1271
  });
@@ -1211,7 +1358,7 @@ function createAuthRoute(deps) {
1211
1358
  description: "Session",
1212
1359
  content: jsonBody(sessionViewSchema)
1213
1360
  } }
1214
- }), (c) => c.json(deps.auth.sessionView(deps.auth.tokenFromCookie(c.req.header("cookie"))))).post("/auth/login", describeRoute({
1361
+ }), (c) => c.json(deps.auth.sessionView(requestIdentity(c, deps.auth).authenticated))).post("/auth/login", describeRoute({
1215
1362
  tags: ["auth"],
1216
1363
  summary: "Exchange the panel password for a session cookie",
1217
1364
  responses: {
@@ -1238,7 +1385,7 @@ function createAuthRoute(deps) {
1238
1385
  sameSite: "Strict",
1239
1386
  httpOnly: true
1240
1387
  }));
1241
- return c.json(deps.auth.sessionView(outcome.token));
1388
+ return c.json(deps.auth.sessionView(true));
1242
1389
  }).post("/auth/logout", describeRoute({
1243
1390
  tags: ["auth"],
1244
1391
  summary: "Drop the session cookie",
@@ -1262,14 +1409,14 @@ function createAuthRoute(deps) {
1262
1409
  }
1263
1410
  }), validate("json", passwordSchema), (c) => {
1264
1411
  const body = c.req.valid("json");
1412
+ const identity = requestIdentity(c, deps.auth);
1265
1413
  const hadPassword = deps.auth.passwordSet;
1266
- const authenticated = deps.auth.validate(deps.auth.tokenFromCookie(c.req.header("cookie"))) !== null;
1267
1414
  const firstSetup = !hadPassword && isLoopbackRequest(c);
1268
- if (!authenticated && !firstSetup) throw new DetailedError("authentication required", {
1415
+ if (!identity.authenticated && !firstSetup) throw new DetailedError("authentication required", {
1269
1416
  statusCode: 401,
1270
1417
  code: AUTH_REQUIRED_CODE
1271
1418
  });
1272
- if (authenticated && hadPassword) {
1419
+ if (identity.authenticated && hadPassword) {
1273
1420
  if (body.currentPassword === void 0) throw new DetailedError("currentPassword is required to change an existing password", {
1274
1421
  statusCode: 400,
1275
1422
  code: "CURRENT_PASSWORD_REQUIRED"
@@ -1279,7 +1426,7 @@ function createAuthRoute(deps) {
1279
1426
  code: "CURRENT_PASSWORD_WRONG"
1280
1427
  });
1281
1428
  }
1282
- deps.auth.setPassword(body.newPassword, { keepToken: deps.auth.tokenFromCookie(c.req.header("cookie")) });
1429
+ deps.auth.setPassword(body.newPassword, { keepToken: identity.session?.token ?? null });
1283
1430
  let enabled = deps.store.config.control.auth.enabled;
1284
1431
  if (!enabled) {
1285
1432
  deps.store.updateControl({ auth: { enabled: true } });
@@ -1299,7 +1446,7 @@ function createAuthRoute(deps) {
1299
1446
  401: ERROR_RESPONSES[401]
1300
1447
  }
1301
1448
  }), (c) => {
1302
- if (deps.auth.validate(deps.auth.tokenFromCookie(c.req.header("cookie"))) === null) throw new DetailedError("authentication required", {
1449
+ if (!requestIdentity(c, deps.auth).authenticated) throw new DetailedError("authentication required", {
1303
1450
  statusCode: 401,
1304
1451
  code: AUTH_REQUIRED_CODE
1305
1452
  });
@@ -1432,6 +1579,7 @@ function buildControlView(store, auth, control, tls) {
1432
1579
  enabled: config.auth.enabled,
1433
1580
  passwordSet: auth.passwordSet,
1434
1581
  passwordUpdatedAt: auth.passwordUpdatedAt,
1582
+ apiTokenSet: auth.apiTokenSet,
1435
1583
  usingDefaultPassword: auth.usingDefaultPassword,
1436
1584
  exposed: exposure.exposed,
1437
1585
  blockedReason: exposure.blockedReason,
@@ -1800,7 +1948,7 @@ function createHealthRoute(deps) {
1800
1948
  }), (c) => {
1801
1949
  const state = deps.supervisor.getState();
1802
1950
  const broken = state.servers.filter((server) => server.config.autostart && server.status === "crashed");
1803
- const authenticated = deps.auth.validate(deps.auth.tokenFromCookie(c.req.header("cookie"))) !== null;
1951
+ const authenticated = requestIdentity(c, deps.auth).authenticated;
1804
1952
  return c.json({
1805
1953
  status: broken.length > 0 ? "degraded" : "ok",
1806
1954
  uptimeMs: Math.round(process.uptime() * 1e3),
@@ -1819,6 +1967,7 @@ function createHealthRoute(deps) {
1819
1967
  var init_health = __esmMin((() => {
1820
1968
  init_factory();
1821
1969
  init_openapi$1();
1970
+ init_auth();
1822
1971
  }));
1823
1972
  //#endregion
1824
1973
  //#region src/api/logs.ts
@@ -2137,12 +2286,49 @@ var init_notifications$1 = __esmMin((() => {
2137
2286
  init_contracts();
2138
2287
  }));
2139
2288
  //#endregion
2289
+ //#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
+ /** What would have to run to bring `from` up to `to`. */
2297
+ function planConfigMigrations(from, options = {}) {
2298
+ const to = options.to ?? 1;
2299
+ return {
2300
+ from,
2301
+ to,
2302
+ steps: (options.migrations ?? configMigrations).filter((migration) => migration.to > from && migration.to <= to).sort((a, b) => a.to - b.to),
2303
+ tooNew: from > to
2304
+ };
2305
+ }
2306
+ /** Applies the plan in order; the caller owns writing the result. */
2307
+ function applyConfigMigrations(config, from, options = {}) {
2308
+ const { steps } = planConfigMigrations(from, options);
2309
+ let current = config;
2310
+ for (const step of steps) current = step.apply(current);
2311
+ return {
2312
+ config: current,
2313
+ applied: steps
2314
+ };
2315
+ }
2316
+ var configMigrations;
2317
+ var init_migrations = __esmMin((() => {
2318
+ configMigrations = [];
2319
+ }));
2320
+ //#endregion
2140
2321
  //#region src/config/schema.ts
2141
- var configSchema;
2322
+ var metaSchema, configSchema, CONFIG_KEYS;
2142
2323
  var init_schema = __esmMin((() => {
2143
2324
  init_contracts();
2325
+ metaSchema = type({
2326
+ writtenBy: "string = \"\"",
2327
+ schema: "number.integer >= 1 = 1"
2328
+ }).onUndeclaredKey("reject");
2144
2329
  configSchema = type({
2145
2330
  $schema: "string?",
2331
+ meta: metaSchema.optional(),
2146
2332
  control: controlSchema.default(() => ({})),
2147
2333
  defaults: defaultsSchema.default(() => ({})),
2148
2334
  logs: logsSchema.default(() => ({})),
@@ -2151,6 +2337,240 @@ var init_schema = __esmMin((() => {
2151
2337
  backups: backupsSchema.default(() => ({})),
2152
2338
  servers: serverSchema.array().default(() => [])
2153
2339
  }).onUndeclaredKey("reject");
2340
+ CONFIG_KEYS = [
2341
+ "$schema",
2342
+ "meta",
2343
+ "control",
2344
+ "defaults",
2345
+ "logs",
2346
+ "notifications",
2347
+ "host",
2348
+ "backups",
2349
+ "servers"
2350
+ ];
2351
+ }));
2352
+ //#endregion
2353
+ //#region src/helpers/version.ts
2354
+ var version_exports = /* @__PURE__ */ __exportAll({ appVersion: () => appVersion });
2355
+ /**
2356
+ * The running release, read from the package manifest.
2357
+ *
2358
+ * The caller may be `src/**` under tsx or the built `dist/cli.js`, so the manifest
2359
+ * is found by walking up rather than by a fixed relative path. `src/cli.ts` keeps
2360
+ * its own read because it may only import node builtins statically.
2361
+ */
2362
+ function appVersion() {
2363
+ if (cached !== null) return cached;
2364
+ let dir = path.dirname(fileURLToPath(import.meta.url));
2365
+ for (let depth = 0; depth < 4; depth++) {
2366
+ try {
2367
+ const manifest = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
2368
+ if (manifest.name === "home-hosted" && typeof manifest.version === "string") {
2369
+ cached = manifest.version;
2370
+ return cached;
2371
+ }
2372
+ } catch {}
2373
+ const parent = path.dirname(dir);
2374
+ if (parent === dir) break;
2375
+ dir = parent;
2376
+ }
2377
+ cached = "0.0.0";
2378
+ return cached;
2379
+ }
2380
+ var cached;
2381
+ var init_version = __esmMin((() => {
2382
+ cached = null;
2383
+ }));
2384
+ //#endregion
2385
+ //#region src/config/parse.ts
2386
+ var parse_exports = /* @__PURE__ */ __exportAll({
2387
+ parseConfig: () => parseConfig,
2388
+ stampConfig: () => stampConfig
2389
+ });
2390
+ function isRecord$1(value) {
2391
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2392
+ }
2393
+ function deleteAtPath(root, path) {
2394
+ let node = root;
2395
+ for (const key of path.slice(0, -1)) {
2396
+ node = Array.isArray(node) ? node[Number(key)] : isRecord$1(node) ? node[key] : void 0;
2397
+ if (node === void 0) return;
2398
+ }
2399
+ const last = path[path.length - 1];
2400
+ if (last === void 0) return;
2401
+ if (isRecord$1(node)) delete node[String(last)];
2402
+ else if (Array.isArray(node) && typeof last === "number") node.splice(last, 1);
2403
+ }
2404
+ /**
2405
+ * Validates one object against one schema, tolerating keys the schema does not
2406
+ * know: a config written by a newer release has to keep working here, and losing
2407
+ * the *whole group* to schema defaults would silently reset the listener port,
2408
+ * the bind and the auth policy over a single unrecognized key.
2409
+ *
2410
+ * Only unrecognized keys are dropped, and each one is reported. Anything else is
2411
+ * a real problem, returned for the caller to refuse on.
2412
+ */
2413
+ function parseTolerant(value, schema, prefix, unknownKeys) {
2414
+ const candidate = structuredClone(value);
2415
+ for (let pass = 0; pass < 25; pass++) {
2416
+ const parsed = schema(candidate);
2417
+ if (!(parsed instanceof type.errors)) return {
2418
+ value: parsed,
2419
+ error: null
2420
+ };
2421
+ const removable = parsed.filter((problem) => problem.problem === UNDECLARED);
2422
+ if (removable.length === 0) return {
2423
+ value: null,
2424
+ error: parsed.summary
2425
+ };
2426
+ if (!isRecord$1(candidate)) return {
2427
+ value: null,
2428
+ error: parsed.summary
2429
+ };
2430
+ for (const problem of removable) {
2431
+ const at = `${prefix}.${problem.path.join(".")}`;
2432
+ if (!unknownKeys.includes(at)) unknownKeys.push(at);
2433
+ deleteAtPath(candidate, problem.path);
2434
+ }
2435
+ }
2436
+ return {
2437
+ value: null,
2438
+ error: "too many unrecognized keys to ignore"
2439
+ };
2440
+ }
2441
+ /**
2442
+ * Reads a `servers.config.json` from any release. Unrecognized keys are reported
2443
+ * and left on disk; blocking problems — including a file this release cannot
2444
+ * understand — land in `errors` and leave `config` null.
2445
+ */
2446
+ function parseConfig(raw, options = {}) {
2447
+ const unknownKeys = [];
2448
+ const errors = [];
2449
+ const warnings = [];
2450
+ const result = {
2451
+ config: null,
2452
+ errors,
2453
+ unknownKeys,
2454
+ warnings,
2455
+ schemaVersion: 1,
2456
+ writtenBy: null
2457
+ };
2458
+ if (!isRecord$1(raw)) {
2459
+ errors.push("the config must contain a JSON object");
2460
+ return result;
2461
+ }
2462
+ const meta = isRecord$1(raw.meta) ? raw.meta : null;
2463
+ const schemaVersion = typeof meta?.schema === "number" ? meta.schema : 1;
2464
+ const writtenBy = typeof meta?.writtenBy === "string" && meta.writtenBy.length > 0 ? meta.writtenBy : null;
2465
+ result.schemaVersion = schemaVersion;
2466
+ result.writtenBy = writtenBy;
2467
+ if (schemaVersion > 1) {
2468
+ errors.push(`written by home-hosted ${writtenBy ?? "a newer release"} (config schema ${schemaVersion}); this release understands schema 1`);
2469
+ return result;
2470
+ }
2471
+ const { steps } = planConfigMigrations(schemaVersion, options);
2472
+ if (steps.length > 0) {
2473
+ errors.push(`config schema ${schemaVersion} needs ${steps.length} migration${steps.length === 1 ? "" : "s"} before this release can use it`);
2474
+ return result;
2475
+ }
2476
+ for (const key of Object.keys(raw)) if (!CONFIG_KEYS.includes(key)) unknownKeys.push(key);
2477
+ const groups = {};
2478
+ for (const [name, schema] of GROUPS) {
2479
+ const parsed = parseTolerant(raw[name] ?? {}, schema, name, unknownKeys);
2480
+ if (parsed.error !== null) errors.push(`${name}: ${parsed.error}`);
2481
+ groups[name] = parsed.value ?? schema({});
2482
+ }
2483
+ const defaults = groups.defaults;
2484
+ const servers = [];
2485
+ const seen = /* @__PURE__ */ new Set();
2486
+ (Array.isArray(raw.servers) ? raw.servers : []).forEach((entry, index) => {
2487
+ const label = `servers[${index}]`;
2488
+ const parsed = parseTolerant(isRecord$1(entry) ? {
2489
+ ...defaults,
2490
+ ...entry
2491
+ } : entry, serverSchema, label, unknownKeys);
2492
+ if (parsed.error !== null) {
2493
+ const id = isRecord$1(entry) ? entry.id : void 0;
2494
+ errors.push(`${label}${typeof id === "string" ? ` ("${id}")` : ""}: ${parsed.error}`);
2495
+ return;
2496
+ }
2497
+ const server = parsed.value;
2498
+ if (seen.has(server.id)) {
2499
+ errors.push(`${label}: duplicate id "${server.id}"`);
2500
+ return;
2501
+ }
2502
+ seen.add(server.id);
2503
+ servers.push({
2504
+ ...server,
2505
+ port: server.port ?? null
2506
+ });
2507
+ });
2508
+ warnings.push(...validateDependencies(servers));
2509
+ if (errors.length > 0) return result;
2510
+ result.config = {
2511
+ meta: {
2512
+ writtenBy: writtenBy ?? "",
2513
+ schema: schemaVersion
2514
+ },
2515
+ control: groups.control,
2516
+ defaults,
2517
+ logs: groups.logs,
2518
+ notifications: groups.notifications,
2519
+ host: groups.host,
2520
+ backups: groups.backups,
2521
+ servers
2522
+ };
2523
+ return result;
2524
+ }
2525
+ /** Dangling dependencies and cycles are reported, not fatal: supervision still runs. */
2526
+ function validateDependencies(servers) {
2527
+ const ids = new Set(servers.map((server) => server.id));
2528
+ const errors = [];
2529
+ for (const server of servers) for (const dependency of server.dependsOn) if (dependency === server.id) errors.push(`"${server.id}" depends on itself`);
2530
+ else if (!ids.has(dependency)) errors.push(`"${server.id}" depends on unknown server "${dependency}"`);
2531
+ const visiting = /* @__PURE__ */ new Set();
2532
+ const settled = /* @__PURE__ */ new Set();
2533
+ const byId = new Map(servers.map((server) => [server.id, server]));
2534
+ const walk = (id) => {
2535
+ if (settled.has(id)) return;
2536
+ if (visiting.has(id)) {
2537
+ errors.push(`dependency cycle through "${id}"`);
2538
+ return;
2539
+ }
2540
+ visiting.add(id);
2541
+ for (const dependency of byId.get(id)?.dependsOn ?? []) walk(dependency);
2542
+ visiting.delete(id);
2543
+ settled.add(id);
2544
+ };
2545
+ for (const server of servers) walk(server.id);
2546
+ return [...new Set(errors)];
2547
+ }
2548
+ /** Adds the stamp every write carries, so the next release can tell what wrote the file. */
2549
+ function stampConfig(draft) {
2550
+ const { $schema, meta: _meta, ...rest } = draft;
2551
+ return {
2552
+ ...$schema === void 0 ? {} : { $schema },
2553
+ meta: {
2554
+ writtenBy: appVersion(),
2555
+ schema: 1
2556
+ },
2557
+ ...rest
2558
+ };
2559
+ }
2560
+ var GROUPS, UNDECLARED;
2561
+ var init_parse = __esmMin((() => {
2562
+ init_migrations();
2563
+ init_schema();
2564
+ init_version();
2565
+ GROUPS = [
2566
+ ["control", controlSchema],
2567
+ ["defaults", defaultsSchema],
2568
+ ["logs", logsSchema],
2569
+ ["notifications", notificationsSchema],
2570
+ ["host", hostSchema],
2571
+ ["backups", backupsSchema]
2572
+ ];
2573
+ UNDECLARED = "must be removed";
2154
2574
  }));
2155
2575
  //#endregion
2156
2576
  //#region src/config/seed.ts
@@ -2180,30 +2600,6 @@ function formatErrors(errors) {
2180
2600
  function isRecord(value) {
2181
2601
  return typeof value === "object" && value !== null && !Array.isArray(value);
2182
2602
  }
2183
- /** Dangling dependencies and cycles are reported, not fatal: supervision still runs. */
2184
- function validateDependencies(servers) {
2185
- const ids = new Set(servers.map((server) => server.id));
2186
- const errors = [];
2187
- for (const server of servers) for (const dependency of server.dependsOn) if (dependency === server.id) errors.push(`"${server.id}" depends on itself`);
2188
- else if (!ids.has(dependency)) errors.push(`"${server.id}" depends on unknown server "${dependency}"`);
2189
- const visiting = /* @__PURE__ */ new Set();
2190
- const done = /* @__PURE__ */ new Set();
2191
- const byId = new Map(servers.map((server) => [server.id, server]));
2192
- const walk = (id, trail) => {
2193
- if (done.has(id)) return;
2194
- if (visiting.has(id)) {
2195
- errors.push(`dependency cycle: ${[...trail, id].join(" -> ")}`);
2196
- return;
2197
- }
2198
- visiting.add(id);
2199
- for (const dependency of byId.get(id)?.dependsOn ?? []) if (ids.has(dependency) && dependency !== id) walk(dependency, [...trail, id]);
2200
- visiting.delete(id);
2201
- done.add(id);
2202
- };
2203
- for (const server of servers) walk(server.id, []);
2204
- return errors;
2205
- }
2206
- /** Nested groups merge so a partial edit never drops a sibling field. */
2207
2603
  function applyPatch(target, patch, mergeKeys) {
2208
2604
  for (const [key, value] of Object.entries(patch)) {
2209
2605
  if (value === void 0) continue;
@@ -2238,6 +2634,8 @@ function mergeGroup(target, patch) {
2238
2634
  }
2239
2635
  var SERVER_MERGE_KEYS, CONTROL_MERGE_KEYS, NOTIFICATION_MERGE_KEYS, EMPTY_MERGE_KEYS, ConfigError, ConfigStore;
2240
2636
  var init_store = __esmMin((() => {
2637
+ init_migrations();
2638
+ init_parse();
2241
2639
  init_schema();
2242
2640
  init_seed();
2243
2641
  init_atomic();
@@ -2259,6 +2657,8 @@ var init_store = __esmMin((() => {
2259
2657
  raw = {};
2260
2658
  resolvedConfig;
2261
2659
  error = null;
2660
+ warnings = [];
2661
+ schemaVersion = 1;
2262
2662
  listeners = /* @__PURE__ */ new Set();
2263
2663
  constructor(file, seed = SEED_CONFIG) {
2264
2664
  this.file = file;
@@ -2273,6 +2673,18 @@ var init_store = __esmMin((() => {
2273
2673
  get configError() {
2274
2674
  return this.error;
2275
2675
  }
2676
+ /** Keys a newer release wrote that this one ignores; nothing to refuse over. */
2677
+ get configWarnings() {
2678
+ return [...this.warnings];
2679
+ }
2680
+ /** The shape the file declares, as last read. */
2681
+ get configSchemaVersion() {
2682
+ return this.schemaVersion;
2683
+ }
2684
+ /** Steps that would have to run before this release could use the file. */
2685
+ get pendingMigrations() {
2686
+ return planConfigMigrations(this.schemaVersion).steps;
2687
+ }
2276
2688
  get servers() {
2277
2689
  return this.resolvedConfig.servers;
2278
2690
  }
@@ -2299,7 +2711,7 @@ var init_store = __esmMin((() => {
2299
2711
  }
2300
2712
  read() {
2301
2713
  if (!fs.existsSync(this.file)) {
2302
- const seed = structuredClone(this.seed);
2714
+ const seed = stampConfig(structuredClone(this.seed));
2303
2715
  writeFileAtomic(this.file, `${JSON.stringify(seed, null, 2)}\n`);
2304
2716
  this.raw = seed;
2305
2717
  this.apply(seed);
@@ -2314,12 +2726,6 @@ var init_store = __esmMin((() => {
2314
2726
  this.resolvedConfig = this.resolveFallback();
2315
2727
  return;
2316
2728
  }
2317
- if (!isRecord(parsed)) {
2318
- this.error = `${path.basename(this.file)} must contain a JSON object`;
2319
- this.raw = {};
2320
- this.resolvedConfig = this.resolveFallback();
2321
- return;
2322
- }
2323
2729
  this.apply(parsed);
2324
2730
  }
2325
2731
  notify() {
@@ -2423,9 +2829,10 @@ var init_store = __esmMin((() => {
2423
2829
  };
2424
2830
  }
2425
2831
  commit(draft) {
2426
- writeFileAtomic(this.file, `${JSON.stringify(draft, null, 2)}\n`);
2427
- this.raw = draft;
2428
- this.apply(draft);
2832
+ const stamped = stampConfig(draft);
2833
+ writeFileAtomic(this.file, `${JSON.stringify(stamped, null, 2)}\n`);
2834
+ this.raw = stamped;
2835
+ this.apply(stamped);
2429
2836
  this.notify();
2430
2837
  }
2431
2838
  resolveFallback() {
@@ -2449,53 +2856,12 @@ var init_store = __esmMin((() => {
2449
2856
  }
2450
2857
  apply(raw) {
2451
2858
  this.raw = raw;
2452
- const errors = [];
2453
- const control = controlSchema(raw.control ?? {});
2454
- const defaults = defaultsSchema(raw.defaults ?? {});
2455
- const logs = logsSchema(raw.logs ?? {});
2456
- const notifications = notificationsSchema(raw.notifications ?? {});
2457
- const host = hostSchema(raw.host ?? {});
2458
- const backups = backupsSchema(raw.backups ?? {});
2459
- if (control instanceof type.errors) errors.push(`control: ${formatErrors(control)}`);
2460
- if (defaults instanceof type.errors) errors.push(`defaults: ${formatErrors(defaults)}`);
2461
- if (logs instanceof type.errors) errors.push(`logs: ${formatErrors(logs)}`);
2462
- if (notifications instanceof type.errors) errors.push(`notifications: ${formatErrors(notifications)}`);
2463
- if (host instanceof type.errors) errors.push(`host: ${formatErrors(host)}`);
2464
- if (backups instanceof type.errors) errors.push(`backups: ${formatErrors(backups)}`);
2465
- const resolvedDefaults = defaults instanceof type.errors ? defaultsSchema({}) : defaults;
2466
- const servers = [];
2467
- const seen = /* @__PURE__ */ new Set();
2468
- (Array.isArray(raw.servers) ? raw.servers : []).forEach((entry, index) => {
2469
- const parsed = serverSchema({
2470
- ...resolvedDefaults,
2471
- ...entry
2472
- });
2473
- if (parsed instanceof type.errors) {
2474
- errors.push(`servers[${index}] (${entry?.id ?? "no id"}): ${formatErrors(parsed)}`);
2475
- return;
2476
- }
2477
- if (seen.has(parsed.id)) {
2478
- errors.push(`servers[${index}]: duplicate id "${parsed.id}"`);
2479
- return;
2480
- }
2481
- seen.add(parsed.id);
2482
- servers.push({
2483
- ...parsed,
2484
- port: parsed.port ?? null
2485
- });
2486
- });
2487
- errors.push(...validateDependencies(servers));
2488
- this.error = errors.length > 0 ? errors.join("; ") : null;
2489
- this.resolvedConfig = {
2490
- $schema: raw.$schema,
2491
- control: control instanceof type.errors ? controlSchema({}) : control,
2492
- defaults: resolvedDefaults,
2493
- logs: logs instanceof type.errors ? logsSchema({}) : logs,
2494
- notifications: notifications instanceof type.errors ? notificationsSchema({}) : notifications,
2495
- host: host instanceof type.errors ? hostSchema({}) : host,
2496
- backups: backups instanceof type.errors ? backupsSchema({}) : backups,
2497
- servers
2498
- };
2859
+ const parsed = parseConfig(raw);
2860
+ this.error = parsed.errors.length > 0 ? parsed.errors.join("; ") : null;
2861
+ this.schemaVersion = parsed.schemaVersion;
2862
+ this.warnings = [...parsed.warnings, ...parsed.unknownKeys.length === 0 ? [] : [`${path.basename(this.file)} carries ${parsed.unknownKeys.length} unrecognized key(s) this release ignores: ${parsed.unknownKeys.join(", ")}`]];
2863
+ if (parsed.config !== null) this.resolvedConfig = parsed.config;
2864
+ else if (this.resolvedConfig === void 0) this.resolvedConfig = this.resolveFallback();
2499
2865
  }
2500
2866
  };
2501
2867
  }));
@@ -2681,6 +3047,25 @@ function createServersRoute(deps) {
2681
3047
  }), validate("param", idParam), (c) => {
2682
3048
  deps.supervisor.clearLogs(c.req.valid("param").id);
2683
3049
  return c.json({ ok: true });
3050
+ }).post("/:id/free-port", describeRoute({
3051
+ tags: ["servers"],
3052
+ summary: "Ask whatever holds this server's port to stop",
3053
+ responses: {
3054
+ 200: {
3055
+ description: "What was signalled",
3056
+ content: jsonBody(freePortResultSchema)
3057
+ },
3058
+ 404: ERROR_RESPONSES[404],
3059
+ 409: { description: "Nothing to free, or the holder is supervised by this panel" }
3060
+ }
3061
+ }), validate("param", idParam), async (c) => {
3062
+ const { id } = c.req.valid("param");
3063
+ const result = await deps.supervisor.freePort(id);
3064
+ if (!result.ok) throw new DetailedError(result.error ?? `could not free the port for "${id}"`, {
3065
+ statusCode: statusFor(result),
3066
+ code: "FREE_PORT_FAILED"
3067
+ });
3068
+ return c.json(result);
2684
3069
  }).patch("/:id", describeRoute({
2685
3070
  tags: ["servers"],
2686
3071
  summary: "Edit a server",
@@ -3197,7 +3582,7 @@ var init_app = __esmMin((() => {
3197
3582
  //#region src/helpers/daemon.ts
3198
3583
  var daemon_exports = /* @__PURE__ */ __exportAll({
3199
3584
  clearRuntime: () => clearRuntime,
3200
- isProcessAlive: () => isProcessAlive,
3585
+ isProcessAlive: () => isProcessAlive$1,
3201
3586
  newToken: () => newToken,
3202
3587
  probeRuntime: () => probeRuntime,
3203
3588
  readRuntime: () => readRuntime,
@@ -3223,7 +3608,7 @@ function newToken() {
3223
3608
  return randomBytes(32).toString("base64url");
3224
3609
  }
3225
3610
  /** Signal 0 only probes the pid; `EPERM` still means the process is there. */
3226
- function isProcessAlive(pid) {
3611
+ function isProcessAlive$1(pid) {
3227
3612
  try {
3228
3613
  process.kill(pid, 0);
3229
3614
  return true;
@@ -3361,6 +3746,46 @@ async function killPortHolders(port) {
3361
3746
  } catch {}
3362
3747
  return pids;
3363
3748
  }
3749
+ /** Signal 0 asks the OS whether the pid still exists, without touching it. */
3750
+ function isProcessAlive(pid) {
3751
+ try {
3752
+ process.kill(pid, 0);
3753
+ return true;
3754
+ } catch {
3755
+ return false;
3756
+ }
3757
+ }
3758
+ function signalPid$1(pid, name) {
3759
+ try {
3760
+ process.kill(pid, name);
3761
+ } catch {}
3762
+ }
3763
+ function delay$3(ms) {
3764
+ return new Promise((resolve) => setTimeout(resolve, ms));
3765
+ }
3766
+ /**
3767
+ * Asks specific pids to leave, politely first: a stray dev server stops on
3768
+ * SIGTERM, and only what ignores it is killed. Deciding *which* pids may be
3769
+ * touched belongs to the caller — this only does the signalling.
3770
+ */
3771
+ async function terminatePids(pids, options = {}) {
3772
+ const graceMs = options.graceMs ?? 3e3;
3773
+ const aliveBefore = [...new Set(pids)].filter(isProcessAlive);
3774
+ for (const pid of aliveBefore) signalPid$1(pid, "SIGTERM");
3775
+ const deadline = Date.now() + graceMs;
3776
+ let alive = aliveBefore.filter(isProcessAlive);
3777
+ while (alive.length > 0 && Date.now() < deadline) {
3778
+ await delay$3(100);
3779
+ alive = alive.filter(isProcessAlive);
3780
+ }
3781
+ const stopped = aliveBefore.filter((pid) => !alive.includes(pid));
3782
+ for (const pid of alive) signalPid$1(pid, "SIGKILL");
3783
+ if (alive.length > 0 && graceMs > 0) await delay$3(150);
3784
+ return {
3785
+ stopped,
3786
+ forced: alive
3787
+ };
3788
+ }
3364
3789
  /** `netstat -ano` lines: ` TCP 127.0.0.1:4010 0.0.0.0:0 LISTENING 1234` */
3365
3790
  function parseNetstatListeners(output, port) {
3366
3791
  const pids = /* @__PURE__ */ new Set();
@@ -3880,6 +4305,39 @@ function collectTree(rootPid, children) {
3880
4305
  }
3881
4306
  return pids;
3882
4307
  }
4308
+ /**
4309
+ * Does this process carry the environment the supervisor gave the entry?
4310
+ *
4311
+ * A program that restarts itself — especially a plugin doing it — leaves behind a
4312
+ * detached process that still inherits `HHOSTED_SERVER_ID`, and that marker is what
4313
+ * tells a legitimate successor apart from a stranger squatting on the port.
4314
+ *
4315
+ * Linux reads `/proc`; macOS asks `ps -E`; Windows has no per-process environment,
4316
+ * so the answer is always "no" there and ownership falls back to the port policy.
4317
+ */
4318
+ async function processCarriesServerId(pid, serverId) {
4319
+ if (serverId.length === 0) return false;
4320
+ const needle = `HHOSTED_SERVER_ID=${serverId}`;
4321
+ if (process.platform === "linux") try {
4322
+ return (await fs.promises.readFile(`/proc/${pid}/environ`, "utf8")).split("\0").includes(needle);
4323
+ } catch {
4324
+ return false;
4325
+ }
4326
+ if (process.platform === "darwin") try {
4327
+ const { stdout } = await execFileAsync$2("ps", [
4328
+ "-p",
4329
+ String(pid),
4330
+ "-E",
4331
+ "-ww",
4332
+ "-o",
4333
+ "command="
4334
+ ], { timeout: 3e3 });
4335
+ return new RegExp(`(?:^|\\s)${needle}(?:\\s|$)`).test(stdout);
4336
+ } catch {
4337
+ return false;
4338
+ }
4339
+ return false;
4340
+ }
3883
4341
  var execFileAsync$2, clockTicks, ProcessSampler;
3884
4342
  var init_proc = __esmMin((() => {
3885
4343
  execFileAsync$2 = promisify(execFile);
@@ -4016,6 +4474,9 @@ async function killTreeWindows(pid) {
4016
4474
  function signalChild(child, signal, killGroup) {
4017
4475
  const pid = child.pid;
4018
4476
  if (pid === void 0) return;
4477
+ signalPid(pid, signal, killGroup);
4478
+ }
4479
+ function signalPid(pid, signal, killGroup) {
4019
4480
  if (process.platform === "win32") {
4020
4481
  if (killGroup) killTreeWindows(pid);
4021
4482
  else try {
@@ -4031,6 +4492,33 @@ function signalChild(child, signal, killGroup) {
4031
4492
  process.kill(pid, signal);
4032
4493
  } catch {}
4033
4494
  }
4495
+ function alive(pid) {
4496
+ try {
4497
+ process.kill(pid, 0);
4498
+ return true;
4499
+ } catch {
4500
+ return false;
4501
+ }
4502
+ }
4503
+ /**
4504
+ * The same shutdown a supervised child gets, for a process we adopted instead of
4505
+ * spawned: a detached successor is not our child, so it is signalled by pid (and
4506
+ * by process group when asked) and its exit is polled rather than awaited.
4507
+ */
4508
+ async function terminatePid(pid, options) {
4509
+ if (!alive(pid)) return "exited";
4510
+ signalPid(pid, options.signal, options.killGroup);
4511
+ const deadline = Date.now() + Math.max(0, options.graceMs);
4512
+ while (Date.now() < deadline && alive(pid)) await delay$2(50);
4513
+ if (!alive(pid)) return "exited";
4514
+ signalPid(pid, "SIGKILL", options.killGroup);
4515
+ const hardDeadline = Date.now() + 2e3;
4516
+ while (Date.now() < hardDeadline && alive(pid)) await delay$2(50);
4517
+ return "force-killed";
4518
+ }
4519
+ function delay$2(ms) {
4520
+ return new Promise((resolve) => setTimeout(resolve, ms));
4521
+ }
4034
4522
  function waitForExit$1(child, timeoutMs) {
4035
4523
  if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true);
4036
4524
  if (timeoutMs <= 0) return Promise.resolve(false);
@@ -4259,10 +4747,11 @@ var init_supervisor = __esmMin((() => {
4259
4747
  error: "supervisor is shutting down"
4260
4748
  };
4261
4749
  const conflict = await this.preflight(entry);
4262
- if (conflict !== null) return {
4750
+ if (conflict.kind === "blocked") return {
4263
4751
  ok: false,
4264
- error: conflict
4752
+ error: conflict.error
4265
4753
  };
4754
+ if (conflict.kind === "adopt") return this.adoptEntry(entry, conflict.pid);
4266
4755
  return this.spawnEntry(entry);
4267
4756
  } finally {
4268
4757
  entry.starting = false;
@@ -4280,6 +4769,97 @@ var init_supervisor = __esmMin((() => {
4280
4769
  await this.stop(id);
4281
4770
  return this.start(id);
4282
4771
  }
4772
+ /**
4773
+ * Frees the port this entry wants, by asking whatever listens on it to leave.
4774
+ *
4775
+ * The pid is never taken from a message: holders are listed again here, and a
4776
+ * listener this panel supervises is refused rather than killed — a port held by
4777
+ * a sibling entry is a configuration mistake, not a stray process. That also
4778
+ * keeps a stale `(pid 1234)` in an old banner from killing a recycled pid.
4779
+ */
4780
+ async freePort(id) {
4781
+ const entry = this.entries.get(id);
4782
+ const empty = {
4783
+ ok: false,
4784
+ port: null,
4785
+ terminated: [],
4786
+ forced: [],
4787
+ skipped: [],
4788
+ free: false
4789
+ };
4790
+ if (!entry) return {
4791
+ ...empty,
4792
+ error: `unknown server "${id}"`
4793
+ };
4794
+ const port = entry.config.port;
4795
+ if (port === null) return {
4796
+ ...empty,
4797
+ error: `server "${id}" has no port configured`
4798
+ };
4799
+ if (entry.starting || entry.stopping) return {
4800
+ ...empty,
4801
+ port,
4802
+ error: `server "${id}" is busy — try again in a moment`
4803
+ };
4804
+ const supervised = this.supervisedPids();
4805
+ const holders = await listPortHolders(port);
4806
+ const ours = holders.filter((pid) => supervised.has(pid));
4807
+ const foreign = holders.filter((pid) => !supervised.has(pid));
4808
+ if (holders.length === 0) return {
4809
+ ...empty,
4810
+ port,
4811
+ error: `nothing is listening on port ${port} any more`
4812
+ };
4813
+ if (foreign.length === 0) {
4814
+ const reason = `port ${port} is held by pid ${ours.join(", ")}, which this panel supervises — stop that server instead`;
4815
+ return {
4816
+ ...empty,
4817
+ port,
4818
+ error: reason,
4819
+ skipped: ours
4820
+ };
4821
+ }
4822
+ this.log(entry, "system", `freeing port ${port}: asking pid ${foreign.join(", ")} to stop`);
4823
+ const { stopped, forced } = await terminatePids(foreign);
4824
+ if (forced.length > 0) this.log(entry, "system", `pid ${forced.join(", ")} ignored SIGTERM and was killed`);
4825
+ const free = await this.waitForPortRelease(entry, port);
4826
+ if (free) {
4827
+ if (entry.status === "conflict") {
4828
+ entry.status = "stopped";
4829
+ entry.lastError = null;
4830
+ }
4831
+ entry.portState = "free";
4832
+ this.log(entry, "system", `port ${port} is free${ours.length > 0 ? ` (pid ${ours.join(", ")} left untouched)` : ""}`);
4833
+ this.publishServer(entry);
4834
+ } else {
4835
+ entry.lastError = `port ${port} is still in use after killing pid ${foreign.join(", ")}`;
4836
+ this.log(entry, "system", entry.lastError);
4837
+ this.publishServer(entry);
4838
+ }
4839
+ return {
4840
+ ok: true,
4841
+ port,
4842
+ terminated: stopped,
4843
+ forced,
4844
+ skipped: ours,
4845
+ free
4846
+ };
4847
+ }
4848
+ /** Pids of the child processes this panel owns, plus itself. */
4849
+ supervisedPids() {
4850
+ const pids = /* @__PURE__ */ new Set([process.pid]);
4851
+ for (const entry of this.entries.values()) if (entry.pid !== null) pids.add(entry.pid);
4852
+ return pids;
4853
+ }
4854
+ /** The port's own answer, with the same short recheck the preflight uses. */
4855
+ async waitForPortRelease(entry, port) {
4856
+ const freeOnAll = async () => {
4857
+ return (await Promise.all(this.occupancyHosts(entry).map((host) => isPortFree(port, host)))).every(Boolean);
4858
+ };
4859
+ if (await freeOnAll()) return true;
4860
+ await delay$1(PORT_RELEASE_RECHECK_MS);
4861
+ return freeOnAll();
4862
+ }
4283
4863
  clearLogs(id) {
4284
4864
  const entry = this.entries.get(id);
4285
4865
  if (!entry) return;
@@ -4351,15 +4931,14 @@ var init_supervisor = __esmMin((() => {
4351
4931
  this.clearRetry(entry);
4352
4932
  entry.nextRetryAt = null;
4353
4933
  entry.stopping = true;
4354
- if (entry.child === null) {
4934
+ if (entry.child === null && entry.pid === null) {
4355
4935
  entry.status = "stopped";
4356
- entry.pid = null;
4357
4936
  this.publishServer(entry);
4358
4937
  return { ok: true };
4359
4938
  }
4360
4939
  entry.status = "stopping";
4361
4940
  this.publishServer(entry);
4362
- if (await terminate(entry.child, entry.config.stop) === "force-killed") this.log(entry, "system", "force-killed after grace period");
4941
+ 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");
4363
4942
  const { port, stop } = entry.config;
4364
4943
  if (stop.killPortHolders && port !== null) {
4365
4944
  const leftover = await listPortHolders(port);
@@ -4371,6 +4950,7 @@ var init_supervisor = __esmMin((() => {
4371
4950
  entry.stopping = false;
4372
4951
  entry.child = null;
4373
4952
  entry.pid = null;
4953
+ entry.adopted = false;
4374
4954
  entry.status = "stopped";
4375
4955
  this.log(entry, "system", "stopped");
4376
4956
  this.publishServer(entry);
@@ -4396,6 +4976,7 @@ var init_supervisor = __esmMin((() => {
4396
4976
  lastProbeAt: 0,
4397
4977
  lastOccupancyProbeAt: 0,
4398
4978
  probing: false,
4979
+ adopted: false,
4399
4980
  starting: false,
4400
4981
  stopping: false,
4401
4982
  bootstrapDone: !config.bootstrap,
@@ -4450,9 +5031,20 @@ var init_supervisor = __esmMin((() => {
4450
5031
  async portAccepts(entry, port, timeoutMs) {
4451
5032
  return (await Promise.all(this.occupancyHosts(entry).map((host) => probePort(host, port, timeoutMs)))).some(Boolean);
4452
5033
  }
5034
+ /**
5035
+ * The port holders that carry this entry's own marker — a program that restarted
5036
+ * itself leaves a detached process behind, and that process is the *same server*,
5037
+ * not a stranger to kill. Read from the environment the supervisor gave the entry,
5038
+ * which a successor inherits unless it scrubs it.
5039
+ */
5040
+ async ownPortHolders(entry, holders) {
5041
+ const own = [];
5042
+ for (const pid of holders) if (await processCarriesServerId(pid, entry.config.id)) own.push(pid);
5043
+ return own;
5044
+ }
4453
5045
  async preflight(entry) {
4454
5046
  const port = entry.config.port;
4455
- if (port === null) return null;
5047
+ if (port === null) return { kind: "free" };
4456
5048
  const hosts = this.occupancyHosts(entry);
4457
5049
  const freeOnAll = async () => {
4458
5050
  return (await Promise.all(hosts.map((host) => isPortFree(port, host)))).every(Boolean);
@@ -4463,18 +5055,80 @@ var init_supervisor = __esmMin((() => {
4463
5055
  free = await freeOnAll();
4464
5056
  }
4465
5057
  entry.portState = free ? "free" : "in-use";
4466
- if (free) return null;
5058
+ if (free) return { kind: "free" };
4467
5059
  const holders = await listPortHolders(port);
5060
+ const own = await this.ownPortHolders(entry, holders);
4468
5061
  const suffix = holders.length > 0 ? ` (pid ${holders.join(", ")})` : "";
4469
- if (entry.config.onPortConflict === "block") {
5062
+ if (own.length > 0 && entry.config.onPortConflict === "follow") return {
5063
+ kind: "adopt",
5064
+ pid: own[0]
5065
+ };
5066
+ if (own.length > 0 && entry.config.onPortConflict === "reclaim") {
5067
+ this.log(entry, "system", `port ${port} is held by pid ${own.join(", ")}, a detached restart of this entry — replacing it with a supervised process`);
5068
+ const { forced } = await terminatePids(own);
5069
+ if (forced.length > 0) this.log(entry, "system", `pid ${forced.join(", ")} ignored SIGTERM and was killed`);
5070
+ await delay$1(PORT_RELEASE_RECHECK_MS);
5071
+ if (await freeOnAll()) {
5072
+ entry.portState = "free";
5073
+ return { kind: "free" };
5074
+ }
5075
+ entry.status = "conflict";
5076
+ entry.lastError = `port ${port} is still in use after replacing pid ${own.join(", ")}`;
5077
+ this.log(entry, "system", entry.lastError);
5078
+ this.publishServer(entry);
5079
+ return {
5080
+ kind: "blocked",
5081
+ error: entry.lastError
5082
+ };
5083
+ }
5084
+ const hint = own.length > 0 ? ` — pid ${own.join(", ")} is a detached restart of this entry: set onPortConflict to "follow" to adopt it, or "reclaim" to replace it with a supervised process` : "";
5085
+ if (entry.config.onPortConflict !== "warn") {
4470
5086
  entry.status = "conflict";
4471
- entry.lastError = `port ${port} is already in use${suffix}`;
4472
- this.log(entry, "system", `${entry.lastError} — not starting (onPortConflict: block)`);
5087
+ entry.lastError = `port ${port} is already in use${suffix}${hint}`;
5088
+ this.log(entry, "system", `${entry.lastError} — not starting (onPortConflict: ${entry.config.onPortConflict})`);
4473
5089
  this.publishServer(entry);
4474
- return entry.lastError;
5090
+ return {
5091
+ kind: "blocked",
5092
+ error: entry.lastError
5093
+ };
4475
5094
  }
4476
- this.log(entry, "system", `warning: port ${port} is already in use${suffix} — starting anyway`);
4477
- return null;
5095
+ this.log(entry, "system", `warning: port ${port} is already in use${suffix}${hint} — starting anyway`);
5096
+ return { kind: "free" };
5097
+ }
5098
+ /**
5099
+ * Takes over a detached successor: no spawn, no duplicate. The pid is supervised
5100
+ * from here on (liveness, health probe, resources, stop), while its output stays
5101
+ * wherever it was redirected.
5102
+ */
5103
+ adoptEntry(entry, pid) {
5104
+ entry.adopted = true;
5105
+ entry.pid = pid;
5106
+ entry.child = null;
5107
+ entry.status = "running";
5108
+ entry.health = entry.config.health.enabled ? "unknown" : "disabled";
5109
+ entry.startedAt = Date.now();
5110
+ entry.lastError = null;
5111
+ this.log(entry, "system", `adopted pid ${pid}: a detached restart of this entry is already serving port ${entry.config.port}`);
5112
+ this.publishServer(entry);
5113
+ return { ok: true };
5114
+ }
5115
+ /** An adopted successor disappeared: fall back to the normal lifecycle. */
5116
+ handleAdoptedExit(entry) {
5117
+ if (entry.pid !== null) this.sampler.forget(entry.pid);
5118
+ const ranForMs = entry.startedAt === null ? 0 : Date.now() - entry.startedAt;
5119
+ entry.adopted = false;
5120
+ entry.pid = null;
5121
+ entry.resources = null;
5122
+ entry.responseMs = null;
5123
+ entry.exitCode = null;
5124
+ entry.exitSignal = null;
5125
+ this.options.history.record(entry.config.id, {
5126
+ type: "exit",
5127
+ detail: "adopted process exited",
5128
+ runtimeMs: ranForMs
5129
+ });
5130
+ this.log(entry, "system", `the adopted process is gone after ${Math.max(1, Math.round(ranForMs / 1e3))}s`);
5131
+ this.afterExit(entry, "adopted process exited", ranForMs, false);
4478
5132
  }
4479
5133
  async runBootstrap(entry) {
4480
5134
  const spec = entry.config.bootstrap;
@@ -4650,6 +5304,7 @@ var init_supervisor = __esmMin((() => {
4650
5304
  if (entry.pid !== null) this.sampler.forget(entry.pid);
4651
5305
  entry.child = null;
4652
5306
  entry.pid = null;
5307
+ entry.adopted = false;
4653
5308
  entry.resources = null;
4654
5309
  entry.responseMs = null;
4655
5310
  entry.exitCode = code;
@@ -4657,12 +5312,19 @@ var init_supervisor = __esmMin((() => {
4657
5312
  const neverStarted = code === null && signal === null && entry.lastError !== null;
4658
5313
  const detail = neverStarted ? entry.lastError : signal !== null ? `signal ${signal}` : `code ${code}`;
4659
5314
  const ranForMs = entry.startedAt === null ? 0 : Date.now() - entry.startedAt;
4660
- const ranFor = `${Math.max(1, Math.round(ranForMs / 1e3))}s`;
4661
5315
  this.options.history.record(entry.config.id, {
4662
5316
  type: "exit",
4663
5317
  detail,
4664
5318
  runtimeMs: ranForMs
4665
5319
  });
5320
+ this.afterExit(entry, detail, ranForMs, neverStarted);
5321
+ }
5322
+ /**
5323
+ * What happens once a process is gone, whether it was a child we spawned or a
5324
+ * detached successor we adopted: back off and retry, or record the crash.
5325
+ */
5326
+ afterExit(entry, detail, ranForMs, neverStarted) {
5327
+ const ranFor = `${Math.max(1, Math.round(ranForMs / 1e3))}s`;
4666
5328
  if (entry.stopping) {
4667
5329
  entry.status = "stopped";
4668
5330
  this.publishServer(entry);
@@ -4704,6 +5366,10 @@ var init_supervisor = __esmMin((() => {
4704
5366
  await this.sampleResources(now);
4705
5367
  await Promise.allSettled([...this.entries.values()].map((entry) => this.probeEntry(entry, now)));
4706
5368
  for (const entry of this.entries.values()) {
5369
+ if (entry.adopted && entry.pid !== null && !isProcessAlive(entry.pid)) {
5370
+ this.handleAdoptedExit(entry);
5371
+ continue;
5372
+ }
4707
5373
  if (await this.enforceMemoryLimit(entry)) continue;
4708
5374
  if (this.shouldForceRestart(entry, now)) {
4709
5375
  await this.restart(entry.config.id);
@@ -4715,7 +5381,7 @@ var init_supervisor = __esmMin((() => {
4715
5381
  }
4716
5382
  /** One scan of /proc covers every server; only live processes are sampled. */
4717
5383
  async sampleResources(now) {
4718
- const due = [...this.entries.values()].filter((entry) => entry.pid !== null && entry.child !== null && now - entry.resourcesSampledAt >= RESOURCE_SAMPLE_INTERVAL_MS);
5384
+ const due = [...this.entries.values()].filter((entry) => entry.pid !== null && (entry.child !== null || entry.adopted) && now - entry.resourcesSampledAt >= RESOURCE_SAMPLE_INTERVAL_MS);
4719
5385
  if (due.length === 0) return;
4720
5386
  for (const entry of due) entry.resourcesSampledAt = now;
4721
5387
  try {
@@ -4817,6 +5483,7 @@ var init_supervisor = __esmMin((() => {
4817
5483
  health: entry.health,
4818
5484
  portState: entry.portState,
4819
5485
  pid: entry.pid,
5486
+ ...entry.adopted ? { adopted: true } : {},
4820
5487
  startedAt: entry.startedAt,
4821
5488
  exitCode: entry.exitCode,
4822
5489
  exitSignal: entry.exitSignal,
@@ -4867,7 +5534,9 @@ var init_supervisor = __esmMin((() => {
4867
5534
  server.restarts,
4868
5535
  server.nextRetryAt,
4869
5536
  server.lastError,
4870
- server.bufferedLines
5537
+ server.bufferedLines,
5538
+ server.responseMs,
5539
+ server.resources?.sampledAt
4871
5540
  ].join(":"))].join("|");
4872
5541
  if (signature === this.lastStateSignature) return;
4873
5542
  this.lastStateSignature = signature;
@@ -4957,11 +5626,15 @@ function controlBlock(configText) {
4957
5626
  return null;
4958
5627
  }
4959
5628
  }
4960
- /** A restored config is only accepted when the full schema can read it. */
5629
+ /**
5630
+ * A restored config is accepted when this release can read it — through the same
5631
+ * tolerant parser the store uses, so an archive from a newer release keeps only
5632
+ * the keys this one understands instead of being refused outright.
5633
+ */
4961
5634
  function isUsableConfig(text) {
4962
5635
  if (text === null) return false;
4963
5636
  try {
4964
- return !(configSchema(JSON.parse(text)) instanceof type.errors);
5637
+ return parseConfig(JSON.parse(text)).config !== null;
4965
5638
  } catch {
4966
5639
  return false;
4967
5640
  }
@@ -4974,12 +5647,9 @@ function isUsableConfig(text) {
4974
5647
  function archiveTargets(configText) {
4975
5648
  if (configText === null) return [];
4976
5649
  try {
4977
- const parsed = configSchema(JSON.parse(configText));
4978
- if (parsed instanceof type.errors) return [];
4979
- return resolveBackupPaths(parsed.servers.map((server) => ({
4980
- ...server,
4981
- port: server.port ?? null
4982
- })), parsed.backups.includePaths).filter((entry) => entry.included).map((entry) => ({
5650
+ const parsed = parseConfig(JSON.parse(configText)).config;
5651
+ if (parsed === null) return [];
5652
+ return resolveBackupPaths(parsed.servers, parsed.backups.includePaths).filter((entry) => entry.included).map((entry) => ({
4983
5653
  path: entry.path,
4984
5654
  origin: entry.origin
4985
5655
  }));
@@ -5007,7 +5677,7 @@ function slugifyPath(target) {
5007
5677
  }
5008
5678
  var MANIFEST, ALLOWED_ROOTS, SUFFIX, BackupService;
5009
5679
  var init_backups = __esmMin((() => {
5010
- init_schema();
5680
+ init_parse();
5011
5681
  init_atomic();
5012
5682
  init_env_file();
5013
5683
  init_paths();
@@ -6552,7 +7222,7 @@ function probeHostFor(bindHost) {
6552
7222
  */
6553
7223
  async function runControlPlane(options) {
6554
7224
  const existing = readRuntime();
6555
- if (existing !== null && existing.pid !== process.pid && isProcessAlive(existing.pid)) {
7225
+ if (existing !== null && existing.pid !== process.pid && isProcessAlive$1(existing.pid)) {
6556
7226
  logger.error(`already running (pid ${existing.pid}) at ${existing.url} — run \`home-hosted down\` first`);
6557
7227
  process.exit(1);
6558
7228
  }
@@ -6561,6 +7231,16 @@ async function runControlPlane(options) {
6561
7231
  const store = new ConfigStore(configPath, SEED_CONFIG);
6562
7232
  store.load();
6563
7233
  store.writeJsonSchema();
7234
+ if (store.configError !== null) {
7235
+ logger.error(`refusing to start: ${store.configError}`);
7236
+ logger.info(`fix ${store.path}, or install the release that wrote it`);
7237
+ process.exit(1);
7238
+ }
7239
+ if (store.pendingMigrations.length > 0) {
7240
+ logger.error(`refusing to start: ${store.path} needs ${store.pendingMigrations.length} migration(s) before ${appVersion()} can use it`);
7241
+ logger.info("run `home-hosted migrate` to see and apply them");
7242
+ process.exit(1);
7243
+ }
6564
7244
  const secrets = new SecretsStore(defaultSecretsPath);
6565
7245
  const auth = new AuthService(secrets, () => store.config.control.auth);
6566
7246
  const tls = new TlsStore(defaultTlsDir);
@@ -6586,7 +7266,7 @@ async function runControlPlane(options) {
6586
7266
  });
6587
7267
  if (!auth.passwordSet) {
6588
7268
  auth.ensureDefaultPassword("hh");
6589
- logger.warn(`no password was set — created the default "hh"; change it in Settings → Password`);
7269
+ logger.warn(`no password was set — created the default "hh"; change it in Settings → Authentication`);
6590
7270
  }
6591
7271
  const configured = store.config.control;
6592
7272
  const intendedHost = options.host === void 0 ? configured.host : parseBind(options.host);
@@ -6711,14 +7391,14 @@ async function runControlPlane(options) {
6711
7391
  logger.box(`home-hosted ${runtime.version}\n${endpoint.url}`);
6712
7392
  logger.info(`config: ${store.path}`);
6713
7393
  logger.info(`secrets: ${secrets.path}${auth.passwordSet ? "" : " (no password set)"}`);
6714
- logger.info(`auth: ${auth.isRequired() ? "required" : "disabled"}${auth.usingDefaultPassword ? " (default password)" : ""}${exposure.exposed ? " · exposed beyond loopback" : ""}`);
7394
+ logger.info(`auth: ${auth.isRequired() ? "required" : "disabled"}${auth.usingDefaultPassword ? " (default password)" : ""}${auth.apiTokenSet ? " · API token set" : ""}${exposure.exposed ? " · exposed beyond loopback" : ""}`);
6715
7395
  logger.info(`logs: ${store.config.logs.persist ? `${logFiles.directory} (max ${store.config.logs.maxBytes} B x ${store.config.logs.keep})` : "memory only"}`);
6716
7396
  logger.info(`project: ${projectDir}`);
6717
7397
  if (ui.custom) {
6718
7398
  const meta = ui.status().meta;
6719
7399
  logger.warn(`custom UI in use${meta === null ? "" : ` (${meta.name}${meta.version === null ? "" : ` ${meta.version}`})`} — if it breaks, run \`home-hosted ui-revert\``);
6720
7400
  }
6721
- if (store.configError !== null) logger.warn(`config problem(s): ${store.configError}`);
7401
+ for (const warning of store.configWarnings) logger.warn(warning);
6722
7402
  for (const entry of supervisor.views()) logger.info(` ${entry.id.padEnd(12)} ${entry.config.command} ${entry.config.args.join(" ")}`.trimEnd());
6723
7403
  if (configured.openBrowser || options.open) openBrowser(endpoint.url);
6724
7404
  if (options.autostart) supervisor.startAll({ autostartOnly: true }).catch((error) => {
@@ -6751,6 +7431,7 @@ var init_src = __esmMin((() => {
6751
7431
  init_open();
6752
7432
  init_paths();
6753
7433
  init_template();
7434
+ init_version();
6754
7435
  init_port();
6755
7436
  init_auth$1();
6756
7437
  init_backups();
@@ -6769,6 +7450,113 @@ var init_src = __esmMin((() => {
6769
7450
  packageRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
6770
7451
  }));
6771
7452
  //#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
7464
+ });
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`;
7468
+ 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")
7476
+ };
7477
+ }
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
6772
7560
  //#region src/cli.ts
6773
7561
  /**
6774
7562
  * The command line. Everything here works before the state directories are known
@@ -6786,6 +7574,9 @@ Usage
6786
7574
  home-hosted restart [options] down, then up
6787
7575
  home-hosted status [--json] is it running, where, and how to reach it
6788
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
6789
7580
  home-hosted ui-revert go back to the stock control panel UI
6790
7581
 
6791
7582
  Options for up/restart
@@ -6797,6 +7588,21 @@ Options for up/restart
6797
7588
  --foreground run in this process instead of detaching (systemd/docker)
6798
7589
  --print-config print the effective config and exit
6799
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
+
6800
7606
  Everywhere
6801
7607
  --home <dir> state directory (default: $HHOSTED_HOME or ~/.home-hosted)
6802
7608
  --project <dir> base for relative entry paths (default: the current directory)
@@ -6806,6 +7612,8 @@ Everywhere
6806
7612
  Environment
6807
7613
  HHOSTED_HOME where config, secrets, logs, TLS and backups live
6808
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
6809
7617
  `;
6810
7618
  var isTty = () => process.stdout.isTTY === true;
6811
7619
  var paint = (code, text) => isTty() ? `\x1B[${code}m${text}\x1B[0m` : text;
@@ -7110,6 +7918,19 @@ function formatDuration(ms) {
7110
7918
  if (hours < 24) return `${hours}h ${minutes % 60}m`;
7111
7919
  return `${Math.floor(hours / 24)}d ${hours % 24}h`;
7112
7920
  }
7921
+ /** A plain y/N question, for decisions that are not secrets. */
7922
+ function prompt(question) {
7923
+ return new Promise((resolve) => {
7924
+ const rl = readline.createInterface({
7925
+ input: process.stdin,
7926
+ output: process.stdout
7927
+ });
7928
+ rl.question(question, (answer) => {
7929
+ rl.close();
7930
+ resolve(answer);
7931
+ });
7932
+ });
7933
+ }
7113
7934
  /** Reads a line with echo suppressed, so the password never lands in scrollback. */
7114
7935
  function promptHidden(question) {
7115
7936
  return new Promise((resolve) => {
@@ -7132,6 +7953,165 @@ function promptHidden(question) {
7132
7953
  });
7133
7954
  });
7134
7955
  }
7956
+ /**
7957
+ * Brings `servers.config.json` up to the schema this release understands.
7958
+ *
7959
+ * Deliberately loud and deliberate: it prints every step first, backs the file up
7960
+ * before writing, refuses to write a config it cannot read, and never runs on its
7961
+ * own — a detached daemon cannot prompt, so consent comes from `--yes`, from
7962
+ * `HHOSTED_MIGRATE=allow`, or from a person at a terminal.
7963
+ */
7964
+ async function migrateConfig(argv) {
7965
+ const { values } = parseArgs({
7966
+ args: argv,
7967
+ options: {
7968
+ "config": {
7969
+ type: "string",
7970
+ short: "c"
7971
+ },
7972
+ "dry-run": { type: "boolean" },
7973
+ "yes": {
7974
+ type: "boolean",
7975
+ short: "y"
7976
+ }
7977
+ },
7978
+ allowPositionals: false
7979
+ });
7980
+ const { defaultConfigPath } = await Promise.resolve().then(() => (init_paths(), paths_exports));
7981
+ const { applyConfigMigrations, CONFIG_SCHEMA, planConfigMigrations } = await Promise.resolve().then(() => (init_migrations(), migrations_exports));
7982
+ const { parseConfig, stampConfig } = await Promise.resolve().then(() => (init_parse(), parse_exports));
7983
+ const { writeFileAtomic } = await Promise.resolve().then(() => (init_atomic(), atomic_exports));
7984
+ const { appVersion } = await Promise.resolve().then(() => (init_version(), version_exports));
7985
+ const file = values.config ?? defaultConfigPath;
7986
+ if (!fs.existsSync(file)) fail(`no config at ${file} — nothing to migrate`);
7987
+ let raw;
7988
+ try {
7989
+ raw = JSON.parse(fs.readFileSync(file, "utf8"));
7990
+ } catch (error) {
7991
+ fail(`cannot read ${file}: ${error instanceof Error ? error.message : String(error)}`);
7992
+ }
7993
+ const meta = typeof raw.meta === "object" && raw.meta !== null ? raw.meta : {};
7994
+ const from = typeof meta.schema === "number" ? meta.schema : CONFIG_SCHEMA;
7995
+ const plan = planConfigMigrations(from);
7996
+ if (plan.tooNew) fail(`${file} was written by home-hosted ${meta.writtenBy ?? "a newer release"} (config schema ${from});\n this release understands schema ${plan.to}. Install that version, or edit the file yourself.`);
7997
+ const stamped = stampConfig(raw);
7998
+ const upToDate = plan.steps.length === 0;
7999
+ if (upToDate && values["dry-run"] !== true && JSON.stringify(stamped) !== JSON.stringify(raw)) {
8000
+ writeFileAtomic(file, `${JSON.stringify(stamped, null, 2)}\n`);
8001
+ process.stdout.write(`${green("config stamped")} in ${file} — written by home-hosted ${appVersion()}, schema ${plan.to}\n`);
8002
+ return;
8003
+ }
8004
+ if (upToDate) {
8005
+ process.stdout.write(`config schema ${from} is already what home-hosted ${appVersion()} understands — nothing to migrate\n`);
8006
+ return;
8007
+ }
8008
+ process.stdout.write(`migrating ${file}: config schema ${from} → ${plan.to}\n`);
8009
+ for (const [index, step] of plan.steps.entries()) process.stdout.write(` ${index + 1}. ${step.describe}\n`);
8010
+ if (values["dry-run"] === true) {
8011
+ process.stdout.write(`${dim(`nothing was written (--dry-run, ${plan.steps.length} step(s) pending)`)}\n`);
8012
+ return;
8013
+ }
8014
+ if (!(values.yes === true || (process.env.HHOSTED_MIGRATE ?? "").toLowerCase() === "allow")) {
8015
+ if (process.stdin.isTTY !== true) fail(`this config needs ${plan.steps.length} migration(s) and this session cannot ask.\n re-run with --yes, or set HHOSTED_MIGRATE=allow for unattended runs`);
8016
+ const answer = await prompt(`Apply ${plan.steps.length} migration(s) to ${path.basename(file)}? [y/N] `);
8017
+ if (!/^yes$|^y$/i.test(answer.trim())) {
8018
+ process.stdout.write("cancelled — nothing was written\n");
8019
+ return;
8020
+ }
8021
+ }
8022
+ const { config, applied } = applyConfigMigrations(raw, from);
8023
+ const parsed = parseConfig(config);
8024
+ if (parsed.config === null) fail(`the migration produced a config this release cannot read:\n ${parsed.errors.join("\n ")}`);
8025
+ const backup = `${file}.bak`;
8026
+ fs.copyFileSync(file, backup);
8027
+ writeFileAtomic(file, `${JSON.stringify(stampConfig(config), null, 2)}\n`);
8028
+ process.stdout.write(`${green(`migrated to schema ${plan.to}`)} (${applied.length} step(s)) in ${file}\n`);
8029
+ process.stdout.write(` ${dim(`previous file kept at ${backup}`)}\n`);
8030
+ for (const key of parsed.unknownKeys) process.stdout.write(` ${dim(`still ignoring an unrecognized key: ${key}`)}\n`);
8031
+ }
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";
8037
+ }
8038
+ function which(command) {
8039
+ return spawnSync(command, ["--version"], {
8040
+ stdio: "ignore",
8041
+ shell: process.platform === "win32"
8042
+ }).status === 0;
8043
+ }
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;
8066
+ 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";
8068
+ const dir = assumeYes ? defaultDir : (await prompt(`Project directory (${defaultDir}) `)).trim() || defaultDir;
8069
+ 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;
8072
+ if (pm !== void 0 && !PACKAGE_MANAGERS.includes(pm)) fail(`unknown package manager: ${pm} (expected one of ${PACKAGE_MANAGERS.join(", ")})`);
8073
+ const detected = detectPackageManager(which);
8074
+ 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);
8076
+ const git = assumeYes ? which("git") : which("git") && await confirm("Initialize a git repository?", true);
8077
+ try {
8078
+ const result = scaffold({
8079
+ dir,
8080
+ name,
8081
+ pm,
8082
+ install,
8083
+ git
8084
+ });
8085
+ process.stdout.write(`${green("project created")} in ${result.dir}\n`);
8086
+ for (const file of result.files) process.stdout.write(` ${dim(file)}\n`);
8087
+ } catch (error) {
8088
+ fail(error instanceof Error ? error.message : String(error));
8089
+ }
8090
+ const target = path.resolve(dir);
8091
+ if (git) {
8092
+ spawnSync("git", ["init", "-q"], {
8093
+ cwd: target,
8094
+ stdio: "inherit",
8095
+ shell: process.platform === "win32"
8096
+ });
8097
+ process.stdout.write(` ${dim("git repository initialized")}\n`);
8098
+ }
8099
+ if (install) {
8100
+ process.stdout.write(`${dim(`installing with ${pm}…`)}\n`);
8101
+ if (spawnSync(pm, installArgs(pm), {
8102
+ cwd: target,
8103
+ stdio: "inherit",
8104
+ shell: process.platform === "win32"
8105
+ }).status !== 0) process.stdout.write(`${paint("33", "install failed")} — run it yourself in ${target}\n`);
8106
+ }
8107
+ const relative = path.relative(process.cwd(), target);
8108
+ const where = relative.length === 0 || relative.startsWith("..") ? target : relative;
8109
+ const cd = relative.length === 0 ? "" : `cd ${where} && `;
8110
+ process.stdout.write(`\nNext:\n`);
8111
+ process.stdout.write(` ${bold(`${cd}${runCommand(pm, "up")}`)} start the panel (default password \`hh\`)\n`);
8112
+ 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`);
8114
+ }
7135
8115
  /** Drops a user-installed UI so the stock panel serves again. */
7136
8116
  async function uiRevert() {
7137
8117
  const { UiService } = await Promise.resolve().then(() => (init_ui(), ui_exports));
@@ -7172,6 +8152,69 @@ async function setPassword(argv) {
7172
8152
  if (password.length < 8) process.stdout.write(`${dim(`"${password}" is short — easy to guess if the panel is reachable beyond loopback`)}\n`);
7173
8153
  process.stdout.write(`${dim("restart the panel for it to take effect: home-hosted restart")}\n`);
7174
8154
  }
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" }
8165
+ },
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;
8176
+ }
8177
+ store.clearApiToken();
8178
+ process.stdout.write(`${green("API token cleared")} in ${defaultSecretsPath} — it stops working immediately\n`);
8179
+ return;
8180
+ }
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`);
8206
+ }
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;
8210
+ 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;
8214
+ } catch {
8215
+ return null;
8216
+ }
8217
+ }
7175
8218
  function version() {
7176
8219
  const manifest = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
7177
8220
  process.stdout.write(`${manifest.version ?? "0.0.0"}\n`);
@@ -7205,6 +8248,18 @@ async function main() {
7205
8248
  applyDirFlags(dirFlags);
7206
8249
  await setPassword(args);
7207
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;
7208
8263
  case "ui-revert":
7209
8264
  applyDirFlags(dirFlags);
7210
8265
  await uiRevert();