@matthesketh/fleet 1.8.1 → 1.11.1

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 (230) 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/patch-systemd.d.ts +7 -1
  37. package/dist/commands/patch-systemd.js +71 -31
  38. package/dist/commands/remove.d.ts +3 -1
  39. package/dist/commands/remove.js +37 -26
  40. package/dist/commands/restart.d.ts +4 -1
  41. package/dist/commands/restart.js +17 -20
  42. package/dist/commands/rollback.d.ts +4 -1
  43. package/dist/commands/rollback.js +33 -42
  44. package/dist/commands/secrets.js +157 -9
  45. package/dist/commands/start.d.ts +4 -1
  46. package/dist/commands/start.js +17 -20
  47. package/dist/commands/status.d.ts +1 -1
  48. package/dist/commands/status.js +21 -26
  49. package/dist/commands/stop.d.ts +4 -1
  50. package/dist/commands/stop.js +17 -20
  51. package/dist/commands/testflight.d.ts +1 -0
  52. package/dist/commands/testflight.js +193 -0
  53. package/dist/commands/update.d.ts +16 -0
  54. package/dist/commands/update.js +95 -0
  55. package/dist/core/audit/cache.d.ts +4 -0
  56. package/dist/core/audit/cache.js +37 -0
  57. package/dist/core/audit/config.d.ts +5 -0
  58. package/dist/core/audit/config.js +35 -0
  59. package/dist/core/audit/greenlight.d.ts +11 -0
  60. package/dist/core/audit/greenlight.js +81 -0
  61. package/dist/core/audit/reporters/cli.d.ts +3 -0
  62. package/dist/core/audit/reporters/cli.js +68 -0
  63. package/dist/core/audit/suppress.d.ts +6 -0
  64. package/dist/core/audit/suppress.js +37 -0
  65. package/dist/core/audit/target.d.ts +5 -0
  66. package/dist/core/audit/target.js +26 -0
  67. package/dist/core/audit/types.d.ts +54 -0
  68. package/dist/core/audit/types.js +5 -0
  69. package/dist/core/backup/browser-api.d.ts +66 -0
  70. package/dist/core/backup/browser-api.js +197 -0
  71. package/dist/core/backup/browser-server.d.ts +11 -0
  72. package/dist/core/backup/browser-server.js +241 -0
  73. package/dist/core/backup/browser-ui.d.ts +5 -0
  74. package/dist/core/backup/browser-ui.js +268 -0
  75. package/dist/core/backup/cloudflare.d.ts +7 -0
  76. package/dist/core/backup/cloudflare.js +82 -0
  77. package/dist/core/backup/config.d.ts +9 -0
  78. package/dist/core/backup/config.js +80 -0
  79. package/dist/core/backup/detect.d.ts +11 -0
  80. package/dist/core/backup/detect.js +71 -0
  81. package/dist/core/backup/dump.d.ts +11 -0
  82. package/dist/core/backup/dump.js +82 -0
  83. package/dist/core/backup/index.d.ts +9 -0
  84. package/dist/core/backup/index.js +9 -0
  85. package/dist/core/backup/repo.d.ts +71 -0
  86. package/dist/core/backup/repo.js +256 -0
  87. package/dist/core/backup/schedule.d.ts +17 -0
  88. package/dist/core/backup/schedule.js +90 -0
  89. package/dist/core/backup/sensitive.d.ts +5 -0
  90. package/dist/core/backup/sensitive.js +37 -0
  91. package/dist/core/backup/status.d.ts +3 -0
  92. package/dist/core/backup/status.js +29 -0
  93. package/dist/core/backup/statuspage.d.ts +23 -0
  94. package/dist/core/backup/statuspage.js +145 -0
  95. package/dist/core/backup/system.d.ts +24 -0
  96. package/dist/core/backup/system.js +209 -0
  97. package/dist/core/backup/totp.d.ts +16 -0
  98. package/dist/core/backup/totp.js +116 -0
  99. package/dist/core/backup/types.d.ts +70 -0
  100. package/dist/core/backup/types.js +7 -0
  101. package/dist/core/backup/unlock.d.ts +19 -0
  102. package/dist/core/backup/unlock.js +69 -0
  103. package/dist/core/boot-refresh.d.ts +1 -1
  104. package/dist/core/boot-refresh.js +10 -9
  105. package/dist/core/deps/actors/pr-creator.d.ts +5 -3
  106. package/dist/core/deps/actors/pr-creator.js +71 -18
  107. package/dist/core/deps/collectors/fetch-with-timeout.d.ts +7 -0
  108. package/dist/core/deps/collectors/fetch-with-timeout.js +16 -0
  109. package/dist/core/deps/collectors/npm.js +3 -1
  110. package/dist/core/deps/collectors/vulnerability.d.ts +8 -0
  111. package/dist/core/deps/collectors/vulnerability.js +31 -2
  112. package/dist/core/deps/config.js +6 -0
  113. package/dist/core/deps/scanner.js +1 -1
  114. package/dist/core/deps/types.d.ts +8 -0
  115. package/dist/core/env.d.ts +3 -0
  116. package/dist/core/env.js +11 -0
  117. package/dist/core/exec.d.ts +1 -0
  118. package/dist/core/exec.js +4 -0
  119. package/dist/core/file-lock.d.ts +18 -0
  120. package/dist/core/file-lock.js +44 -0
  121. package/dist/core/git-onboard.js +10 -13
  122. package/dist/core/github.d.ts +3 -1
  123. package/dist/core/github.js +10 -7
  124. package/dist/core/logs-policy.d.ts +5 -0
  125. package/dist/core/logs-policy.js +20 -1
  126. package/dist/core/operator.d.ts +21 -0
  127. package/dist/core/operator.js +54 -0
  128. package/dist/core/registry.d.ts +18 -0
  129. package/dist/core/registry.js +26 -0
  130. package/dist/core/routines/schema.d.ts +11 -11
  131. package/dist/core/routines/schema.js +14 -3
  132. package/dist/core/routines/store.d.ts +8 -8
  133. package/dist/core/secrets-ops.d.ts +31 -6
  134. package/dist/core/secrets-ops.js +208 -102
  135. package/dist/core/secrets-providers.js +2 -2
  136. package/dist/core/secrets-rotation.d.ts +1 -1
  137. package/dist/core/secrets-rotation.js +58 -52
  138. package/dist/core/secrets-v2-cleanup.d.ts +19 -0
  139. package/dist/core/secrets-v2-cleanup.js +94 -0
  140. package/dist/core/secrets-v2-creds.d.ts +9 -0
  141. package/dist/core/secrets-v2-creds.js +44 -0
  142. package/dist/core/secrets-v2-install.d.ts +13 -0
  143. package/dist/core/secrets-v2-install.js +76 -0
  144. package/dist/core/secrets-v2-keypair.d.ts +10 -0
  145. package/dist/core/secrets-v2-keypair.js +31 -0
  146. package/dist/core/secrets-v2-migrate.d.ts +29 -0
  147. package/dist/core/secrets-v2-migrate.js +395 -0
  148. package/dist/core/secrets-v2-ops.d.ts +36 -0
  149. package/dist/core/secrets-v2-ops.js +184 -0
  150. package/dist/core/secrets-v2-protocol.d.ts +19 -0
  151. package/dist/core/secrets-v2-protocol.js +60 -0
  152. package/dist/core/secrets-v2-snapshot.d.ts +36 -0
  153. package/dist/core/secrets-v2-snapshot.js +115 -0
  154. package/dist/core/secrets-v2.d.ts +21 -0
  155. package/dist/core/secrets-v2.js +249 -0
  156. package/dist/core/secrets.d.ts +39 -4
  157. package/dist/core/secrets.js +91 -11
  158. package/dist/core/self-update.d.ts +32 -11
  159. package/dist/core/self-update.js +52 -14
  160. package/dist/core/testflight/asc.d.ts +12 -0
  161. package/dist/core/testflight/asc.js +101 -0
  162. package/dist/core/testflight/credentials.d.ts +3 -0
  163. package/dist/core/testflight/credentials.js +35 -0
  164. package/dist/core/testflight/resolve.d.ts +6 -0
  165. package/dist/core/testflight/resolve.js +44 -0
  166. package/dist/core/testflight/types.d.ts +13 -0
  167. package/dist/core/testflight/types.js +3 -0
  168. package/dist/core/testflight/workflow.d.ts +17 -0
  169. package/dist/core/testflight/workflow.js +65 -0
  170. package/dist/core/validate.d.ts +1 -0
  171. package/dist/core/validate.js +8 -0
  172. package/dist/index.js +0 -0
  173. package/dist/mcp/audit-tools.d.ts +2 -0
  174. package/dist/mcp/audit-tools.js +94 -0
  175. package/dist/mcp/git-tools.js +1 -1
  176. package/dist/mcp/registry-bridge.d.ts +10 -0
  177. package/dist/mcp/registry-bridge.js +65 -0
  178. package/dist/mcp/secrets-tools.js +2 -2
  179. package/dist/mcp/server.js +16 -82
  180. package/dist/mcp/testflight-tools.d.ts +2 -0
  181. package/dist/mcp/testflight-tools.js +52 -0
  182. package/dist/registry/context.d.ts +7 -0
  183. package/dist/registry/context.js +37 -0
  184. package/dist/registry/index.d.ts +5 -0
  185. package/dist/registry/index.js +44 -0
  186. package/dist/registry/parse-args.d.ts +13 -0
  187. package/dist/registry/parse-args.js +74 -0
  188. package/dist/registry/registry.d.ts +24 -0
  189. package/dist/registry/registry.js +26 -0
  190. package/dist/registry/render.d.ts +3 -0
  191. package/dist/registry/render.js +29 -0
  192. package/dist/registry/types.d.ts +50 -0
  193. package/dist/registry/types.js +1 -0
  194. package/dist/templates/agent-unit.d.ts +5 -0
  195. package/dist/templates/agent-unit.js +40 -0
  196. package/dist/templates/app-unit-edit.d.ts +2 -0
  197. package/dist/templates/app-unit-edit.js +46 -0
  198. package/dist/templates/compose-edit.d.ts +2 -0
  199. package/dist/templates/compose-edit.js +156 -0
  200. package/dist/templates/nginx.js +11 -0
  201. package/dist/templates/systemd.js +6 -0
  202. package/dist/tui/components/ArgForm.d.ts +7 -0
  203. package/dist/tui/components/ArgForm.js +64 -0
  204. package/dist/tui/components/ArgForm.test.d.ts +1 -0
  205. package/dist/tui/components/ArgForm.test.js +19 -0
  206. package/dist/tui/components/KeyHint.js +5 -0
  207. package/dist/tui/hooks/use-secrets.d.ts +8 -8
  208. package/dist/tui/hooks/use-secrets.js +7 -7
  209. package/dist/tui/router.d.ts +1 -0
  210. package/dist/tui/router.js +26 -9
  211. package/dist/tui/router.test.d.ts +1 -0
  212. package/dist/tui/router.test.js +13 -0
  213. package/dist/tui/routines/components/SignalsGrid.test.js +2 -2
  214. package/dist/tui/routines/tabs/ScaffoldTab.js +1 -1
  215. package/dist/tui/tests/redaction-rerender.test.d.ts +1 -0
  216. package/dist/tui/tests/redaction-rerender.test.js +53 -0
  217. package/dist/tui/tests/scroll-flicker-proof.test.d.ts +1 -0
  218. package/dist/tui/tests/scroll-flicker-proof.test.js +145 -0
  219. package/dist/tui/types.d.ts +1 -1
  220. package/dist/tui/views/CommandPalette.d.ts +5 -0
  221. package/dist/tui/views/CommandPalette.js +90 -0
  222. package/dist/tui/views/CommandPalette.test.d.ts +1 -0
  223. package/dist/tui/views/CommandPalette.test.js +117 -0
  224. package/dist/tui/views/Dashboard.js +9 -6
  225. package/dist/tui/views/HealthView.js +9 -4
  226. package/dist/tui/views/SecretEdit.js +15 -16
  227. package/dist/tui/views/SecretEdit.test.d.ts +1 -0
  228. package/dist/tui/views/SecretEdit.test.js +82 -0
  229. package/dist/tui/views/SecretsView.js +26 -16
  230. package/package.json +8 -5
@@ -109,15 +109,14 @@ declare const FileSchema: z.ZodObject<{
109
109
  createdAt: z.ZodOptional<z.ZodString>;
110
110
  updatedAt: z.ZodOptional<z.ZodString>;
111
111
  }, "strip", z.ZodTypeAny, {
112
- enabled: boolean;
113
112
  name: string;
113
+ enabled: boolean;
114
114
  id: string;
115
115
  notify: {
116
116
  config: Record<string, unknown>;
117
117
  kind: "email" | "stdout" | "webhook" | "slack";
118
118
  on: "always" | "failure" | "success";
119
119
  }[];
120
- description: string;
121
120
  schedule: {
122
121
  kind: "manual";
123
122
  } | {
@@ -126,6 +125,8 @@ declare const FileSchema: z.ZodObject<{
126
125
  randomizedDelaySec: number;
127
126
  persistent: boolean;
128
127
  };
128
+ tags: string[];
129
+ description: string;
129
130
  targets: string[];
130
131
  perTarget: boolean;
131
132
  task: {
@@ -149,7 +150,6 @@ declare const FileSchema: z.ZodObject<{
149
150
  tool: string;
150
151
  args: Record<string, unknown>;
151
152
  };
152
- tags: string[];
153
153
  updatedAt?: string | undefined;
154
154
  createdAt?: string | undefined;
155
155
  }, {
@@ -191,25 +191,24 @@ declare const FileSchema: z.ZodObject<{
191
191
  config?: Record<string, unknown> | undefined;
192
192
  on?: "always" | "failure" | "success" | undefined;
193
193
  }[] | undefined;
194
+ tags?: string[] | undefined;
194
195
  description?: string | undefined;
195
196
  targets?: string[] | undefined;
196
197
  perTarget?: boolean | undefined;
197
- tags?: string[] | undefined;
198
198
  createdAt?: string | undefined;
199
199
  }>, "many">;
200
200
  defaultsSeededAt: z.ZodOptional<z.ZodString>;
201
201
  }, "strip", z.ZodTypeAny, {
202
202
  version: 1;
203
203
  routines: {
204
- enabled: boolean;
205
204
  name: string;
205
+ enabled: boolean;
206
206
  id: string;
207
207
  notify: {
208
208
  config: Record<string, unknown>;
209
209
  kind: "email" | "stdout" | "webhook" | "slack";
210
210
  on: "always" | "failure" | "success";
211
211
  }[];
212
- description: string;
213
212
  schedule: {
214
213
  kind: "manual";
215
214
  } | {
@@ -218,6 +217,8 @@ declare const FileSchema: z.ZodObject<{
218
217
  randomizedDelaySec: number;
219
218
  persistent: boolean;
220
219
  };
220
+ tags: string[];
221
+ description: string;
221
222
  targets: string[];
222
223
  perTarget: boolean;
223
224
  task: {
@@ -241,7 +242,6 @@ declare const FileSchema: z.ZodObject<{
241
242
  tool: string;
242
243
  args: Record<string, unknown>;
243
244
  };
244
- tags: string[];
245
245
  updatedAt?: string | undefined;
246
246
  createdAt?: string | undefined;
247
247
  }[];
@@ -287,10 +287,10 @@ declare const FileSchema: z.ZodObject<{
287
287
  config?: Record<string, unknown> | undefined;
288
288
  on?: "always" | "failure" | "success" | undefined;
289
289
  }[] | undefined;
290
+ tags?: string[] | undefined;
290
291
  description?: string | undefined;
291
292
  targets?: string[] | undefined;
292
293
  perTarget?: boolean | undefined;
293
- tags?: string[] | undefined;
294
294
  createdAt?: string | undefined;
295
295
  }[];
296
296
  defaultsSeededAt?: string | undefined;
@@ -8,10 +8,15 @@ export declare function safeSealApp(app: string, content: string, sourceFile: st
8
8
  export declare function safeSealDbSecrets(app: string, secretsMap: Record<string, string>, sourceDir: string): SealValidation;
9
9
  export declare function setSecret(app: string, key: string, value: string, opts?: {
10
10
  allowWeak?: boolean;
11
- }): void;
11
+ }): Promise<void>;
12
+ /** read a single secret value. deliberately does NOT hold the manifest
13
+ * lock — a concurrent setSecret/seal does an atomic file rename, so the
14
+ * worst-case race here is reading the immediate-pre-rotation vault blob.
15
+ * audit logging happens at the command layer so programmatic reads
16
+ * (background sync, validation) don't pollute the audit trail. */
12
17
  export declare function getSecret(app: string, key: string): string | null;
13
- export declare function importEnvFile(app: string, path: string): number;
14
- export declare function importDbSecrets(app: string, dir: string): number;
18
+ export declare function importEnvFile(app: string, path: string): Promise<number>;
19
+ export declare function importDbSecrets(app: string, dir: string): Promise<number>;
15
20
  export declare function exportApp(app: string): string;
16
21
  export interface DriftResult {
17
22
  app: string;
@@ -22,12 +27,32 @@ export interface DriftResult {
22
27
  }
23
28
  export declare function detectDrift(app?: string): DriftResult[];
24
29
  export declare function unsealAll(): void;
25
- export declare function sealFromRuntime(app?: string): string[];
26
- export declare function rotateKey(): {
30
+ export declare function sealFromRuntime(app?: string): Promise<string[]>;
31
+ /**
32
+ * Rotate the age private key and re-encrypt every app's vault file with it.
33
+ *
34
+ * RECOVERY PROCEDURE (manual, only if rollback itself fails):
35
+ * 1. The previous private key is preserved at `<KEY_PATH>.old` while a
36
+ * rotation is in flight. If you see that file lying around, a rotation
37
+ * crashed mid-way.
38
+ * 2. Each app's pre-rotate encrypted file is preserved as
39
+ * `vault/<app>.{env,secrets}.age.bak-rotate-<ts>` for the duration of
40
+ * the rotation. They are removed automatically on success OR after a
41
+ * successful rollback.
42
+ * 3. To restore by hand: copy `<KEY_PATH>.old` back to `<KEY_PATH>`
43
+ * (chmod 0600), then for each `*.bak-rotate-<ts>` copy it over the
44
+ * matching encrypted file. This puts the vault back into the
45
+ * pre-rotation state.
46
+ *
47
+ * On a partial-failure path inside this function, that recovery is performed
48
+ * automatically before re-throwing. The whole rotation runs under the
49
+ * manifest's inter-process lock.
50
+ */
51
+ export declare function rotateKey(): Promise<{
27
52
  oldPubkey: string;
28
53
  newPubkey: string;
29
54
  appsRotated: string[];
30
- };
55
+ }>;
31
56
  export declare function getStatus(): {
32
57
  initialized: boolean;
33
58
  sealed: boolean;
@@ -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>;