aicodeman 1.42.0 → 1.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -12
- package/dist/claude-session-title.d.ts +6 -4
- package/dist/claude-session-title.d.ts.map +1 -1
- package/dist/claude-session-title.js +6 -4
- package/dist/claude-session-title.js.map +1 -1
- package/dist/cli-agent.d.ts +3 -3
- package/dist/cli-agent.d.ts.map +1 -1
- package/dist/cli-agent.js +8 -6
- package/dist/cli-agent.js.map +1 -1
- package/dist/config/cli-registry/patterns.d.ts +1 -1
- package/dist/config/cli-registry/patterns.d.ts.map +1 -1
- package/dist/config/cli-registry/patterns.js +3 -0
- package/dist/config/cli-registry/patterns.js.map +1 -1
- package/dist/config/cli-registry/schema.d.ts +1 -0
- package/dist/config/cli-registry/schema.d.ts.map +1 -1
- package/dist/config/cli-registry/schema.js +5 -3
- package/dist/config/cli-registry/schema.js.map +1 -1
- package/dist/config/cli-registry/stock.d.ts.map +1 -1
- package/dist/config/cli-registry/stock.js +263 -4
- package/dist/config/cli-registry/stock.js.map +1 -1
- package/dist/config/cli-registry/types.d.ts +13 -5
- package/dist/config/cli-registry/types.d.ts.map +1 -1
- package/dist/config/map-limits.d.ts +13 -1
- package/dist/config/map-limits.d.ts.map +1 -1
- package/dist/config/map-limits.js +28 -1
- package/dist/config/map-limits.js.map +1 -1
- package/dist/config-backup.d.ts +279 -0
- package/dist/config-backup.d.ts.map +1 -0
- package/dist/config-backup.js +794 -0
- package/dist/config-backup.js.map +1 -0
- package/dist/cron/cron-service.d.ts +5 -5
- package/dist/cron/cron-service.js +6 -6
- package/dist/cron/cron-service.js.map +1 -1
- package/dist/custom-model-hosts.d.ts +16 -0
- package/dist/custom-model-hosts.d.ts.map +1 -1
- package/dist/custom-model-hosts.js.map +1 -1
- package/dist/hooks-config.d.ts +24 -0
- package/dist/hooks-config.d.ts.map +1 -1
- package/dist/hooks-config.js +47 -0
- package/dist/hooks-config.js.map +1 -1
- package/dist/intent-store.d.ts +2 -0
- package/dist/intent-store.d.ts.map +1 -1
- package/dist/intent-store.js +9 -0
- package/dist/intent-store.js.map +1 -1
- package/dist/mcp-sync-targets.d.ts +6 -3
- package/dist/mcp-sync-targets.d.ts.map +1 -1
- package/dist/mcp-sync-targets.js +6 -3
- package/dist/mcp-sync-targets.js.map +1 -1
- package/dist/mux-interface.d.ts +28 -7
- package/dist/mux-interface.d.ts.map +1 -1
- package/dist/prompt-cache-keepwarm.d.ts +193 -0
- package/dist/prompt-cache-keepwarm.d.ts.map +1 -0
- package/dist/prompt-cache-keepwarm.js +359 -0
- package/dist/prompt-cache-keepwarm.js.map +1 -0
- package/dist/respawn-controller.d.ts.map +1 -1
- package/dist/respawn-controller.js +6 -5
- package/dist/respawn-controller.js.map +1 -1
- package/dist/session-auto-ops.d.ts.map +1 -1
- package/dist/session-auto-ops.js +6 -1
- package/dist/session-auto-ops.js.map +1 -1
- package/dist/session-cli-registry-bridge.d.ts +15 -1
- package/dist/session-cli-registry-bridge.d.ts.map +1 -1
- package/dist/session-cli-registry-bridge.js +20 -3
- package/dist/session-cli-registry-bridge.js.map +1 -1
- package/dist/session-submit-verifier.d.ts +19 -0
- package/dist/session-submit-verifier.d.ts.map +1 -1
- package/dist/session-submit-verifier.js +88 -0
- package/dist/session-submit-verifier.js.map +1 -1
- package/dist/session.d.ts +83 -12
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +150 -16
- package/dist/session.js.map +1 -1
- package/dist/state-store.d.ts +10 -0
- package/dist/state-store.d.ts.map +1 -1
- package/dist/state-store.js +35 -0
- package/dist/state-store.js.map +1 -1
- package/dist/tab-layout-service.d.ts +33 -0
- package/dist/tab-layout-service.d.ts.map +1 -1
- package/dist/tab-layout-service.js +75 -3
- package/dist/tab-layout-service.js.map +1 -1
- package/dist/tmux-manager.d.ts +52 -9
- package/dist/tmux-manager.d.ts.map +1 -1
- package/dist/tmux-manager.js +148 -30
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tmux-prefix-table.d.ts +54 -0
- package/dist/tmux-prefix-table.d.ts.map +1 -0
- package/dist/tmux-prefix-table.js +137 -0
- package/dist/tmux-prefix-table.js.map +1 -0
- package/dist/tui/tui-app.d.ts +9 -1
- package/dist/tui/tui-app.d.ts.map +1 -1
- package/dist/tui/tui-app.js +21 -8
- package/dist/tui/tui-app.js.map +1 -1
- package/dist/tui/tui-client.d.ts +3 -2
- package/dist/tui/tui-client.d.ts.map +1 -1
- package/dist/tui/tui-client.js +3 -2
- package/dist/tui/tui-client.js.map +1 -1
- package/dist/types/app-state.d.ts +6 -0
- package/dist/types/app-state.d.ts.map +1 -1
- package/dist/types/app-state.js.map +1 -1
- package/dist/types/session.d.ts +72 -5
- package/dist/types/session.d.ts.map +1 -1
- package/dist/types/session.js +2 -1
- package/dist/types/session.js.map +1 -1
- package/dist/usage-telemetry.d.ts +15 -0
- package/dist/usage-telemetry.d.ts.map +1 -1
- package/dist/usage-telemetry.js +45 -2
- package/dist/usage-telemetry.js.map +1 -1
- package/dist/utils/cli-installed-probes.d.ts +1 -1
- package/dist/utils/cli-installed-probes.d.ts.map +1 -1
- package/dist/utils/cli-installed-probes.js +4 -2
- package/dist/utils/cli-installed-probes.js.map +1 -1
- package/dist/utils/copilot-cli-resolver.d.ts +48 -0
- package/dist/utils/copilot-cli-resolver.d.ts.map +1 -0
- package/dist/utils/copilot-cli-resolver.js +112 -0
- package/dist/utils/copilot-cli-resolver.js.map +1 -0
- package/dist/utils/copilot-plan-usage.d.ts +46 -0
- package/dist/utils/copilot-plan-usage.d.ts.map +1 -0
- package/dist/utils/copilot-plan-usage.js +95 -0
- package/dist/utils/copilot-plan-usage.js.map +1 -0
- package/dist/utils/index.d.ts +3 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +3 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/single-line.d.ts +22 -0
- package/dist/utils/single-line.d.ts.map +1 -0
- package/dist/utils/single-line.js +30 -0
- package/dist/utils/single-line.js.map +1 -0
- package/dist/web/approval-inbox.d.ts +6 -0
- package/dist/web/approval-inbox.d.ts.map +1 -1
- package/dist/web/approval-inbox.js +2 -2
- package/dist/web/approval-inbox.js.map +1 -1
- package/dist/web/case-path.d.ts +4 -2
- package/dist/web/case-path.d.ts.map +1 -1
- package/dist/web/case-path.js +13 -4
- package/dist/web/case-path.js.map +1 -1
- package/dist/web/plan-usage-latest.d.ts +2 -1
- package/dist/web/plan-usage-latest.d.ts.map +1 -1
- package/dist/web/plan-usage-latest.js +12 -2
- package/dist/web/plan-usage-latest.js.map +1 -1
- package/dist/web/ports/index.d.ts +1 -0
- package/dist/web/ports/index.d.ts.map +1 -1
- package/dist/web/ports/keep-warm-port.d.ts +9 -0
- package/dist/web/ports/keep-warm-port.d.ts.map +1 -0
- package/dist/web/ports/keep-warm-port.js +6 -0
- package/dist/web/ports/keep-warm-port.js.map +1 -0
- package/dist/web/ports/session-port.d.ts +3 -1
- package/dist/web/ports/session-port.d.ts.map +1 -1
- package/dist/web/public/admin-ui.js.gz +0 -0
- package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
- package/dist/web/public/app.9314ee60.js +43 -0
- package/dist/web/public/app.9314ee60.js.br +0 -0
- package/dist/web/public/app.9314ee60.js.gz +0 -0
- package/dist/web/public/approvals-ui.js.gz +0 -0
- package/dist/web/public/{constants.12b0c247.js → constants.4866ecc3.js} +205 -3
- package/dist/web/public/constants.4866ecc3.js.br +0 -0
- package/dist/web/public/constants.4866ecc3.js.gz +0 -0
- package/dist/web/public/cron-ui.js.gz +0 -0
- package/dist/web/public/entrance-animations.js.gz +0 -0
- package/dist/web/public/git-status-ui.js.gz +0 -0
- package/dist/web/public/home-sessions.js.gz +0 -0
- package/dist/web/public/host-wake-ui.js.gz +0 -0
- package/dist/web/public/i18n.22ca902b.js +2 -0
- package/dist/web/public/i18n.22ca902b.js.br +0 -0
- package/dist/web/public/i18n.22ca902b.js.gz +0 -0
- package/dist/web/public/{image-input.fcdcb46c.js → image-input.941386e7.js} +24 -14
- package/dist/web/public/image-input.941386e7.js.br +0 -0
- package/dist/web/public/image-input.941386e7.js.gz +0 -0
- package/dist/web/public/index.html +110 -16
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/input-cjk.8bc46081.js.gz +0 -0
- package/dist/web/public/{keyboard-accessory.b42f539b.js → keyboard-accessory.4e8b20ac.js} +51 -22
- package/dist/web/public/keyboard-accessory.4e8b20ac.js.br +0 -0
- package/dist/web/public/keyboard-accessory.4e8b20ac.js.gz +0 -0
- package/dist/web/public/{mobile-handlers.0ef9c311.js → mobile-handlers.44adaa07.js} +48 -16
- package/dist/web/public/mobile-handlers.44adaa07.js.br +0 -0
- package/dist/web/public/mobile-handlers.44adaa07.js.gz +0 -0
- package/dist/web/public/mobile-ime-preview.2dbaf262.js.gz +0 -0
- package/dist/web/public/mobile-overview.js +1 -0
- package/dist/web/public/mobile-overview.js.br +0 -0
- package/dist/web/public/mobile-overview.js.gz +0 -0
- package/dist/web/public/mobile.12fb18de.css +1 -0
- package/dist/web/public/mobile.12fb18de.css.br +0 -0
- package/dist/web/public/mobile.12fb18de.css.gz +0 -0
- package/dist/web/public/notification-manager.feb82c04.js.gz +0 -0
- package/dist/web/public/orchestrator-panel.js.gz +0 -0
- package/dist/web/public/{panels-ui.73ec0137.js → panels-ui.cbb24ba1.js} +66 -66
- package/dist/web/public/panels-ui.cbb24ba1.js.br +0 -0
- package/dist/web/public/panels-ui.cbb24ba1.js.gz +0 -0
- package/dist/web/public/ralph-panel.b350e2ef.js.gz +0 -0
- package/dist/web/public/ralph-wizard.b6c0dd19.js.gz +0 -0
- package/dist/web/public/readmymind-ui.js.gz +0 -0
- package/dist/web/public/reboot-restore-ui.js.gz +0 -0
- package/dist/web/public/respawn-ui.d7adcf6a.js.gz +0 -0
- package/dist/web/public/sanitize-html.e7147e76.js.gz +0 -0
- package/dist/web/public/security-notices.json +21 -0
- package/dist/web/public/session-lineage.js.gz +0 -0
- package/dist/web/public/{session-ui.d52ff6aa.js → session-ui.9c1f009b.js} +8 -4
- package/dist/web/public/session-ui.9c1f009b.js.br +0 -0
- package/dist/web/public/session-ui.9c1f009b.js.gz +0 -0
- package/dist/web/public/settings-ui.fea9ddae.js +106 -0
- package/dist/web/public/settings-ui.fea9ddae.js.br +0 -0
- package/dist/web/public/settings-ui.fea9ddae.js.gz +0 -0
- package/dist/web/public/spreadsheet-preview-worker.js.gz +0 -0
- package/dist/web/public/spreadsheet-preview.75c751de.js.gz +0 -0
- package/dist/web/public/spreadsheet-xlsx-core.js.gz +0 -0
- package/dist/web/public/styles.e096fcd6.css +1 -0
- package/dist/web/public/styles.e096fcd6.css.br +0 -0
- package/dist/web/public/styles.e096fcd6.css.gz +0 -0
- package/dist/web/public/subagent-windows.07277a58.js.gz +0 -0
- package/dist/web/public/sw.js +2 -2
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/public/tab-layout-browser.2d6189f7.js.gz +0 -0
- package/dist/web/public/tab-rail-resize.7157e0bd.js.gz +0 -0
- package/dist/web/public/terminal-keycode229-recovery.af09f99a.js.gz +0 -0
- package/dist/web/public/terminal-split.js.gz +0 -0
- package/dist/web/public/terminal-tile.js +41 -22
- package/dist/web/public/terminal-tile.js.br +0 -0
- package/dist/web/public/terminal-tile.js.gz +0 -0
- package/dist/web/public/terminal-ui.68dbd352.js +2 -0
- package/dist/web/public/terminal-ui.68dbd352.js.br +0 -0
- package/dist/web/public/terminal-ui.68dbd352.js.gz +0 -0
- package/dist/web/public/tile-grid.js +3 -6
- package/dist/web/public/tile-grid.js.br +0 -0
- package/dist/web/public/tile-grid.js.gz +0 -0
- package/dist/web/public/ultracode-panel.js.gz +0 -0
- package/dist/web/public/ultracode-windows.js.gz +0 -0
- package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
- package/dist/web/public/vendor/exceljs.min.js.gz +0 -0
- package/dist/web/public/vendor/fflate.min.js.gz +0 -0
- package/dist/web/public/vendor/marked.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-predictive-echo.ae5e3d5d.js.gz +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js.gz +0 -0
- package/dist/web/public/vendor/xterm.css.gz +0 -0
- package/dist/web/public/vendor/xterm.min.js.gz +0 -0
- package/dist/web/public/voice-input.2342c37b.js.gz +0 -0
- package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
- package/dist/web/public/webview-tabs.js.gz +0 -0
- package/dist/web/public/whats-new.json +9 -9
- package/dist/web/response-viewer-transcript.d.ts +6 -0
- package/dist/web/response-viewer-transcript.d.ts.map +1 -1
- package/dist/web/response-viewer-transcript.js +134 -1
- package/dist/web/response-viewer-transcript.js.map +1 -1
- package/dist/web/routes/admin-routes.d.ts +2 -1
- package/dist/web/routes/admin-routes.d.ts.map +1 -1
- package/dist/web/routes/admin-routes.js +3 -0
- package/dist/web/routes/admin-routes.js.map +1 -1
- package/dist/web/routes/case-routes.d.ts +2 -2
- package/dist/web/routes/case-routes.d.ts.map +1 -1
- package/dist/web/routes/case-routes.js +81 -3
- package/dist/web/routes/case-routes.js.map +1 -1
- package/dist/web/routes/config-backup-routes.d.ts +54 -0
- package/dist/web/routes/config-backup-routes.d.ts.map +1 -0
- package/dist/web/routes/config-backup-routes.js +268 -0
- package/dist/web/routes/config-backup-routes.js.map +1 -0
- package/dist/web/routes/custom-model-routes.d.ts +17 -1
- package/dist/web/routes/custom-model-routes.d.ts.map +1 -1
- package/dist/web/routes/custom-model-routes.js +118 -13
- package/dist/web/routes/custom-model-routes.js.map +1 -1
- package/dist/web/routes/hook-event-routes.d.ts.map +1 -1
- package/dist/web/routes/hook-event-routes.js +26 -5
- package/dist/web/routes/hook-event-routes.js.map +1 -1
- package/dist/web/routes/index.d.ts +1 -0
- package/dist/web/routes/index.d.ts.map +1 -1
- package/dist/web/routes/index.js +1 -0
- package/dist/web/routes/index.js.map +1 -1
- package/dist/web/routes/mcp-sync-routes.d.ts +4 -4
- package/dist/web/routes/mcp-sync-routes.js +4 -4
- package/dist/web/routes/ralph-routes.js +2 -2
- package/dist/web/routes/ralph-routes.js.map +1 -1
- package/dist/web/routes/reboot-restore-routes.d.ts.map +1 -1
- package/dist/web/routes/reboot-restore-routes.js +2 -0
- package/dist/web/routes/reboot-restore-routes.js.map +1 -1
- package/dist/web/routes/session-routes.d.ts +14 -4
- package/dist/web/routes/session-routes.d.ts.map +1 -1
- package/dist/web/routes/session-routes.js +131 -30
- package/dist/web/routes/session-routes.js.map +1 -1
- package/dist/web/routes/status-telemetry-routes.d.ts +2 -2
- package/dist/web/routes/status-telemetry-routes.d.ts.map +1 -1
- package/dist/web/routes/status-telemetry-routes.js +8 -4
- package/dist/web/routes/status-telemetry-routes.js.map +1 -1
- package/dist/web/routes/system-routes.d.ts +2 -2
- package/dist/web/routes/system-routes.d.ts.map +1 -1
- package/dist/web/routes/system-routes.js +18 -0
- package/dist/web/routes/system-routes.js.map +1 -1
- package/dist/web/routes/webview-routes.d.ts.map +1 -1
- package/dist/web/routes/webview-routes.js +1 -0
- package/dist/web/routes/webview-routes.js.map +1 -1
- package/dist/web/schemas.d.ts +32 -0
- package/dist/web/schemas.d.ts.map +1 -1
- package/dist/web/schemas.js +64 -4
- package/dist/web/schemas.js.map +1 -1
- package/dist/web/server.d.ts +12 -2
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +127 -6
- package/dist/web/server.js.map +1 -1
- package/dist/web/session-listener-wiring.d.ts +6 -0
- package/dist/web/session-listener-wiring.d.ts.map +1 -1
- package/dist/web/session-listener-wiring.js +10 -3
- package/dist/web/session-listener-wiring.js.map +1 -1
- package/dist/web/sse-events.d.ts +5 -2
- package/dist/web/sse-events.d.ts.map +1 -1
- package/dist/web/sse-events.js +5 -2
- package/dist/web/sse-events.js.map +1 -1
- package/dist/web/webview-proxy.d.ts +8 -3
- package/dist/web/webview-proxy.d.ts.map +1 -1
- package/dist/web/webview-proxy.js +23 -5
- package/dist/web/webview-proxy.js.map +1 -1
- package/package.json +1 -1
- package/skills/codeman/SKILL.md +23 -11
- package/skills/codeman/preamble.sh +12 -2
- package/skills/codeman/reference/endpoints.md +16 -15
- package/skills/codeman/reference/messaging.md +5 -5
- package/skills/codeman/reference/recipes.md +2 -2
- package/skills/codeman/reference/verbs.md +25 -17
- package/dist/web/public/app.f04bb2a5.js +0 -43
- package/dist/web/public/app.f04bb2a5.js.br +0 -0
- package/dist/web/public/app.f04bb2a5.js.gz +0 -0
- package/dist/web/public/constants.12b0c247.js.br +0 -0
- package/dist/web/public/constants.12b0c247.js.gz +0 -0
- package/dist/web/public/i18n.06659676.js +0 -2
- package/dist/web/public/i18n.06659676.js.br +0 -0
- package/dist/web/public/i18n.06659676.js.gz +0 -0
- package/dist/web/public/image-input.fcdcb46c.js.br +0 -0
- package/dist/web/public/image-input.fcdcb46c.js.gz +0 -0
- package/dist/web/public/keyboard-accessory.b42f539b.js.br +0 -0
- package/dist/web/public/keyboard-accessory.b42f539b.js.gz +0 -0
- package/dist/web/public/mobile-handlers.0ef9c311.js.br +0 -0
- package/dist/web/public/mobile-handlers.0ef9c311.js.gz +0 -0
- package/dist/web/public/mobile.2dff1580.css +0 -1
- package/dist/web/public/mobile.2dff1580.css.br +0 -0
- package/dist/web/public/mobile.2dff1580.css.gz +0 -0
- package/dist/web/public/panels-ui.73ec0137.js.br +0 -0
- package/dist/web/public/panels-ui.73ec0137.js.gz +0 -0
- package/dist/web/public/session-ui.d52ff6aa.js.br +0 -0
- package/dist/web/public/session-ui.d52ff6aa.js.gz +0 -0
- package/dist/web/public/settings-ui.3a22bd2a.js +0 -97
- package/dist/web/public/settings-ui.3a22bd2a.js.br +0 -0
- package/dist/web/public/settings-ui.3a22bd2a.js.gz +0 -0
- package/dist/web/public/styles.ba6a9b84.css +0 -1
- package/dist/web/public/styles.ba6a9b84.css.br +0 -0
- package/dist/web/public/styles.ba6a9b84.css.gz +0 -0
- package/dist/web/public/terminal-ui.0a7ccc29.js +0 -2
- package/dist/web/public/terminal-ui.0a7ccc29.js.br +0 -0
- package/dist/web/public/terminal-ui.0a7ccc29.js.gz +0 -0
|
@@ -0,0 +1,794 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Config backups: timestamped snapshots of the files that hold what a user has
|
|
3
|
+
* customized, kept in a default-but-configurable folder with configurable retention.
|
|
4
|
+
*
|
|
5
|
+
* Why it exists: those files are plain JSON in the data dir, rewritten by the server, the Settings UI,
|
|
6
|
+
* the CLI and (once, by a mis-configured test run) by things that should never touch them, and there
|
|
7
|
+
* was no copy to go back to. A snapshot is taken at startup and whenever the tracked files' content
|
|
8
|
+
* changes, so the history is one entry per actual change rather than one per timer tick.
|
|
9
|
+
*
|
|
10
|
+
* Safety rules the code enforces rather than documents:
|
|
11
|
+
* - The tracked file list is a fixed allowlist, never "everything in the data dir".
|
|
12
|
+
* - Retention only ever deletes folders this module made (name pattern AND a parseable manifest), so
|
|
13
|
+
* pointing the backup folder at a directory that holds other things cannot delete them.
|
|
14
|
+
* - The newest snapshot is never pruned, whatever the retention numbers say.
|
|
15
|
+
* - A snapshot is built in a temp folder and renamed into place, so a crash leaves no half snapshot.
|
|
16
|
+
* - Restore verifies each file against the manifest's SHA-256 and takes a `pre-restore` snapshot first.
|
|
17
|
+
* - Snapshot folders are 0700 and their files 0600: some tracked files carry credentials.
|
|
18
|
+
*
|
|
19
|
+
* Pure of the server: every function takes the directories it works in.
|
|
20
|
+
*
|
|
21
|
+
* @module config-backup
|
|
22
|
+
*/
|
|
23
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
24
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
25
|
+
import { homedir } from 'node:os';
|
|
26
|
+
import { isAbsolute, join, resolve, sep } from 'node:path';
|
|
27
|
+
/**
|
|
28
|
+
* The files (relative to the data dir) a snapshot holds: what a user configured, not what the server
|
|
29
|
+
* regenerates (session state, logs, the hook secret, docker seeds, caches).
|
|
30
|
+
*
|
|
31
|
+
* ⚠️ Not tracked: `state.json`. It also holds cron jobs and respawn presets, but it is rewritten with
|
|
32
|
+
* every session event, so tracking it would make a snapshot per event and bury the real changes.
|
|
33
|
+
*
|
|
34
|
+
* ⚠️ `.env`, `users.json`, `custom-model-hosts.json` and `push-keys.json` carry credentials or keys.
|
|
35
|
+
* They are already in the data dir with the same permissions; a snapshot folder is 0700 with 0600
|
|
36
|
+
* files, so the default location widens nothing. A custom location is the user's own choice.
|
|
37
|
+
*/
|
|
38
|
+
export const CONFIG_BACKUP_FILES = [
|
|
39
|
+
'settings.json',
|
|
40
|
+
'clis.json',
|
|
41
|
+
'custom-model-hosts.json',
|
|
42
|
+
'webhook.json',
|
|
43
|
+
'intents.json',
|
|
44
|
+
'linked-cases.json',
|
|
45
|
+
'remote-hosts.json',
|
|
46
|
+
'remote-cases.json',
|
|
47
|
+
'docker-hosts.json',
|
|
48
|
+
'docker-cases.json',
|
|
49
|
+
'webviews.json',
|
|
50
|
+
'push-keys.json',
|
|
51
|
+
'users.json',
|
|
52
|
+
'.env',
|
|
53
|
+
];
|
|
54
|
+
export const CONFIG_BACKUP_DEFAULTS = { enabled: true, keepCount: 20, keepDays: 30 };
|
|
55
|
+
/** 1 would delete the state before every change, so a bad write could never be undone. */
|
|
56
|
+
export const CONFIG_BACKUP_MIN_KEEP_COUNT = 2;
|
|
57
|
+
export const CONFIG_BACKUP_MAX_KEEP_COUNT = 500;
|
|
58
|
+
export const CONFIG_BACKUP_MAX_KEEP_DAYS = 3650;
|
|
59
|
+
/** `cfg-YYYYMMDD-HHMMSS`, plus `-2`, `-3`… if two land in one second. */
|
|
60
|
+
const SNAPSHOT_ID_RE = /^cfg-\d{8}-\d{6}(?:-\d{1,3})?$/;
|
|
61
|
+
const MANIFEST = 'manifest.json';
|
|
62
|
+
const intIn = (value, min, max, fallback) => {
|
|
63
|
+
const n = typeof value === 'number' ? value : typeof value === 'string' && value.trim() ? Number(value) : NaN;
|
|
64
|
+
if (!Number.isFinite(n))
|
|
65
|
+
return fallback;
|
|
66
|
+
return Math.min(max, Math.max(min, Math.floor(n)));
|
|
67
|
+
};
|
|
68
|
+
/** `~`-expand and require an absolute path; anything else means "use the default". */
|
|
69
|
+
export function normalizeBackupDir(raw, home = homedir()) {
|
|
70
|
+
if (typeof raw !== 'string')
|
|
71
|
+
return null;
|
|
72
|
+
let value = raw.trim();
|
|
73
|
+
if (!value || value.includes('\0'))
|
|
74
|
+
return null;
|
|
75
|
+
if (value === '~')
|
|
76
|
+
value = home;
|
|
77
|
+
else if (value.startsWith('~/'))
|
|
78
|
+
value = join(home, value.slice(2));
|
|
79
|
+
if (!isAbsolute(value))
|
|
80
|
+
return null;
|
|
81
|
+
return resolve(value);
|
|
82
|
+
}
|
|
83
|
+
/** Read the four `configBackup*` settings out of a settings.json object, clamped and defaulted. */
|
|
84
|
+
export function readConfigBackupSettings(raw) {
|
|
85
|
+
const s = raw ?? {};
|
|
86
|
+
return {
|
|
87
|
+
// Absent means ON: the feature protects against an event nobody schedules.
|
|
88
|
+
enabled: s.configBackupEnabled !== false,
|
|
89
|
+
dir: normalizeBackupDir(s.configBackupDir),
|
|
90
|
+
keepCount: intIn(s.configBackupKeepCount, CONFIG_BACKUP_MIN_KEEP_COUNT, CONFIG_BACKUP_MAX_KEEP_COUNT, CONFIG_BACKUP_DEFAULTS.keepCount),
|
|
91
|
+
keepDays: intIn(s.configBackupKeepDays, 0, CONFIG_BACKUP_MAX_KEEP_DAYS, CONFIG_BACKUP_DEFAULTS.keepDays),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
export function defaultConfigBackupDir(dataDir) {
|
|
95
|
+
return join(dataDir, 'backups', 'config');
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The folder snapshots go to: the configured one, or the default. A custom folder that IS the data dir
|
|
99
|
+
* falls back to the default, because snapshot folders and the live files must not share a directory.
|
|
100
|
+
*/
|
|
101
|
+
export function resolveConfigBackupDir(settings, dataDir) {
|
|
102
|
+
const fallback = defaultConfigBackupDir(dataDir);
|
|
103
|
+
const dir = settings.dir;
|
|
104
|
+
if (!dir)
|
|
105
|
+
return fallback;
|
|
106
|
+
const data = resolve(dataDir);
|
|
107
|
+
if (dir === data)
|
|
108
|
+
return fallback;
|
|
109
|
+
return dir;
|
|
110
|
+
}
|
|
111
|
+
const ORIGIN_FILE = 'config-backup-id';
|
|
112
|
+
const ORIGIN_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
113
|
+
/**
|
|
114
|
+
* Who owns a snapshot: a random id made once and kept in the data dir. A hostname would not do (a
|
|
115
|
+
* Docker container gets a new one on every recreate, which orphaned every earlier snapshot), nor
|
|
116
|
+
* would the path alone (two machines can share a `$HOME` path). It is a file of its own, not the
|
|
117
|
+
* hook secret, and is not tracked or restored. If it cannot be written the id is not persisted, so
|
|
118
|
+
* snapshots made meanwhile are not listed later; the data dir being unwritable breaks far more.
|
|
119
|
+
*/
|
|
120
|
+
export function configBackupOrigin(dataDir) {
|
|
121
|
+
const path = join(dataDir, ORIGIN_FILE);
|
|
122
|
+
try {
|
|
123
|
+
const existing = readFileSync(path, 'utf8').trim();
|
|
124
|
+
if (ORIGIN_RE.test(existing))
|
|
125
|
+
return existing;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
/* missing: made below */
|
|
129
|
+
}
|
|
130
|
+
const id = randomUUID();
|
|
131
|
+
try {
|
|
132
|
+
mkdirSync(dataDir, { recursive: true });
|
|
133
|
+
writeFileSync(path, id + '\n', { mode: 0o600 });
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
/* see above */
|
|
137
|
+
}
|
|
138
|
+
return id;
|
|
139
|
+
}
|
|
140
|
+
/** The id already made for this data dir, or null. Read-only: listing must not write a file. */
|
|
141
|
+
export function readConfigBackupOrigin(dataDir) {
|
|
142
|
+
try {
|
|
143
|
+
const existing = readFileSync(join(dataDir, ORIGIN_FILE), 'utf8').trim();
|
|
144
|
+
return ORIGIN_RE.test(existing) ? existing : null;
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const sha256 = (data) => createHash('sha256').update(data).digest('hex');
|
|
151
|
+
/**
|
|
152
|
+
* Keys a tracked file rewrites on its own schedule, with no change to anything the user set, so
|
|
153
|
+
* they must not make a snapshot look different. `custom-model-hosts.json` carries a
|
|
154
|
+
* `lastDiscoveredAt` per host that the model-discovery poll refreshes every 5 minutes: before this,
|
|
155
|
+
* the change check never matched and the snapshot folder filled with copies that differed by one
|
|
156
|
+
* timestamp, evicting every older snapshot within about 100 minutes at the default keep count.
|
|
157
|
+
* The snapshot itself still stores the file exactly as it was, and restore still verifies against
|
|
158
|
+
* the file's own digest; only the "has anything changed?" comparison looks through these keys.
|
|
159
|
+
*/
|
|
160
|
+
const VOLATILE_KEYS = {
|
|
161
|
+
'custom-model-hosts.json': ['lastDiscoveredAt'],
|
|
162
|
+
// Rewritten by activity, not by anything the user set: a Basic-auth login (at most once a minute
|
|
163
|
+
// per user), a session started in another case, every captured prompt, and a Docker case's
|
|
164
|
+
// conversation id after a /clear, and a web tab being opened.
|
|
165
|
+
'users.json': ['lastLoginAt'],
|
|
166
|
+
'settings.json': ['lastUsedCase'],
|
|
167
|
+
'intents.json': ['recentPrompts', 'updatedAt'],
|
|
168
|
+
'docker-cases.json': ['lastClaudeSessionId'],
|
|
169
|
+
'webviews.json': ['lastOpenedAt'],
|
|
170
|
+
};
|
|
171
|
+
function withoutKeys(value, drop) {
|
|
172
|
+
if (Array.isArray(value))
|
|
173
|
+
return value.map((v) => withoutKeys(v, drop));
|
|
174
|
+
if (value && typeof value === 'object') {
|
|
175
|
+
const out = {};
|
|
176
|
+
for (const [k, v] of Object.entries(value)) {
|
|
177
|
+
if (!drop.has(k))
|
|
178
|
+
out[k] = withoutKeys(v, drop);
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
return value;
|
|
183
|
+
}
|
|
184
|
+
/** Digest used only to decide whether two states are the same: the file's own, minus its volatile keys. */
|
|
185
|
+
function changeDigest(name, data, rawSha) {
|
|
186
|
+
const volatile = VOLATILE_KEYS[name];
|
|
187
|
+
if (!volatile)
|
|
188
|
+
return rawSha;
|
|
189
|
+
try {
|
|
190
|
+
return sha256(JSON.stringify(withoutKeys(JSON.parse(data.toString('utf8')), new Set(volatile))));
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return rawSha; // not JSON (or unreadable as such): compare the bytes, as for any other file
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function stamp(now) {
|
|
197
|
+
const d = new Date(now);
|
|
198
|
+
const p = (n, w = 2) => String(n).padStart(w, '0');
|
|
199
|
+
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
200
|
+
}
|
|
201
|
+
function readManifest(dir, id, origin) {
|
|
202
|
+
if (!SNAPSHOT_ID_RE.test(id))
|
|
203
|
+
return null;
|
|
204
|
+
try {
|
|
205
|
+
const m = JSON.parse(readFileSync(join(dir, id, MANIFEST), 'utf8'));
|
|
206
|
+
if (!m || m.id !== id || !Array.isArray(m.files) || typeof m.createdAt !== 'number' || typeof m.hash !== 'string') {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
if (origin !== undefined && m.origin !== origin)
|
|
210
|
+
return null;
|
|
211
|
+
return m;
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/** A folder's entry names; none when it cannot be read (absent, or not a folder). */
|
|
218
|
+
function folderEntries(dir) {
|
|
219
|
+
try {
|
|
220
|
+
return readdirSync(dir);
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return [];
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* The manifests of the snapshots this module made among `names` in `dir`, newest first. Foreign
|
|
228
|
+
* folders and files are ignored, and so are other instances' snapshots when `origin` is given (see
|
|
229
|
+
* {@link configBackupOrigin}). Each one is a file read, so a pass does this once and passes the
|
|
230
|
+
* result on (see {@link runConfigBackupPass}).
|
|
231
|
+
*/
|
|
232
|
+
function manifestsIn(dir, names, origin) {
|
|
233
|
+
const out = [];
|
|
234
|
+
for (const id of names) {
|
|
235
|
+
const m = readManifest(dir, id, origin);
|
|
236
|
+
if (m)
|
|
237
|
+
out.push(m);
|
|
238
|
+
}
|
|
239
|
+
return out.sort((a, b) => b.createdAt - a.createdAt || (a.id < b.id ? 1 : -1));
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Snapshots this module made in `dir`, newest first. Foreign folders and files are ignored, and so
|
|
243
|
+
* are other instances' snapshots when `origin` is given (see {@link configBackupOrigin}).
|
|
244
|
+
*/
|
|
245
|
+
export function listConfigBackups(dir, origin) {
|
|
246
|
+
return manifestsIn(dir, folderEntries(dir), origin).map((m) => {
|
|
247
|
+
const files = m.files.map((f) => ({ name: f.name, bytes: f.bytes }));
|
|
248
|
+
return {
|
|
249
|
+
id: m.id,
|
|
250
|
+
createdAt: m.createdAt,
|
|
251
|
+
reason: m.reason,
|
|
252
|
+
appVersion: m.appVersion,
|
|
253
|
+
files,
|
|
254
|
+
bytes: files.reduce((n, f) => n + f.bytes, 0),
|
|
255
|
+
};
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
/** Snapshot the tracked files that exist, unless the latest snapshot already holds exactly this state. */
|
|
259
|
+
export function createConfigBackup(opts) {
|
|
260
|
+
return snapshotConfig(opts).result;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* {@link createConfigBackup}, also returning the manifest it wrote, over this instance's snapshots
|
|
264
|
+
* when the caller has already listed them (`listed`, newest first): one walk of the folder per pass.
|
|
265
|
+
*/
|
|
266
|
+
function snapshotConfig(opts, listed) {
|
|
267
|
+
const now = opts.now ?? Date.now();
|
|
268
|
+
const present = [];
|
|
269
|
+
for (const name of CONFIG_BACKUP_FILES) {
|
|
270
|
+
try {
|
|
271
|
+
const path = join(opts.dataDir, name);
|
|
272
|
+
if (!statSync(path).isFile())
|
|
273
|
+
continue;
|
|
274
|
+
const data = readFileSync(path);
|
|
275
|
+
const sha = sha256(data);
|
|
276
|
+
present.push({ name, data, sha, change: changeDigest(name, data, sha) });
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
/* absent or unreadable: simply not part of this snapshot */
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (present.length === 0)
|
|
283
|
+
return { result: { status: 'empty' }, manifest: null };
|
|
284
|
+
const hash = sha256(present.map((f) => `${f.name}\0${f.change}`).join('\n'));
|
|
285
|
+
const origin = listed?.origin ?? configBackupOrigin(opts.dataDir);
|
|
286
|
+
const existing = listed?.existing ?? manifestsIn(opts.backupDir, folderEntries(opts.backupDir), origin);
|
|
287
|
+
if (!opts.force && existing[0] && existing[0].hash === hash) {
|
|
288
|
+
return { result: { status: 'unchanged', id: existing[0].id }, manifest: null };
|
|
289
|
+
}
|
|
290
|
+
// Never dated earlier than this instance's newest snapshot. One written while the clock read ahead
|
|
291
|
+
// (a dual-boot machine, a VM resumed with a stale clock) would otherwise stay newest after the
|
|
292
|
+
// correction, differ from every later pass, and have each one write a copy until real time caught up.
|
|
293
|
+
const createdAt = existing[0] ? Math.max(now, existing[0].createdAt + 1) : now;
|
|
294
|
+
mkdirSync(opts.backupDir, { recursive: true, mode: 0o700 });
|
|
295
|
+
const base = `cfg-${stamp(createdAt)}`;
|
|
296
|
+
let id = base;
|
|
297
|
+
for (let n = 2; existsSync(join(opts.backupDir, id)); n++)
|
|
298
|
+
id = `${base}-${n}`;
|
|
299
|
+
const tmp = join(opts.backupDir, `.tmp-${id}-${process.pid}`);
|
|
300
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
301
|
+
mkdirSync(tmp, { mode: 0o700 });
|
|
302
|
+
const manifest = {
|
|
303
|
+
id,
|
|
304
|
+
createdAt,
|
|
305
|
+
reason: opts.reason,
|
|
306
|
+
appVersion: opts.appVersion ?? '',
|
|
307
|
+
hash,
|
|
308
|
+
origin,
|
|
309
|
+
...(opts.keepCount !== undefined && { keepCount: opts.keepCount }),
|
|
310
|
+
...(opts.keepDays !== undefined && { keepDays: opts.keepDays }),
|
|
311
|
+
files: present.map((f) => ({ name: f.name, bytes: f.data.length, sha256: f.sha })),
|
|
312
|
+
};
|
|
313
|
+
try {
|
|
314
|
+
for (const f of present)
|
|
315
|
+
writeFileSync(join(tmp, f.name), f.data, { mode: 0o600 });
|
|
316
|
+
writeFileSync(join(tmp, MANIFEST), JSON.stringify(manifest, null, 2) + '\n', { mode: 0o600 });
|
|
317
|
+
renameSync(tmp, join(opts.backupDir, id));
|
|
318
|
+
try {
|
|
319
|
+
chmodSync(join(opts.backupDir, id), 0o700);
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
/* best effort on filesystems without modes */
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
catch (err) {
|
|
326
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
327
|
+
throw err;
|
|
328
|
+
}
|
|
329
|
+
const bytes = present.reduce((n, f) => n + f.data.length, 0);
|
|
330
|
+
return { result: { status: 'created', id, files: present.length, bytes }, manifest };
|
|
331
|
+
}
|
|
332
|
+
/** Remove `.tmp-cfg-*` build folders a crashed snapshot left behind (older than an hour, so a live one on a shared folder is safe). */
|
|
333
|
+
export function sweepConfigBackupTemps(dir, now = Date.now()) {
|
|
334
|
+
sweepTemps(dir, folderEntries(dir), now);
|
|
335
|
+
}
|
|
336
|
+
function sweepTemps(dir, names, now) {
|
|
337
|
+
for (const n of names) {
|
|
338
|
+
if (!/^\.tmp-cfg-\d{8}-\d{6}(?:-\d{1,3})?-\d+$/.test(n))
|
|
339
|
+
continue;
|
|
340
|
+
const st = lstatSync(join(dir, n), { throwIfNoEntry: false });
|
|
341
|
+
if (st && now - st.mtimeMs > 3_600_000)
|
|
342
|
+
rmSync(join(dir, n), { recursive: true, force: true });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* The retention recorded in this instance's newest snapshot, or null when it recorded none. `listed`
|
|
347
|
+
* is this instance's manifests in `dir`, newest first, when the caller already read them.
|
|
348
|
+
*/
|
|
349
|
+
export function latestBackupRetention(dir, origin, listed) {
|
|
350
|
+
// The newest manifest that recorded one: a snapshot from before this field existed must not hide it.
|
|
351
|
+
for (const m of listed ?? manifestsIn(dir, folderEntries(dir), origin)) {
|
|
352
|
+
if (typeof m.keepCount === 'number' && typeof m.keepDays === 'number') {
|
|
353
|
+
return { keepCount: m.keepCount, keepDays: m.keepDays };
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* The settings in effect, with each retention key that `raw` (the parsed settings.json) lacks, as
|
|
360
|
+
* after a reset, taken from what the newest backup recorded in `dir` instead of the defaults. It
|
|
361
|
+
* looks at the retention keys only (not the folder), so re-entering a custom folder after a reset
|
|
362
|
+
* finds that folder's own manifests. One place for the timer pass and for what the GET route
|
|
363
|
+
* reports, so they cannot disagree.
|
|
364
|
+
*/
|
|
365
|
+
export function withRecordedRetention(settings, raw, dir, origin, listed) {
|
|
366
|
+
const lacksCount = !('configBackupKeepCount' in raw);
|
|
367
|
+
const lacksDays = !('configBackupKeepDays' in raw);
|
|
368
|
+
if ((!lacksCount && !lacksDays) || !origin)
|
|
369
|
+
return settings;
|
|
370
|
+
const kept = latestBackupRetention(dir, origin, listed);
|
|
371
|
+
if (!kept)
|
|
372
|
+
return settings;
|
|
373
|
+
return {
|
|
374
|
+
...settings,
|
|
375
|
+
...(lacksCount && { keepCount: kept.keepCount }),
|
|
376
|
+
...(lacksDays && { keepDays: kept.keepDays }),
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Apply retention: keep the newest `keepCount`, and (when `keepDays` > 0) drop anything older than
|
|
381
|
+
* that. The newest snapshot always survives. Returns the ids removed.
|
|
382
|
+
*/
|
|
383
|
+
export function pruneConfigBackups(dir, opts) {
|
|
384
|
+
return pruneListed(dir, manifestsIn(dir, folderEntries(dir), opts.origin), opts);
|
|
385
|
+
}
|
|
386
|
+
/** {@link pruneConfigBackups} over a listing already made (`all`, newest first). */
|
|
387
|
+
function pruneListed(dir, all, opts) {
|
|
388
|
+
const now = opts.now ?? Date.now();
|
|
389
|
+
const cutoff = opts.keepDays > 0 ? now - opts.keepDays * 86_400_000 : null;
|
|
390
|
+
const removed = [];
|
|
391
|
+
all.forEach((b, index) => {
|
|
392
|
+
if (index === 0)
|
|
393
|
+
return;
|
|
394
|
+
const tooMany = index >= Math.max(1, opts.keepCount);
|
|
395
|
+
// A snapshot's state was live until the next one was made: age it from then. Aging by its own
|
|
396
|
+
// createdAt deleted the last good copy on the first change after a quiet month.
|
|
397
|
+
const tooOld = cutoff !== null && all[index - 1].createdAt < cutoff;
|
|
398
|
+
if (!tooMany && !tooOld)
|
|
399
|
+
return;
|
|
400
|
+
// The listing already proved this is one of ours (name pattern + manifest).
|
|
401
|
+
const target = join(dir, b.id);
|
|
402
|
+
if (!target.startsWith(resolve(dir) + sep))
|
|
403
|
+
return;
|
|
404
|
+
try {
|
|
405
|
+
rmSync(target, { recursive: true, force: true });
|
|
406
|
+
removed.push(b.id);
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
/* leave it; the next pass retries */
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
return removed;
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* One pass of the timer or of Back up now: sweep stale temp folders, snapshot when the state changed,
|
|
416
|
+
* apply retention. The folder is read and each manifest parsed ONCE, and that listing feeds the
|
|
417
|
+
* recorded-retention fallback, the change check and the prune. It used to be listed for each of the
|
|
418
|
+
* three, which on a network folder is every manifest read three times every five minutes.
|
|
419
|
+
*/
|
|
420
|
+
export function runConfigBackupPass(opts) {
|
|
421
|
+
const names = folderEntries(opts.backupDir);
|
|
422
|
+
sweepTemps(opts.backupDir, names, Date.now());
|
|
423
|
+
const origin = configBackupOrigin(opts.dataDir);
|
|
424
|
+
const existing = manifestsIn(opts.backupDir, names, origin);
|
|
425
|
+
// No retention in the file (reset, or unreadable): keep what the newest snapshot recorded.
|
|
426
|
+
const { keepCount, keepDays } = withRecordedRetention(opts.settings, opts.raw, opts.backupDir, origin, existing);
|
|
427
|
+
const { result, manifest } = snapshotConfig({
|
|
428
|
+
dataDir: opts.dataDir,
|
|
429
|
+
backupDir: opts.backupDir,
|
|
430
|
+
reason: opts.reason,
|
|
431
|
+
appVersion: opts.appVersion,
|
|
432
|
+
force: opts.force,
|
|
433
|
+
keepCount,
|
|
434
|
+
keepDays,
|
|
435
|
+
now: opts.now,
|
|
436
|
+
}, { origin, existing });
|
|
437
|
+
if (!opts.prune)
|
|
438
|
+
return { result, removed: [] };
|
|
439
|
+
// A snapshot made now is dated after existing[0], so it heads the listing retention counts.
|
|
440
|
+
const all = manifest ? [manifest, ...existing] : existing;
|
|
441
|
+
return { result, removed: pruneListed(opts.backupDir, all, { keepCount, keepDays, now: opts.now }) };
|
|
442
|
+
}
|
|
443
|
+
export class ConfigBackupNotFoundError extends Error {
|
|
444
|
+
}
|
|
445
|
+
export class ConfigBackupCorruptError extends Error {
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* A restore that wrote some files and could not write others (a full disk, a directory where a file
|
|
449
|
+
* should be). Every file passed verification first, so the ones written are the backup's: the caller
|
|
450
|
+
* still has to reload what they feed and tell the user which files are which.
|
|
451
|
+
*/
|
|
452
|
+
export class ConfigBackupPartialRestoreError extends Error {
|
|
453
|
+
restored;
|
|
454
|
+
failed;
|
|
455
|
+
untouched;
|
|
456
|
+
safetySnapshot;
|
|
457
|
+
constructor(restored, failed, untouched, safetySnapshot) {
|
|
458
|
+
const done = restored.length ? `${restored.join(', ')} restored` : 'nothing was restored';
|
|
459
|
+
const undo = safetySnapshot ? `; the files from before the restore are in backup ${safetySnapshot}` : '';
|
|
460
|
+
super(`Restore incomplete: could not write ${failed.map((f) => f.name).join(', ')} (${failed[0]?.error}); ${done}${undo}`);
|
|
461
|
+
this.restored = restored;
|
|
462
|
+
this.failed = failed;
|
|
463
|
+
this.untouched = untouched;
|
|
464
|
+
this.safetySnapshot = safetySnapshot;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Restore refuses what this module never writes. Snapshots are 0700/0600 real files, and a restore
|
|
469
|
+
* writes the file as 0600: a symlink, or a file with a group/world bit, was planted or loosened
|
|
470
|
+
* after the snapshot, and rewriting it as 0600 would turn it into trusted config (`clis.json`
|
|
471
|
+
* selects the binaries Codeman spawns; `isUnsafePermissions` refuses the same bits there).
|
|
472
|
+
* POSIX only, like `isUnsafePermissions`. The mode test is skipped (the symlink test is not) on a
|
|
473
|
+
* filesystem that does not keep modes, see {@link folderKeepsModes}.
|
|
474
|
+
*/
|
|
475
|
+
function assertTrustedSnapshotEntry(path, label, checkMode) {
|
|
476
|
+
if (process.platform === 'win32')
|
|
477
|
+
return;
|
|
478
|
+
const st = lstatSync(path, { throwIfNoEntry: false });
|
|
479
|
+
if (!st)
|
|
480
|
+
return; // missing is reported by the read that follows
|
|
481
|
+
if (st.isSymbolicLink())
|
|
482
|
+
throw new ConfigBackupCorruptError(`${label} is a symlink, so it is not used`);
|
|
483
|
+
if (checkMode && (st.mode & 0o077) !== 0) {
|
|
484
|
+
throw new ConfigBackupCorruptError(`${label} is readable or writable by group or others, so it is not used`);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Whether the backup folder's filesystem keeps a file's mode: vfat/exFAT, CIFS shares and WSL's
|
|
489
|
+
* `/mnt/d` report 0755 or 0777 for everything, so the group/world test would refuse every backup
|
|
490
|
+
* there even though snapshots succeed (`createConfigBackup` already expects mode-less filesystems).
|
|
491
|
+
* Probed with a 0600 temp file made with `wx` under a random name; if the probe cannot run, the
|
|
492
|
+
* strict test stays on.
|
|
493
|
+
*/
|
|
494
|
+
function folderKeepsModes(dir) {
|
|
495
|
+
if (process.platform === 'win32')
|
|
496
|
+
return false;
|
|
497
|
+
// A random name and 'wx' (fail if anything is there): a planted symlink or directory with a
|
|
498
|
+
// guessable name must neither be written through nor break the probe. Only a file this call
|
|
499
|
+
// created is removed.
|
|
500
|
+
const probe = join(dir, `.mode-probe-${randomUUID()}`);
|
|
501
|
+
let created = false;
|
|
502
|
+
try {
|
|
503
|
+
writeFileSync(probe, '', { mode: 0o600, flag: 'wx' });
|
|
504
|
+
created = true;
|
|
505
|
+
chmodSync(probe, 0o600);
|
|
506
|
+
return (lstatSync(probe).mode & 0o777 & 0o077) === 0;
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
return true;
|
|
510
|
+
}
|
|
511
|
+
finally {
|
|
512
|
+
if (created)
|
|
513
|
+
rmSync(probe, { force: true });
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
/** The folder's own backup settings stay as they are now: restoring an old `configBackupDir` hides the `pre-restore` copy. */
|
|
517
|
+
const BACKUP_SETTING_KEYS = ['configBackupEnabled', 'configBackupDir', 'configBackupKeepCount', 'configBackupKeepDays'];
|
|
518
|
+
function keepCurrentBackupSettings(snapshot, livePath) {
|
|
519
|
+
try {
|
|
520
|
+
const restored = JSON.parse(snapshot.toString('utf8'));
|
|
521
|
+
const live = JSON.parse(readFileSync(livePath, 'utf8'));
|
|
522
|
+
if (!restored || typeof restored !== 'object' || !live || typeof live !== 'object')
|
|
523
|
+
return snapshot;
|
|
524
|
+
let changed = false;
|
|
525
|
+
// A reset live file lacks the retention keys. Taking "absent" for them would bring the defaults
|
|
526
|
+
// back (20 copies, 30 days) on the next pass, so the backup's own retention stays.
|
|
527
|
+
for (const key of BACKUP_SETTING_KEYS) {
|
|
528
|
+
if (!(key in live) && (key === 'configBackupKeepCount' || key === 'configBackupKeepDays'))
|
|
529
|
+
continue;
|
|
530
|
+
if (JSON.stringify(restored[key]) === JSON.stringify(live[key]))
|
|
531
|
+
continue;
|
|
532
|
+
changed = true;
|
|
533
|
+
if (key in live)
|
|
534
|
+
restored[key] = live[key];
|
|
535
|
+
else
|
|
536
|
+
delete restored[key];
|
|
537
|
+
}
|
|
538
|
+
if (!changed)
|
|
539
|
+
return snapshot; // byte-identical to the snapshot when the folder settings did not move
|
|
540
|
+
return Buffer.from(JSON.stringify(restored, null, 2) + '\n');
|
|
541
|
+
}
|
|
542
|
+
catch {
|
|
543
|
+
return snapshot; // not JSON, or no live file to take the values from
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Put a snapshot's files back into the data dir. Every file is checked against the manifest's digest
|
|
548
|
+
* BEFORE anything is written, a `pre-restore` snapshot of the live files is taken first (unless the
|
|
549
|
+
* newest backup already holds that state, which is then reported instead), and each file is replaced
|
|
550
|
+
* atomically (temp + rename) with mode 0600. A file that cannot be written does not stop the others;
|
|
551
|
+
* it throws {@link ConfigBackupPartialRestoreError} at the end, naming both lists.
|
|
552
|
+
*/
|
|
553
|
+
export function restoreConfigBackup(opts) {
|
|
554
|
+
const origin = readConfigBackupOrigin(opts.dataDir);
|
|
555
|
+
const manifest = origin ? readManifest(opts.backupDir, opts.id, origin) : null;
|
|
556
|
+
if (!manifest || !origin)
|
|
557
|
+
throw new ConfigBackupNotFoundError(`No config backup named ${opts.id}`);
|
|
558
|
+
const allowed = new Set(CONFIG_BACKUP_FILES);
|
|
559
|
+
const loaded = [];
|
|
560
|
+
const checkMode = opts.keepsModes ?? folderKeepsModes(opts.backupDir);
|
|
561
|
+
assertTrustedSnapshotEntry(join(opts.backupDir, opts.id), `Backup ${opts.id}`, checkMode);
|
|
562
|
+
for (const f of manifest.files) {
|
|
563
|
+
if (!allowed.has(f.name))
|
|
564
|
+
continue;
|
|
565
|
+
let data;
|
|
566
|
+
assertTrustedSnapshotEntry(join(opts.backupDir, opts.id, f.name), `${f.name} in ${opts.id}`, checkMode);
|
|
567
|
+
try {
|
|
568
|
+
data = readFileSync(join(opts.backupDir, opts.id, f.name));
|
|
569
|
+
}
|
|
570
|
+
catch {
|
|
571
|
+
throw new ConfigBackupCorruptError(`${f.name} is missing from ${opts.id}`);
|
|
572
|
+
}
|
|
573
|
+
if (sha256(data) !== f.sha256)
|
|
574
|
+
throw new ConfigBackupCorruptError(`${f.name} in ${opts.id} does not match its checksum`);
|
|
575
|
+
loaded.push({ name: f.name, data });
|
|
576
|
+
}
|
|
577
|
+
if (loaded.length === 0)
|
|
578
|
+
throw new ConfigBackupCorruptError(`${opts.id} holds no restorable files`);
|
|
579
|
+
// One walk of the folder serves both the recorded retention and the snapshot below.
|
|
580
|
+
const existing = manifestsIn(opts.backupDir, folderEntries(opts.backupDir), origin);
|
|
581
|
+
const { result: safety } = snapshotConfig({
|
|
582
|
+
dataDir: opts.dataDir,
|
|
583
|
+
backupDir: opts.backupDir,
|
|
584
|
+
reason: 'pre-restore',
|
|
585
|
+
appVersion: opts.appVersion,
|
|
586
|
+
// Not forced: when the live files equal the newest backup, that backup already is the undo copy,
|
|
587
|
+
// and a forced duplicate would spend a retention slot on nothing.
|
|
588
|
+
now: opts.now,
|
|
589
|
+
// The retention in force, so this newest snapshot does not hide it from the next pass.
|
|
590
|
+
...(latestBackupRetention(opts.backupDir, origin, existing) ?? {}),
|
|
591
|
+
}, { origin, existing });
|
|
592
|
+
mkdirSync(opts.dataDir, { recursive: true });
|
|
593
|
+
const restored = [];
|
|
594
|
+
const failed = [];
|
|
595
|
+
for (const f of loaded) {
|
|
596
|
+
const target = join(opts.dataDir, f.name);
|
|
597
|
+
const tmp = `${target}.restore-${process.pid}`;
|
|
598
|
+
try {
|
|
599
|
+
writeFileSync(tmp, f.name === 'settings.json' ? keepCurrentBackupSettings(f.data, target) : f.data, {
|
|
600
|
+
mode: 0o600,
|
|
601
|
+
});
|
|
602
|
+
renameSync(tmp, target);
|
|
603
|
+
restored.push(f.name);
|
|
604
|
+
}
|
|
605
|
+
catch (err) {
|
|
606
|
+
try {
|
|
607
|
+
rmSync(tmp, { force: true });
|
|
608
|
+
}
|
|
609
|
+
catch {
|
|
610
|
+
/* the temp is a leftover at worst; the failure below is what matters */
|
|
611
|
+
}
|
|
612
|
+
// Keep going: each file is replaced on its own, and the files written before this one are the
|
|
613
|
+
// backup's already. Stopping here used to answer a bare 500 that named neither list.
|
|
614
|
+
failed.push({ name: f.name, error: err instanceof Error ? err.message : String(err) });
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
// A restore never deletes: tracked files the snapshot did not hold stay as they are.
|
|
618
|
+
const untouched = CONFIG_BACKUP_FILES.filter((n) => !restored.includes(n) &&
|
|
619
|
+
!failed.some((x) => x.name === n) &&
|
|
620
|
+
lstatSync(join(opts.dataDir, n), { throwIfNoEntry: false })?.isFile());
|
|
621
|
+
const safetySnapshot = safety.status === 'empty' ? null : safety.id;
|
|
622
|
+
if (failed.length)
|
|
623
|
+
throw new ConfigBackupPartialRestoreError(restored, failed, untouched, safetySnapshot);
|
|
624
|
+
return { restored, untouched, safetySnapshot };
|
|
625
|
+
}
|
|
626
|
+
/** The live files in the data dir, as a side of a diff. */
|
|
627
|
+
export const CONFIG_DIFF_CURRENT = 'current';
|
|
628
|
+
const MAX_DIFF_CHANGES = 200;
|
|
629
|
+
const MAX_DIFF_VALUE_CHARS = 200;
|
|
630
|
+
/** Files whose every value is a credential or key: only names are ever shown. */
|
|
631
|
+
const REDACT_ALL_FILES = new Set([
|
|
632
|
+
'.env',
|
|
633
|
+
'users.json',
|
|
634
|
+
'push-keys.json',
|
|
635
|
+
'webhook.json',
|
|
636
|
+
'intents.json',
|
|
637
|
+
]);
|
|
638
|
+
/** Elsewhere a value is hidden when its own key looks like a credential. */
|
|
639
|
+
const SECRET_KEY_RE = /(token|secret|passw|credential|auth|key$)/i;
|
|
640
|
+
function flattenJson(value, path, out) {
|
|
641
|
+
if (Array.isArray(value) && value.length > 0) {
|
|
642
|
+
value.forEach((v, i) => flattenJson(v, `${path}[${i}]`, out));
|
|
643
|
+
}
|
|
644
|
+
else if (value && typeof value === 'object' && Object.keys(value).length > 0) {
|
|
645
|
+
for (const [k, v] of Object.entries(value)) {
|
|
646
|
+
flattenJson(v, path ? `${path}.${k}` : k, out);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
else {
|
|
650
|
+
out.set(path || '(root)', JSON.stringify(value) ?? 'null');
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
function parseEnv(text) {
|
|
654
|
+
const out = new Map();
|
|
655
|
+
for (const line of text.split(/\r?\n/)) {
|
|
656
|
+
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*=(.*)$/.exec(line);
|
|
657
|
+
if (m)
|
|
658
|
+
out.set(m[1], m[2].trim());
|
|
659
|
+
}
|
|
660
|
+
return out;
|
|
661
|
+
}
|
|
662
|
+
/** Per-key view of a tracked file, or null when it is not JSON/env text. */
|
|
663
|
+
function leavesOf(name, data) {
|
|
664
|
+
const text = data.toString('utf8');
|
|
665
|
+
if (name === '.env')
|
|
666
|
+
return parseEnv(text);
|
|
667
|
+
try {
|
|
668
|
+
const parsed = JSON.parse(text);
|
|
669
|
+
const volatile = VOLATILE_KEYS[name];
|
|
670
|
+
const out = new Map();
|
|
671
|
+
flattenJson(volatile ? withoutKeys(parsed, new Set(volatile)) : parsed, '', out);
|
|
672
|
+
return out;
|
|
673
|
+
}
|
|
674
|
+
catch {
|
|
675
|
+
return null;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
/** A URL value loses its userinfo, query and fragment: they carry passwords and tokens. */
|
|
679
|
+
function scrubUrl(jsonText) {
|
|
680
|
+
try {
|
|
681
|
+
const v = JSON.parse(jsonText);
|
|
682
|
+
if (typeof v !== 'string' || !/^[a-z][a-z0-9+.-]*:\/\//i.test(v))
|
|
683
|
+
return jsonText;
|
|
684
|
+
const u = new URL(v);
|
|
685
|
+
if (!u.username && !u.password && !u.search && !u.hash)
|
|
686
|
+
return jsonText;
|
|
687
|
+
return JSON.stringify(`${u.protocol}//${u.host}${u.pathname}`);
|
|
688
|
+
}
|
|
689
|
+
catch {
|
|
690
|
+
return jsonText;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
const clip = (s) => (s.length > MAX_DIFF_VALUE_CHARS ? `${s.slice(0, MAX_DIFF_VALUE_CHARS)}…` : s);
|
|
694
|
+
function diffLeaves(name, a, b) {
|
|
695
|
+
const all = REDACT_ALL_FILES.has(name);
|
|
696
|
+
const changes = [];
|
|
697
|
+
for (const path of [...new Set([...a.keys(), ...b.keys()])].sort()) {
|
|
698
|
+
const before = a.get(path);
|
|
699
|
+
const after = b.get(path);
|
|
700
|
+
if (before === after)
|
|
701
|
+
continue;
|
|
702
|
+
const kind = before === undefined ? 'added' : after === undefined ? 'removed' : 'changed';
|
|
703
|
+
if (all || SECRET_KEY_RE.test(path.split('.').pop() ?? '')) {
|
|
704
|
+
changes.push({ path, kind, redacted: true });
|
|
705
|
+
}
|
|
706
|
+
else {
|
|
707
|
+
changes.push({
|
|
708
|
+
path,
|
|
709
|
+
kind,
|
|
710
|
+
...(before !== undefined && { before: clip(scrubUrl(before)) }),
|
|
711
|
+
...(after !== undefined && { after: clip(scrubUrl(after)) }),
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
return { changes: changes.slice(0, MAX_DIFF_CHANGES), truncated: changes.length > MAX_DIFF_CHANGES };
|
|
716
|
+
}
|
|
717
|
+
/** The tracked files of one side, verified: a snapshot's against its manifest, the live ones as they are. */
|
|
718
|
+
function loadDiffSide(side, dataDir, backupDir) {
|
|
719
|
+
const out = new Map();
|
|
720
|
+
if (side === CONFIG_DIFF_CURRENT) {
|
|
721
|
+
for (const name of CONFIG_BACKUP_FILES) {
|
|
722
|
+
try {
|
|
723
|
+
const path = join(dataDir, name);
|
|
724
|
+
if (statSync(path).isFile())
|
|
725
|
+
out.set(name, readFileSync(path));
|
|
726
|
+
}
|
|
727
|
+
catch {
|
|
728
|
+
/* absent or unreadable: not part of this side */
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
return out;
|
|
732
|
+
}
|
|
733
|
+
const origin = readConfigBackupOrigin(dataDir);
|
|
734
|
+
const manifest = origin ? readManifest(backupDir, side, origin) : null;
|
|
735
|
+
if (!manifest)
|
|
736
|
+
throw new ConfigBackupNotFoundError(`No config backup named ${side}`);
|
|
737
|
+
const allowed = new Set(CONFIG_BACKUP_FILES);
|
|
738
|
+
const checkMode = folderKeepsModes(backupDir);
|
|
739
|
+
assertTrustedSnapshotEntry(join(backupDir, side), `Backup ${side}`, checkMode);
|
|
740
|
+
for (const f of manifest.files) {
|
|
741
|
+
if (!allowed.has(f.name))
|
|
742
|
+
continue;
|
|
743
|
+
let data;
|
|
744
|
+
assertTrustedSnapshotEntry(join(backupDir, side, f.name), `${f.name} in ${side}`, checkMode);
|
|
745
|
+
try {
|
|
746
|
+
data = readFileSync(join(backupDir, side, f.name));
|
|
747
|
+
}
|
|
748
|
+
catch {
|
|
749
|
+
throw new ConfigBackupCorruptError(`${f.name} is missing from ${side}`);
|
|
750
|
+
}
|
|
751
|
+
if (sha256(data) !== f.sha256)
|
|
752
|
+
throw new ConfigBackupCorruptError(`${f.name} in ${side} does not match its checksum`);
|
|
753
|
+
out.set(f.name, data);
|
|
754
|
+
}
|
|
755
|
+
return out;
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* What differs between two states of the tracked files; a state is a snapshot id or `current`
|
|
759
|
+
* (the live files). Never returns the value of a credential: see REDACT_ALL_FILES and SECRET_KEY_RE.
|
|
760
|
+
*/
|
|
761
|
+
export function diffConfigBackups(opts) {
|
|
762
|
+
const a = loadDiffSide(opts.from, opts.dataDir, opts.backupDir);
|
|
763
|
+
const b = loadDiffSide(opts.to, opts.dataDir, opts.backupDir);
|
|
764
|
+
const files = [];
|
|
765
|
+
for (const name of CONFIG_BACKUP_FILES) {
|
|
766
|
+
const before = a.get(name);
|
|
767
|
+
const after = b.get(name);
|
|
768
|
+
if (!before && !after)
|
|
769
|
+
continue;
|
|
770
|
+
const base = { name, changes: [], truncated: false };
|
|
771
|
+
if (before && after && sha256(before) === sha256(after)) {
|
|
772
|
+
files.push({ ...base, status: 'unchanged', detail: 'keys' });
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
const la = before ? leavesOf(name, before) : new Map();
|
|
776
|
+
const lb = after ? leavesOf(name, after) : new Map();
|
|
777
|
+
const status = !before ? 'added' : !after ? 'removed' : 'changed';
|
|
778
|
+
if (!la || !lb) {
|
|
779
|
+
files.push({ ...base, status, detail: 'bytes' });
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
const { changes, truncated } = diffLeaves(name, la, lb);
|
|
783
|
+
// Only volatile keys differed (a discovery timestamp): the same state as far as the user is concerned.
|
|
784
|
+
files.push({
|
|
785
|
+
name,
|
|
786
|
+
status: changes.length || status !== 'changed' ? status : 'unchanged',
|
|
787
|
+
changes,
|
|
788
|
+
truncated,
|
|
789
|
+
detail: 'keys',
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
return { from: opts.from, to: opts.to, files, changed: files.filter((f) => f.status !== 'unchanged').length };
|
|
793
|
+
}
|
|
794
|
+
//# sourceMappingURL=config-backup.js.map
|