@matthesketh/fleet 1.8.0 → 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 +10 -7
  228. package/dist/tui/views/HealthView.js +14 -5
  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 +9 -6
@@ -28,7 +28,7 @@ function tryTightenPerms(envPath, app) {
28
28
  process.stderr.write(`[fleet-unseal] perm tightening skipped for ${app}: ${err}\n`);
29
29
  }
30
30
  }
31
- import { KEY_PATH, VAULT_DIR, RUNTIME_DIR, loadManifest, saveManifest, decryptApp, parseSecretsBundle, sealApp, sealDbSecrets, ageEncrypt, ageDecryptFile, getPublicKey, isInitialized, isSealed, backupVaultFile, restoreVaultFile, removeBackup, } from './secrets.js';
31
+ import { KEY_PATH, VAULT_DIR, RUNTIME_DIR, loadManifest, saveManifest, decryptApp, parseSecretsBundle, sealApp, sealDbSecrets, ageEncrypt, ageDecryptFile, getPublicKey, isInitialized, isSealed, backupVaultFile, restoreVaultFile, removeBackup, lockManifest, } from './secrets.js';
32
32
  // --- helpers ---
33
33
  function safeEqual(a, b) {
34
34
  const bufA = Buffer.from(a);
@@ -72,15 +72,24 @@ export function validateBeforeSeal(app, newContent) {
72
72
  return { added, removed, unchanged };
73
73
  }
74
74
  // --- Phase 8: Safe seal wrappers ---
75
+ //
76
+ // safeSealApp / safeSealDbSecrets are the "validate + backup + seal +
77
+ // cleanup" primitives. They do NOT take the manifest lock themselves because
78
+ // they're called from inside already-locked outer flows (setSecret,
79
+ // sealFromRuntime). External callers that need concurrency safety should go
80
+ // via setSecret / importEnvFile / importDbSecrets / sealFromRuntime instead;
81
+ // those wrap the whole flow in lockManifest().
75
82
  export function safeSealApp(app, content, sourceFile) {
76
83
  const validation = validateBeforeSeal(app, content);
77
- backupVaultFile(app);
84
+ const bak = backupVaultFile(app);
78
85
  try {
79
86
  sealApp(app, content, sourceFile);
80
- removeBackup(app);
87
+ if (bak)
88
+ removeBackup(app, bak);
81
89
  }
82
90
  catch (err) {
83
- restoreVaultFile(app);
91
+ if (bak)
92
+ restoreVaultFile(app, bak);
84
93
  throw err;
85
94
  }
86
95
  return validation;
@@ -92,18 +101,20 @@ export function safeSealDbSecrets(app, secretsMap, sourceDir) {
92
101
  const parts = filenames.map(f => `${SECRET_DELIMITER}${f}---\n${secretsMap[f]}`);
93
102
  const bundleContent = parts.join('\n');
94
103
  const validation = validateBeforeSeal(app, bundleContent);
95
- backupVaultFile(app);
104
+ const bak = backupVaultFile(app);
96
105
  try {
97
106
  sealDbSecrets(app, secretsMap, sourceDir);
98
- removeBackup(app);
107
+ if (bak)
108
+ removeBackup(app, bak);
99
109
  }
100
110
  catch (err) {
101
- restoreVaultFile(app);
111
+ if (bak)
112
+ restoreVaultFile(app, bak);
102
113
  throw err;
103
114
  }
104
115
  return validation;
105
116
  }
106
- export function setSecret(app, key, value, opts = {}) {
117
+ export async function setSecret(app, key, value, opts = {}) {
107
118
  assertAppName(app);
108
119
  assertSecretKey(key);
109
120
  // Entropy / placeholder check unless explicitly bypassed.
@@ -114,26 +125,37 @@ export function setSecret(app, key, value, opts = {}) {
114
125
  throw new SecretsError(`${entropyErr}. Pass --allow-weak to override (not recommended).`);
115
126
  }
116
127
  }
117
- const plaintext = decryptApp(app);
118
- const manifest = loadManifest();
119
- const entry = manifest.apps[app];
120
- if (entry.type !== 'env')
121
- throw new SecretsError(`Cannot set key/value on secrets-dir type for ${app}`);
122
- const lines = plaintext.split('\n');
123
- let found = false;
124
- const updated = lines.map(line => {
125
- const eqIdx = line.indexOf('=');
126
- if (eqIdx > 0 && line.substring(0, eqIdx) === key) {
127
- found = true;
128
- return `${key}=${value}`;
129
- }
130
- return line;
128
+ // Hold the manifest lock for the entire decrypt → mutate → re-seal cycle so
129
+ // a parallel CLI/cron writer can't insert a stale write between our read
130
+ // and our seal. safeSealApp does its own loadManifest/saveManifest inside —
131
+ // those reads/writes happen under our lock.
132
+ await lockManifest(() => {
133
+ const plaintext = decryptApp(app);
134
+ const manifest = loadManifest();
135
+ const entry = manifest.apps[app];
136
+ if (entry.type !== 'env')
137
+ throw new SecretsError(`Cannot set key/value on secrets-dir type for ${app}`);
138
+ const lines = plaintext.split('\n');
139
+ let found = false;
140
+ const updated = lines.map(line => {
141
+ const eqIdx = line.indexOf('=');
142
+ if (eqIdx > 0 && line.substring(0, eqIdx) === key) {
143
+ found = true;
144
+ return `${key}=${value}`;
145
+ }
146
+ return line;
147
+ });
148
+ if (!found)
149
+ updated.push(`${key}=${value}`);
150
+ safeSealApp(app, updated.join('\n'), entry.sourceFile);
151
+ auditLog({ op: 'set', app, secret: key, ok: true });
131
152
  });
132
- if (!found)
133
- updated.push(`${key}=${value}`);
134
- safeSealApp(app, updated.join('\n'), entry.sourceFile);
135
- auditLog({ op: 'set', app, secret: key, ok: true });
136
153
  }
154
+ /** read a single secret value. deliberately does NOT hold the manifest
155
+ * lock — a concurrent setSecret/seal does an atomic file rename, so the
156
+ * worst-case race here is reading the immediate-pre-rotation vault blob.
157
+ * audit logging happens at the command layer so programmatic reads
158
+ * (background sync, validation) don't pollute the audit trail. */
137
159
  export function getSecret(app, key) {
138
160
  const plaintext = decryptApp(app);
139
161
  const manifest = loadManifest();
@@ -154,26 +176,30 @@ export function getSecret(app, key) {
154
176
  // human-driven reads (set/get/import/export). Programmatic reads done by
155
177
  // other fleet operations (sealing, validation, drift) are not audited to
156
178
  // avoid log noise.
157
- export function importEnvFile(app, path) {
179
+ export async function importEnvFile(app, path) {
158
180
  if (!existsSync(path))
159
181
  throw new SecretsError(`File not found: ${path}`);
160
182
  const content = readFileSync(path, 'utf-8');
161
- // importEnvFile is an explicit replace — bypass validation, but still backup
162
- backupVaultFile(app);
163
- try {
164
- sealApp(app, content, path);
165
- removeBackup(app);
166
- }
167
- catch (err) {
168
- restoreVaultFile(app);
169
- auditLog({ op: 'import', app, ok: false, details: `${path}: ${err}` });
170
- throw err;
171
- }
172
- const manifest = loadManifest();
173
- auditLog({ op: 'import', app, ok: true, details: `${path}: ${manifest.apps[app].keyCount} keys` });
174
- return manifest.apps[app].keyCount;
183
+ return await lockManifest(() => {
184
+ // importEnvFile is an explicit replace — bypass validation, but still backup
185
+ const bak = backupVaultFile(app);
186
+ try {
187
+ sealApp(app, content, path);
188
+ if (bak)
189
+ removeBackup(app, bak);
190
+ }
191
+ catch (err) {
192
+ if (bak)
193
+ restoreVaultFile(app, bak);
194
+ auditLog({ op: 'import', app, ok: false, details: `${path}: ${err}` });
195
+ throw err;
196
+ }
197
+ const manifest = loadManifest();
198
+ auditLog({ op: 'import', app, ok: true, details: `${path}: ${manifest.apps[app].keyCount} keys` });
199
+ return manifest.apps[app].keyCount;
200
+ });
175
201
  }
176
- export function importDbSecrets(app, dir) {
202
+ export async function importDbSecrets(app, dir) {
177
203
  if (!existsSync(dir))
178
204
  throw new SecretsError(`Directory not found: ${dir}`);
179
205
  const stat = statSync(dir);
@@ -184,17 +210,21 @@ export function importDbSecrets(app, dir) {
184
210
  for (const file of files) {
185
211
  secretsMap[file] = readFileSync(join(dir, file), 'utf-8');
186
212
  }
187
- // importDbSecrets is an explicit replace — bypass validation, but still backup
188
- backupVaultFile(app);
189
- try {
190
- sealDbSecrets(app, secretsMap, dir);
191
- removeBackup(app);
192
- }
193
- catch (err) {
194
- restoreVaultFile(app);
195
- throw err;
196
- }
197
- return files.length;
213
+ return await lockManifest(() => {
214
+ // importDbSecrets is an explicit replace — bypass validation, but still backup
215
+ const bak = backupVaultFile(app);
216
+ try {
217
+ sealDbSecrets(app, secretsMap, dir);
218
+ if (bak)
219
+ removeBackup(app, bak);
220
+ }
221
+ catch (err) {
222
+ if (bak)
223
+ restoreVaultFile(app, bak);
224
+ throw err;
225
+ }
226
+ return files.length;
227
+ });
198
228
  }
199
229
  export function exportApp(app) {
200
230
  auditLog({ op: 'export', app, ok: true });
@@ -322,58 +352,134 @@ export function unsealAll() {
322
352
  }
323
353
  }
324
354
  }
325
- export function sealFromRuntime(app) {
326
- const manifest = loadManifest();
327
- const apps = app ? [app] : Object.keys(manifest.apps);
328
- const sealed = [];
329
- for (const a of apps) {
330
- const entry = manifest.apps[a];
331
- if (!entry)
332
- throw new SecretsError(`No secrets found for app: ${a}`);
333
- if (entry.type === 'env') {
334
- const runtimePath = join(RUNTIME_DIR, a, '.env');
335
- if (!existsSync(runtimePath))
336
- throw new SecretsError(`Runtime file not found: ${runtimePath}`);
337
- const content = readFileSync(runtimePath, 'utf-8');
338
- safeSealApp(a, content, entry.sourceFile);
339
- }
340
- else {
341
- const runtimeDir = join(RUNTIME_DIR, a, 'secrets');
342
- if (!existsSync(runtimeDir))
343
- throw new SecretsError(`Runtime dir not found: ${runtimeDir}`);
344
- const dirFiles = readdirSync(runtimeDir);
345
- const secretsMap = {};
346
- for (const f of dirFiles) {
347
- secretsMap[f] = readFileSync(join(runtimeDir, f), 'utf-8');
355
+ export async function sealFromRuntime(app) {
356
+ return await lockManifest(() => {
357
+ const manifest = loadManifest();
358
+ const apps = app ? [app] : Object.keys(manifest.apps);
359
+ const sealed = [];
360
+ for (const a of apps) {
361
+ const entry = manifest.apps[a];
362
+ if (!entry)
363
+ throw new SecretsError(`No secrets found for app: ${a}`);
364
+ if (entry.type === 'env') {
365
+ const runtimePath = join(RUNTIME_DIR, a, '.env');
366
+ if (!existsSync(runtimePath))
367
+ throw new SecretsError(`Runtime file not found: ${runtimePath}`);
368
+ const content = readFileSync(runtimePath, 'utf-8');
369
+ safeSealApp(a, content, entry.sourceFile);
370
+ }
371
+ else {
372
+ const runtimeDir = join(RUNTIME_DIR, a, 'secrets');
373
+ if (!existsSync(runtimeDir))
374
+ throw new SecretsError(`Runtime dir not found: ${runtimeDir}`);
375
+ const dirFiles = readdirSync(runtimeDir);
376
+ const secretsMap = {};
377
+ for (const f of dirFiles) {
378
+ secretsMap[f] = readFileSync(join(runtimeDir, f), 'utf-8');
379
+ }
380
+ safeSealDbSecrets(a, secretsMap, entry.sourceFile);
348
381
  }
349
- safeSealDbSecrets(a, secretsMap, entry.sourceFile);
382
+ sealed.push(a);
350
383
  }
351
- sealed.push(a);
352
- }
353
- return sealed;
384
+ return sealed;
385
+ });
354
386
  }
355
- export function rotateKey() {
356
- const manifest = loadManifest();
357
- const oldPubkey = getPublicKey();
358
- const decrypted = {};
359
- for (const [app, entry] of Object.entries(manifest.apps)) {
360
- decrypted[app] = ageDecryptFile(join(VAULT_DIR, entry.encryptedFile));
361
- }
362
- const backupPath = KEY_PATH + '.old';
363
- copyFileSync(KEY_PATH, backupPath);
364
- const keygen = execSafe('age-keygen', ['-o', KEY_PATH]);
365
- if (!keygen.ok)
366
- throw new SecretsError(`Failed to generate new key: ${keygen.stderr}`);
367
- chmodSync(KEY_PATH, 0o600);
368
- const newPubkey = getPublicKey();
369
- for (const [app, entry] of Object.entries(manifest.apps)) {
370
- const encrypted = ageEncrypt(decrypted[app]);
371
- writeFileSync(join(VAULT_DIR, entry.encryptedFile), encrypted);
372
- entry.lastSealedAt = new Date().toISOString();
373
- }
374
- saveManifest(manifest);
375
- rmSync(backupPath, { force: true });
376
- return { oldPubkey, newPubkey, appsRotated: Object.keys(manifest.apps) };
387
+ /**
388
+ * Rotate the age private key and re-encrypt every app's vault file with it.
389
+ *
390
+ * RECOVERY PROCEDURE (manual, only if rollback itself fails):
391
+ * 1. The previous private key is preserved at `<KEY_PATH>.old` while a
392
+ * rotation is in flight. If you see that file lying around, a rotation
393
+ * crashed mid-way.
394
+ * 2. Each app's pre-rotate encrypted file is preserved as
395
+ * `vault/<app>.{env,secrets}.age.bak-rotate-<ts>` for the duration of
396
+ * the rotation. They are removed automatically on success OR after a
397
+ * successful rollback.
398
+ * 3. To restore by hand: copy `<KEY_PATH>.old` back to `<KEY_PATH>`
399
+ * (chmod 0600), then for each `*.bak-rotate-<ts>` copy it over the
400
+ * matching encrypted file. This puts the vault back into the
401
+ * pre-rotation state.
402
+ *
403
+ * On a partial-failure path inside this function, that recovery is performed
404
+ * automatically before re-throwing. The whole rotation runs under the
405
+ * manifest's inter-process lock.
406
+ */
407
+ export async function rotateKey() {
408
+ return await lockManifest(() => {
409
+ const manifest = loadManifest();
410
+ const oldPubkey = getPublicKey();
411
+ // 1. Decrypt all apps with the OLD key (still on disk at KEY_PATH).
412
+ const decrypted = {};
413
+ for (const [app, entry] of Object.entries(manifest.apps)) {
414
+ decrypted[app] = ageDecryptFile(join(VAULT_DIR, entry.encryptedFile));
415
+ }
416
+ // 2. Backup the old key so we can roll back if anything below throws.
417
+ const backupPath = KEY_PATH + '.old';
418
+ copyFileSync(KEY_PATH, backupPath);
419
+ // 3. Generate the new key in place. If keygen fails BEFORE we've mutated
420
+ // any vault file, the old key on disk is still good — clean up the
421
+ // sidecar backup and bail without touching the vault.
422
+ const keygen = execSafe('age-keygen', ['-o', KEY_PATH]);
423
+ if (!keygen.ok) {
424
+ rmSync(backupPath, { force: true });
425
+ throw new SecretsError(`Failed to generate new key: ${keygen.stderr}`);
426
+ }
427
+ chmodSync(KEY_PATH, 0o600);
428
+ const newPubkey = getPublicKey();
429
+ // 4. Snapshot every app's CURRENT (still old-key-encrypted) vault file
430
+ // BEFORE we start rewriting anything. Same rotation tag for all of them
431
+ // so a human can grep for `bak-rotate-<ts>` if they need to recover.
432
+ const rotateTag = `rotate-${Date.now()}`;
433
+ const backups = [];
434
+ try {
435
+ for (const app of Object.keys(manifest.apps)) {
436
+ const b = backupVaultFile(app, rotateTag);
437
+ if (b)
438
+ backups.push({ app, bak: b });
439
+ }
440
+ // 5. Re-encrypt each app's plaintext under the NEW key and overwrite
441
+ // its vault file. If any encryption or write throws partway through,
442
+ // we land in the catch block below.
443
+ for (const [app, entry] of Object.entries(manifest.apps)) {
444
+ const encrypted = ageEncrypt(decrypted[app]);
445
+ writeFileSync(join(VAULT_DIR, entry.encryptedFile), encrypted);
446
+ entry.lastSealedAt = new Date().toISOString();
447
+ }
448
+ saveManifest(manifest);
449
+ // 6. Success — drop the per-app rotation backups and the old-key sidecar.
450
+ for (const b of backups)
451
+ rmSync(b.bak, { force: true });
452
+ rmSync(backupPath, { force: true });
453
+ }
454
+ catch (err) {
455
+ // Rollback: put the old key back, then restore every vault file from the
456
+ // matching pre-rotate backup. If rollback itself fails we deliberately
457
+ // leak the .bak-rotate-* files and KEY_PATH.old so a human has the
458
+ // pieces needed to recover by hand (see comment at top of function).
459
+ try {
460
+ copyFileSync(backupPath, KEY_PATH);
461
+ chmodSync(KEY_PATH, 0o600);
462
+ for (const b of backups) {
463
+ const entry = manifest.apps[b.app];
464
+ if (!entry)
465
+ continue;
466
+ copyFileSync(b.bak, join(VAULT_DIR, entry.encryptedFile));
467
+ }
468
+ }
469
+ catch (rollbackErr) {
470
+ throw new SecretsError(`rotateKey failed AND rollback failed: ${err.message}; ` +
471
+ `rollback: ${rollbackErr.message}; ` +
472
+ `manual recovery needed (KEY_PATH.old + vault/*.bak-${rotateTag} files preserved)`);
473
+ }
474
+ // Rollback succeeded — vault is back where it started under the old key.
475
+ // Safe to clean up the per-app backups and the old-key sidecar.
476
+ for (const b of backups)
477
+ rmSync(b.bak, { force: true });
478
+ rmSync(backupPath, { force: true });
479
+ throw new SecretsError(`rotateKey failed (rolled back): ${err.message}`);
480
+ }
481
+ return { oldPubkey, newPubkey, appsRotated: Object.keys(manifest.apps) };
482
+ });
377
483
  }
378
484
  export function getStatus() {
379
485
  const init = isInitialized();
@@ -124,7 +124,7 @@ export const PROVIDERS = [
124
124
  matches: /^(EMAIL_SERVER_PASSWORD|GMAIL_APP_PASSWORD|SMTP_PASS|SMTP_PASSWORD)$/,
125
125
  name: 'Gmail App Password / SMTP Password',
126
126
  url: 'https://myaccount.google.com/apppasswords',
127
- instructions: '1. Sign in and create a new App Password (e.g. "macpool-2026")\n' +
127
+ instructions: '1. Sign in and create a new App Password (e.g. "poolside-2026")\n' +
128
128
  '2. Copy the 16-character value and paste below WITHOUT spaces\n' +
129
129
  '3. Revoke the old App Password from the same page',
130
130
  // Gmail app passwords are 16 lowercase alphanumeric chars. Google
@@ -193,7 +193,7 @@ export const PROVIDERS = [
193
193
  rotationFrequencyDays: 365,
194
194
  strategy: 'at-rest-key',
195
195
  },
196
- // ── Bookwhen (used by macpool) ───────────────────────────────────────────
196
+ // ── Bookwhen (used by poolside) ───────────────────────────────────────────
197
197
  {
198
198
  id: 'bookwhen-token',
199
199
  matches: /^BOOKWHEN_API_TOKEN$/,
@@ -49,4 +49,4 @@ export declare function performRotation(app: string, key: string, newValue: stri
49
49
  dryRun?: boolean;
50
50
  notes?: string;
51
51
  dataMigrated?: boolean;
52
- }): RotationResult;
52
+ }): Promise<RotationResult>;
@@ -4,7 +4,7 @@
4
4
  * gate, and rolls back on failure. Pure functions where possible — I/O isolated
5
5
  * to thin wrappers so tests can run without a real vault.
6
6
  */
7
- import { decryptApp, sealApp, loadManifest } from './secrets.js';
7
+ import { decryptApp, sealApp, loadManifest, lockManifest } from './secrets.js';
8
8
  import { snapshotApp, restoreSnapshot } from './secrets-snapshots.js';
9
9
  import { auditLog } from './secrets-audit.js';
10
10
  import { markRotated } from './secrets-metadata.js';
@@ -108,58 +108,64 @@ export function applyRotation(plaintext, key, newValue, strategy) {
108
108
  * snapshot → seal → audit. Restart + health-gate are caller's responsibility
109
109
  * (we want the engine pure-ish so it's easy to test).
110
110
  */
111
- export function performRotation(app, key, newValue, opts = {}) {
112
- const manifest = loadManifest();
113
- const entry = manifest.apps[app];
114
- if (!entry)
115
- throw new SecretsError(`No app in manifest: ${app}`);
116
- if (entry.type !== 'env') {
117
- throw new SecretsError(`Rotation only supports env-type apps, got ${entry.type}`);
118
- }
119
- const provider = classifySecret(key);
120
- const strategy = provider?.strategy ?? 'immediate';
121
- if (strategy === 'user-issued') {
122
- throw new SecretsError(`${key} is a user-issued token. Rotating yours doesn't help — invalidate per-user instead.`);
123
- }
124
- // Strict typed opt — was previously a substring match on opts.notes which
125
- // could be bypassed by any caller embedding the flag in free-text notes.
126
- if (strategy === 'at-rest-key' && !opts.dataMigrated) {
127
- throw new SecretsError(`${key} encrypts data at rest. Re-encrypt your data first, then pass --data-migrated.`);
128
- }
129
- if (opts.dryRun) {
130
- auditLog({ op: 'rotate-attempted', app, secret: key, ok: true, details: 'dry-run' });
131
- return { app, key, strategy, snapshot: '(dry-run)', rolledBack: false };
132
- }
133
- // 1. Snapshot before any change.
134
- const snapshot = snapshotApp(app);
135
- auditLog({ op: 'snapshot', app, secret: key, ok: true, details: snapshot });
136
- try {
137
- // 2. Decrypt, apply rotation, re-encrypt.
138
- const plaintext = decryptApp(app);
139
- const updated = applyRotation(plaintext, key, newValue, strategy);
140
- sealApp(app, updated, entry.sourceFile);
141
- // 3. Stamp metadata.
142
- markRotated(app, key, { strategy, notes: opts.notes });
143
- auditLog({ op: 'rotate', app, secret: key, ok: true, details: `strategy=${strategy}` });
144
- return { app, key, strategy, snapshot, rolledBack: false };
145
- }
146
- catch (err) {
147
- const reason = err instanceof Error ? err.message : String(err);
148
- // Restore from snapshot on any failure.
111
+ export async function performRotation(app, key, newValue, opts = {}) {
112
+ // Hold the manifest lock for the whole snapshot → seal → markRotated cycle
113
+ // so a concurrent CLI/cron writer can't slip a stale write between our
114
+ // seal and our metadata stamp. markRotated calls saveManifest internally;
115
+ // that write happens under our lock.
116
+ return await lockManifest(() => {
117
+ const manifest = loadManifest();
118
+ const entry = manifest.apps[app];
119
+ if (!entry)
120
+ throw new SecretsError(`No app in manifest: ${app}`);
121
+ if (entry.type !== 'env') {
122
+ throw new SecretsError(`Rotation only supports env-type apps, got ${entry.type}`);
123
+ }
124
+ const provider = classifySecret(key);
125
+ const strategy = provider?.strategy ?? 'immediate';
126
+ if (strategy === 'user-issued') {
127
+ throw new SecretsError(`${key} is a user-issued token. Rotating yours doesn't help invalidate per-user instead.`);
128
+ }
129
+ // Strict typed opt — was previously a substring match on opts.notes which
130
+ // could be bypassed by any caller embedding the flag in free-text notes.
131
+ if (strategy === 'at-rest-key' && !opts.dataMigrated) {
132
+ throw new SecretsError(`${key} encrypts data at rest. Re-encrypt your data first, then pass --data-migrated.`);
133
+ }
134
+ if (opts.dryRun) {
135
+ auditLog({ op: 'rotate-attempted', app, secret: key, ok: true, details: 'dry-run' });
136
+ return { app, key, strategy, snapshot: '(dry-run)', rolledBack: false };
137
+ }
138
+ // 1. Snapshot before any change.
139
+ const snapshot = snapshotApp(app);
140
+ auditLog({ op: 'snapshot', app, secret: key, ok: true, details: snapshot });
149
141
  try {
150
- restoreSnapshot(app);
151
- auditLog({ op: 'rollback', app, secret: key, ok: true, details: `auto: ${reason}` });
142
+ // 2. Decrypt, apply rotation, re-encrypt.
143
+ const plaintext = decryptApp(app);
144
+ const updated = applyRotation(plaintext, key, newValue, strategy);
145
+ sealApp(app, updated, entry.sourceFile);
146
+ // 3. Stamp metadata.
147
+ markRotated(app, key, { strategy, notes: opts.notes });
148
+ auditLog({ op: 'rotate', app, secret: key, ok: true, details: `strategy=${strategy}` });
149
+ return { app, key, strategy, snapshot, rolledBack: false };
152
150
  }
153
- catch (rollbackErr) {
154
- auditLog({
155
- op: 'rollback',
156
- app,
157
- secret: key,
158
- ok: false,
159
- details: `auto rollback also failed: ${rollbackErr}`,
160
- });
151
+ catch (err) {
152
+ const reason = err instanceof Error ? err.message : String(err);
153
+ // Restore from snapshot on any failure.
154
+ try {
155
+ restoreSnapshot(app);
156
+ auditLog({ op: 'rollback', app, secret: key, ok: true, details: `auto: ${reason}` });
157
+ }
158
+ catch (rollbackErr) {
159
+ auditLog({
160
+ op: 'rollback',
161
+ app,
162
+ secret: key,
163
+ ok: false,
164
+ details: `auto rollback also failed: ${rollbackErr}`,
165
+ });
166
+ }
167
+ auditLog({ op: 'rotate-failed', app, secret: key, ok: false, details: reason });
168
+ return { app, key, strategy, snapshot, rolledBack: true, reason };
161
169
  }
162
- auditLog({ op: 'rotate-failed', app, secret: key, ok: false, details: reason });
163
- return { app, key, strategy, snapshot, rolledBack: true, reason };
164
- }
170
+ });
165
171
  }
@@ -0,0 +1,19 @@
1
+ export interface CleanupOpts {
2
+ app: string;
3
+ retentionDays?: number;
4
+ dryRun?: boolean;
5
+ }
6
+ export interface CleanupResult {
7
+ app: string;
8
+ removedBak: boolean;
9
+ removedSnapshots: string[];
10
+ keptSnapshots: string[];
11
+ dryRun: boolean;
12
+ }
13
+ /**
14
+ * parse a filesystem-safe snapshot timestamp back to a Date.
15
+ * input: '2026-05-06T12-00-00-000Z' (colons and dots replaced with dashes)
16
+ * output: Date('2026-05-06T12:00:00.000Z'), or null if unparseable
17
+ */
18
+ export declare function parseSnapshotTimestamp(ts: string): Date | null;
19
+ export declare function cleanupV2Backups(opts: CleanupOpts): Promise<CleanupResult>;
@@ -0,0 +1,94 @@
1
+ import { dirname, join } from 'node:path';
2
+ import { existsSync, readdirSync, rmSync, unlinkSync } from 'node:fs';
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { findApp, load } from './registry.js';
6
+ import { listSnapshots } from './secrets-v2-snapshot.js';
7
+ import { SecretsError } from './errors.js';
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ /** resolve vault dir: env override (used in tests) or the default computed path */
10
+ function resolveVaultDir() {
11
+ return process.env.FLEET_VAULT_DIR ?? join(__dirname, '..', '..', 'vault');
12
+ }
13
+ /** read manifest directly without calling requireInit() */
14
+ function readManifest(vaultDir) {
15
+ const p = join(vaultDir, 'manifest.json');
16
+ if (!existsSync(p))
17
+ return { version: 1, apps: {} };
18
+ try {
19
+ return JSON.parse(readFileSync(p, 'utf-8'));
20
+ }
21
+ catch {
22
+ return { version: 1, apps: {} };
23
+ }
24
+ }
25
+ /**
26
+ * parse a filesystem-safe snapshot timestamp back to a Date.
27
+ * input: '2026-05-06T12-00-00-000Z' (colons and dots replaced with dashes)
28
+ * output: Date('2026-05-06T12:00:00.000Z'), or null if unparseable
29
+ */
30
+ export function parseSnapshotTimestamp(ts) {
31
+ const m = ts.match(/^(\d{4}-\d{2}-\d{2}T)(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/);
32
+ if (!m)
33
+ return null;
34
+ return new Date(`${m[1]}${m[2]}:${m[3]}:${m[4]}.${m[5]}Z`);
35
+ }
36
+ export async function cleanupV2Backups(opts) {
37
+ const { app, retentionDays = 30, dryRun = false } = opts;
38
+ const registry = load();
39
+ const appEntry = findApp(registry, app);
40
+ if (!appEntry) {
41
+ throw new SecretsError(`app '${app}' not found in fleet registry`);
42
+ }
43
+ const vaultDir = resolveVaultDir();
44
+ const manifest = readManifest(vaultDir);
45
+ const entry = manifest.apps[app];
46
+ if (!entry || entry.mode !== 'socket') {
47
+ throw new SecretsError(`app '${app}' is not in v2 mode; cleanup is for post-v2-migration apps only`);
48
+ }
49
+ const cutoff = Date.now() - retentionDays * 86_400_000;
50
+ const backupRoot = join(vaultDir, 'backups');
51
+ const snapshots = listSnapshots(backupRoot, app);
52
+ const removedSnapshots = [];
53
+ const keptSnapshots = [];
54
+ for (const snap of snapshots) {
55
+ const ts = parseSnapshotTimestamp(snap.timestamp);
56
+ if (!ts) {
57
+ // unparseable timestamp — keep rather than risk destroying unknown content
58
+ keptSnapshots.push(snap.timestamp);
59
+ continue;
60
+ }
61
+ if (ts.getTime() < cutoff) {
62
+ removedSnapshots.push(snap.timestamp);
63
+ if (!dryRun) {
64
+ rmSync(snap.dir, { recursive: true, force: true });
65
+ // best-effort: remove the parent timestamp dir if it's now empty
66
+ const parentDir = dirname(snap.dir);
67
+ try {
68
+ const remaining = readdirSync(parentDir);
69
+ if (remaining.length === 0)
70
+ rmSync(parentDir, { recursive: true, force: true });
71
+ }
72
+ catch { /* ignore */ }
73
+ }
74
+ }
75
+ else {
76
+ keptSnapshots.push(snap.timestamp);
77
+ }
78
+ }
79
+ let removedBak = false;
80
+ const bakPath = join(vaultDir, `${app}.env.age.v1.bak`);
81
+ if (existsSync(bakPath)) {
82
+ if (!dryRun) {
83
+ try {
84
+ unlinkSync(bakPath);
85
+ removedBak = true;
86
+ }
87
+ catch { /* best-effort */ }
88
+ }
89
+ else {
90
+ removedBak = true;
91
+ }
92
+ }
93
+ return { app, removedBak, removedSnapshots, keptSnapshots, dryRun };
94
+ }