home-hosted 0.8.1 → 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, 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",
@@ -1475,21 +1491,37 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
1475
1491
  backupEntrySchema = type({
1476
1492
  /** `global:settings` | `global:secrets` | `global:tls` | `workspace:<id>`. */
1477
1493
  "id": "string",
1494
+ /**
1495
+ * The panel's own English wording. Kept because it is what an older UI reads, and what a
1496
+ * single-language UI wants; a translated UI prefers the `*Key` beside it.
1497
+ */
1478
1498
  "label": "string",
1499
+ /** A catalog key for `label`, with its placeholder values, so a UI can render its own language. */
1500
+ "labelKey?": "string",
1501
+ "labelParams?": "Record<string, string>",
1479
1502
  "kind": "\"settings\" | \"secrets\" | \"tls\" | \"workspace\"",
1480
1503
  "workspaceId?": "string",
1481
1504
  "note": "string | null",
1505
+ /** The same pair for `note`: the panel knows which reason it is, the UI knows the wording. */
1506
+ "noteKey?": "string",
1507
+ "noteParams?": "Record<string, string>",
1482
1508
  /** What this entry would capture; a workspace carries several leaves. */
1483
1509
  "items": type({
1484
1510
  "id": "string",
1485
1511
  "label": "string",
1512
+ "labelKey?": "string",
1513
+ "labelParams?": "Record<string, string>",
1486
1514
  /** `settings`/`servers`/`secrets` for a workspace, `data` for a declared path. */
1487
1515
  "kind": "\"settings\" | \"servers\" | \"secrets\" | \"data\"",
1516
+ /** Which secret a `secrets` leaf carries (`password`, `apiToken`, `telegram`, `ddns`, …). */
1517
+ "secret?": "string",
1488
1518
  "path?": "string",
1489
1519
  /** Who declared a data path: `global`, `<serverId>:backupPaths` or `<serverId>:<ENV>`. */
1490
1520
  "origin?": "string",
1491
1521
  "included": "boolean",
1492
1522
  "note": "string | null",
1523
+ "noteKey?": "string",
1524
+ "noteParams?": "Record<string, string>",
1493
1525
  "ignoreGenerated?": "boolean"
1494
1526
  }).array()
1495
1527
  });
@@ -1507,27 +1539,53 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
1507
1539
  /** A backup entry or leaf id (`workspace:<id>:servers`, `data:<path>`, ...). */
1508
1540
  "id": "string",
1509
1541
  "label": "string",
1542
+ "labelKey?": "string",
1543
+ "labelParams?": "Record<string, string>",
1510
1544
  "kind": "\"settings\" | \"secrets\" | \"servers\" | \"tls\" | \"data\"",
1511
1545
  "workspaceId?": "string",
1512
1546
  /** false when the current instance does not declare it, or it is not in the archive. */
1513
1547
  "restorable": "boolean",
1514
1548
  /** Echo of the request's selection, so the checkboxes round-trip. */
1515
1549
  "selected": "boolean",
1516
- "note": "string | null"
1550
+ "note": "string | null",
1551
+ "noteKey?": "string",
1552
+ "noteParams?": "Record<string, string>"
1553
+ });
1554
+ restoreSkipSchema = type({
1555
+ "label": "string",
1556
+ "labelKey?": "string",
1557
+ "labelParams?": "Record<string, string>",
1558
+ /** A catalog key for the parenthesis, e.g. `not selected`; absent when there is none. */
1559
+ "reasonKey?": "string",
1560
+ "reasonParams?": "Record<string, string>"
1517
1561
  });
1518
1562
  restorePlanSchema = type({
1519
- dryRun: "boolean",
1520
- encrypted: "boolean",
1563
+ "dryRun": "boolean",
1564
+ "encrypted": "boolean",
1521
1565
  /** The archive needs a password (none or a wrong one was supplied). */
1522
- needsPassword: "boolean",
1523
- items: restoreItemSchema.array(),
1524
- applied: type("string[]"),
1525
- skipped: type("string[]"),
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
+ }),
1575
+ "items": restoreItemSchema.array(),
1576
+ "applied": type("string[]"),
1577
+ /**
1578
+ * The ids `applied` names, so a UI resolves each through `items` and renders its own label.
1579
+ * `applied` itself stays: it is what a UI with one language reads.
1580
+ */
1581
+ "appliedIds?": type("string[]"),
1582
+ "skipped": type("string[]"),
1583
+ "skippedItems?": restoreSkipSchema.array(),
1526
1584
  /** Only the panel's own listener needs a restart; its servers are re-read live. */
1527
- restartRequired: "boolean",
1585
+ "restartRequired": "boolean",
1528
1586
  /** The panel re-read the restored config within this same restore. */
1529
- reloaded: "boolean",
1530
- error: "string?"
1587
+ "reloaded": "boolean",
1588
+ "error": "string?"
1531
1589
  });
1532
1590
  backupCreateSchema = type({
1533
1591
  /** Optional: encrypts the archive. Never stored. */
@@ -2603,6 +2661,14 @@ function createAuthGuard(deps) {
2603
2661
  await next();
2604
2662
  };
2605
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
+ }
2606
2672
  var PUBLIC_PATHS, AUTH_REQUIRED_CODE;
2607
2673
  var init_auth = __esmMin((() => {
2608
2674
  init_loopback();
@@ -3611,7 +3677,7 @@ function createControlRoute(deps) {
3611
3677
  }
3612
3678
  }), validate("query", workspaceQuerySchema), async (c) => {
3613
3679
  assertLocalCall(c, "start or stop a server");
3614
- 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" });
3615
3681
  return c.json(result, statusForAction(result));
3616
3682
  }).post("/servers/:id/stop", describeRoute({
3617
3683
  tags: ["panel"],
@@ -3635,7 +3701,7 @@ function createControlRoute(deps) {
3635
3701
  }
3636
3702
  }), validate("query", workspaceQuerySchema), async (c) => {
3637
3703
  assertLocalCall(c, "restart a server");
3638
- 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" });
3639
3705
  return c.json(result, statusForAction(result));
3640
3706
  });
3641
3707
  }
@@ -6391,8 +6457,8 @@ var init_events = __esmMin((() => {
6391
6457
  /**
6392
6458
  * Liveness for external monitors. Mounted outside `/api`, so it answers without a
6393
6459
  * session: it reports whether the panel itself is serving, and 503 when an
6394
- * autostart server has crashed. Server details are only included for an
6395
- * 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.
6396
6462
  */
6397
6463
  function createHealthRoute(deps) {
6398
6464
  return appFactory.createApp().get("/healthz", describeRoute({
@@ -6403,12 +6469,12 @@ function createHealthRoute(deps) {
6403
6469
  description: "Serving",
6404
6470
  content: jsonBody(healthResponseSchema)
6405
6471
  },
6406
- 503: { description: "An autostart server has crashed" }
6472
+ 503: { description: "An autostart server has crashed, or a running one is unhealthy" }
6407
6473
  }
6408
6474
  }), (c) => {
6409
6475
  const state = deps.panel.getState();
6410
6476
  const servers = state.workspaces.flatMap((workspace) => workspace.servers);
6411
- 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");
6412
6478
  const authenticated = requestIdentity(c, deps.auth).authenticated;
6413
6479
  return c.json({
6414
6480
  status: broken.length > 0 ? "degraded" : "ok",
@@ -10329,7 +10395,7 @@ function createServersRoute(deps) {
10329
10395
  404: ERROR_RESPONSES[404]
10330
10396
  }
10331
10397
  }), validate("query", workspaceQuerySchema), validate("param", idParam$1), async (c) => {
10332
- 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) });
10333
10399
  return c.json(result, statusForAction(result));
10334
10400
  }).post("/:id/stop", describeRoute({
10335
10401
  tags: ["servers"],
@@ -10349,7 +10415,7 @@ function createServersRoute(deps) {
10349
10415
  404: ERROR_RESPONSES[404]
10350
10416
  }
10351
10417
  }), validate("query", workspaceQuerySchema), validate("param", idParam$1), async (c) => {
10352
- 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) });
10353
10419
  return c.json(result, statusForAction(result));
10354
10420
  }).post("/:id/clear-logs", describeRoute({
10355
10421
  tags: ["servers"],
@@ -10437,6 +10503,7 @@ var init_$_routes$1 = __esmMin((() => {
10437
10503
  init_openapi$1();
10438
10504
  init_validator();
10439
10505
  init_workspace();
10506
+ init_auth();
10440
10507
  init_events$1();
10441
10508
  init_contracts();
10442
10509
  idParam$1 = type({ id: "string >= 1" });
@@ -11395,6 +11462,90 @@ var init_acme_challenge = __esmMin((() => {
11395
11462
  };
11396
11463
  }));
11397
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
11398
11549
  //#region src/helpers/env-file.ts
11399
11550
  /** `KEY=value` files, with optional `export `, `#` comments and quoted values. */
11400
11551
  function parseEnvFile(text) {
@@ -11652,6 +11803,41 @@ function computeBackoff(attempt, options) {
11652
11803
  }
11653
11804
  var init_backoff = __esmMin((() => {}));
11654
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
11655
11841
  //#region src/providers/health-check.ts
11656
11842
  /** TCP connect timing, used for the default `port` mode and readiness. */
11657
11843
  async function probeTcp(host, port, timeoutMs) {
@@ -12272,6 +12458,7 @@ var init_supervisor = __esmMin((() => {
12272
12458
  init_paths();
12273
12459
  init_runtime();
12274
12460
  init_template();
12461
+ init_entry_state();
12275
12462
  init_health_check();
12276
12463
  init_identity();
12277
12464
  init_nanny$2();
@@ -12339,7 +12526,7 @@ var init_supervisor = __esmMin((() => {
12339
12526
  }
12340
12527
  }
12341
12528
  const targets = [...this.entries.values()].filter((entry) => !options.autostartOnly || entry.config.autostart).map((entry) => entry.config);
12342
- 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" });
12343
12530
  }
12344
12531
  /**
12345
12532
  * Stops every entry this command owns. A persistent entry is deliberately not among
@@ -12370,11 +12557,13 @@ var init_supervisor = __esmMin((() => {
12370
12557
  error: `server "${id}" is stopping`
12371
12558
  };
12372
12559
  entry.starting = true;
12560
+ entry.actor = options.retry ? "policy" : options.actor ?? "policy";
12373
12561
  this.clearRetry(entry);
12374
12562
  if (!options.retry) {
12375
12563
  entry.restarts = 0;
12376
12564
  entry.healthFailures = 0;
12377
12565
  entry.unhealthySince = null;
12566
+ this.persistEntryState(entry);
12378
12567
  }
12379
12568
  try {
12380
12569
  await this.startDependencies(entry);
@@ -12387,6 +12576,14 @@ var init_supervisor = __esmMin((() => {
12387
12576
  entry.health = entry.config.health.enabled ? "unknown" : "disabled";
12388
12577
  this.publishServer(entry);
12389
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
+ }
12390
12587
  await this.runBootstrap(entry);
12391
12588
  if (entry.stopping) return {
12392
12589
  ok: false,
@@ -12401,7 +12598,7 @@ var init_supervisor = __esmMin((() => {
12401
12598
  ok: false,
12402
12599
  error: conflict.error
12403
12600
  };
12404
- 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}`);
12405
12602
  return this.spawnEntry(entry);
12406
12603
  } finally {
12407
12604
  entry.starting = false;
@@ -12412,9 +12609,9 @@ var init_supervisor = __esmMin((() => {
12412
12609
  if (!entry) return missingServer(id);
12413
12610
  return this.stopEntry(entry);
12414
12611
  }
12415
- async restart(id) {
12612
+ async restart(id, options = {}) {
12416
12613
  await this.stop(id);
12417
- return this.start(id);
12614
+ return this.start(id, options);
12418
12615
  }
12419
12616
  /**
12420
12617
  * Frees the port this entry wants, by asking whatever listens on it to leave.
@@ -12646,6 +12843,10 @@ var init_supervisor = __esmMin((() => {
12646
12843
  entry.pid = null;
12647
12844
  entry.adopted = false;
12648
12845
  entry.nannyExit = null;
12846
+ if (entry.lastChild !== null && !isProcessAlive(entry.lastChild.pid)) {
12847
+ entry.lastChild = null;
12848
+ this.persistEntryState(entry);
12849
+ }
12649
12850
  entry.status = survivor === null ? "stopped" : "conflict";
12650
12851
  if (survivor !== null) entry.lastError = `pid ${survivor} survived the stop`;
12651
12852
  this.log(entry, "system", survivor === null ? "stopped" : entry.lastError);
@@ -12656,6 +12857,8 @@ var init_supervisor = __esmMin((() => {
12656
12857
  };
12657
12858
  }
12658
12859
  createEntry(config) {
12860
+ const persisted = readEntryState(entryStatePath(this.options.nannyDir, config.id));
12861
+ const lastPid = persisted?.lastPid ?? null;
12659
12862
  return {
12660
12863
  config,
12661
12864
  status: "stopped",
@@ -12664,9 +12867,13 @@ var init_supervisor = __esmMin((() => {
12664
12867
  child: null,
12665
12868
  pid: null,
12666
12869
  startedAt: null,
12870
+ lastChild: !config.persistent && lastPid !== null ? {
12871
+ pid: lastPid,
12872
+ startedAt: persisted?.lastStartedAt ?? 0
12873
+ } : null,
12667
12874
  exitCode: null,
12668
12875
  exitSignal: null,
12669
- restarts: 0,
12876
+ restarts: persisted?.restarts ?? 0,
12670
12877
  lastError: null,
12671
12878
  nextRetryAt: null,
12672
12879
  retryTimer: null,
@@ -12679,8 +12886,9 @@ var init_supervisor = __esmMin((() => {
12679
12886
  nannyExit: null,
12680
12887
  starting: false,
12681
12888
  stopping: false,
12889
+ actor: "policy",
12682
12890
  removed: false,
12683
- bootstrapDone: !config.bootstrap,
12891
+ bootstrapDone: persisted?.bootstrapDone ?? !config.bootstrap,
12684
12892
  logs: new LogBuffer(config.logBufferLines),
12685
12893
  echoed: /* @__PURE__ */ new Set(),
12686
12894
  responseMs: null,
@@ -12698,6 +12906,7 @@ var init_supervisor = __esmMin((() => {
12698
12906
  this.relay.unfollow(id);
12699
12907
  this.options.logFiles.clear(id);
12700
12908
  this.options.history.forget(id);
12909
+ clearEntryState(entryStatePath(this.options.nannyDir, id));
12701
12910
  entry.removed = true;
12702
12911
  this.stopEntry(entry).catch((error) => {
12703
12912
  logger.error(`could not stop the removed server ${id}`, error);
@@ -12821,16 +13030,6 @@ var init_supervisor = __esmMin((() => {
12821
13030
  return { kind: "free" };
12822
13031
  }
12823
13032
  /**
12824
- * Takes over a detached successor: no spawn, no duplicate. The pid is supervised
12825
- * from here on (liveness, health probe, resources, stop), while its output stays
12826
- * wherever it was redirected.
12827
- *
12828
- * For a persistent entry that output is not a mystery: its nanny is still writing
12829
- * the entry's log file, so the relay is started here too. Without this, adopting a
12830
- * persistent entry whose state file was lost would show its history but never
12831
- * another live line.
12832
- */
12833
- /**
12834
13033
  * Stops the listed port holders and reports whether the port came free.
12835
13034
  *
12836
13035
  * `kill` and `reclaim` are the same sequence over a different pid list — the foreign
@@ -12855,7 +13054,21 @@ var init_supervisor = __esmMin((() => {
12855
13054
  error: entry.lastError
12856
13055
  };
12857
13056
  }
12858
- 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) {
12859
13072
  entry.adopted = true;
12860
13073
  entry.pid = pid;
12861
13074
  entry.child = null;
@@ -12864,12 +13077,60 @@ var init_supervisor = __esmMin((() => {
12864
13077
  entry.startedAt = Date.now();
12865
13078
  entry.lastError = null;
12866
13079
  if (entry.config.persistent) this.followPersistent(entry, true);
12867
- 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}`);
12868
13081
  this.publishServer(entry);
12869
13082
  return { ok: true };
12870
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
+ }
12871
13131
  /** An adopted successor disappeared: fall back to the normal lifecycle. */
12872
13132
  handleAdoptedExit(entry) {
13133
+ const adoptedPid = entry.pid;
12873
13134
  if (entry.pid !== null) this.sampler.forget(entry.pid);
12874
13135
  if (entry.config.persistent) this.consumeNannyExit(entry);
12875
13136
  const ranForMs = entry.nannyExit?.runtimeMs ?? (entry.startedAt === null ? 0 : Date.now() - entry.startedAt);
@@ -12877,6 +13138,7 @@ var init_supervisor = __esmMin((() => {
12877
13138
  entry.nannyExit = null;
12878
13139
  entry.adopted = false;
12879
13140
  entry.pid = null;
13141
+ this.forgetRecordedChild(entry, adoptedPid);
12880
13142
  entry.resources = null;
12881
13143
  entry.responseMs = null;
12882
13144
  entry.exitCode = null;
@@ -12917,6 +13179,7 @@ var init_supervisor = __esmMin((() => {
12917
13179
  } catch (error) {
12918
13180
  this.log(entry, "system", `bootstrap could not start: ${error.message} — continuing anyway`);
12919
13181
  entry.bootstrapDone = true;
13182
+ this.persistEntryState(entry);
12920
13183
  return;
12921
13184
  }
12922
13185
  child.stdout?.on("data", (chunk) => splitter.push("stdout", chunk));
@@ -12939,6 +13202,7 @@ var init_supervisor = __esmMin((() => {
12939
13202
  });
12940
13203
  });
12941
13204
  entry.bootstrapDone = true;
13205
+ this.persistEntryState(entry);
12942
13206
  if (code === 0) this.log(entry, "system", "bootstrap finished");
12943
13207
  else if (code !== null) this.log(entry, "system", `bootstrap exited with code ${code} — continuing anyway`);
12944
13208
  }
@@ -13080,9 +13344,17 @@ var init_supervisor = __esmMin((() => {
13080
13344
  entry.child = child;
13081
13345
  entry.pid = child.pid ?? null;
13082
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
+ }
13083
13354
  this.options.history.record(entry.config.id, {
13084
13355
  type: "start",
13085
- detail
13356
+ detail,
13357
+ actor: entry.actor
13086
13358
  });
13087
13359
  entry.exitCode = null;
13088
13360
  entry.exitSignal = null;
@@ -13272,6 +13544,7 @@ var init_supervisor = __esmMin((() => {
13272
13544
  entry.responseMs = null;
13273
13545
  entry.exitCode = code;
13274
13546
  entry.exitSignal = signal;
13547
+ this.forgetRecordedChild(entry, child.pid ?? null);
13275
13548
  const neverStarted = nannyExit !== null ? nannyExit.code === null && nannyExit.signal === null : code === null && signal === null && entry.lastError !== null;
13276
13549
  const detail = neverStarted ? entry.lastError ?? "the entry could not start" : nannyExit !== null ? describeNannyExit(nannyExit) : describeNannyExit({
13277
13550
  code,
@@ -13300,10 +13573,12 @@ var init_supervisor = __esmMin((() => {
13300
13573
  }
13301
13574
  const restart = entry.config.restart;
13302
13575
  if (ranForMs >= restart.resetAfterMs) entry.restarts = 0;
13576
+ this.persistEntryState(entry);
13303
13577
  this.log(entry, "system", neverStarted ? `did not start: ${detail}` : `exited with ${detail} after ${ranFor}`);
13304
13578
  entry.lastError = neverStarted ? detail : `exited with ${detail}`;
13305
13579
  if (restart.enabled && entry.restarts < restart.maxRetries) {
13306
13580
  entry.restarts += 1;
13581
+ this.persistEntryState(entry);
13307
13582
  const backoffMs = computeBackoff(entry.restarts, restart);
13308
13583
  entry.status = "backoff";
13309
13584
  entry.nextRetryAt = Date.now() + backoffMs;
@@ -13439,6 +13714,15 @@ var init_supervisor = __esmMin((() => {
13439
13714
  entry.retryTimer = null;
13440
13715
  }
13441
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
+ }
13442
13726
  buildVars(entry) {
13443
13727
  return serverTemplateVars(entry.config);
13444
13728
  }
@@ -13666,11 +13950,15 @@ function resolveBackupPaths(servers, includePaths = [], vars) {
13666
13950
  ignoreGenerated: entry.ignoreGenerated
13667
13951
  };
13668
13952
  const note = parent.path === entry.path ? `already declared by ${parent.origin}` : `covered by ${parent.path}`;
13953
+ const noteKey = parent.path === entry.path ? "settings.backup.noteAlreadyDeclared" : "settings.backup.noteCovered";
13954
+ const noteParams = parent.path === entry.path ? { origin: parent.origin } : { path: parent.path };
13669
13955
  return {
13670
13956
  path: entry.path,
13671
13957
  origin: entry.origin,
13672
13958
  included: false,
13673
13959
  note,
13960
+ noteKey,
13961
+ noteParams,
13674
13962
  ignoreGenerated: entry.ignoreGenerated
13675
13963
  };
13676
13964
  });
@@ -13718,6 +14006,28 @@ function labelOf(plan, id) {
13718
14006
  return plan.items.find((item) => item.id === id)?.label ?? id;
13719
14007
  }
13720
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
+ /**
13721
14031
  * `servers.config.json` is only a readability gate here: the store applies the
13722
14032
  * workspace's own defaults on the next load, and an archive from another machine
13723
14033
  * need not match this one's default shapes exactly.
@@ -13738,13 +14048,16 @@ function readableWorkspaceSettings(text) {
13738
14048
  return false;
13739
14049
  }
13740
14050
  }
13741
- var MANIFEST, ALLOWED_ROOTS, SUFFIX, RELOADABLE, BackupService;
14051
+ var MANIFEST, ALLOWED_ROOTS, SUFFIX, RELOADABLE, SECRET_LABELS, BackupService;
13742
14052
  var init_backups = __esmMin((() => {
14053
+ init_migrations();
13743
14054
  init_parse();
14055
+ init_secret_parts();
13744
14056
  init_atomic();
13745
14057
  init_env_file();
13746
14058
  init_paths();
13747
14059
  init_template();
14060
+ init_version();
13748
14061
  init_archive();
13749
14062
  init_supervisor();
13750
14063
  init_error_message();
@@ -13758,6 +14071,24 @@ var init_backups = __esmMin((() => {
13758
14071
  ]);
13759
14072
  SUFFIX = ".zip";
13760
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
+ };
13761
14092
  BackupService = class {
13762
14093
  options;
13763
14094
  /**
@@ -13820,6 +14151,16 @@ var init_backups = __esmMin((() => {
13820
14151
  const wanted = options.include === void 0 ? null : new Set(options.include);
13821
14152
  const capturesGlobal = (id) => wanted === null || wanted.has(id);
13822
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
+ };
13823
14164
  const dir = this.resolveDir();
13824
14165
  const sources = this.options.getSources();
13825
14166
  const staging = path.join(dir, `.staging-${Date.now()}`);
@@ -13832,15 +14173,41 @@ var init_backups = __esmMin((() => {
13832
14173
  const copy = (relative, source, ignoreGenerated = false) => {
13833
14174
  if (this.copyInto(staging, relative, source, ignoreGenerated)) captured++;
13834
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}:`));
13835
14202
  if (capturesGlobal("global:settings")) copy("global/settings.json", sources.globalSettingsPath);
13836
- if (capturesGlobal("global:secrets")) copy("global/secrets.json", sources.globalSecretsPath);
14203
+ if (capturesSecrets("global:secrets")) copySecrets("global/secrets.json", sources.globalSecretsPath, "global:secrets", "global");
13837
14204
  if (capturesGlobal("global:tls")) copy("global/tls", sources.tlsDir);
13838
14205
  const data = [];
13839
14206
  for (const workspace of sources.workspaces) {
13840
14207
  const root = `workspaces/${workspace.id}`;
13841
14208
  if (capturesWorkspace(workspace.id, "settings")) copy(`${root}/settings.json`, workspace.settingsPath);
13842
14209
  if (capturesWorkspace(workspace.id, "servers")) copy(`${root}/servers.config.json`, workspace.serversPath);
13843
- 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);
13844
14211
  for (const declared of this.declaredData(workspace.servers, sources.includePaths)) {
13845
14212
  if (!declared.included) continue;
13846
14213
  if (!capturesWorkspace(workspace.id, `data:${declared.path}`)) continue;
@@ -13871,6 +14238,8 @@ var init_backups = __esmMin((() => {
13871
14238
  version: 1,
13872
14239
  createdAt,
13873
14240
  hostname: os.hostname(),
14241
+ writtenBy: appVersion(),
14242
+ schema: 1,
13874
14243
  data
13875
14244
  };
13876
14245
  fs.writeFileSync(path.join(staging, MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`);
@@ -13927,7 +14296,9 @@ var init_backups = __esmMin((() => {
13927
14296
  needsPassword: false,
13928
14297
  items: [],
13929
14298
  applied: [],
14299
+ appliedIds: [],
13930
14300
  skipped: [],
14301
+ skippedItems: [],
13931
14302
  restartRequired: false,
13932
14303
  reloaded: false
13933
14304
  };
@@ -13970,13 +14341,24 @@ var init_backups = __esmMin((() => {
13970
14341
  names: entries.map((entry) => entry.name),
13971
14342
  ...password === null ? {} : { password }
13972
14343
  });
13973
- for (const name of extracted.skipped) plan.skipped.push(`${name} (symbolic link, skipped)`);
14344
+ for (const name of extracted.skipped) {
14345
+ plan.skipped.push(`${name} (symbolic link, skipped)`);
14346
+ plan.skippedItems.push({
14347
+ label: name,
14348
+ reasonKey: "settings.restore.reasonSymbolicLink"
14349
+ });
14350
+ }
13974
14351
  const manifestPath = path.join(staging, MANIFEST);
13975
14352
  if (!fs.existsSync(manifestPath)) return {
13976
14353
  ...plan,
13977
14354
  error: "the archive has no manifest"
13978
14355
  };
13979
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
+ };
13980
14362
  const sources = this.options.getSources();
13981
14363
  const selectedIds = options.include === void 0 ? null : new Set(options.include);
13982
14364
  const actions = /* @__PURE__ */ new Map();
@@ -13987,6 +14369,13 @@ var init_backups = __esmMin((() => {
13987
14369
  selected: false
13988
14370
  });
13989
14371
  plan.skipped.push(`${item.label}${item.note === null ? "" : ` (${item.note})`}`);
14372
+ plan.skippedItems.push({
14373
+ label: item.label,
14374
+ ...item.labelKey === void 0 ? {} : { labelKey: item.labelKey },
14375
+ ...item.labelParams === void 0 ? {} : { labelParams: item.labelParams },
14376
+ reasonKey: item.noteKey ?? "settings.restore.reasonUnrestorable",
14377
+ ...item.noteParams === void 0 ? {} : { reasonParams: item.noteParams }
14378
+ });
13990
14379
  return;
13991
14380
  }
13992
14381
  const selected = selectedIds === null || selectedIds.has(item.id) || aliases.some((alias) => selectedIds.has(alias));
@@ -13995,14 +14384,30 @@ var init_backups = __esmMin((() => {
13995
14384
  selected
13996
14385
  });
13997
14386
  if (selected) actions.set(item.id, apply);
13998
- else plan.skipped.push(`${item.label} (not selected)`);
14387
+ else {
14388
+ plan.skipped.push(`${item.label} (not selected)`);
14389
+ plan.skippedItems.push({
14390
+ label: item.label,
14391
+ ...item.labelKey === void 0 ? {} : { labelKey: item.labelKey },
14392
+ ...item.labelParams === void 0 ? {} : { labelParams: item.labelParams },
14393
+ reasonKey: "settings.restore.reasonNotSelected"
14394
+ });
14395
+ }
13999
14396
  };
14000
14397
  const settingsText = readText(path.join(staging, "global", "settings.json"));
14001
14398
  const parsedGlobal = readableGlobalSettings(settingsText) ? parseGlobalSettings(JSON.parse(settingsText)).config : null;
14002
- if (settingsText !== null && parsedGlobal === null) plan.skipped.push("Global settings (the archive's settings are not valid)");
14399
+ if (settingsText !== null && parsedGlobal === null) {
14400
+ plan.skipped.push("Global settings (the archive's settings are not valid)");
14401
+ plan.skippedItems.push({
14402
+ label: "Global settings",
14403
+ labelKey: "settings.backup.entryGlobalSettings",
14404
+ reasonKey: "settings.restore.reasonInvalidSettings"
14405
+ });
14406
+ }
14003
14407
  if (parsedGlobal !== null && settingsText !== null) addItem({
14004
14408
  id: "global:settings",
14005
14409
  label: "Global settings",
14410
+ labelKey: "settings.backup.entryGlobalSettings",
14006
14411
  kind: "settings",
14007
14412
  restorable: true,
14008
14413
  note: null
@@ -14010,21 +14415,39 @@ var init_backups = __esmMin((() => {
14010
14415
  writeFileAtomic(sources.globalSettingsPath, settingsText);
14011
14416
  }, ["config"]);
14012
14417
  const globalSecrets = readText(path.join(staging, "global", "secrets.json"));
14013
- if (globalSecrets !== null) addItem({
14014
- id: "global:secrets",
14015
- label: "Global secrets (password, API token)",
14016
- kind: "secrets",
14017
- restorable: true,
14018
- note: null
14019
- }, () => {
14020
- writeFileAtomic(sources.globalSecretsPath, globalSecrets, { mode: 384 });
14021
- }, ["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
+ }
14022
14444
  const tlsInArchive = path.join(staging, "global", "tls");
14023
14445
  if (fs.existsSync(tlsInArchive)) {
14024
14446
  const files = fs.readdirSync(tlsInArchive).filter((file) => fs.statSync(path.join(tlsInArchive, file)).isFile());
14025
14447
  addItem({
14026
14448
  id: "global:tls",
14027
14449
  label: "TLS certificate",
14450
+ labelKey: "settings.backup.entryTlsCertificate",
14028
14451
  kind: "tls",
14029
14452
  restorable: true,
14030
14453
  note: null
@@ -14070,10 +14493,16 @@ var init_backups = __esmMin((() => {
14070
14493
  addItem({
14071
14494
  id: `workspace:${id}:${suffix}`,
14072
14495
  label: `${id}: ${label}`,
14496
+ labelKey: "settings.backup.restoreItemLabel",
14497
+ labelParams: {
14498
+ workspace: id,
14499
+ item: suffix
14500
+ },
14073
14501
  kind: suffix,
14074
14502
  workspaceId: id,
14075
14503
  restorable: false,
14076
- note: "unknown workspace"
14504
+ note: "unknown workspace",
14505
+ noteKey: "settings.backup.noteUnknownWorkspace"
14077
14506
  }, null);
14078
14507
  }
14079
14508
  continue;
@@ -14082,6 +14511,11 @@ var init_backups = __esmMin((() => {
14082
14511
  if (readableWorkspaceSettings(settings)) addItem({
14083
14512
  id: `workspace:${id}:settings`,
14084
14513
  label: `${workspace.label}: Settings`,
14514
+ labelKey: "settings.backup.restoreItemLabel",
14515
+ labelParams: {
14516
+ workspace: workspace.label,
14517
+ item: "settings"
14518
+ },
14085
14519
  kind: "settings",
14086
14520
  workspaceId: id,
14087
14521
  restorable: true,
@@ -14089,7 +14523,18 @@ var init_backups = __esmMin((() => {
14089
14523
  }, () => {
14090
14524
  writeFileAtomic(workspace.settingsPath, settings);
14091
14525
  });
14092
- else plan.skipped.push(`${workspace.label}: Settings (the archive's settings are not valid)`);
14526
+ else {
14527
+ plan.skipped.push(`${workspace.label}: Settings (the archive's settings are not valid)`);
14528
+ plan.skippedItems.push({
14529
+ label: `${workspace.label}: Settings`,
14530
+ labelKey: "settings.backup.restoreItemLabel",
14531
+ labelParams: {
14532
+ workspace: workspace.label,
14533
+ item: "settings"
14534
+ },
14535
+ reasonKey: "settings.restore.reasonInvalidSettings"
14536
+ });
14537
+ }
14093
14538
  }
14094
14539
  if (servers !== null) {
14095
14540
  const parsed = readableServersFile(servers);
@@ -14098,6 +14543,11 @@ var init_backups = __esmMin((() => {
14098
14543
  addItem({
14099
14544
  id: `workspace:${id}:servers`,
14100
14545
  label: `${workspace.label}: Servers`,
14546
+ labelKey: "settings.backup.restoreItemLabel",
14547
+ labelParams: {
14548
+ workspace: workspace.label,
14549
+ item: "servers"
14550
+ },
14101
14551
  kind: "servers",
14102
14552
  workspaceId: id,
14103
14553
  restorable: true,
@@ -14105,18 +14555,51 @@ var init_backups = __esmMin((() => {
14105
14555
  }, () => {
14106
14556
  writeFileAtomic(workspace.serversPath, servers);
14107
14557
  });
14108
- } else plan.skipped.push(`${workspace.label}: Servers (the archive's servers are not valid)`);
14558
+ } else {
14559
+ plan.skipped.push(`${workspace.label}: Servers (the archive's servers are not valid)`);
14560
+ plan.skippedItems.push({
14561
+ label: `${workspace.label}: Servers`,
14562
+ labelKey: "settings.backup.restoreItemLabel",
14563
+ labelParams: {
14564
+ workspace: workspace.label,
14565
+ item: "servers"
14566
+ },
14567
+ reasonKey: "settings.restore.reasonInvalidServers"
14568
+ });
14569
+ }
14570
+ }
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
+ });
14109
14602
  }
14110
- if (secrets !== null) addItem({
14111
- id: `workspace:${id}:secrets`,
14112
- label: `${workspace.label}: Secrets`,
14113
- kind: "secrets",
14114
- workspaceId: id,
14115
- restorable: true,
14116
- note: null
14117
- }, () => {
14118
- writeFileAtomic(workspace.secretsPath, secrets, { mode: 384 });
14119
- });
14120
14603
  }
14121
14604
  const instanceTargets = [];
14122
14605
  for (const workspace of sources.workspaces) for (const declared of this.declaredData(workspace.servers, sources.includePaths)) {
@@ -14179,21 +14662,27 @@ var init_backups = __esmMin((() => {
14179
14662
  const declaredByBackup = archiveAll.some((candidate) => candidate.origin === origin && (workspace === void 0 || candidate.workspace === workspace));
14180
14663
  addItem({
14181
14664
  ...base,
14182
- note: declaredByBackup ? "declared by the backup's config, which is not being restored" : "not declared by this config, nor by the backup"
14665
+ note: declaredByBackup ? "declared by the backup's config, which is not being restored" : "not declared by this config, nor by the backup",
14666
+ noteKey: declaredByBackup ? "settings.backup.noteDeclaredByBackup" : "settings.backup.noteNotDeclared"
14183
14667
  }, null, aliases);
14184
14668
  continue;
14185
14669
  }
14186
14670
  if (!fs.existsSync(from)) {
14187
14671
  addItem({
14188
14672
  ...base,
14189
- note: "missing from the archive"
14673
+ note: "missing from the archive",
14674
+ noteKey: "settings.backup.noteMissingFromArchive"
14190
14675
  }, null, aliases);
14191
14676
  continue;
14192
14677
  }
14193
14678
  addItem({
14194
14679
  ...base,
14195
14680
  restorable: true,
14196
- note: target.path === archivedPath ? null : `restored from ${archivedPath}`
14681
+ ...target.path === archivedPath ? { note: null } : {
14682
+ note: `restored from ${archivedPath}`,
14683
+ noteKey: "settings.backup.noteRestoredFrom",
14684
+ noteParams: { path: archivedPath }
14685
+ }
14197
14686
  }, () => fs.cpSync(from, target.path, {
14198
14687
  recursive: true,
14199
14688
  force: true,
@@ -14207,12 +14696,14 @@ var init_backups = __esmMin((() => {
14207
14696
  }
14208
14697
  if (!options.confirm) {
14209
14698
  plan.applied = [...actions.keys()].map((id) => labelOf(plan, id));
14699
+ plan.appliedIds = [...actions.keys()];
14210
14700
  return plan;
14211
14701
  }
14212
14702
  for (const [id, apply] of actions) {
14213
14703
  apply();
14214
14704
  plan.applied.push(labelOf(plan, id));
14215
14705
  }
14706
+ plan.appliedIds = [...actions.keys()];
14216
14707
  if (this.options.onRestored !== void 0 && [...actions.keys()].some((id) => RELOADABLE.test(id))) {
14217
14708
  plan.reloaded = true;
14218
14709
  this.options.onRestored();
@@ -14239,26 +14730,36 @@ var init_backups = __esmMin((() => {
14239
14730
  }
14240
14731
  /** Selectable entries: the three global slices, then one per workspace. */
14241
14732
  entries(sources) {
14733
+ const missing = (path, key) => {
14734
+ const absent = this.missingNote(path);
14735
+ return absent === null ? { note: null } : {
14736
+ note: absent,
14737
+ noteKey: key
14738
+ };
14739
+ };
14242
14740
  const entries = [
14243
14741
  {
14244
14742
  id: "global:settings",
14245
14743
  label: "Global settings",
14744
+ labelKey: "settings.backup.entryGlobalSettings",
14246
14745
  kind: "settings",
14247
- note: this.missingNote(sources.globalSettingsPath),
14746
+ ...missing(sources.globalSettingsPath, "settings.backup.noteMissing"),
14248
14747
  items: []
14249
14748
  },
14250
14749
  {
14251
14750
  id: "global:secrets",
14252
14751
  label: "Global secrets (password, API token)",
14752
+ labelKey: "settings.backup.entryGlobalSecrets",
14253
14753
  kind: "secrets",
14254
- note: this.missingNote(sources.globalSecretsPath),
14255
- items: []
14754
+ ...missing(sources.globalSecretsPath, "settings.backup.noteMissing"),
14755
+ items: this.secretItems("global", "global:secrets", sources.globalSecretsPath)
14256
14756
  },
14257
14757
  {
14258
14758
  id: "global:tls",
14259
14759
  label: "TLS certificate",
14760
+ labelKey: "settings.backup.entryTlsCertificate",
14260
14761
  kind: "tls",
14261
- note: this.missingNote(sources.tlsDir),
14762
+ ...missing(sources.tlsDir, "settings.backup.noteMissing"),
14262
14763
  items: []
14263
14764
  }
14264
14765
  ];
@@ -14272,30 +14773,62 @@ var init_backups = __esmMin((() => {
14272
14773
  });
14273
14774
  return entries;
14274
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
+ }
14275
14797
  workspaceItems(workspace, includePaths) {
14276
14798
  const id = workspace.id;
14799
+ const note = (path) => {
14800
+ const absent = this.missingNote(path);
14801
+ return absent === null ? { note: null } : {
14802
+ note: absent,
14803
+ noteKey: "settings.backup.noteMissing"
14804
+ };
14805
+ };
14277
14806
  const items = [
14278
14807
  {
14279
14808
  id: `workspace:${id}:settings`,
14280
14809
  label: "Settings",
14810
+ labelKey: "settings.backup.leafSettings",
14281
14811
  kind: "settings",
14282
14812
  included: true,
14283
- note: this.missingNote(workspace.settingsPath)
14813
+ ...note(workspace.settingsPath)
14284
14814
  },
14285
14815
  {
14286
14816
  id: `workspace:${id}:servers`,
14287
14817
  label: "Servers",
14818
+ labelKey: "settings.backup.leafServers",
14288
14819
  kind: "servers",
14289
14820
  included: true,
14290
- note: this.missingNote(workspace.serversPath)
14821
+ ...note(workspace.serversPath)
14291
14822
  },
14292
14823
  {
14293
14824
  id: `workspace:${id}:secrets`,
14294
14825
  label: "Secrets",
14826
+ labelKey: "settings.backup.leafSecrets",
14295
14827
  kind: "secrets",
14296
14828
  included: true,
14297
- note: this.missingNote(workspace.secretsPath)
14298
- }
14829
+ ...note(workspace.secretsPath)
14830
+ },
14831
+ ...this.secretItems("workspace", `workspace:${id}:secrets`, workspace.secretsPath)
14299
14832
  ];
14300
14833
  for (const declared of this.declaredData(workspace.servers, includePaths)) items.push({
14301
14834
  id: `workspace:${id}:data:${declared.path}`,
@@ -14305,6 +14838,8 @@ var init_backups = __esmMin((() => {
14305
14838
  origin: declared.origin,
14306
14839
  included: declared.included,
14307
14840
  note: declared.note,
14841
+ ...declared.noteKey === void 0 ? {} : { noteKey: declared.noteKey },
14842
+ ...declared.noteParams === void 0 ? {} : { noteParams: declared.noteParams },
14308
14843
  ignoreGenerated: declared.ignoreGenerated
14309
14844
  });
14310
14845
  return items;
@@ -14320,7 +14855,8 @@ var init_backups = __esmMin((() => {
14320
14855
  if (entry.included && isInside(entry.path, archiveDir)) return {
14321
14856
  ...entry,
14322
14857
  included: false,
14323
- note: "contains the backup directory"
14858
+ note: "contains the backup directory",
14859
+ noteKey: "settings.backup.noteContainsBackupDir"
14324
14860
  };
14325
14861
  return entry;
14326
14862
  });