home-hosted 0.8.2 → 0.8.3

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
@@ -709,7 +709,7 @@ function parseBind(value) {
709
709
  const parsed = bindSchema(value);
710
710
  return parsed instanceof type.errors ? null : parsed;
711
711
  }
712
- var bindSchema, workspaceIdSchema, workspaceSchema, workspaceCreateSchema, workspaceRenameSchema, portSchema, onPortConflictSchema, onPortConflictDefaultSchema, restartSchema, httpCheckSchema, resourcesSchema, healthSchema, stopSchema, bootstrapSchema, bootstrapOrNullSchema, labelOrNullSchema, logBufferLinesSchema, serverSchema, authSchema, telegramSchema, notificationsSchema, logsSchema, tlsSchema, hostSchema, ddnsRecordTypeSchema, ddnsAccountSchema, ddnsDomainSchema, ddnsIpv4Schema, ddnsIpv6Schema, ddnsConfigSchema, ddnsRecordStateSchema, ddnsRecordViewSchema, ddnsStatusSchema, ddnsProviderFieldSchema, ddnsProviderInfoSchema, ddnsViewSchema, ddnsCredentialsSchema, backupsSchema, controlSchema, defaultsSchema, restartPatchSchema, httpCheckPatchSchema, resourcesPatchSchema, healthPatchSchema, stopPatchSchema, authPatchSchema, telegramPatchSchema, notificationsPatchSchema, hostPatchSchema, backupsPatchSchema, logsPatchSchema, tlsPatchSchema, controlPatchSchema, defaultsPatchSchema, editableFields, serverPatchSchema, serverCreateSchema, nannySpecSchema, nannyExitSchema, nannyStateSchema, settingsPatchSchema, workspaceSettingsPatchSchema, authStatusSchema, tlsStatusSchema, proxyEngineSchema, proxyTargetSchema, proxyTlsModeSchema, proxyRouteSchema, proxyCertificateSchema, proxyCertificateStateSchema, proxyDns01Schema, proxyConfigSchema, proxyEngineSourceSchema, proxyEngineStatusSchema, proxyEngineInfoSchema, proxyRouteStatusSchema, proxyRouteViewSchema, proxyRunStateSchema, proxyStatusSchema, proxyCertificateViewSchema, proxyDnsAccountViewSchema, proxyViewSchema, proxyRoutePatchSchema, proxyPatchSchema, proxyCertificateUploadSchema, proxyEngineInstallSchema, telegramStatusSchema, telegramChatsSchema, notificationViewSchema, sessionViewSchema, loginSchema, passwordValueSchema, passwordSchema, serverStatusSchema, healthStateSchema, portStateSchema, logStreamSchema, logLineSchema, historyEventSchema, serverHistorySchema, processResourcesSchema, hostDiskSchema, hostViewSchema, backupFileSchema, backupEntrySchema, backupsViewSchema, restoreItemSchema, restoreSkipSchema, restorePlanSchema, backupCreateSchema, restoreRequestSchema, serverViewSchema, healthResponseSchema, controlViewSchema, uiMetaSchema, uiStatusSchema, workspaceViewSchema, appStateSchema, logQuerySchema, freePortResultSchema, logHistoryQuerySchema, logFileInfoSchema, logServerViewSchema, logServersViewSchema, notificationActionSchema, telegramTokenSchema, apiErrorSchema, tlsUploadSchema, settingsViewSchema, settingsSavedSchema, workspaceSettingsViewSchema, workspaceSettingsSavedSchema;
712
+ var bindSchema, workspaceIdSchema, workspaceSchema, workspaceCreateSchema, workspaceRenameSchema, portSchema, onPortConflictSchema, onPortConflictDefaultSchema, restartSchema, httpCheckSchema, resourcesSchema, healthSchema, stopSchema, bootstrapSchema, bootstrapOrNullSchema, labelOrNullSchema, logBufferLinesSchema, serverSchema, authSchema, telegramSchema, notificationsSchema, logsSchema, tlsSchema, hostSchema, ddnsRecordTypeSchema, ddnsAccountSchema, ddnsDomainSchema, ddnsIpv4Schema, ddnsIpv6Schema, ddnsConfigSchema, ddnsRecordStateSchema, ddnsRecordViewSchema, ddnsStatusSchema, ddnsProviderFieldSchema, ddnsProviderInfoSchema, ddnsViewSchema, ddnsCredentialsSchema, backupsSchema, controlSchema, defaultsSchema, restartPatchSchema, httpCheckPatchSchema, resourcesPatchSchema, healthPatchSchema, stopPatchSchema, authPatchSchema, telegramPatchSchema, notificationsPatchSchema, hostPatchSchema, backupsPatchSchema, logsPatchSchema, tlsPatchSchema, controlPatchSchema, defaultsPatchSchema, editableFields, serverPatchSchema, serverCreateSchema, nannySpecSchema, nannyExitSchema, nannyStateSchema, entryStateSchema, settingsPatchSchema, workspaceSettingsPatchSchema, authStatusSchema, tlsStatusSchema, proxyEngineSchema, proxyTargetSchema, proxyTlsModeSchema, proxyRouteSchema, proxyCertificateSchema, proxyCertificateStateSchema, proxyDns01Schema, proxyConfigSchema, proxyEngineSourceSchema, proxyEngineStatusSchema, proxyEngineInfoSchema, proxyRouteStatusSchema, proxyRouteViewSchema, proxyRunStateSchema, proxyStatusSchema, proxyCertificateViewSchema, proxyDnsAccountViewSchema, proxyViewSchema, proxyRoutePatchSchema, proxyPatchSchema, proxyCertificateUploadSchema, proxyEngineInstallSchema, telegramStatusSchema, telegramChatsSchema, notificationViewSchema, sessionViewSchema, loginSchema, passwordValueSchema, passwordSchema, serverStatusSchema, healthStateSchema, portStateSchema, logStreamSchema, logLineSchema, historyActorSchema, historyEventSchema, serverHistorySchema, processResourcesSchema, hostDiskSchema, hostViewSchema, backupFileSchema, backupEntrySchema, backupsViewSchema, restoreItemSchema, restoreSkipSchema, restorePlanSchema, backupCreateSchema, restoreRequestSchema, serverViewSchema, healthResponseSchema, controlViewSchema, uiMetaSchema, uiStatusSchema, workspaceViewSchema, appStateSchema, logQuerySchema, freePortResultSchema, logHistoryQuerySchema, logFileInfoSchema, logServerViewSchema, logServersViewSchema, notificationActionSchema, telegramTokenSchema, apiErrorSchema, tlsUploadSchema, settingsViewSchema, settingsSavedSchema, workspaceSettingsViewSchema, workspaceSettingsSavedSchema;
713
713
  var init_contracts = __esmMin((() => {
714
714
  bindSchema = type("\"local\" | \"lan\" | /^\\d{1,3}(?:\\.\\d{1,3}){3}$/");
715
715
  workspaceIdSchema = type("/^[a-z0-9][a-z0-9_-]*$/");
@@ -1154,6 +1154,19 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
1154
1154
  "heartbeatAt": "number",
1155
1155
  "lastExit?": nannyExitSchema
1156
1156
  }).onUndeclaredKey("reject");
1157
+ entryStateSchema = type({
1158
+ /** Attempts spent on the current crash sequence; reset once the process stays up. */
1159
+ "restarts": "number.integer >= 0",
1160
+ /** The `runOnce` bootstrap already ran for this entry. */
1161
+ "bootstrapDone": "boolean",
1162
+ /**
1163
+ * The last child this panel spawned for a non-persistent entry. A detached child
1164
+ * outlives the panel, so this is the only way a later boot recognizes its own
1165
+ * survivor instead of spawning a second copy beside it.
1166
+ */
1167
+ "lastPid?": "number | null",
1168
+ "lastStartedAt?": "number | null"
1169
+ });
1157
1170
  settingsPatchSchema = type({
1158
1171
  control: controlPatchSchema.optional(),
1159
1172
  host: hostPatchSchema.optional(),
@@ -1419,13 +1432,16 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
1419
1432
  stream: logStreamSchema,
1420
1433
  text: "string"
1421
1434
  });
1435
+ historyActorSchema = type.enumerated("policy", "user", "agent", "boot");
1422
1436
  historyEventSchema = type({
1423
- serverId: "string",
1424
- ts: "number",
1425
- type: "\"start\" | \"exit\" | \"crash\" | \"forced-restart\" | \"unhealthy\" | \"recovered\"",
1426
- detail: "string",
1437
+ "serverId": "string",
1438
+ "ts": "number",
1439
+ "type": "\"start\" | \"exit\" | \"crash\" | \"forced-restart\" | \"unhealthy\" | \"recovered\"",
1440
+ "detail": "string",
1427
1441
  /** How long the process had been up, recorded on exit and crash. */
1428
- runtimeMs: "number?"
1442
+ "runtimeMs": "number?",
1443
+ /** Who caused it; absent on events from before this field existed. */
1444
+ "actor?": historyActorSchema
1429
1445
  });
1430
1446
  serverHistorySchema = type({
1431
1447
  windowMs: "number",
@@ -1497,6 +1513,8 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
1497
1513
  "labelParams?": "Record<string, string>",
1498
1514
  /** `settings`/`servers`/`secrets` for a workspace, `data` for a declared path. */
1499
1515
  "kind": "\"settings\" | \"servers\" | \"secrets\" | \"data\"",
1516
+ /** Which secret a `secrets` leaf carries (`password`, `apiToken`, `telegram`, `ddns`, …). */
1517
+ "secret?": "string",
1500
1518
  "path?": "string",
1501
1519
  /** Who declared a data path: `global`, `<serverId>:backupPaths` or `<serverId>:<ENV>`. */
1502
1520
  "origin?": "string",
@@ -1546,6 +1564,14 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
1546
1564
  "encrypted": "boolean",
1547
1565
  /** The archive needs a password (none or a wrong one was supplied). */
1548
1566
  "needsPassword": "boolean",
1567
+ /**
1568
+ * Where the archive says it came from. Optional: an archive written before this
1569
+ * stamp existed carries neither field, and an older panel never sends the row.
1570
+ */
1571
+ "archive?": type({
1572
+ "writtenBy?": "string",
1573
+ "schema?": "number"
1574
+ }),
1549
1575
  "items": restoreItemSchema.array(),
1550
1576
  "applied": type("string[]"),
1551
1577
  /**
@@ -2635,6 +2661,14 @@ function createAuthGuard(deps) {
2635
2661
  await next();
2636
2662
  };
2637
2663
  }
2664
+ /**
2665
+ * Who is behind a request, as a history event names them: a cookie is a person at
2666
+ * the panel, a bearer token is a script or an agent. Anonymous callers can only be
2667
+ * loopback requests while auth is unarmed, which is the browser's own origin.
2668
+ */
2669
+ function requestActor(c, auth) {
2670
+ return requestIdentity(c, auth).method === "token" ? "agent" : "user";
2671
+ }
2638
2672
  var PUBLIC_PATHS, AUTH_REQUIRED_CODE;
2639
2673
  var init_auth = __esmMin((() => {
2640
2674
  init_loopback();
@@ -3643,7 +3677,7 @@ function createControlRoute(deps) {
3643
3677
  }
3644
3678
  }), validate("query", workspaceQuerySchema), async (c) => {
3645
3679
  assertLocalCall(c, "start or stop a server");
3646
- const result = await requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace).supervisor.start(c.req.param("id"));
3680
+ const result = await requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace).supervisor.start(c.req.param("id"), { actor: "user" });
3647
3681
  return c.json(result, statusForAction(result));
3648
3682
  }).post("/servers/:id/stop", describeRoute({
3649
3683
  tags: ["panel"],
@@ -3667,7 +3701,7 @@ function createControlRoute(deps) {
3667
3701
  }
3668
3702
  }), validate("query", workspaceQuerySchema), async (c) => {
3669
3703
  assertLocalCall(c, "restart a server");
3670
- const result = await requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace).supervisor.restart(c.req.param("id"));
3704
+ const result = await requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace).supervisor.restart(c.req.param("id"), { actor: "user" });
3671
3705
  return c.json(result, statusForAction(result));
3672
3706
  });
3673
3707
  }
@@ -6423,8 +6457,8 @@ var init_events = __esmMin((() => {
6423
6457
  /**
6424
6458
  * Liveness for external monitors. Mounted outside `/api`, so it answers without a
6425
6459
  * session: it reports whether the panel itself is serving, and 503 when an
6426
- * autostart server has crashed. Server details are only included for an
6427
- * authenticated caller.
6460
+ * autostart server has crashed, or a running one is failing its own health check.
6461
+ * Server details are only included for an authenticated caller.
6428
6462
  */
6429
6463
  function createHealthRoute(deps) {
6430
6464
  return appFactory.createApp().get("/healthz", describeRoute({
@@ -6435,12 +6469,12 @@ function createHealthRoute(deps) {
6435
6469
  description: "Serving",
6436
6470
  content: jsonBody(healthResponseSchema)
6437
6471
  },
6438
- 503: { description: "An autostart server has crashed" }
6472
+ 503: { description: "An autostart server has crashed, or a running one is unhealthy" }
6439
6473
  }
6440
6474
  }), (c) => {
6441
6475
  const state = deps.panel.getState();
6442
6476
  const servers = state.workspaces.flatMap((workspace) => workspace.servers);
6443
- const broken = servers.filter((server) => server.config.autostart && server.status === "crashed");
6477
+ const broken = servers.filter((server) => server.config.autostart && server.status === "crashed" || server.status === "running" && server.health === "unhealthy");
6444
6478
  const authenticated = requestIdentity(c, deps.auth).authenticated;
6445
6479
  return c.json({
6446
6480
  status: broken.length > 0 ? "degraded" : "ok",
@@ -10361,7 +10395,7 @@ function createServersRoute(deps) {
10361
10395
  404: ERROR_RESPONSES[404]
10362
10396
  }
10363
10397
  }), validate("query", workspaceQuerySchema), validate("param", idParam$1), async (c) => {
10364
- const result = await requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace).supervisor.start(c.req.valid("param").id);
10398
+ const result = await requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace).supervisor.start(c.req.valid("param").id, { actor: requestActor(c, deps.auth) });
10365
10399
  return c.json(result, statusForAction(result));
10366
10400
  }).post("/:id/stop", describeRoute({
10367
10401
  tags: ["servers"],
@@ -10381,7 +10415,7 @@ function createServersRoute(deps) {
10381
10415
  404: ERROR_RESPONSES[404]
10382
10416
  }
10383
10417
  }), validate("query", workspaceQuerySchema), validate("param", idParam$1), async (c) => {
10384
- const result = await requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace).supervisor.restart(c.req.valid("param").id);
10418
+ const result = await requireWorkspace(deps.panel.requireWorkspace.bind(deps.panel), c.req.valid("query").workspace).supervisor.restart(c.req.valid("param").id, { actor: requestActor(c, deps.auth) });
10385
10419
  return c.json(result, statusForAction(result));
10386
10420
  }).post("/:id/clear-logs", describeRoute({
10387
10421
  tags: ["servers"],
@@ -10469,6 +10503,7 @@ var init_$_routes$1 = __esmMin((() => {
10469
10503
  init_openapi$1();
10470
10504
  init_validator();
10471
10505
  init_workspace();
10506
+ init_auth();
10472
10507
  init_events$1();
10473
10508
  init_contracts();
10474
10509
  idParam$1 = type({ id: "string >= 1" });
@@ -11427,6 +11462,90 @@ var init_acme_challenge = __esmMin((() => {
11427
11462
  };
11428
11463
  }));
11429
11464
  //#endregion
11465
+ //#region src/config/secret-parts.ts
11466
+ /**
11467
+ * The parts of one scope, in a stable order: the known parts first in the fixed order
11468
+ * password, apiToken (global) / telegram, ddns (workspace) — then any other top-level
11469
+ * key present in `present`, sorted, as its own part with `fields: [key]`.
11470
+ *
11471
+ * An unknown key is taken as belonging to the scope being asked about, because the file
11472
+ * it was read from is the only evidence of where it lives: this release does not know the
11473
+ * key, so classifying it by name would drop a future *global* secret on the floor.
11474
+ */
11475
+ function secretParts(scope, present) {
11476
+ const keys = new Set(present);
11477
+ const known = KNOWN[scope];
11478
+ const parts = known.filter((key) => keys.has(key)).map((key) => ({
11479
+ key,
11480
+ fields: EXTRA_FIELDS[key] ?? [key],
11481
+ scope
11482
+ }));
11483
+ const consumed = new Set(parts.flatMap((part) => part.fields));
11484
+ const rest = [...keys].filter((key) => KNOWN_SCOPES[key] === void 0 && !known.includes(key) && !consumed.has(key)).sort();
11485
+ return [...parts, ...rest.map((key) => ({
11486
+ key,
11487
+ fields: [key],
11488
+ scope
11489
+ }))];
11490
+ }
11491
+ /** The top-level keys of a raw secrets document that hold a non-null value. */
11492
+ function presentSecretKeys(source) {
11493
+ return Object.entries(source).filter(([, value]) => value !== null && value !== void 0).map(([key]) => key);
11494
+ }
11495
+ /**
11496
+ * A JSON string carrying only `fields`, taken from `source`. Returns null when `source`
11497
+ * carries none of them. Always includes a field whose value is `null` ONLY if it is
11498
+ * present as a key and non-null; a null/absent field is omitted.
11499
+ */
11500
+ function pickSecrets(source, fields) {
11501
+ const picked = {};
11502
+ for (const field of fields) {
11503
+ const value = source[field];
11504
+ if (value !== null && value !== void 0) picked[field] = value;
11505
+ }
11506
+ return Object.keys(picked).length === 0 ? null : JSON.stringify(picked);
11507
+ }
11508
+ /** Parses a secrets document, tolerating anything: not-an-object, or broken JSON returns {}. */
11509
+ function parseSecrets(text) {
11510
+ try {
11511
+ const parsed = JSON.parse(text);
11512
+ return isRecord(parsed) ? parsed : {};
11513
+ } catch {
11514
+ return {};
11515
+ }
11516
+ }
11517
+ /**
11518
+ * Merges `piece` into `current`, changing ONLY the fields in `fields` — every other key
11519
+ * of `current` is preserved (this is what stops restoring the Telegram token from wiping
11520
+ * the DDNS credentials). A field that is absent from `piece` is also DELETED from the
11521
+ * result, so restoring an older archive that predates a secret clears it rather than
11522
+ * leaving a stale value. Returns a new object; does not mutate its arguments.
11523
+ */
11524
+ function mergeSecrets(current, piece, fields) {
11525
+ const merged = { ...current };
11526
+ for (const field of fields) {
11527
+ const value = piece[field];
11528
+ if (value === null || value === void 0) delete merged[field];
11529
+ else merged[field] = value;
11530
+ }
11531
+ return merged;
11532
+ }
11533
+ var KNOWN, KNOWN_SCOPES, EXTRA_FIELDS;
11534
+ var init_secret_parts = __esmMin((() => {
11535
+ init_shape();
11536
+ KNOWN = {
11537
+ global: ["password", "apiToken"],
11538
+ workspace: ["telegram", "ddns"]
11539
+ };
11540
+ KNOWN_SCOPES = {
11541
+ password: "global",
11542
+ apiToken: "global",
11543
+ telegram: "workspace",
11544
+ ddns: "workspace"
11545
+ };
11546
+ EXTRA_FIELDS = { ddns: ["ddns", "ddnsKdf"] };
11547
+ }));
11548
+ //#endregion
11430
11549
  //#region src/helpers/env-file.ts
11431
11550
  /** `KEY=value` files, with optional `export `, `#` comments and quoted values. */
11432
11551
  function parseEnvFile(text) {
@@ -11684,6 +11803,41 @@ function computeBackoff(attempt, options) {
11684
11803
  }
11685
11804
  var init_backoff = __esmMin((() => {}));
11686
11805
  //#endregion
11806
+ //#region src/providers/entry-state.ts
11807
+ /**
11808
+ * The small slice of a server's runtime the panel has to remember across its own
11809
+ * restarts: how much of the retry budget is spent, and whether a `runOnce` bootstrap
11810
+ * already ran.
11811
+ *
11812
+ * Both lived only in memory, so a panel restart — a `restart`, a crash, a reboot —
11813
+ * handed a crash-looping server a fresh `maxRetries` and re-ran its bootstrap. The
11814
+ * retry counter has to outlive the process that increments it to bound anything.
11815
+ */
11816
+ /** One JSON file per entry, beside the nanny's own state. */
11817
+ function entryStatePath(dir, id) {
11818
+ return path.join(dir, `${id}.run.json`);
11819
+ }
11820
+ function readEntryState(file) {
11821
+ try {
11822
+ const parsed = entryStateSchema(JSON.parse(fs.readFileSync(file, "utf8")));
11823
+ return parsed instanceof type.errors ? null : parsed;
11824
+ } catch {
11825
+ return null;
11826
+ }
11827
+ }
11828
+ function writeEntryState(file, state) {
11829
+ try {
11830
+ writeFileAtomic(file, `${JSON.stringify(state)}\n`);
11831
+ } catch {}
11832
+ }
11833
+ function clearEntryState(file) {
11834
+ fs.rmSync(file, { force: true });
11835
+ }
11836
+ var init_entry_state = __esmMin((() => {
11837
+ init_atomic();
11838
+ init_contracts();
11839
+ }));
11840
+ //#endregion
11687
11841
  //#region src/providers/health-check.ts
11688
11842
  /** TCP connect timing, used for the default `port` mode and readiness. */
11689
11843
  async function probeTcp(host, port, timeoutMs) {
@@ -12304,6 +12458,7 @@ var init_supervisor = __esmMin((() => {
12304
12458
  init_paths();
12305
12459
  init_runtime();
12306
12460
  init_template();
12461
+ init_entry_state();
12307
12462
  init_health_check();
12308
12463
  init_identity();
12309
12464
  init_nanny$2();
@@ -12371,7 +12526,7 @@ var init_supervisor = __esmMin((() => {
12371
12526
  }
12372
12527
  }
12373
12528
  const targets = [...this.entries.values()].filter((entry) => !options.autostartOnly || entry.config.autostart).map((entry) => entry.config);
12374
- for (const config of orderByDependencies(targets)) await this.start(config.id);
12529
+ for (const config of orderByDependencies(targets)) await this.start(config.id, { actor: "boot" });
12375
12530
  }
12376
12531
  /**
12377
12532
  * Stops every entry this command owns. A persistent entry is deliberately not among
@@ -12402,11 +12557,13 @@ var init_supervisor = __esmMin((() => {
12402
12557
  error: `server "${id}" is stopping`
12403
12558
  };
12404
12559
  entry.starting = true;
12560
+ entry.actor = options.retry ? "policy" : options.actor ?? "policy";
12405
12561
  this.clearRetry(entry);
12406
12562
  if (!options.retry) {
12407
12563
  entry.restarts = 0;
12408
12564
  entry.healthFailures = 0;
12409
12565
  entry.unhealthySince = null;
12566
+ this.persistEntryState(entry);
12410
12567
  }
12411
12568
  try {
12412
12569
  await this.startDependencies(entry);
@@ -12419,6 +12576,14 @@ var init_supervisor = __esmMin((() => {
12419
12576
  entry.health = entry.config.health.enabled ? "unknown" : "disabled";
12420
12577
  this.publishServer(entry);
12421
12578
  if (entry.config.persistent && await this.resumePersistent(entry)) return { ok: true };
12579
+ if (!entry.config.persistent) {
12580
+ const recorded = await this.resolveRecordedChild(entry);
12581
+ if (recorded.kind === "blocked") return {
12582
+ ok: false,
12583
+ error: recorded.error
12584
+ };
12585
+ if (recorded.kind === "adopt") return this.adoptEntry(entry, recorded.pid, "the child this panel started before it died is still running");
12586
+ }
12422
12587
  await this.runBootstrap(entry);
12423
12588
  if (entry.stopping) return {
12424
12589
  ok: false,
@@ -12433,7 +12598,7 @@ var init_supervisor = __esmMin((() => {
12433
12598
  ok: false,
12434
12599
  error: conflict.error
12435
12600
  };
12436
- if (conflict.kind === "adopt") return this.adoptEntry(entry, conflict.pid);
12601
+ if (conflict.kind === "adopt") return this.adoptEntry(entry, conflict.pid, `a detached restart of this entry is already serving port ${entry.config.port}`);
12437
12602
  return this.spawnEntry(entry);
12438
12603
  } finally {
12439
12604
  entry.starting = false;
@@ -12444,9 +12609,9 @@ var init_supervisor = __esmMin((() => {
12444
12609
  if (!entry) return missingServer(id);
12445
12610
  return this.stopEntry(entry);
12446
12611
  }
12447
- async restart(id) {
12612
+ async restart(id, options = {}) {
12448
12613
  await this.stop(id);
12449
- return this.start(id);
12614
+ return this.start(id, options);
12450
12615
  }
12451
12616
  /**
12452
12617
  * Frees the port this entry wants, by asking whatever listens on it to leave.
@@ -12678,6 +12843,10 @@ var init_supervisor = __esmMin((() => {
12678
12843
  entry.pid = null;
12679
12844
  entry.adopted = false;
12680
12845
  entry.nannyExit = null;
12846
+ if (entry.lastChild !== null && !isProcessAlive(entry.lastChild.pid)) {
12847
+ entry.lastChild = null;
12848
+ this.persistEntryState(entry);
12849
+ }
12681
12850
  entry.status = survivor === null ? "stopped" : "conflict";
12682
12851
  if (survivor !== null) entry.lastError = `pid ${survivor} survived the stop`;
12683
12852
  this.log(entry, "system", survivor === null ? "stopped" : entry.lastError);
@@ -12688,6 +12857,8 @@ var init_supervisor = __esmMin((() => {
12688
12857
  };
12689
12858
  }
12690
12859
  createEntry(config) {
12860
+ const persisted = readEntryState(entryStatePath(this.options.nannyDir, config.id));
12861
+ const lastPid = persisted?.lastPid ?? null;
12691
12862
  return {
12692
12863
  config,
12693
12864
  status: "stopped",
@@ -12696,9 +12867,13 @@ var init_supervisor = __esmMin((() => {
12696
12867
  child: null,
12697
12868
  pid: null,
12698
12869
  startedAt: null,
12870
+ lastChild: !config.persistent && lastPid !== null ? {
12871
+ pid: lastPid,
12872
+ startedAt: persisted?.lastStartedAt ?? 0
12873
+ } : null,
12699
12874
  exitCode: null,
12700
12875
  exitSignal: null,
12701
- restarts: 0,
12876
+ restarts: persisted?.restarts ?? 0,
12702
12877
  lastError: null,
12703
12878
  nextRetryAt: null,
12704
12879
  retryTimer: null,
@@ -12711,8 +12886,9 @@ var init_supervisor = __esmMin((() => {
12711
12886
  nannyExit: null,
12712
12887
  starting: false,
12713
12888
  stopping: false,
12889
+ actor: "policy",
12714
12890
  removed: false,
12715
- bootstrapDone: !config.bootstrap,
12891
+ bootstrapDone: persisted?.bootstrapDone ?? !config.bootstrap,
12716
12892
  logs: new LogBuffer(config.logBufferLines),
12717
12893
  echoed: /* @__PURE__ */ new Set(),
12718
12894
  responseMs: null,
@@ -12730,6 +12906,7 @@ var init_supervisor = __esmMin((() => {
12730
12906
  this.relay.unfollow(id);
12731
12907
  this.options.logFiles.clear(id);
12732
12908
  this.options.history.forget(id);
12909
+ clearEntryState(entryStatePath(this.options.nannyDir, id));
12733
12910
  entry.removed = true;
12734
12911
  this.stopEntry(entry).catch((error) => {
12735
12912
  logger.error(`could not stop the removed server ${id}`, error);
@@ -12853,16 +13030,6 @@ var init_supervisor = __esmMin((() => {
12853
13030
  return { kind: "free" };
12854
13031
  }
12855
13032
  /**
12856
- * Takes over a detached successor: no spawn, no duplicate. The pid is supervised
12857
- * from here on (liveness, health probe, resources, stop), while its output stays
12858
- * wherever it was redirected.
12859
- *
12860
- * For a persistent entry that output is not a mystery: its nanny is still writing
12861
- * the entry's log file, so the relay is started here too. Without this, adopting a
12862
- * persistent entry whose state file was lost would show its history but never
12863
- * another live line.
12864
- */
12865
- /**
12866
13033
  * Stops the listed port holders and reports whether the port came free.
12867
13034
  *
12868
13035
  * `kill` and `reclaim` are the same sequence over a different pid list — the foreign
@@ -12887,7 +13054,21 @@ var init_supervisor = __esmMin((() => {
12887
13054
  error: entry.lastError
12888
13055
  };
12889
13056
  }
12890
- adoptEntry(entry, pid) {
13057
+ /**
13058
+ * Takes over a detached successor: no spawn, no duplicate. The pid is supervised
13059
+ * from here on (liveness, health probe, resources, stop), while its output stays
13060
+ * wherever it was redirected.
13061
+ *
13062
+ * For a persistent entry that output is not a mystery: its nanny is still writing
13063
+ * the entry's log file, so the relay is started here too. Without this, adopting a
13064
+ * persistent entry whose state file was lost would show its history but never
13065
+ * another live line.
13066
+ *
13067
+ * `reason` is the caller's sentence for *why* this pid is ours: a port holder and a
13068
+ * recorded child that survived a hard panel death are the same adoption, said
13069
+ * differently, and the log line is the only place a person sees which happened.
13070
+ */
13071
+ adoptEntry(entry, pid, reason) {
12891
13072
  entry.adopted = true;
12892
13073
  entry.pid = pid;
12893
13074
  entry.child = null;
@@ -12896,12 +13077,60 @@ var init_supervisor = __esmMin((() => {
12896
13077
  entry.startedAt = Date.now();
12897
13078
  entry.lastError = null;
12898
13079
  if (entry.config.persistent) this.followPersistent(entry, true);
12899
- this.log(entry, "system", `adopted pid ${pid}: a detached restart of this entry is already serving port ${entry.config.port}`);
13080
+ this.log(entry, "system", `adopted pid ${pid}: ${reason}`);
12900
13081
  this.publishServer(entry);
12901
13082
  return { ok: true };
12902
13083
  }
13084
+ /**
13085
+ * The child this entry's state file recorded, when it is still that same process.
13086
+ *
13087
+ * A non-persistent child is spawned detached, so it survives a panel killed hard while
13088
+ * nothing else remembers it — `preflight` cannot help a portless entry, which is how a
13089
+ * portless worker got a second copy beside the running one. A pid is recycled, so
13090
+ * liveness alone proves nothing: the birth time and the marker decide, and a pid this
13091
+ * platform cannot place is never adopted on a guess.
13092
+ *
13093
+ * The record is cleared only once its process is known to be gone or to be somebody
13094
+ * else, so a stale pid is never chased and a live one is never forgotten into a twin.
13095
+ */
13096
+ async resolveRecordedChild(entry) {
13097
+ const recorded = entry.lastChild;
13098
+ if (recorded === null) return { kind: "none" };
13099
+ if (recorded.pid === process.pid) {
13100
+ entry.lastChild = null;
13101
+ this.persistEntryState(entry);
13102
+ return { kind: "none" };
13103
+ }
13104
+ const liveness = recorded.startedAt > 0 ? await processLiveness(recorded.pid, recorded.startedAt) : isProcessAlive(recorded.pid) ? "unknown" : "gone";
13105
+ if (liveness === "gone" || liveness === "recycled") {
13106
+ entry.lastChild = null;
13107
+ this.persistEntryState(entry);
13108
+ return { kind: "none" };
13109
+ }
13110
+ if (await processCarriesServerId(recorded.pid, entry.config.id)) return {
13111
+ kind: "adopt",
13112
+ pid: recorded.pid
13113
+ };
13114
+ if (entry.config.port !== null) return { kind: "none" };
13115
+ const error = `pid ${recorded.pid} may be a child this panel started for "${entry.config.id}" before it died, and this platform cannot confirm whose it is — not starting a second copy, stop pid ${recorded.pid} to clear it`;
13116
+ entry.status = "conflict";
13117
+ entry.lastError = error;
13118
+ this.log(entry, "system", error);
13119
+ this.publishServer(entry);
13120
+ return {
13121
+ kind: "blocked",
13122
+ error
13123
+ };
13124
+ }
13125
+ /** Clears the durable record of the last child, once that pid is known to be gone. */
13126
+ forgetRecordedChild(entry, pid) {
13127
+ if (pid === null || entry.lastChild === null || entry.lastChild.pid !== pid) return;
13128
+ entry.lastChild = null;
13129
+ this.persistEntryState(entry);
13130
+ }
12903
13131
  /** An adopted successor disappeared: fall back to the normal lifecycle. */
12904
13132
  handleAdoptedExit(entry) {
13133
+ const adoptedPid = entry.pid;
12905
13134
  if (entry.pid !== null) this.sampler.forget(entry.pid);
12906
13135
  if (entry.config.persistent) this.consumeNannyExit(entry);
12907
13136
  const ranForMs = entry.nannyExit?.runtimeMs ?? (entry.startedAt === null ? 0 : Date.now() - entry.startedAt);
@@ -12909,6 +13138,7 @@ var init_supervisor = __esmMin((() => {
12909
13138
  entry.nannyExit = null;
12910
13139
  entry.adopted = false;
12911
13140
  entry.pid = null;
13141
+ this.forgetRecordedChild(entry, adoptedPid);
12912
13142
  entry.resources = null;
12913
13143
  entry.responseMs = null;
12914
13144
  entry.exitCode = null;
@@ -12949,6 +13179,7 @@ var init_supervisor = __esmMin((() => {
12949
13179
  } catch (error) {
12950
13180
  this.log(entry, "system", `bootstrap could not start: ${error.message} — continuing anyway`);
12951
13181
  entry.bootstrapDone = true;
13182
+ this.persistEntryState(entry);
12952
13183
  return;
12953
13184
  }
12954
13185
  child.stdout?.on("data", (chunk) => splitter.push("stdout", chunk));
@@ -12971,6 +13202,7 @@ var init_supervisor = __esmMin((() => {
12971
13202
  });
12972
13203
  });
12973
13204
  entry.bootstrapDone = true;
13205
+ this.persistEntryState(entry);
12974
13206
  if (code === 0) this.log(entry, "system", "bootstrap finished");
12975
13207
  else if (code !== null) this.log(entry, "system", `bootstrap exited with code ${code} — continuing anyway`);
12976
13208
  }
@@ -13112,9 +13344,17 @@ var init_supervisor = __esmMin((() => {
13112
13344
  entry.child = child;
13113
13345
  entry.pid = child.pid ?? null;
13114
13346
  entry.startedAt = Date.now();
13347
+ if (!entry.config.persistent && child.pid !== void 0) {
13348
+ entry.lastChild = {
13349
+ pid: child.pid,
13350
+ startedAt: entry.startedAt
13351
+ };
13352
+ this.persistEntryState(entry);
13353
+ }
13115
13354
  this.options.history.record(entry.config.id, {
13116
13355
  type: "start",
13117
- detail
13356
+ detail,
13357
+ actor: entry.actor
13118
13358
  });
13119
13359
  entry.exitCode = null;
13120
13360
  entry.exitSignal = null;
@@ -13304,6 +13544,7 @@ var init_supervisor = __esmMin((() => {
13304
13544
  entry.responseMs = null;
13305
13545
  entry.exitCode = code;
13306
13546
  entry.exitSignal = signal;
13547
+ this.forgetRecordedChild(entry, child.pid ?? null);
13307
13548
  const neverStarted = nannyExit !== null ? nannyExit.code === null && nannyExit.signal === null : code === null && signal === null && entry.lastError !== null;
13308
13549
  const detail = neverStarted ? entry.lastError ?? "the entry could not start" : nannyExit !== null ? describeNannyExit(nannyExit) : describeNannyExit({
13309
13550
  code,
@@ -13332,10 +13573,12 @@ var init_supervisor = __esmMin((() => {
13332
13573
  }
13333
13574
  const restart = entry.config.restart;
13334
13575
  if (ranForMs >= restart.resetAfterMs) entry.restarts = 0;
13576
+ this.persistEntryState(entry);
13335
13577
  this.log(entry, "system", neverStarted ? `did not start: ${detail}` : `exited with ${detail} after ${ranFor}`);
13336
13578
  entry.lastError = neverStarted ? detail : `exited with ${detail}`;
13337
13579
  if (restart.enabled && entry.restarts < restart.maxRetries) {
13338
13580
  entry.restarts += 1;
13581
+ this.persistEntryState(entry);
13339
13582
  const backoffMs = computeBackoff(entry.restarts, restart);
13340
13583
  entry.status = "backoff";
13341
13584
  entry.nextRetryAt = Date.now() + backoffMs;
@@ -13471,6 +13714,15 @@ var init_supervisor = __esmMin((() => {
13471
13714
  entry.retryTimer = null;
13472
13715
  }
13473
13716
  }
13717
+ /** The decisions that have to survive this panel, and nothing else. */
13718
+ persistEntryState(entry) {
13719
+ writeEntryState(entryStatePath(this.options.nannyDir, entry.config.id), {
13720
+ restarts: entry.restarts,
13721
+ bootstrapDone: entry.bootstrapDone,
13722
+ lastPid: entry.config.persistent ? null : entry.lastChild?.pid ?? null,
13723
+ lastStartedAt: entry.config.persistent ? null : entry.lastChild?.startedAt ?? null
13724
+ });
13725
+ }
13474
13726
  buildVars(entry) {
13475
13727
  return serverTemplateVars(entry.config);
13476
13728
  }
@@ -13754,6 +14006,28 @@ function labelOf(plan, id) {
13754
14006
  return plan.items.find((item) => item.id === id)?.label ?? id;
13755
14007
  }
13756
14008
  /**
14009
+ * Restores only `fields` from an archived secrets document into a live one.
14010
+ *
14011
+ * The archive may predate a secret the live file holds, and the live file may hold one
14012
+ * the archive never carried: merging by field is what makes restoring the Telegram token
14013
+ * leave the DDNS credentials alone, and what clears a secret the archive does not carry
14014
+ * rather than leaving the newer value behind as if it had been restored.
14015
+ */
14016
+ function mergeIntoFile(target, archived, fields) {
14017
+ const merged = mergeSecrets(parseSecrets(readText(target) ?? ""), parseSecrets(archived), fields);
14018
+ writeFileAtomic(target, `${JSON.stringify(merged, null, 2)}\n`, { mode: 384 });
14019
+ }
14020
+ /**
14021
+ * What an archive says about where it came from. Both fields are absent on an archive
14022
+ * written before they existed, so a UI shows the row only when there is something to show.
14023
+ */
14024
+ function manifestInfo(manifest) {
14025
+ return {
14026
+ ...manifest.writtenBy === void 0 ? {} : { writtenBy: manifest.writtenBy },
14027
+ ...manifest.schema === void 0 ? {} : { schema: manifest.schema }
14028
+ };
14029
+ }
14030
+ /**
13757
14031
  * `servers.config.json` is only a readability gate here: the store applies the
13758
14032
  * workspace's own defaults on the next load, and an archive from another machine
13759
14033
  * need not match this one's default shapes exactly.
@@ -13774,13 +14048,16 @@ function readableWorkspaceSettings(text) {
13774
14048
  return false;
13775
14049
  }
13776
14050
  }
13777
- var MANIFEST, ALLOWED_ROOTS, SUFFIX, RELOADABLE, BackupService;
14051
+ var MANIFEST, ALLOWED_ROOTS, SUFFIX, RELOADABLE, SECRET_LABELS, BackupService;
13778
14052
  var init_backups = __esmMin((() => {
14053
+ init_migrations();
13779
14054
  init_parse();
14055
+ init_secret_parts();
13780
14056
  init_atomic();
13781
14057
  init_env_file();
13782
14058
  init_paths();
13783
14059
  init_template();
14060
+ init_version();
13784
14061
  init_archive();
13785
14062
  init_supervisor();
13786
14063
  init_error_message();
@@ -13794,6 +14071,24 @@ var init_backups = __esmMin((() => {
13794
14071
  ]);
13795
14072
  SUFFIX = ".zip";
13796
14073
  RELOADABLE = /^global:settings$|^workspace:[^:]+:(?:settings|servers)$/;
14074
+ SECRET_LABELS = {
14075
+ password: {
14076
+ en: "Password",
14077
+ key: "settings.backup.secretPassword"
14078
+ },
14079
+ apiToken: {
14080
+ en: "API token",
14081
+ key: "settings.backup.secretApiToken"
14082
+ },
14083
+ telegram: {
14084
+ en: "Telegram bot token",
14085
+ key: "settings.backup.secretTelegram"
14086
+ },
14087
+ ddns: {
14088
+ en: "DDNS credentials",
14089
+ key: "settings.backup.secretDdns"
14090
+ }
14091
+ };
13797
14092
  BackupService = class {
13798
14093
  options;
13799
14094
  /**
@@ -13856,6 +14151,16 @@ var init_backups = __esmMin((() => {
13856
14151
  const wanted = options.include === void 0 ? null : new Set(options.include);
13857
14152
  const capturesGlobal = (id) => wanted === null || wanted.has(id);
13858
14153
  const capturesWorkspace = (id, suffix) => wanted === null || wanted.has(`workspace:${id}`) || wanted.has(`workspace:${id}:${suffix}`);
14154
+ /**
14155
+ * The secrets of one scope the request asked for; `null` means "every one of them".
14156
+ * `workspaceId` is how a bare `workspace:<id>` selection covers all of its leaves.
14157
+ */
14158
+ const selectedSecretFields = (entryId, path, scope, workspaceId) => {
14159
+ const parts = secretParts(scope, presentSecretKeys(parseSecrets(readText(path) ?? "")));
14160
+ const coversEntry = wanted === null || wanted.has(entryId) || workspaceId !== void 0 && wanted.has(`workspace:${workspaceId}`);
14161
+ const picked = parts.filter((part) => coversEntry || wanted.has(`${entryId}:${part.key}`) || part.fields.some((field) => wanted.has(`${entryId}:${field}`)));
14162
+ return picked.length === parts.length ? null : picked.flatMap((part) => part.fields);
14163
+ };
13859
14164
  const dir = this.resolveDir();
13860
14165
  const sources = this.options.getSources();
13861
14166
  const staging = path.join(dir, `.staging-${Date.now()}`);
@@ -13868,15 +14173,41 @@ var init_backups = __esmMin((() => {
13868
14173
  const copy = (relative, source, ignoreGenerated = false) => {
13869
14174
  if (this.copyInto(staging, relative, source, ignoreGenerated)) captured++;
13870
14175
  };
14176
+ /**
14177
+ * Captures one secrets file, narrowed to the picked secrets. Copying the whole file
14178
+ * would capture the ones that were *not* picked, so a per-secret selection writes a
14179
+ * staged document holding only those — and the restore side merges by field, which
14180
+ * is what keeps a restore from wiping a secret the archive never carried.
14181
+ */
14182
+ const copySecrets = (relative, source, entryId, scope, workspaceId) => {
14183
+ const text = readText(source);
14184
+ if (text === null) return;
14185
+ const fields = selectedSecretFields(entryId, source, scope, workspaceId);
14186
+ if (fields === null) {
14187
+ copy(relative, source);
14188
+ return;
14189
+ }
14190
+ const chosen = pickSecrets(parseSecrets(text), fields);
14191
+ if (chosen === null) return;
14192
+ const target = path.join(staging, relative);
14193
+ fs.mkdirSync(path.dirname(target), { recursive: true });
14194
+ fs.writeFileSync(target, `${chosen}\n`);
14195
+ captured++;
14196
+ };
14197
+ /**
14198
+ * True when the request touches a secrets entry at all: the entry itself, one of its
14199
+ * leaves, or — for a workspace — the workspace id, which names every leaf it has.
14200
+ */
14201
+ const capturesSecrets = (entryId, workspaceId) => wanted === null || wanted.has(entryId) || workspaceId !== void 0 && wanted.has(`workspace:${workspaceId}`) || [...wanted].some((id) => id.startsWith(`${entryId}:`));
13871
14202
  if (capturesGlobal("global:settings")) copy("global/settings.json", sources.globalSettingsPath);
13872
- if (capturesGlobal("global:secrets")) copy("global/secrets.json", sources.globalSecretsPath);
14203
+ if (capturesSecrets("global:secrets")) copySecrets("global/secrets.json", sources.globalSecretsPath, "global:secrets", "global");
13873
14204
  if (capturesGlobal("global:tls")) copy("global/tls", sources.tlsDir);
13874
14205
  const data = [];
13875
14206
  for (const workspace of sources.workspaces) {
13876
14207
  const root = `workspaces/${workspace.id}`;
13877
14208
  if (capturesWorkspace(workspace.id, "settings")) copy(`${root}/settings.json`, workspace.settingsPath);
13878
14209
  if (capturesWorkspace(workspace.id, "servers")) copy(`${root}/servers.config.json`, workspace.serversPath);
13879
- if (capturesWorkspace(workspace.id, "secrets")) copy(`${root}/secrets.json`, workspace.secretsPath);
14210
+ if (capturesSecrets(`workspace:${workspace.id}:secrets`, workspace.id)) copySecrets(`${root}/secrets.json`, workspace.secretsPath, `workspace:${workspace.id}:secrets`, "workspace", workspace.id);
13880
14211
  for (const declared of this.declaredData(workspace.servers, sources.includePaths)) {
13881
14212
  if (!declared.included) continue;
13882
14213
  if (!capturesWorkspace(workspace.id, `data:${declared.path}`)) continue;
@@ -13907,6 +14238,8 @@ var init_backups = __esmMin((() => {
13907
14238
  version: 1,
13908
14239
  createdAt,
13909
14240
  hostname: os.hostname(),
14241
+ writtenBy: appVersion(),
14242
+ schema: 1,
13910
14243
  data
13911
14244
  };
13912
14245
  fs.writeFileSync(path.join(staging, MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`);
@@ -14021,6 +14354,11 @@ var init_backups = __esmMin((() => {
14021
14354
  error: "the archive has no manifest"
14022
14355
  };
14023
14356
  const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
14357
+ plan.archive = manifestInfo(manifest);
14358
+ if (manifest.schema !== void 0 && manifest.schema > 1) return {
14359
+ ...plan,
14360
+ error: `written by home-hosted ${manifest.writtenBy ?? "a newer release"} (config schema ${manifest.schema}); this release understands schema 1`
14361
+ };
14024
14362
  const sources = this.options.getSources();
14025
14363
  const selectedIds = options.include === void 0 ? null : new Set(options.include);
14026
14364
  const actions = /* @__PURE__ */ new Map();
@@ -14077,16 +14415,32 @@ var init_backups = __esmMin((() => {
14077
14415
  writeFileAtomic(sources.globalSettingsPath, settingsText);
14078
14416
  }, ["config"]);
14079
14417
  const globalSecrets = readText(path.join(staging, "global", "secrets.json"));
14080
- if (globalSecrets !== null) addItem({
14081
- id: "global:secrets",
14082
- label: "Global secrets (password, API token)",
14083
- labelKey: "settings.backup.entryGlobalSecrets",
14084
- kind: "secrets",
14085
- restorable: true,
14086
- note: null
14087
- }, () => {
14088
- writeFileAtomic(sources.globalSecretsPath, globalSecrets, { mode: 384 });
14089
- }, ["secrets"]);
14418
+ if (globalSecrets !== null) {
14419
+ addItem({
14420
+ id: "global:secrets",
14421
+ label: "Global secrets (password, API token)",
14422
+ labelKey: "settings.backup.entryGlobalSecrets",
14423
+ kind: "secrets",
14424
+ restorable: true,
14425
+ note: null
14426
+ }, () => {
14427
+ writeFileAtomic(sources.globalSecretsPath, globalSecrets, { mode: 384 });
14428
+ }, ["secrets"]);
14429
+ for (const part of secretParts("global", presentSecretKeys(parseSecrets(globalSecrets)))) addItem({
14430
+ id: `global:secrets:${part.key}`,
14431
+ label: `Global secrets: ${SECRET_LABELS[part.key]?.en ?? part.key}`,
14432
+ labelKey: "settings.backup.restoreItemLabel",
14433
+ labelParams: {
14434
+ workspace: "global",
14435
+ item: part.key
14436
+ },
14437
+ kind: "secrets",
14438
+ restorable: true,
14439
+ note: null
14440
+ }, () => {
14441
+ mergeIntoFile(sources.globalSecretsPath, globalSecrets, part.fields);
14442
+ });
14443
+ }
14090
14444
  const tlsInArchive = path.join(staging, "global", "tls");
14091
14445
  if (fs.existsSync(tlsInArchive)) {
14092
14446
  const files = fs.readdirSync(tlsInArchive).filter((file) => fs.statSync(path.join(tlsInArchive, file)).isFile());
@@ -14214,21 +14568,38 @@ var init_backups = __esmMin((() => {
14214
14568
  });
14215
14569
  }
14216
14570
  }
14217
- if (secrets !== null) addItem({
14218
- id: `workspace:${id}:secrets`,
14219
- label: `${workspace.label}: Secrets`,
14220
- labelKey: "settings.backup.restoreItemLabel",
14221
- labelParams: {
14222
- workspace: workspace.label,
14223
- item: "secrets"
14224
- },
14225
- kind: "secrets",
14226
- workspaceId: id,
14227
- restorable: true,
14228
- note: null
14229
- }, () => {
14230
- writeFileAtomic(workspace.secretsPath, secrets, { mode: 384 });
14231
- });
14571
+ if (secrets !== null) {
14572
+ addItem({
14573
+ id: `workspace:${id}:secrets`,
14574
+ label: `${workspace.label}: Secrets`,
14575
+ labelKey: "settings.backup.restoreItemLabel",
14576
+ labelParams: {
14577
+ workspace: workspace.label,
14578
+ item: "secrets"
14579
+ },
14580
+ kind: "secrets",
14581
+ workspaceId: id,
14582
+ restorable: true,
14583
+ note: null
14584
+ }, () => {
14585
+ writeFileAtomic(workspace.secretsPath, secrets, { mode: 384 });
14586
+ });
14587
+ for (const part of secretParts("workspace", presentSecretKeys(parseSecrets(secrets)))) addItem({
14588
+ id: `workspace:${id}:secrets:${part.key}`,
14589
+ label: `${workspace.label}: ${SECRET_LABELS[part.key]?.en ?? part.key}`,
14590
+ labelKey: "settings.backup.restoreItemLabel",
14591
+ labelParams: {
14592
+ workspace: workspace.label,
14593
+ item: part.key
14594
+ },
14595
+ kind: "secrets",
14596
+ workspaceId: id,
14597
+ restorable: true,
14598
+ note: null
14599
+ }, () => {
14600
+ mergeIntoFile(workspace.secretsPath, secrets, part.fields);
14601
+ });
14602
+ }
14232
14603
  }
14233
14604
  const instanceTargets = [];
14234
14605
  for (const workspace of sources.workspaces) for (const declared of this.declaredData(workspace.servers, sources.includePaths)) {
@@ -14381,7 +14752,7 @@ var init_backups = __esmMin((() => {
14381
14752
  labelKey: "settings.backup.entryGlobalSecrets",
14382
14753
  kind: "secrets",
14383
14754
  ...missing(sources.globalSecretsPath, "settings.backup.noteMissing"),
14384
- items: []
14755
+ items: this.secretItems("global", "global:secrets", sources.globalSecretsPath)
14385
14756
  },
14386
14757
  {
14387
14758
  id: "global:tls",
@@ -14402,6 +14773,27 @@ var init_backups = __esmMin((() => {
14402
14773
  });
14403
14774
  return entries;
14404
14775
  }
14776
+ /**
14777
+ * One leaf per secret the file actually holds, so a restore can bring back the
14778
+ * password without the API token, or the Telegram token without the DDNS credentials.
14779
+ *
14780
+ * The set comes from the file's own top-level keys rather than a list here, so a
14781
+ * secret added later appears on its own — the failure mode of a second list is a
14782
+ * secret that can be captured but never restored individually.
14783
+ */
14784
+ secretItems(scope, entryId, path) {
14785
+ const present = presentSecretKeys(parseSecrets(readText(path) ?? ""));
14786
+ if (present.length === 0) return [];
14787
+ return secretParts(scope, present).map((part) => ({
14788
+ id: `${entryId}:${part.key}`,
14789
+ label: SECRET_LABELS[part.key]?.en ?? part.key,
14790
+ ...SECRET_LABELS[part.key] === void 0 ? {} : { labelKey: SECRET_LABELS[part.key].key },
14791
+ kind: "secrets",
14792
+ secret: part.key,
14793
+ included: true,
14794
+ note: null
14795
+ }));
14796
+ }
14405
14797
  workspaceItems(workspace, includePaths) {
14406
14798
  const id = workspace.id;
14407
14799
  const note = (path) => {
@@ -14435,7 +14827,8 @@ var init_backups = __esmMin((() => {
14435
14827
  kind: "secrets",
14436
14828
  included: true,
14437
14829
  ...note(workspace.secretsPath)
14438
- }
14830
+ },
14831
+ ...this.secretItems("workspace", `workspace:${id}:secrets`, workspace.secretsPath)
14439
14832
  ];
14440
14833
  for (const declared of this.declaredData(workspace.servers, includePaths)) items.push({
14441
14834
  id: `workspace:${id}:data:${declared.path}`,