@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
@@ -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
+ }
@@ -0,0 +1,9 @@
1
+ export declare const CRED_DIR = "/etc/fleet/credentials";
2
+ export declare function credentialPathFor(app: string): string;
3
+ export declare function encryptCredential(args: {
4
+ name: string;
5
+ plaintext: string;
6
+ outputPath: string;
7
+ }): void;
8
+ export declare function credentialExists(app: string): boolean;
9
+ export declare function removeCredential(app: string): void;
@@ -0,0 +1,44 @@
1
+ import { existsSync, mkdirSync, chmodSync, unlinkSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { execSafe } from './exec.js';
4
+ import { SecretsError } from './errors.js';
5
+ export const CRED_DIR = '/etc/fleet/credentials';
6
+ export function credentialPathFor(app) {
7
+ const p = join(CRED_DIR, `${app}.cred`);
8
+ if (!p.startsWith(CRED_DIR + '/')) {
9
+ throw new SecretsError(`invalid app name: ${app}`);
10
+ }
11
+ return p;
12
+ }
13
+ export function encryptCredential(args) {
14
+ const dir = dirname(args.outputPath);
15
+ if (!existsSync(dir)) {
16
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
17
+ }
18
+ const r = execSafe('systemd-creds', ['encrypt', '--name', args.name, '-', args.outputPath], { input: args.plaintext });
19
+ if (!r.ok) {
20
+ const safeStderr = args.plaintext.length > 0
21
+ ? r.stderr.split(args.plaintext).join('[redacted]')
22
+ : r.stderr;
23
+ throw new SecretsError(`systemd-creds encrypt failed: ${safeStderr}`);
24
+ }
25
+ try {
26
+ chmodSync(args.outputPath, 0o600);
27
+ }
28
+ catch (chmodErr) {
29
+ try {
30
+ unlinkSync(args.outputPath);
31
+ }
32
+ catch { /* ignore */ }
33
+ throw new SecretsError(`chmod failed for ${args.outputPath}: ${chmodErr.message}`);
34
+ }
35
+ }
36
+ export function credentialExists(app) {
37
+ return existsSync(credentialPathFor(app));
38
+ }
39
+ export function removeCredential(app) {
40
+ const p = credentialPathFor(app);
41
+ if (existsSync(p)) {
42
+ unlinkSync(p);
43
+ }
44
+ }
@@ -0,0 +1,13 @@
1
+ export interface InstallResult {
2
+ agentBinaryInstalled: boolean;
3
+ unitFileInstalled: boolean;
4
+ daemonReloaded: boolean;
5
+ templateParseable: boolean;
6
+ }
7
+ export declare function installV2(opts?: {
8
+ dryRun?: boolean;
9
+ agentSourcePath?: string;
10
+ destBinaryPath?: string;
11
+ unitFilePath?: string;
12
+ vaultPath?: string;
13
+ }): Promise<InstallResult>;
@@ -0,0 +1,76 @@
1
+ import { existsSync, readFileSync, writeFileSync, copyFileSync, chmodSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { execSafe } from './exec.js';
5
+ import { SecretsError } from './errors.js';
6
+ import { generateAgentUnit } from '../templates/agent-unit.js';
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const DEFAULT_AGENT_SOURCE = join(__dirname, '..', 'bin', 'fleet-agent.js');
9
+ const DEFAULT_BINARY_DEST = '/usr/local/bin/fleet-agent';
10
+ const DEFAULT_UNIT_PATH = '/etc/systemd/system/fleet-secrets-agent@.service';
11
+ /** vault dir resolution. mirrors secrets-v2-cleanup.ts — env override
12
+ * takes precedence so tests and bespoke deploys both work without
13
+ * hardcoding an operator-specific path into the systemd unit. */
14
+ function resolveVaultDir() {
15
+ return process.env.FLEET_VAULT_DIR ?? join(__dirname, '..', '..', 'vault');
16
+ }
17
+ export async function installV2(opts = {}) {
18
+ const sourcePath = opts.agentSourcePath ?? DEFAULT_AGENT_SOURCE;
19
+ const destPath = opts.destBinaryPath ?? DEFAULT_BINARY_DEST;
20
+ const unitPath = opts.unitFilePath ?? DEFAULT_UNIT_PATH;
21
+ const vaultPath = opts.vaultPath ?? resolveVaultDir();
22
+ const dryRun = opts.dryRun ?? false;
23
+ if (!existsSync(sourcePath)) {
24
+ throw new SecretsError(`agent binary source not found at ${sourcePath} — run 'npm run build' first`);
25
+ }
26
+ const sourceContent = readFileSync(sourcePath);
27
+ const result = {
28
+ agentBinaryInstalled: false,
29
+ unitFileInstalled: false,
30
+ daemonReloaded: false,
31
+ templateParseable: false,
32
+ };
33
+ // install binary if changed (byte-equal comparison)
34
+ let needBinaryWrite = !existsSync(destPath);
35
+ if (!needBinaryWrite) {
36
+ const existingContent = readFileSync(destPath);
37
+ needBinaryWrite = !existingContent.equals(sourceContent);
38
+ }
39
+ if (needBinaryWrite) {
40
+ if (!dryRun) {
41
+ copyFileSync(sourcePath, destPath);
42
+ chmodSync(destPath, 0o755);
43
+ }
44
+ result.agentBinaryInstalled = true;
45
+ }
46
+ // install unit file if changed (text-equal comparison)
47
+ const unitContent = generateAgentUnit(vaultPath);
48
+ let needUnitWrite = !existsSync(unitPath);
49
+ if (!needUnitWrite) {
50
+ const existing = readFileSync(unitPath, 'utf-8');
51
+ needUnitWrite = existing !== unitContent;
52
+ }
53
+ if (needUnitWrite) {
54
+ if (!dryRun) {
55
+ writeFileSync(unitPath, unitContent);
56
+ chmodSync(unitPath, 0o644);
57
+ }
58
+ result.unitFileInstalled = true;
59
+ }
60
+ // daemon-reload only if we wrote something
61
+ if ((result.agentBinaryInstalled || result.unitFileInstalled) && !dryRun) {
62
+ const r = execSafe('systemctl', ['daemon-reload']);
63
+ if (!r.ok)
64
+ throw new SecretsError(`systemctl daemon-reload failed: ${r.stderr}`);
65
+ result.daemonReloaded = true;
66
+ }
67
+ // verify template is parseable (soft check — not thrown on failure)
68
+ if (!dryRun) {
69
+ const r = execSafe('systemctl', ['cat', 'fleet-secrets-agent@verify.service']);
70
+ result.templateParseable = r.ok;
71
+ }
72
+ else {
73
+ result.templateParseable = true;
74
+ }
75
+ return result;
76
+ }
@@ -0,0 +1,10 @@
1
+ export interface Keypair {
2
+ publicKey: string;
3
+ privateKey: string;
4
+ }
5
+ export declare function reencryptForRecipient(args: {
6
+ ciphertext: string;
7
+ oldKeyPath: string;
8
+ newRecipient: string;
9
+ }): string;
10
+ export declare function generateKeypair(): Keypair;
@@ -0,0 +1,31 @@
1
+ import { execSafe } from './exec.js';
2
+ import { SecretsError } from './errors.js';
3
+ export function reencryptForRecipient(args) {
4
+ const dec = execSafe('age', ['-d', '-i', args.oldKeyPath], { input: args.ciphertext });
5
+ if (!dec.ok) {
6
+ throw new SecretsError(`decrypt failed: ${dec.stderr}`);
7
+ }
8
+ const plaintext = dec.stdout;
9
+ const enc = execSafe('age', ['-r', args.newRecipient, '--armor'], { input: plaintext });
10
+ if (!enc.ok) {
11
+ throw new SecretsError(`encrypt failed: ${enc.stderr}`);
12
+ }
13
+ return enc.stdout;
14
+ }
15
+ export function generateKeypair() {
16
+ const r = execSafe('age-keygen', []);
17
+ if (!r.ok)
18
+ throw new SecretsError(`age-keygen failed: ${r.stderr}`);
19
+ const lines = r.stdout.split('\n');
20
+ const pub = lines.find(l => l.startsWith('# public key: '))?.slice('# public key: '.length).trim();
21
+ const priv = lines.find(l => l.startsWith('AGE-SECRET-KEY-'))?.trim();
22
+ if (!pub || !priv) {
23
+ const safeOut = r.stdout
24
+ .split('\n')
25
+ .filter(l => !l.includes('AGE-SECRET-KEY-'))
26
+ .join('\n')
27
+ .slice(0, 200);
28
+ throw new SecretsError(`could not parse age-keygen output: ${safeOut}`);
29
+ }
30
+ return { publicKey: pub, privateKey: priv };
31
+ }
@@ -0,0 +1,29 @@
1
+ export interface MigrateOpts {
2
+ app: string;
3
+ noRestartApp?: boolean;
4
+ dryRun?: boolean;
5
+ }
6
+ export interface MigrateStep {
7
+ step: number;
8
+ name: string;
9
+ ok: boolean;
10
+ detail?: string;
11
+ }
12
+ export interface MigrateResult {
13
+ app: string;
14
+ snapshotDir: string | null;
15
+ steps: MigrateStep[];
16
+ rolledBack: boolean;
17
+ }
18
+ export declare function migrateAppToV2(opts: MigrateOpts): Promise<MigrateResult>;
19
+ export interface RevertOpts {
20
+ app: string;
21
+ snapshotTimestamp?: string;
22
+ }
23
+ export interface RevertResult {
24
+ app: string;
25
+ snapshotUsed: string;
26
+ steps: MigrateStep[];
27
+ ok: boolean;
28
+ }
29
+ export declare function revertAppFromV2(opts: RevertOpts): Promise<RevertResult>;