@matthesketh/fleet 1.8.1 → 1.11.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.
Files changed (233) hide show
  1. package/README.md +186 -16
  2. package/dist/bin/fleet-agent.d.ts +2 -0
  3. package/dist/bin/fleet-agent.js +7 -0
  4. package/dist/cli.d.ts +5 -0
  5. package/dist/cli.js +73 -31
  6. package/dist/commands/add.d.ts +2 -1
  7. package/dist/commands/add.js +66 -59
  8. package/dist/commands/audit.d.ts +1 -0
  9. package/dist/commands/audit.js +144 -0
  10. package/dist/commands/backup.d.ts +1 -0
  11. package/dist/commands/backup.js +510 -0
  12. package/dist/commands/boot-start.d.ts +3 -1
  13. package/dist/commands/boot-start.js +39 -47
  14. package/dist/commands/completions.d.ts +6 -0
  15. package/dist/commands/completions.js +83 -0
  16. package/dist/commands/config.d.ts +16 -0
  17. package/dist/commands/config.js +96 -0
  18. package/dist/commands/deploy.js +3 -2
  19. package/dist/commands/deps.js +5 -1
  20. package/dist/commands/doctor.d.ts +32 -0
  21. package/dist/commands/doctor.js +186 -0
  22. package/dist/commands/egress.d.ts +1 -1
  23. package/dist/commands/egress.js +13 -10
  24. package/dist/commands/freeze.d.ts +8 -4
  25. package/dist/commands/freeze.js +77 -59
  26. package/dist/commands/git.js +2 -2
  27. package/dist/commands/health.d.ts +2 -1
  28. package/dist/commands/health.js +38 -56
  29. package/dist/commands/init.d.ts +2 -1
  30. package/dist/commands/init.js +83 -73
  31. package/dist/commands/install-mcp.d.ts +3 -1
  32. package/dist/commands/install-mcp.js +53 -34
  33. package/dist/commands/list.d.ts +2 -1
  34. package/dist/commands/list.js +22 -19
  35. package/dist/commands/logs.js +1 -1
  36. package/dist/commands/notify.d.ts +1 -0
  37. package/dist/commands/notify.js +51 -0
  38. package/dist/commands/patch-systemd.d.ts +7 -1
  39. package/dist/commands/patch-systemd.js +71 -31
  40. package/dist/commands/remove.d.ts +3 -1
  41. package/dist/commands/remove.js +37 -26
  42. package/dist/commands/restart.d.ts +4 -1
  43. package/dist/commands/restart.js +17 -20
  44. package/dist/commands/rollback.d.ts +4 -1
  45. package/dist/commands/rollback.js +33 -42
  46. package/dist/commands/secrets.js +157 -9
  47. package/dist/commands/start.d.ts +4 -1
  48. package/dist/commands/start.js +17 -20
  49. package/dist/commands/status.d.ts +1 -1
  50. package/dist/commands/status.js +21 -26
  51. package/dist/commands/stop.d.ts +4 -1
  52. package/dist/commands/stop.js +17 -20
  53. package/dist/commands/testflight.d.ts +1 -0
  54. package/dist/commands/testflight.js +193 -0
  55. package/dist/commands/update.d.ts +16 -0
  56. package/dist/commands/update.js +95 -0
  57. package/dist/core/audit/cache.d.ts +4 -0
  58. package/dist/core/audit/cache.js +37 -0
  59. package/dist/core/audit/config.d.ts +5 -0
  60. package/dist/core/audit/config.js +35 -0
  61. package/dist/core/audit/greenlight.d.ts +11 -0
  62. package/dist/core/audit/greenlight.js +81 -0
  63. package/dist/core/audit/reporters/cli.d.ts +3 -0
  64. package/dist/core/audit/reporters/cli.js +68 -0
  65. package/dist/core/audit/suppress.d.ts +6 -0
  66. package/dist/core/audit/suppress.js +37 -0
  67. package/dist/core/audit/target.d.ts +5 -0
  68. package/dist/core/audit/target.js +26 -0
  69. package/dist/core/audit/types.d.ts +54 -0
  70. package/dist/core/audit/types.js +5 -0
  71. package/dist/core/backup/browser-api.d.ts +66 -0
  72. package/dist/core/backup/browser-api.js +197 -0
  73. package/dist/core/backup/browser-server.d.ts +11 -0
  74. package/dist/core/backup/browser-server.js +241 -0
  75. package/dist/core/backup/browser-ui.d.ts +5 -0
  76. package/dist/core/backup/browser-ui.js +268 -0
  77. package/dist/core/backup/cloudflare.d.ts +7 -0
  78. package/dist/core/backup/cloudflare.js +82 -0
  79. package/dist/core/backup/config.d.ts +9 -0
  80. package/dist/core/backup/config.js +80 -0
  81. package/dist/core/backup/detect.d.ts +11 -0
  82. package/dist/core/backup/detect.js +71 -0
  83. package/dist/core/backup/dump.d.ts +11 -0
  84. package/dist/core/backup/dump.js +82 -0
  85. package/dist/core/backup/index.d.ts +9 -0
  86. package/dist/core/backup/index.js +9 -0
  87. package/dist/core/backup/repo.d.ts +71 -0
  88. package/dist/core/backup/repo.js +256 -0
  89. package/dist/core/backup/schedule.d.ts +17 -0
  90. package/dist/core/backup/schedule.js +90 -0
  91. package/dist/core/backup/sensitive.d.ts +5 -0
  92. package/dist/core/backup/sensitive.js +37 -0
  93. package/dist/core/backup/status.d.ts +3 -0
  94. package/dist/core/backup/status.js +29 -0
  95. package/dist/core/backup/statuspage.d.ts +23 -0
  96. package/dist/core/backup/statuspage.js +145 -0
  97. package/dist/core/backup/system.d.ts +24 -0
  98. package/dist/core/backup/system.js +209 -0
  99. package/dist/core/backup/totp.d.ts +16 -0
  100. package/dist/core/backup/totp.js +116 -0
  101. package/dist/core/backup/types.d.ts +70 -0
  102. package/dist/core/backup/types.js +7 -0
  103. package/dist/core/backup/unlock.d.ts +19 -0
  104. package/dist/core/backup/unlock.js +69 -0
  105. package/dist/core/boot-refresh.d.ts +1 -1
  106. package/dist/core/boot-refresh.js +10 -9
  107. package/dist/core/deps/actors/pr-creator.d.ts +5 -3
  108. package/dist/core/deps/actors/pr-creator.js +71 -18
  109. package/dist/core/deps/collectors/fetch-with-timeout.d.ts +7 -0
  110. package/dist/core/deps/collectors/fetch-with-timeout.js +16 -0
  111. package/dist/core/deps/collectors/npm.js +3 -1
  112. package/dist/core/deps/collectors/vulnerability.d.ts +8 -0
  113. package/dist/core/deps/collectors/vulnerability.js +31 -2
  114. package/dist/core/deps/config.js +6 -0
  115. package/dist/core/deps/scanner.js +1 -1
  116. package/dist/core/deps/types.d.ts +8 -0
  117. package/dist/core/env.d.ts +3 -0
  118. package/dist/core/env.js +11 -0
  119. package/dist/core/exec.d.ts +1 -0
  120. package/dist/core/exec.js +4 -0
  121. package/dist/core/file-lock.d.ts +18 -0
  122. package/dist/core/file-lock.js +44 -0
  123. package/dist/core/git-onboard.js +10 -13
  124. package/dist/core/github.d.ts +3 -1
  125. package/dist/core/github.js +10 -7
  126. package/dist/core/logs-policy.d.ts +5 -0
  127. package/dist/core/logs-policy.js +20 -1
  128. package/dist/core/operator.d.ts +21 -0
  129. package/dist/core/operator.js +54 -0
  130. package/dist/core/registry.d.ts +18 -0
  131. package/dist/core/registry.js +26 -0
  132. package/dist/core/routines/schema.d.ts +11 -11
  133. package/dist/core/routines/schema.js +14 -3
  134. package/dist/core/routines/store.d.ts +8 -8
  135. package/dist/core/secrets-ops.d.ts +31 -6
  136. package/dist/core/secrets-ops.js +208 -102
  137. package/dist/core/secrets-providers.js +2 -2
  138. package/dist/core/secrets-rotation.d.ts +1 -1
  139. package/dist/core/secrets-rotation.js +58 -52
  140. package/dist/core/secrets-v2-cleanup.d.ts +19 -0
  141. package/dist/core/secrets-v2-cleanup.js +94 -0
  142. package/dist/core/secrets-v2-creds.d.ts +9 -0
  143. package/dist/core/secrets-v2-creds.js +44 -0
  144. package/dist/core/secrets-v2-install.d.ts +13 -0
  145. package/dist/core/secrets-v2-install.js +76 -0
  146. package/dist/core/secrets-v2-keypair.d.ts +10 -0
  147. package/dist/core/secrets-v2-keypair.js +31 -0
  148. package/dist/core/secrets-v2-migrate.d.ts +29 -0
  149. package/dist/core/secrets-v2-migrate.js +395 -0
  150. package/dist/core/secrets-v2-ops.d.ts +36 -0
  151. package/dist/core/secrets-v2-ops.js +184 -0
  152. package/dist/core/secrets-v2-protocol.d.ts +19 -0
  153. package/dist/core/secrets-v2-protocol.js +60 -0
  154. package/dist/core/secrets-v2-snapshot.d.ts +36 -0
  155. package/dist/core/secrets-v2-snapshot.js +115 -0
  156. package/dist/core/secrets-v2.d.ts +21 -0
  157. package/dist/core/secrets-v2.js +249 -0
  158. package/dist/core/secrets.d.ts +39 -4
  159. package/dist/core/secrets.js +91 -11
  160. package/dist/core/self-update.d.ts +32 -11
  161. package/dist/core/self-update.js +52 -14
  162. package/dist/core/testflight/asc.d.ts +12 -0
  163. package/dist/core/testflight/asc.js +101 -0
  164. package/dist/core/testflight/credentials.d.ts +3 -0
  165. package/dist/core/testflight/credentials.js +35 -0
  166. package/dist/core/testflight/eas.d.ts +4 -0
  167. package/dist/core/testflight/eas.js +38 -0
  168. package/dist/core/testflight/resolve.d.ts +6 -0
  169. package/dist/core/testflight/resolve.js +44 -0
  170. package/dist/core/testflight/types.d.ts +13 -0
  171. package/dist/core/testflight/types.js +3 -0
  172. package/dist/core/testflight/workflow.d.ts +17 -0
  173. package/dist/core/testflight/workflow.js +65 -0
  174. package/dist/core/validate.d.ts +1 -0
  175. package/dist/core/validate.js +8 -0
  176. package/dist/mcp/audit-tools.d.ts +2 -0
  177. package/dist/mcp/audit-tools.js +94 -0
  178. package/dist/mcp/git-tools.js +1 -1
  179. package/dist/mcp/registry-bridge.d.ts +10 -0
  180. package/dist/mcp/registry-bridge.js +65 -0
  181. package/dist/mcp/secrets-tools.js +2 -2
  182. package/dist/mcp/server.js +16 -82
  183. package/dist/mcp/testflight-tools.d.ts +2 -0
  184. package/dist/mcp/testflight-tools.js +52 -0
  185. package/dist/registry/context.d.ts +7 -0
  186. package/dist/registry/context.js +37 -0
  187. package/dist/registry/index.d.ts +5 -0
  188. package/dist/registry/index.js +44 -0
  189. package/dist/registry/parse-args.d.ts +13 -0
  190. package/dist/registry/parse-args.js +74 -0
  191. package/dist/registry/registry.d.ts +24 -0
  192. package/dist/registry/registry.js +26 -0
  193. package/dist/registry/render.d.ts +3 -0
  194. package/dist/registry/render.js +29 -0
  195. package/dist/registry/types.d.ts +50 -0
  196. package/dist/registry/types.js +1 -0
  197. package/dist/templates/agent-unit.d.ts +5 -0
  198. package/dist/templates/agent-unit.js +40 -0
  199. package/dist/templates/app-unit-edit.d.ts +2 -0
  200. package/dist/templates/app-unit-edit.js +46 -0
  201. package/dist/templates/compose-edit.d.ts +2 -0
  202. package/dist/templates/compose-edit.js +156 -0
  203. package/dist/templates/nginx.js +11 -0
  204. package/dist/templates/systemd.js +6 -0
  205. package/dist/tui/components/ArgForm.d.ts +7 -0
  206. package/dist/tui/components/ArgForm.js +64 -0
  207. package/dist/tui/components/ArgForm.test.d.ts +1 -0
  208. package/dist/tui/components/ArgForm.test.js +19 -0
  209. package/dist/tui/components/KeyHint.js +5 -0
  210. package/dist/tui/hooks/use-secrets.d.ts +8 -8
  211. package/dist/tui/hooks/use-secrets.js +7 -7
  212. package/dist/tui/router.d.ts +1 -0
  213. package/dist/tui/router.js +26 -9
  214. package/dist/tui/router.test.d.ts +1 -0
  215. package/dist/tui/router.test.js +13 -0
  216. package/dist/tui/routines/components/SignalsGrid.test.js +2 -2
  217. package/dist/tui/routines/tabs/ScaffoldTab.js +1 -1
  218. package/dist/tui/tests/redaction-rerender.test.d.ts +1 -0
  219. package/dist/tui/tests/redaction-rerender.test.js +53 -0
  220. package/dist/tui/tests/scroll-flicker-proof.test.d.ts +1 -0
  221. package/dist/tui/tests/scroll-flicker-proof.test.js +145 -0
  222. package/dist/tui/types.d.ts +1 -1
  223. package/dist/tui/views/CommandPalette.d.ts +5 -0
  224. package/dist/tui/views/CommandPalette.js +90 -0
  225. package/dist/tui/views/CommandPalette.test.d.ts +1 -0
  226. package/dist/tui/views/CommandPalette.test.js +117 -0
  227. package/dist/tui/views/Dashboard.js +9 -6
  228. package/dist/tui/views/HealthView.js +9 -4
  229. package/dist/tui/views/SecretEdit.js +15 -16
  230. package/dist/tui/views/SecretEdit.test.d.ts +1 -0
  231. package/dist/tui/views/SecretEdit.test.js +82 -0
  232. package/dist/tui/views/SecretsView.js +26 -16
  233. package/package.json +8 -5
@@ -0,0 +1,268 @@
1
+ /** server-rendered html for the backup explorer. self-contained — inline
2
+ * css + vanilla js, no build step, no external assets. all routes are
3
+ * served under the /backups/ prefix by nginx. */
4
+ const SHARED_CSS = `
5
+ :root { color-scheme: dark; }
6
+ * { box-sizing: border-box; }
7
+ body { margin: 0; padding: 2rem; background: #0d1117; color: #c9d1d9;
8
+ font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
9
+ a { color: #58a6ff; text-decoration: none; }
10
+ h1 { font-size: 1.1rem; margin: 0 0 1rem; color: #e6edf3; }
11
+ button, select, input {
12
+ font: inherit; background: #161b22; color: #c9d1d9;
13
+ border: 1px solid #30363d; border-radius: 6px; padding: 0.35rem 0.6rem; }
14
+ button { cursor: pointer; }
15
+ button:hover { border-color: #58a6ff; }
16
+ .err { background: #3d1418; border: 1px solid #f85149; color: #ffa198;
17
+ padding: 0.5rem 0.8rem; border-radius: 6px; margin: 0.75rem 0; }
18
+ `;
19
+ export function renderLoginPage() {
20
+ return `<!doctype html>
21
+ <html lang="en"><head>
22
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
23
+ <title>fleet backups — login</title>
24
+ <style>${SHARED_CSS}
25
+ .box { max-width: 320px; margin: 12vh auto; }
26
+ #code { width: 100%; text-align: center; letter-spacing: 0.3em; font-size: 1.4rem; margin: 0.75rem 0; }
27
+ </style></head><body>
28
+ <div class="box">
29
+ <h1>fleet backups</h1>
30
+ <p>enter your authenticator code</p>
31
+ <input id="code" inputmode="numeric" maxlength="6" autocomplete="one-time-code" autofocus>
32
+ <button id="go" style="width:100%">unlock</button>
33
+ <div id="err" class="err" style="display:none"></div>
34
+ </div>
35
+ <script>
36
+ const code = document.getElementById('code');
37
+ const err = document.getElementById('err');
38
+ async function submit() {
39
+ err.style.display = 'none';
40
+ const res = await fetch('/backups/api/login', {
41
+ method: 'POST',
42
+ headers: { 'Content-Type': 'application/json', 'X-Fleet-Backup': '1' },
43
+ body: JSON.stringify({ code: code.value.trim() }),
44
+ });
45
+ if (res.ok) { location.href = '/backups/'; return; }
46
+ err.textContent = 'invalid code';
47
+ err.style.display = 'block';
48
+ code.value = '';
49
+ code.focus();
50
+ }
51
+ document.getElementById('go').onclick = submit;
52
+ code.addEventListener('keydown', e => { if (e.key === 'Enter') submit(); });
53
+ </script>
54
+ </body></html>`;
55
+ }
56
+ export function renderExplorerPage() {
57
+ return `<!doctype html>
58
+ <html lang="en"><head>
59
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
60
+ <title>fleet backups — explorer</title>
61
+ <style>${SHARED_CSS}
62
+ .bar { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; margin-bottom: 1rem; }
63
+ .crumbs { margin: 0.5rem 0; color: #8b949e; }
64
+ .crumbs a { margin: 0 0.15rem; }
65
+ table { border-collapse: collapse; width: 100%; max-width: 960px; }
66
+ th, td { text-align: left; padding: 0.4rem 0.7rem; border-bottom: 1px solid #21262d; }
67
+ th { color: #8b949e; font-size: 0.78rem; text-transform: uppercase; }
68
+ td.num { text-align: right; font-variant-numeric: tabular-nums; }
69
+ tr.locked td.name { color: #6e7681; }
70
+ .lock { color: #d29922; }
71
+ .acts button { padding: 0.15rem 0.45rem; font-size: 0.8rem; }
72
+ #staging { margin-top: 1.5rem; max-width: 960px; }
73
+ #staging summary { cursor: pointer; color: #8b949e; }
74
+ #viewer { margin-top: 1rem; max-width: 960px; }
75
+ #viewer pre { background: #161b22; border: 1px solid #30363d; padding: 0.8rem;
76
+ border-radius: 6px; overflow: auto; max-height: 60vh; }
77
+ .spin { color: #8b949e; }
78
+ </style></head><body>
79
+ <h1>fleet backups — explorer</h1>
80
+ <div class="bar">
81
+ <select id="app"></select>
82
+ <select id="snap"></select>
83
+ </div>
84
+ <div id="crumbs" class="crumbs"></div>
85
+ <div id="err" class="err" style="display:none"></div>
86
+ <div id="tree"><span class="spin">loading…</span></div>
87
+ <div id="viewer"></div>
88
+ <details id="staging"><summary>staging restores</summary><div id="stagingBody"></div></details>
89
+ <script>
90
+ const API = '/backups/api/';
91
+ const H = { 'X-Fleet-Backup': '1' };
92
+ const $ = id => document.getElementById(id);
93
+ const err = msg => { const e = $('err'); e.textContent = msg; e.style.display = 'block'; };
94
+ const clearErr = () => { $('err').style.display = 'none'; };
95
+ let state = { app: '', snap: '', path: '/' };
96
+
97
+ async function api(path, opts) {
98
+ const res = await fetch(API + path, { headers: H, ...opts });
99
+ if (res.status === 401) { location.href = '/backups/login'; throw new Error('auth'); }
100
+ return res;
101
+ }
102
+ const fmtSize = n => n < 1024 ? n + ' B'
103
+ : n < 1048576 ? (n/1024).toFixed(1) + ' KB'
104
+ : n < 1073741824 ? (n/1048576).toFixed(1) + ' MB'
105
+ : (n/1073741824).toFixed(2) + ' GB';
106
+
107
+ async function loadApps() {
108
+ const r = await api('apps');
109
+ const data = await r.json();
110
+ const sel = $('app');
111
+ sel.innerHTML = '';
112
+ for (const a of data.apps) {
113
+ const o = document.createElement('option');
114
+ o.value = a.app; o.textContent = a.app;
115
+ sel.appendChild(o);
116
+ }
117
+ const qp = new URLSearchParams(location.search).get('app');
118
+ if (qp) sel.value = qp;
119
+ state.app = sel.value;
120
+ await loadSnapshots();
121
+ }
122
+
123
+ async function loadSnapshots() {
124
+ const r = await api('snapshots?app=' + encodeURIComponent(state.app));
125
+ const data = await r.json();
126
+ const sel = $('snap');
127
+ sel.innerHTML = '';
128
+ for (const s of data.snapshots) {
129
+ const o = document.createElement('option');
130
+ o.value = s.shortId;
131
+ o.textContent = s.shortId + ' ' + (s.time || '').slice(0, 19) + ' ' + (s.tags || []).join(',');
132
+ sel.appendChild(o);
133
+ }
134
+ state.snap = sel.value || '';
135
+ state.path = '/';
136
+ await loadTree();
137
+ }
138
+
139
+ function renderCrumbs() {
140
+ const c = $('crumbs');
141
+ c.innerHTML = '';
142
+ const parts = state.path.split('/').filter(Boolean);
143
+ const root = document.createElement('a');
144
+ root.textContent = '/'; root.href = '#';
145
+ root.onclick = e => { e.preventDefault(); state.path = '/'; loadTree(); };
146
+ c.appendChild(root);
147
+ let acc = '';
148
+ for (const p of parts) {
149
+ acc += '/' + p;
150
+ const seg = acc;
151
+ const a = document.createElement('a');
152
+ a.textContent = p; a.href = '#';
153
+ a.onclick = e => { e.preventDefault(); state.path = seg; loadTree(); };
154
+ c.appendChild(document.createTextNode(' / '));
155
+ c.appendChild(a);
156
+ }
157
+ }
158
+
159
+ async function loadTree() {
160
+ clearErr();
161
+ $('viewer').innerHTML = '';
162
+ $('tree').innerHTML = '<span class="spin">loading…</span>';
163
+ renderCrumbs();
164
+ let data;
165
+ try {
166
+ const r = await api('ls?app=' + encodeURIComponent(state.app)
167
+ + '&snap=' + encodeURIComponent(state.snap)
168
+ + '&path=' + encodeURIComponent(state.path));
169
+ if (!r.ok) { err((await r.json()).error || 'ls failed'); $('tree').innerHTML=''; return; }
170
+ data = await r.json();
171
+ } catch (e) { return; }
172
+ const rows = data.entries.map(e => {
173
+ const lock = e.sensitive ? '<span class="lock" title="locked — restore only">&#128274;</span> ' : '';
174
+ const nameCell = e.type === 'dir'
175
+ ? '<a href="#" data-dir="' + encodeURIComponent(e.path) + '">' + lock + e.name + '/</a>'
176
+ : (e.sensitive ? lock + e.name
177
+ : '<a href="#" data-file="' + encodeURIComponent(e.path) + '">' + lock + e.name + '</a>');
178
+ return '<tr class="' + (e.sensitive ? 'locked' : '') + '">'
179
+ + '<td class="name">' + nameCell + '</td>'
180
+ + '<td>' + e.type + '</td>'
181
+ + '<td class="num">' + (e.type === 'file' ? fmtSize(e.size) : '') + '</td>'
182
+ + '<td>' + (e.mtime || '').slice(0, 19) + '</td>'
183
+ + '<td class="acts">'
184
+ + (e.type === 'file' && !e.sensitive
185
+ ? '<button data-dl="' + encodeURIComponent(e.path) + '">download</button> ' : '')
186
+ + '<button data-restore="' + encodeURIComponent(e.path) + '">restore</button>'
187
+ + '</td></tr>';
188
+ }).join('');
189
+ $('tree').innerHTML = '<table><thead><tr><th>name</th><th>type</th>'
190
+ + '<th class="num">size</th><th>modified</th><th>actions</th></tr></thead><tbody>'
191
+ + rows + '</tbody></table>';
192
+ bindRows();
193
+ }
194
+
195
+ function bindRows() {
196
+ document.querySelectorAll('[data-dir]').forEach(a => a.onclick = e => {
197
+ e.preventDefault();
198
+ state.path = decodeURIComponent(a.dataset.dir);
199
+ loadTree();
200
+ });
201
+ document.querySelectorAll('[data-file]').forEach(a => a.onclick = e => {
202
+ e.preventDefault();
203
+ viewFile(decodeURIComponent(a.dataset.file));
204
+ });
205
+ document.querySelectorAll('[data-dl]').forEach(b => b.onclick = () => {
206
+ const p = decodeURIComponent(b.dataset.dl);
207
+ location.href = API + 'file?app=' + encodeURIComponent(state.app)
208
+ + '&snap=' + encodeURIComponent(state.snap)
209
+ + '&path=' + encodeURIComponent(p) + '&dl=1';
210
+ });
211
+ document.querySelectorAll('[data-restore]').forEach(b => b.onclick = () =>
212
+ doRestore(decodeURIComponent(b.dataset.restore)));
213
+ }
214
+
215
+ async function viewFile(path) {
216
+ const url = API + 'file?app=' + encodeURIComponent(state.app)
217
+ + '&snap=' + encodeURIComponent(state.snap)
218
+ + '&path=' + encodeURIComponent(path);
219
+ const r = await api(url.replace(API, ''));
220
+ if (!r.ok) { err((await r.json()).error || 'view failed'); return; }
221
+ const ct = r.headers.get('Content-Type') || '';
222
+ const v = $('viewer');
223
+ if (ct.startsWith('image/')) {
224
+ v.innerHTML = '<img src="' + url + '" style="max-width:100%">';
225
+ } else if (ct.startsWith('text/') || ct.startsWith('application/json')) {
226
+ const txt = await r.text();
227
+ v.innerHTML = '<pre></pre>';
228
+ v.querySelector('pre').textContent = txt;
229
+ } else {
230
+ v.innerHTML = '<embed src="' + url + '" style="width:100%;height:60vh">';
231
+ }
232
+ }
233
+
234
+ async function doRestore(path) {
235
+ if (!confirm('Restore to a staging dir?\\n\\nsnapshot: ' + state.snap + '\\npath: ' + path)) return;
236
+ clearErr();
237
+ const r = await api('restore', {
238
+ method: 'POST',
239
+ headers: { ...H, 'Content-Type': 'application/json' },
240
+ body: JSON.stringify({ app: state.app, snap: state.snap, path }),
241
+ });
242
+ const data = await r.json();
243
+ if (!r.ok) { err(data.error || 'restore failed'); return; }
244
+ alert('restored ' + data.fileCount + ' file(s) to:\\n' + data.target);
245
+ loadStaging();
246
+ }
247
+
248
+ async function loadStaging() {
249
+ const r = await api('staging');
250
+ const data = await r.json();
251
+ const body = $('stagingBody');
252
+ const dirs = data.staging || [];
253
+ body.innerHTML = dirs.length
254
+ ? dirs.map(d => '<div>' + d.path + ' — ' + fmtSize(d.bytes) + ' — ' + d.age
255
+ + ' <button data-del="' + encodeURIComponent(d.path) + '">delete</button></div>').join('')
256
+ : '<div class="spin">none</div>';
257
+ body.querySelectorAll('[data-del]').forEach(b => b.onclick = async () => {
258
+ await api('staging?path=' + encodeURIComponent(decodeURIComponent(b.dataset.del)), { method: 'DELETE' });
259
+ loadStaging();
260
+ });
261
+ }
262
+
263
+ $('app').onchange = () => { state.app = $('app').value; loadSnapshots(); };
264
+ $('snap').onchange = () => { state.snap = $('snap').value; state.path = '/'; loadTree(); };
265
+ loadApps().then(loadStaging).catch(() => {});
266
+ </script>
267
+ </body></html>`;
268
+ }
@@ -0,0 +1,7 @@
1
+ import { FleetError } from '../errors.js';
2
+ export declare class CloudflareError extends FleetError {
3
+ }
4
+ /** export every zone's DNS records + page rules. returns a json blob suitable
5
+ * for restic stdin. used by the `system` backup so we can rebuild dns from
6
+ * a single file even if cloudflare account access is lost. */
7
+ export declare function exportAllZones(): string;
@@ -0,0 +1,82 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { requireEnv } from '../env.js';
3
+ import { FleetError } from '../errors.js';
4
+ import { execSafe } from '../exec.js';
5
+ /** path to the cloudflare credentials ini. */
6
+ function cfCredIni() { return requireEnv('FLEET_CF_CRED'); }
7
+ export class CloudflareError extends FleetError {
8
+ }
9
+ function loadCreds() {
10
+ const credPath = cfCredIni();
11
+ if (!existsSync(credPath))
12
+ return {};
13
+ const lines = readFileSync(credPath, 'utf-8').split('\n');
14
+ const out = {};
15
+ for (const line of lines) {
16
+ const t = line.trim();
17
+ if (!t || t.startsWith('#') || t.startsWith('['))
18
+ continue;
19
+ const eq = t.indexOf('=');
20
+ if (eq < 1)
21
+ continue;
22
+ const k = t.slice(0, eq).trim().toLowerCase().replace(/[_-]/g, '');
23
+ const v = t.slice(eq + 1).trim();
24
+ if (k === 'dnscloudflareapitoken' || k === 'cfapitoken' || k === 'apitoken') {
25
+ out.apiToken = v;
26
+ }
27
+ else if (k === 'dnscloudflareemail' || k === 'email') {
28
+ out.email = v;
29
+ }
30
+ else if (k === 'dnscloudflareapikey' || k === 'apikey') {
31
+ out.apiKey = v;
32
+ }
33
+ }
34
+ return out;
35
+ }
36
+ function cfCurl(path, creds) {
37
+ const headers = [];
38
+ if (creds.apiToken) {
39
+ headers.push('-H', `Authorization: Bearer ${creds.apiToken}`);
40
+ }
41
+ else if (creds.email && creds.apiKey) {
42
+ headers.push('-H', `X-Auth-Email: ${creds.email}`);
43
+ headers.push('-H', `X-Auth-Key: ${creds.apiKey}`);
44
+ }
45
+ else {
46
+ return { ok: false, stdout: '', stderr: 'no cloudflare credentials found' };
47
+ }
48
+ const r = execSafe('curl', [
49
+ '-sS', '-m', '30',
50
+ ...headers,
51
+ `https://api.cloudflare.com/client/v4${path}`,
52
+ ], { timeout: 35_000 });
53
+ return r;
54
+ }
55
+ /** export every zone's DNS records + page rules. returns a json blob suitable
56
+ * for restic stdin. used by the `system` backup so we can rebuild dns from
57
+ * a single file even if cloudflare account access is lost. */
58
+ export function exportAllZones() {
59
+ const creds = loadCreds();
60
+ if (!creds.apiToken && !(creds.email && creds.apiKey)) {
61
+ throw new CloudflareError(`no cloudflare credentials at ${cfCredIni()}`);
62
+ }
63
+ const zonesResp = cfCurl('/zones?per_page=200', creds);
64
+ if (!zonesResp.ok)
65
+ throw new CloudflareError(`zone list failed: ${zonesResp.stderr}`);
66
+ const zones = JSON.parse(zonesResp.stdout).result;
67
+ const out = { exportedAt: new Date().toISOString(), zones: [] };
68
+ const zonesOut = out.zones;
69
+ for (const z of zones) {
70
+ const dns = cfCurl(`/zones/${z.id}/dns_records?per_page=1000`, creds);
71
+ const pageRules = cfCurl(`/zones/${z.id}/pagerules?per_page=200`, creds);
72
+ const settings = cfCurl(`/zones/${z.id}/settings`, creds);
73
+ zonesOut.push({
74
+ id: z.id,
75
+ name: z.name,
76
+ dnsRecords: dns.ok ? JSON.parse(dns.stdout).result : { error: dns.stderr },
77
+ pageRules: pageRules.ok ? JSON.parse(pageRules.stdout).result : { error: pageRules.stderr },
78
+ settings: settings.ok ? JSON.parse(settings.stdout).result : { error: settings.stderr },
79
+ });
80
+ }
81
+ return JSON.stringify(out, null, 2);
82
+ }
@@ -0,0 +1,9 @@
1
+ import { AppBackupConfig, Retention } from './types.js';
2
+ export declare function backupConfigDir(): string;
3
+ export declare function backupVaultDir(): string;
4
+ export declare const DEFAULT_RETENTION: Retention;
5
+ export declare const DEFAULT_EXCLUDES: string[];
6
+ export declare function loadConfig(app: string): AppBackupConfig | null;
7
+ export declare function saveConfig(cfg: AppBackupConfig): void;
8
+ export declare function listConfiguredApps(): string[];
9
+ export declare function validateAppName(app: string): void;
@@ -0,0 +1,80 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { requireEnv } from '../env.js';
4
+ import { FleetError } from '../errors.js';
5
+ import { isPseudoApp } from './types.js';
6
+ // read at call time so test env overrides land. consumers that want the
7
+ // resolved path should call backupConfigDir() / backupVaultDir().
8
+ export function backupConfigDir() {
9
+ return process.env.FLEET_BACKUP_CONFIG_DIR ?? '/etc/fleet/backups';
10
+ }
11
+ // the vault holds the age-encrypted restic passwords — no safe default,
12
+ // so an unset FLEET_BACKUP_VAULT_DIR is a hard error rather than a guess.
13
+ export function backupVaultDir() {
14
+ return requireEnv('FLEET_BACKUP_VAULT_DIR');
15
+ }
16
+ export const DEFAULT_RETENTION = {
17
+ hourly: 24,
18
+ daily: 14,
19
+ weekly: 8,
20
+ monthly: 12,
21
+ };
22
+ export const DEFAULT_EXCLUDES = [
23
+ 'node_modules',
24
+ '.next',
25
+ 'dist',
26
+ 'build',
27
+ 'target',
28
+ '__pycache__',
29
+ '.cache',
30
+ '.venv',
31
+ 'venv',
32
+ '.npm',
33
+ '.yarn',
34
+ 'coverage',
35
+ '.pytest_cache',
36
+ '*.log',
37
+ '*.pid',
38
+ '*.lock',
39
+ '.DS_Store',
40
+ 'tmp',
41
+ ];
42
+ function configPath(app) {
43
+ return join(backupConfigDir(), `${app}.json`);
44
+ }
45
+ export function loadConfig(app) {
46
+ const path = configPath(app);
47
+ if (!existsSync(path))
48
+ return null;
49
+ const raw = JSON.parse(readFileSync(path, 'utf-8'));
50
+ // basic shape check
51
+ if (!raw.app || !Array.isArray(raw.paths) || !raw.retention) {
52
+ throw new FleetError(`malformed backup config at ${path}`);
53
+ }
54
+ return raw;
55
+ }
56
+ export function saveConfig(cfg) {
57
+ const dir = backupConfigDir();
58
+ if (!existsSync(dir)) {
59
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
60
+ }
61
+ writeFileSync(configPath(cfg.app), JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
62
+ }
63
+ export function listConfiguredApps() {
64
+ const dir = backupConfigDir();
65
+ if (!existsSync(dir))
66
+ return [];
67
+ return readdirSync(dir)
68
+ .filter(f => f.endsWith('.json'))
69
+ .map(f => f.slice(0, -5))
70
+ .sort();
71
+ }
72
+ export function validateAppName(app) {
73
+ if (!app)
74
+ throw new FleetError('app name required');
75
+ if (isPseudoApp(app))
76
+ return;
77
+ if (!/^[a-z0-9_][a-z0-9._-]{0,62}$/.test(app)) {
78
+ throw new FleetError(`invalid app name: ${app}`);
79
+ }
80
+ }
@@ -0,0 +1,11 @@
1
+ import { AppBackupConfig, DumpHook, Schedule } from './types.js';
2
+ /** detect the db dump hook (if any) for a registered fleet app. matches by
3
+ * container image keyword: postgres/mysql/mongo/redis. */
4
+ export declare function detectDumpHook(appName: string): DumpHook | undefined;
5
+ /** detect named docker volumes attached to the app's containers. anonymous
6
+ * volumes (uuid names) are skipped — they're transient. */
7
+ export declare function detectVolumes(appName: string): string[];
8
+ /** decide a sensible default schedule based on whether the app has a db. */
9
+ export declare function defaultScheduleFor(hasDump: boolean): Schedule;
10
+ /** build a baseline config for a registered fleet app. */
11
+ export declare function detectAppConfig(appName: string): AppBackupConfig | null;
@@ -0,0 +1,71 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { listContainers } from '../docker.js';
3
+ import { execSafe } from '../exec.js';
4
+ import { load as loadRegistry } from '../registry.js';
5
+ import { DEFAULT_RETENTION, DEFAULT_EXCLUDES } from './config.js';
6
+ /** detect the db dump hook (if any) for a registered fleet app. matches by
7
+ * container image keyword: postgres/mysql/mongo/redis. */
8
+ export function detectDumpHook(appName) {
9
+ const all = listContainers();
10
+ const candidates = all.filter(c => c.name.startsWith(appName) ||
11
+ c.name.endsWith(`-${appName}`) ||
12
+ c.name === appName);
13
+ for (const c of candidates) {
14
+ const img = c.image.toLowerCase();
15
+ if (img.includes('postgres') || img.includes('postgis')) {
16
+ return { type: 'postgres', container: c.name };
17
+ }
18
+ if (img.startsWith('mysql') || img.includes('mariadb')) {
19
+ return { type: 'mysql', container: c.name };
20
+ }
21
+ if (img.includes('mongo')) {
22
+ return { type: 'mongo', container: c.name };
23
+ }
24
+ if (img.startsWith('redis')) {
25
+ return { type: 'redis', container: c.name };
26
+ }
27
+ }
28
+ return undefined;
29
+ }
30
+ /** detect named docker volumes attached to the app's containers. anonymous
31
+ * volumes (uuid names) are skipped — they're transient. */
32
+ export function detectVolumes(appName) {
33
+ const r = execSafe('docker', ['ps', '-q', '--filter', `name=${appName}`], { timeout: 5_000 });
34
+ if (!r.ok)
35
+ return [];
36
+ const vols = new Set();
37
+ for (const cid of r.stdout.split('\n').filter(Boolean)) {
38
+ const v = execSafe('docker', ['inspect', cid, '--format', '{{range .Mounts}}{{if eq .Type "volume"}}{{.Name}}|{{end}}{{end}}'], { timeout: 5_000 });
39
+ for (const name of v.stdout.split('|').filter(Boolean)) {
40
+ // skip anonymous (uuid-looking)
41
+ if (!/^[0-9a-f]{60,}$/i.test(name)) {
42
+ vols.add(name);
43
+ }
44
+ }
45
+ }
46
+ return [...vols].sort();
47
+ }
48
+ /** decide a sensible default schedule based on whether the app has a db. */
49
+ export function defaultScheduleFor(hasDump) {
50
+ return hasDump ? 'hourly' : 'daily';
51
+ }
52
+ /** build a baseline config for a registered fleet app. */
53
+ export function detectAppConfig(appName) {
54
+ const reg = loadRegistry();
55
+ const app = reg.apps.find(a => a.name === appName);
56
+ if (!app)
57
+ return null;
58
+ const composeDir = app.composePath;
59
+ const paths = existsSync(composeDir) ? [composeDir] : [];
60
+ const dump = detectDumpHook(appName);
61
+ const volumes = detectVolumes(appName);
62
+ return {
63
+ app: appName,
64
+ schedule: defaultScheduleFor(!!dump),
65
+ paths,
66
+ exclude: DEFAULT_EXCLUDES,
67
+ volumes: volumes.length > 0 ? volumes : undefined,
68
+ preDump: dump,
69
+ retention: DEFAULT_RETENTION,
70
+ };
71
+ }
@@ -0,0 +1,11 @@
1
+ import { FleetError } from '../errors.js';
2
+ import { DumpHook } from './types.js';
3
+ export declare class DumpError extends FleetError {
4
+ }
5
+ /** returns the shell command that streams a database dump to stdout.
6
+ * caller pipes the output into `restic backup --stdin` via sh -c so the
7
+ * dump bytes flow kernel-to-kernel and never enter node's spawnSync
8
+ * buffer (which has a 1mb ceiling and dies on multi-gb dumps). */
9
+ export declare function dumpStreamCommand(hook: DumpHook): string;
10
+ /** filename used inside the restic snapshot for the dump stream. */
11
+ export declare function dumpFilename(hook: DumpHook): string;
@@ -0,0 +1,82 @@
1
+ import { FleetError } from '../errors.js';
2
+ export class DumpError extends FleetError {
3
+ }
4
+ /** sh single-quote escape — wraps s in '...' with embedded quotes escaped
5
+ * as '\''. safe even if s contains $, `, \, *, spaces, or single quotes. */
6
+ function shq(s) {
7
+ return `'${s.replace(/'/g, "'\\''")}'`;
8
+ }
9
+ /** returns the shell command that streams a database dump to stdout.
10
+ * caller pipes the output into `restic backup --stdin` via sh -c so the
11
+ * dump bytes flow kernel-to-kernel and never enter node's spawnSync
12
+ * buffer (which has a 1mb ceiling and dies on multi-gb dumps). */
13
+ export function dumpStreamCommand(hook) {
14
+ switch (hook.type) {
15
+ case 'postgres': {
16
+ // postgres_user is set as env in shared-postgres compose; password
17
+ // auth not needed because pg_dumpall runs as the postgres unix user
18
+ // and gets peer auth on the unix socket inside the container.
19
+ const user = hook.user ? shq(hook.user) : `"$${hook.userEnv ?? 'POSTGRES_USER'}"`;
20
+ const inner = hook.db
21
+ ? `pg_dump -U ${user} -d ${shq(hook.db)} --no-owner --no-acl --clean --if-exists`
22
+ : `pg_dumpall -U ${user} --no-role-passwords`;
23
+ return `docker exec ${hook.container} sh -c ${shq(inner)}`;
24
+ }
25
+ case 'mysql': {
26
+ const user = hook.user ?? (hook.userEnv ? `\${${hook.userEnv}}` : 'root');
27
+ const dbFlag = hook.db ? shq(hook.db) : '--all-databases';
28
+ const passwordExpr = hook.passwordFile
29
+ ? `"$(cat ${shq(hook.passwordFile)})"`
30
+ : hook.passwordEnv
31
+ ? `"\${${hook.passwordEnv}}"`
32
+ : '"${MYSQL_ROOT_PASSWORD}"';
33
+ const inner = `mysqldump -u${user} -p${passwordExpr} --single-transaction --routines --triggers ${dbFlag}`;
34
+ return `docker exec ${hook.container} sh -c ${shq(inner)}`;
35
+ }
36
+ case 'mongo': {
37
+ const user = hook.user ?? (hook.userEnv ? `\${${hook.userEnv}}` : 'root');
38
+ const dbFlag = hook.db ? `--db=${shq(hook.db)}` : '';
39
+ const passwordExpr = hook.passwordFile
40
+ ? `"$(cat ${shq(hook.passwordFile)})"`
41
+ : hook.passwordEnv
42
+ ? `"\${${hook.passwordEnv}}"`
43
+ : '"${MONGO_INITDB_ROOT_PASSWORD}"';
44
+ const inner = `mongodump --archive --quiet --username ${user} --password ${passwordExpr} --authenticationDatabase admin ${dbFlag}`.trim();
45
+ return `docker exec ${hook.container} sh -c ${shq(inner)}`;
46
+ }
47
+ case 'redis': {
48
+ const portFlag = hook.port ? `-p ${hook.port}` : '';
49
+ // redis-cli --rdb writes to a tempfile, then we cat it. >/dev/null on
50
+ // the rdb step keeps redis-cli's progress chatter out of the stream.
51
+ // when a host command supplies the password, inject it via docker exec
52
+ // -e so it never lives on the redis-cli cmdline (which would show in
53
+ // ps inside the container).
54
+ if (hook.passwordHostCommand) {
55
+ const inner = `redis-cli --no-auth-warning ${portFlag} -a "$REDIS_PASSWORD" --rdb /tmp/dump.rdb >/dev/null && cat /tmp/dump.rdb`;
56
+ return `docker exec -e REDIS_PASSWORD="$(${hook.passwordHostCommand})" ${hook.container} sh -c ${shq(inner)}`;
57
+ }
58
+ const passwordExpr = hook.passwordFile
59
+ ? `"$(cat ${shq(hook.passwordFile)})"`
60
+ : hook.passwordEnv
61
+ ? `"\${${hook.passwordEnv}}"`
62
+ : '"${REDIS_PASSWORD:-}"';
63
+ const inner = `redis-cli --no-auth-warning ${portFlag} -a ${passwordExpr} --rdb /tmp/dump.rdb >/dev/null && cat /tmp/dump.rdb`;
64
+ return `docker exec ${hook.container} sh -c ${shq(inner)}`;
65
+ }
66
+ default:
67
+ throw new DumpError(`unsupported dump type: ${hook.type}`);
68
+ }
69
+ }
70
+ /** filename used inside the restic snapshot for the dump stream. */
71
+ export function dumpFilename(hook) {
72
+ switch (hook.type) {
73
+ case 'postgres':
74
+ return `${hook.db ?? 'all'}.pg.sql`;
75
+ case 'mysql':
76
+ return `${hook.db ?? 'all'}.mysql.sql`;
77
+ case 'mongo':
78
+ return `${hook.db ?? 'all'}.mongo.archive`;
79
+ case 'redis':
80
+ return `dump.rdb`;
81
+ }
82
+ }
@@ -0,0 +1,9 @@
1
+ export * from './types.js';
2
+ export * from './config.js';
3
+ export * from './unlock.js';
4
+ export * from './repo.js';
5
+ export * from './dump.js';
6
+ export * from './system.js';
7
+ export * from './cloudflare.js';
8
+ export * from './schedule.js';
9
+ export * from './detect.js';
@@ -0,0 +1,9 @@
1
+ export * from './types.js';
2
+ export * from './config.js';
3
+ export * from './unlock.js';
4
+ export * from './repo.js';
5
+ export * from './dump.js';
6
+ export * from './system.js';
7
+ export * from './cloudflare.js';
8
+ export * from './schedule.js';
9
+ export * from './detect.js';