@matthesketh/fleet 1.2.0 → 1.7.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 (218) hide show
  1. package/README.md +183 -251
  2. package/dist/adapters/detector/index.d.ts +8 -0
  3. package/dist/adapters/detector/index.js +54 -0
  4. package/dist/adapters/notifier/index.d.ts +2 -0
  5. package/dist/adapters/notifier/index.js +2 -0
  6. package/dist/adapters/notifier/stdout.d.ts +2 -0
  7. package/dist/adapters/notifier/stdout.js +8 -0
  8. package/dist/adapters/notifier/webhook.d.ts +9 -0
  9. package/dist/adapters/notifier/webhook.js +38 -0
  10. package/dist/adapters/runner/claude-cli.d.ts +7 -0
  11. package/dist/adapters/runner/claude-cli.js +231 -0
  12. package/dist/adapters/runner/mcp-call.d.ts +8 -0
  13. package/dist/adapters/runner/mcp-call.js +82 -0
  14. package/dist/adapters/runner/shell.d.ts +2 -0
  15. package/dist/adapters/runner/shell.js +103 -0
  16. package/dist/adapters/scheduler/systemd-timer.d.ts +17 -0
  17. package/dist/adapters/scheduler/systemd-timer.js +149 -0
  18. package/dist/adapters/signals/ci-status.d.ts +2 -0
  19. package/dist/adapters/signals/ci-status.js +79 -0
  20. package/dist/adapters/signals/container-up.d.ts +5 -0
  21. package/dist/adapters/signals/container-up.js +54 -0
  22. package/dist/adapters/signals/git-clean.d.ts +2 -0
  23. package/dist/adapters/signals/git-clean.js +55 -0
  24. package/dist/adapters/signals/index.d.ts +6 -0
  25. package/dist/adapters/signals/index.js +7 -0
  26. package/dist/adapters/types.d.ts +52 -0
  27. package/dist/adapters/types.js +1 -0
  28. package/dist/cli.js +46 -2
  29. package/dist/commands/add.js +0 -6
  30. package/dist/commands/boot-start.d.ts +1 -0
  31. package/dist/commands/boot-start.js +51 -0
  32. package/dist/commands/deploy.js +13 -0
  33. package/dist/commands/deps.js +5 -0
  34. package/dist/commands/egress.d.ts +1 -0
  35. package/dist/commands/egress.js +106 -0
  36. package/dist/commands/freeze.d.ts +4 -0
  37. package/dist/commands/freeze.js +64 -0
  38. package/dist/commands/guard.d.ts +1 -0
  39. package/dist/commands/guard.js +144 -0
  40. package/dist/commands/logs.d.ts +1 -1
  41. package/dist/commands/logs.js +237 -8
  42. package/dist/commands/patch-systemd.d.ts +1 -0
  43. package/dist/commands/patch-systemd.js +126 -0
  44. package/dist/commands/rollback.d.ts +1 -0
  45. package/dist/commands/rollback.js +58 -0
  46. package/dist/commands/routine-run.d.ts +1 -0
  47. package/dist/commands/routine-run.js +122 -0
  48. package/dist/commands/routines.d.ts +1 -0
  49. package/dist/commands/routines.js +25 -0
  50. package/dist/commands/secrets.js +449 -16
  51. package/dist/commands/status.js +7 -3
  52. package/dist/commands/watchdog.d.ts +1 -1
  53. package/dist/commands/watchdog.js +16 -40
  54. package/dist/core/boot-refresh.d.ts +57 -0
  55. package/dist/core/boot-refresh.js +116 -0
  56. package/dist/core/deps/actors/pr-creator.js +11 -9
  57. package/dist/core/deps/collectors/docker-running.js +2 -2
  58. package/dist/core/deps/collectors/github-pr.js +5 -2
  59. package/dist/core/deps/collectors/npm.js +10 -5
  60. package/dist/core/deps/collectors/vulnerability.js +10 -6
  61. package/dist/core/deps/reporters/motd.js +1 -1
  62. package/dist/core/deps/reporters/telegram.js +2 -29
  63. package/dist/core/docker.js +45 -15
  64. package/dist/core/egress.d.ts +41 -0
  65. package/dist/core/egress.js +161 -0
  66. package/dist/core/exec.d.ts +7 -1
  67. package/dist/core/exec.js +25 -17
  68. package/dist/core/git.d.ts +1 -0
  69. package/dist/core/git.js +36 -23
  70. package/dist/core/github.js +27 -8
  71. package/dist/core/health.d.ts +3 -0
  72. package/dist/core/health.js +15 -3
  73. package/dist/core/logs-multi.d.ts +73 -0
  74. package/dist/core/logs-multi.js +163 -0
  75. package/dist/core/logs-policy.d.ts +55 -0
  76. package/dist/core/logs-policy.js +148 -0
  77. package/dist/core/nginx.js +8 -4
  78. package/dist/core/notify.d.ts +15 -0
  79. package/dist/core/notify.js +55 -0
  80. package/dist/core/registry.d.ts +25 -0
  81. package/dist/core/registry.js +57 -10
  82. package/dist/core/routines/cost-queries.d.ts +24 -0
  83. package/dist/core/routines/cost-queries.js +65 -0
  84. package/dist/core/routines/db.d.ts +9 -0
  85. package/dist/core/routines/db.js +126 -0
  86. package/dist/core/routines/defaults.d.ts +2 -0
  87. package/dist/core/routines/defaults.js +72 -0
  88. package/dist/core/routines/engine.d.ts +59 -0
  89. package/dist/core/routines/engine.js +175 -0
  90. package/dist/core/routines/incidents.d.ts +13 -0
  91. package/dist/core/routines/incidents.js +35 -0
  92. package/dist/core/routines/schema.d.ts +418 -0
  93. package/dist/core/routines/schema.js +113 -0
  94. package/dist/core/routines/signals-collector.d.ts +35 -0
  95. package/dist/core/routines/signals-collector.js +114 -0
  96. package/dist/core/routines/store.d.ts +316 -0
  97. package/dist/core/routines/store.js +99 -0
  98. package/dist/core/routines/test-utils.d.ts +2 -0
  99. package/dist/core/routines/test-utils.js +13 -0
  100. package/dist/core/secrets-audit.d.ts +21 -0
  101. package/dist/core/secrets-audit.js +60 -0
  102. package/dist/core/secrets-metadata.d.ts +39 -0
  103. package/dist/core/secrets-metadata.js +82 -0
  104. package/dist/core/secrets-motd.d.ts +20 -0
  105. package/dist/core/secrets-motd.js +72 -0
  106. package/dist/core/secrets-ops.d.ts +3 -1
  107. package/dist/core/secrets-ops.js +78 -13
  108. package/dist/core/secrets-providers.d.ts +50 -0
  109. package/dist/core/secrets-providers.js +291 -0
  110. package/dist/core/secrets-rotation.d.ts +52 -0
  111. package/dist/core/secrets-rotation.js +165 -0
  112. package/dist/core/secrets-snapshots.d.ts +26 -0
  113. package/dist/core/secrets-snapshots.js +95 -0
  114. package/dist/core/secrets-validate.js +2 -1
  115. package/dist/core/secrets.d.ts +12 -1
  116. package/dist/core/secrets.js +35 -24
  117. package/dist/core/self-update.d.ts +41 -0
  118. package/dist/core/self-update.js +73 -0
  119. package/dist/core/systemd.js +29 -12
  120. package/dist/core/telegram.d.ts +6 -0
  121. package/dist/core/telegram.js +32 -0
  122. package/dist/core/validate.d.ts +7 -0
  123. package/dist/core/validate.js +42 -0
  124. package/dist/index.js +0 -4
  125. package/dist/mcp/deps-tools.js +9 -1
  126. package/dist/mcp/git-tools.js +4 -4
  127. package/dist/mcp/server.js +193 -8
  128. package/dist/templates/systemd.js +3 -3
  129. package/dist/templates/unseal.js +5 -1
  130. package/dist/tui/components/KeyHint.js +10 -0
  131. package/dist/tui/exec-bridge.js +26 -12
  132. package/dist/tui/hooks/use-fleet-data.js +5 -2
  133. package/dist/tui/hooks/use-health.js +5 -2
  134. package/dist/tui/router.js +60 -7
  135. package/dist/tui/routines/RoutinesApp.d.ts +8 -0
  136. package/dist/tui/routines/RoutinesApp.js +277 -0
  137. package/dist/tui/routines/components/AlertsPanel.d.ts +7 -0
  138. package/dist/tui/routines/components/AlertsPanel.js +22 -0
  139. package/dist/tui/routines/components/AlertsPanel.test.d.ts +1 -0
  140. package/dist/tui/routines/components/AlertsPanel.test.js +52 -0
  141. package/dist/tui/routines/components/CommandPalette.d.ts +12 -0
  142. package/dist/tui/routines/components/CommandPalette.js +21 -0
  143. package/dist/tui/routines/components/LiveRunPanel.d.ts +12 -0
  144. package/dist/tui/routines/components/LiveRunPanel.js +107 -0
  145. package/dist/tui/routines/components/RoutineForm.d.ts +8 -0
  146. package/dist/tui/routines/components/RoutineForm.js +254 -0
  147. package/dist/tui/routines/components/SignalsGrid.d.ts +13 -0
  148. package/dist/tui/routines/components/SignalsGrid.js +34 -0
  149. package/dist/tui/routines/components/SignalsGrid.test.d.ts +1 -0
  150. package/dist/tui/routines/components/SignalsGrid.test.js +43 -0
  151. package/dist/tui/routines/format.d.ts +7 -0
  152. package/dist/tui/routines/format.js +51 -0
  153. package/dist/tui/routines/hooks/use-git-fleet.d.ts +33 -0
  154. package/dist/tui/routines/hooks/use-git-fleet.js +82 -0
  155. package/dist/tui/routines/hooks/use-logs-stream.d.ts +13 -0
  156. package/dist/tui/routines/hooks/use-logs-stream.js +64 -0
  157. package/dist/tui/routines/hooks/use-ops-fleet.d.ts +20 -0
  158. package/dist/tui/routines/hooks/use-ops-fleet.js +70 -0
  159. package/dist/tui/routines/hooks/use-repo-detail.d.ts +31 -0
  160. package/dist/tui/routines/hooks/use-repo-detail.js +104 -0
  161. package/dist/tui/routines/hooks/use-security.d.ts +33 -0
  162. package/dist/tui/routines/hooks/use-security.js +110 -0
  163. package/dist/tui/routines/hooks/use-signals.d.ts +9 -0
  164. package/dist/tui/routines/hooks/use-signals.js +60 -0
  165. package/dist/tui/routines/runtime.d.ts +20 -0
  166. package/dist/tui/routines/runtime.js +40 -0
  167. package/dist/tui/routines/tabs/CostTab.d.ts +7 -0
  168. package/dist/tui/routines/tabs/CostTab.js +24 -0
  169. package/dist/tui/routines/tabs/DashboardTab.d.ts +15 -0
  170. package/dist/tui/routines/tabs/DashboardTab.js +10 -0
  171. package/dist/tui/routines/tabs/GitTab.d.ts +6 -0
  172. package/dist/tui/routines/tabs/GitTab.js +39 -0
  173. package/dist/tui/routines/tabs/LogsTab.d.ts +6 -0
  174. package/dist/tui/routines/tabs/LogsTab.js +58 -0
  175. package/dist/tui/routines/tabs/OpsTab.d.ts +6 -0
  176. package/dist/tui/routines/tabs/OpsTab.js +34 -0
  177. package/dist/tui/routines/tabs/RepoDetailView.d.ts +6 -0
  178. package/dist/tui/routines/tabs/RepoDetailView.js +12 -0
  179. package/dist/tui/routines/tabs/RoutinesTab.d.ts +10 -0
  180. package/dist/tui/routines/tabs/RoutinesTab.js +58 -0
  181. package/dist/tui/routines/tabs/ScaffoldTab.d.ts +2 -0
  182. package/dist/tui/routines/tabs/ScaffoldTab.js +127 -0
  183. package/dist/tui/routines/tabs/SecurityTab.d.ts +6 -0
  184. package/dist/tui/routines/tabs/SecurityTab.js +31 -0
  185. package/dist/tui/routines/tabs/SettingsTab.d.ts +6 -0
  186. package/dist/tui/routines/tabs/SettingsTab.js +61 -0
  187. package/dist/tui/routines/tabs/TimelineTab.d.ts +7 -0
  188. package/dist/tui/routines/tabs/TimelineTab.js +26 -0
  189. package/dist/tui/state.js +1 -1
  190. package/dist/tui/tests/keyboard-integration.test.js +3 -0
  191. package/dist/tui/tests/test-app.js +1 -1
  192. package/dist/tui/types.d.ts +2 -2
  193. package/dist/tui/views/AppDetail.js +3 -4
  194. package/dist/tui/views/HealthView.js +7 -1
  195. package/dist/tui/views/LogsView.js +24 -1
  196. package/dist/tui/views/MultiLogsView.d.ts +2 -0
  197. package/dist/tui/views/MultiLogsView.js +165 -0
  198. package/dist/tui/views/SecretEdit.js +10 -3
  199. package/dist/tui/views/SecretsView.js +6 -3
  200. package/dist/ui/prompt.d.ts +52 -0
  201. package/dist/ui/prompt.js +169 -0
  202. package/package.json +34 -21
  203. package/scripts/guard/cert-expiry-watch +109 -0
  204. package/scripts/guard/cf-audit-monitor +169 -0
  205. package/scripts/guard/cf-snapshot +124 -0
  206. package/scripts/guard/cron.d-cf-protect +11 -0
  207. package/scripts/guard/dns-drift-watch +138 -0
  208. package/scripts/guard/fleet-guard +282 -0
  209. package/scripts/guard/fleet-guard-execute +197 -0
  210. package/scripts/guard/notify +108 -0
  211. package/dist/commands/motd.d.ts +0 -1
  212. package/dist/commands/motd.js +0 -10
  213. package/dist/templates/motd.d.ts +0 -1
  214. package/dist/templates/motd.js +0 -7
  215. package/dist/tui/components/AppList.d.ts +0 -12
  216. package/dist/tui/components/AppList.js +0 -32
  217. package/dist/tui/hooks/use-keyboard.d.ts +0 -1
  218. package/dist/tui/hooks/use-keyboard.js +0 -44
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Rotation engine: takes a parsed plaintext env, applies a per-secret rotation
3
+ * (immediate, dual-mode, etc.), re-seals, restarts the service, runs a health
4
+ * gate, and rolls back on failure. Pure functions where possible — I/O isolated
5
+ * to thin wrappers so tests can run without a real vault.
6
+ */
7
+ import { decryptApp, sealApp, loadManifest } from './secrets.js';
8
+ import { snapshotApp, restoreSnapshot } from './secrets-snapshots.js';
9
+ import { auditLog } from './secrets-audit.js';
10
+ import { markRotated } from './secrets-metadata.js';
11
+ import { classifySecret } from './secrets-providers.js';
12
+ import { SecretsError } from './errors.js';
13
+ /** Mask a NEW (just-entered) secret value for confirmation display. */
14
+ export function maskNewValue(value) {
15
+ if (value.length <= 4)
16
+ return `*** (${value.length} chars)`;
17
+ if (value.length <= 12)
18
+ return `${value.slice(0, 2)}***${value.slice(-2)} (${value.length} chars)`;
19
+ return `${value.slice(0, 4)}…${value.slice(-4)} (${value.length} chars)`;
20
+ }
21
+ /** Validate against provider format if any. Returns null on pass, error string on fail. */
22
+ export function validateFormat(value, provider) {
23
+ if (!provider?.format)
24
+ return null;
25
+ if (provider.format.test(value))
26
+ return null;
27
+ return `Value does not match ${provider.name} format (${provider.format.source})`;
28
+ }
29
+ /** Reject obvious placeholders / low-entropy strings. */
30
+ export function checkEntropy(value) {
31
+ const lower = value.toLowerCase().trim();
32
+ const placeholders = [
33
+ 'todo', 'changeme', 'change-me', 'change_me', 'placeholder',
34
+ 'password', 'secret', 'changethis', 'change-this',
35
+ 'foo', 'bar', 'baz', 'test', 'example', 'xxx', 'yyy', 'zzz',
36
+ 'replace_me', 'replace-me', 'fixme',
37
+ ];
38
+ if (placeholders.includes(lower)) {
39
+ // Don't echo the rejected value — even an obvious placeholder might be
40
+ // an unintended paste of a real secret that just happened to start with
41
+ // a placeholder substring.
42
+ return `Value looks like a placeholder, not a real secret`;
43
+ }
44
+ if (value.length < 8) {
45
+ return `Value too short (${value.length} chars) — secrets should be ≥ 8 chars`;
46
+ }
47
+ if (/^(.)\1+$/.test(value)) {
48
+ return `Value is all the same character`;
49
+ }
50
+ return null;
51
+ }
52
+ export function parseEnv(plaintext) {
53
+ const lines = [];
54
+ for (const raw of plaintext.split('\n')) {
55
+ if (raw.trim() === '' || raw.trim().startsWith('#')) {
56
+ lines.push({ kind: 'raw', text: raw });
57
+ continue;
58
+ }
59
+ const eq = raw.indexOf('=');
60
+ if (eq < 0) {
61
+ lines.push({ kind: 'raw', text: raw });
62
+ continue;
63
+ }
64
+ lines.push({ kind: 'kv', key: raw.substring(0, eq), value: raw.substring(eq + 1) });
65
+ }
66
+ return lines;
67
+ }
68
+ export function serialiseEnv(lines) {
69
+ return lines
70
+ .map(l => (l.kind === 'kv' ? `${l.key}=${l.value}` : l.text))
71
+ .join('\n');
72
+ }
73
+ /**
74
+ * Apply a single secret update. For dual-mode strategies, the OLD value is
75
+ * preserved as <NAME>_PREVIOUS so the app can verify legacy tokens during
76
+ * the grace period. Returns the new env content.
77
+ */
78
+ export function applyRotation(plaintext, key, newValue, strategy) {
79
+ const lines = parseEnv(plaintext);
80
+ const idx = lines.findIndex(l => l.kind === 'kv' && l.key === key);
81
+ if (idx < 0)
82
+ throw new SecretsError(`Key not found in env: ${key}`);
83
+ const existing = lines[idx];
84
+ if (existing.kind !== 'kv')
85
+ throw new SecretsError('Internal: kv expected');
86
+ if (strategy === 'dual-mode') {
87
+ const prevKey = `${key}_PREVIOUS`;
88
+ const oldValue = existing.value;
89
+ // Replace primary with new value
90
+ lines[idx] = { kind: 'kv', key, value: newValue };
91
+ // Insert/update the _PREVIOUS line right after the primary
92
+ const prevIdx = lines.findIndex(l => l.kind === 'kv' && l.key === prevKey);
93
+ if (prevIdx >= 0) {
94
+ lines[prevIdx] = { kind: 'kv', key: prevKey, value: oldValue };
95
+ }
96
+ else {
97
+ lines.splice(idx + 1, 0, { kind: 'kv', key: prevKey, value: oldValue });
98
+ }
99
+ }
100
+ else {
101
+ lines[idx] = { kind: 'kv', key, value: newValue };
102
+ }
103
+ return serialiseEnv(lines);
104
+ }
105
+ /**
106
+ * Full rotation pipeline. Caller is expected to have already collected and
107
+ * validated the new value via the interactive prompts. This orchestrates
108
+ * snapshot → seal → audit. Restart + health-gate are caller's responsibility
109
+ * (we want the engine pure-ish so it's easy to test).
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.
149
+ try {
150
+ restoreSnapshot(app);
151
+ auditLog({ op: 'rollback', app, secret: key, ok: true, details: `auto: ${reason}` });
152
+ }
153
+ catch (rollbackErr) {
154
+ auditLog({
155
+ op: 'rollback',
156
+ app,
157
+ secret: key,
158
+ ok: false,
159
+ details: `auto rollback also failed: ${rollbackErr}`,
160
+ });
161
+ }
162
+ auditLog({ op: 'rotate-failed', app, secret: key, ok: false, details: reason });
163
+ return { app, key, strategy, snapshot, rolledBack: true, reason };
164
+ }
165
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Pre-rotation vault snapshots. Every destructive operation copies the
3
+ * current encrypted file to vault/.snapshots/<app>-<timestamp>.env.age
4
+ * BEFORE making changes. Restoration is one command.
5
+ *
6
+ * Snapshots are immutable (copy + atomic-rename pattern). Cleanup is
7
+ * manual via `fleet secrets snapshots prune` — we never auto-delete.
8
+ */
9
+ export interface Snapshot {
10
+ app: string;
11
+ timestamp: string;
12
+ path: string;
13
+ sizeBytes: number;
14
+ }
15
+ /** Pre-rotation copy. Returns the absolute path to the snapshot. */
16
+ export declare function snapshotApp(app: string): string;
17
+ /** All snapshots for an app, newest first. */
18
+ export declare function listSnapshots(app: string): Snapshot[];
19
+ /**
20
+ * Restore a snapshot. Without a timestamp, uses the newest. Replaces the live
21
+ * vault file in-place. Returns the snapshot that was used.
22
+ */
23
+ export declare function restoreSnapshot(app: string, timestamp?: string): Snapshot;
24
+ /** Delete snapshots older than `keep` (count, newest kept). Returns # deleted. */
25
+ export declare function pruneSnapshots(app: string, keep: number): number;
26
+ export declare function getSnapshotDir(): string;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Pre-rotation vault snapshots. Every destructive operation copies the
3
+ * current encrypted file to vault/.snapshots/<app>-<timestamp>.env.age
4
+ * BEFORE making changes. Restoration is one command.
5
+ *
6
+ * Snapshots are immutable (copy + atomic-rename pattern). Cleanup is
7
+ * manual via `fleet secrets snapshots prune` — we never auto-delete.
8
+ */
9
+ import { existsSync, mkdirSync, copyFileSync, readdirSync, statSync, renameSync, unlinkSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { loadManifest, VAULT_DIR } from './secrets.js';
12
+ import { SecretsError } from './errors.js';
13
+ // Computed lazily via snapshotDir() so test mocks of VAULT_DIR work cleanly.
14
+ function snapshotDir() {
15
+ return join(VAULT_DIR, '.snapshots');
16
+ }
17
+ function ensureSnapshotDir() {
18
+ if (!existsSync(snapshotDir())) {
19
+ mkdirSync(snapshotDir(), { recursive: true, mode: 0o700 });
20
+ }
21
+ }
22
+ /** Pre-rotation copy. Returns the absolute path to the snapshot. */
23
+ export function snapshotApp(app) {
24
+ ensureSnapshotDir();
25
+ const manifest = loadManifest();
26
+ const entry = manifest.apps[app];
27
+ if (!entry)
28
+ throw new SecretsError(`No app in manifest: ${app}`);
29
+ const src = join(VAULT_DIR, entry.encryptedFile);
30
+ if (!existsSync(src))
31
+ throw new SecretsError(`Vault file missing: ${entry.encryptedFile}`);
32
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
33
+ const dest = join(snapshotDir(), `${app}-${ts}.env.age`);
34
+ const tmp = dest + '.tmp';
35
+ copyFileSync(src, tmp);
36
+ renameSync(tmp, dest); // atomic
37
+ return dest;
38
+ }
39
+ /** All snapshots for an app, newest first. */
40
+ export function listSnapshots(app) {
41
+ if (!existsSync(snapshotDir()))
42
+ return [];
43
+ const prefix = `${app}-`;
44
+ return readdirSync(snapshotDir())
45
+ .filter(f => f.startsWith(prefix) && f.endsWith('.env.age'))
46
+ .map(f => {
47
+ const path = join(snapshotDir(), f);
48
+ const ts = f.substring(prefix.length, f.length - '.env.age'.length);
49
+ return {
50
+ app,
51
+ timestamp: ts,
52
+ path,
53
+ sizeBytes: statSync(path).size,
54
+ };
55
+ })
56
+ .sort((a, b) => b.timestamp.localeCompare(a.timestamp));
57
+ }
58
+ /**
59
+ * Restore a snapshot. Without a timestamp, uses the newest. Replaces the live
60
+ * vault file in-place. Returns the snapshot that was used.
61
+ */
62
+ export function restoreSnapshot(app, timestamp) {
63
+ const snaps = listSnapshots(app);
64
+ if (snaps.length === 0)
65
+ throw new SecretsError(`No snapshots for ${app}`);
66
+ const target = timestamp
67
+ ? snaps.find(s => s.timestamp === timestamp)
68
+ : snaps[0];
69
+ if (!target)
70
+ throw new SecretsError(`Snapshot not found: ${timestamp}`);
71
+ const manifest = loadManifest();
72
+ const entry = manifest.apps[app];
73
+ if (!entry)
74
+ throw new SecretsError(`No app in manifest: ${app}`);
75
+ const dest = join(VAULT_DIR, entry.encryptedFile);
76
+ // Atomic replace: copy → fsync → rename. A crash mid-restore leaves the
77
+ // original vault file intact (the tmp file is the only thing in flux).
78
+ const tmp = dest + '.restore.tmp';
79
+ copyFileSync(target.path, tmp);
80
+ renameSync(tmp, dest);
81
+ return target;
82
+ }
83
+ /** Delete snapshots older than `keep` (count, newest kept). Returns # deleted. */
84
+ export function pruneSnapshots(app, keep) {
85
+ const snaps = listSnapshots(app);
86
+ if (snaps.length <= keep)
87
+ return 0;
88
+ const drop = snaps.slice(keep);
89
+ for (const s of drop)
90
+ unlinkSync(s.path);
91
+ return drop.length;
92
+ }
93
+ export function getSnapshotDir() {
94
+ return snapshotDir();
95
+ }
@@ -43,7 +43,8 @@ export function validateApp(appName) {
43
43
  let composePath;
44
44
  let composeFile = null;
45
45
  if (appName === 'docker-databases') {
46
- composePath = '/home/matt/docker-databases';
46
+ const reg = load();
47
+ composePath = reg.infrastructure.databases.composePath;
47
48
  }
48
49
  else {
49
50
  const reg = load();
@@ -1,6 +1,12 @@
1
- export declare const VAULT_DIR = "/home/matt/fleet/vault";
1
+ export declare const VAULT_DIR: string;
2
2
  export declare const KEY_PATH = "/etc/fleet/age.key";
3
3
  export declare const RUNTIME_DIR = "/run/fleet-secrets";
4
+ export interface SecretMetadata {
5
+ lastRotated: string;
6
+ provider?: string;
7
+ strategy?: 'immediate' | 'dual-mode' | 'at-rest-key' | 'user-issued';
8
+ notes?: string;
9
+ }
4
10
  export interface ManifestEntry {
5
11
  type: 'env' | 'secrets-dir';
6
12
  encryptedFile: string;
@@ -8,6 +14,11 @@ export interface ManifestEntry {
8
14
  files?: string[];
9
15
  lastSealedAt: string;
10
16
  keyCount: number;
17
+ /** Per-secret metadata, keyed by secret name. Backwards-compatible: missing means
18
+ * lastRotated falls back to lastSealedAt and provider is auto-classified at read time. */
19
+ secrets?: Record<string, SecretMetadata>;
20
+ /** Per-app age recipient public key, used by harden --per-app to limit blast radius. */
21
+ recipient?: string;
11
22
  }
12
23
  export interface Manifest {
13
24
  version: number;
@@ -1,17 +1,17 @@
1
1
  import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, chmodSync, rmSync, copyFileSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { execSync } from 'node:child_process';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
4
  import { SecretsError, VaultNotInitializedError } from './errors.js';
5
- export const VAULT_DIR = '/home/matt/fleet/vault';
5
+ import { execSafe } from './exec.js';
6
+ import { assertAppName, assertFilePath } from './validate.js';
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ export const VAULT_DIR = join(__dirname, '..', '..', 'vault');
6
9
  export const KEY_PATH = '/etc/fleet/age.key';
7
10
  export const RUNTIME_DIR = '/run/fleet-secrets';
8
11
  const MANIFEST_PATH = join(VAULT_DIR, 'manifest.json');
9
12
  const SECRET_DELIMITER = '---SECRET:';
10
13
  export function ensureAge() {
11
- try {
12
- execSync('which age', { stdio: 'pipe' });
13
- }
14
- catch {
14
+ if (!execSafe('which', ['age']).ok) {
15
15
  throw new SecretsError('age not found. Install with: apt install age');
16
16
  }
17
17
  }
@@ -27,7 +27,10 @@ function requireInit() {
27
27
  }
28
28
  export function getPublicKey() {
29
29
  requireInit();
30
- return execSync(`age-keygen -y ${KEY_PATH}`, { encoding: 'utf-8' }).trim();
30
+ const r = execSafe('age-keygen', ['-y', KEY_PATH]);
31
+ if (!r.ok)
32
+ throw new SecretsError(`Failed to read public key: ${r.stderr}`);
33
+ return r.stdout;
31
34
  }
32
35
  export function initVault() {
33
36
  ensureAge();
@@ -37,7 +40,9 @@ export function initVault() {
37
40
  if (!existsSync(keyDir)) {
38
41
  mkdirSync(keyDir, { recursive: true, mode: 0o700 });
39
42
  }
40
- execSync(`age-keygen -o ${KEY_PATH} 2>/dev/null`);
43
+ const keygen = execSafe('age-keygen', ['-o', KEY_PATH]);
44
+ if (!keygen.ok)
45
+ throw new SecretsError(`Failed to generate key: ${keygen.stderr}`);
41
46
  chmodSync(KEY_PATH, 0o600);
42
47
  if (!existsSync(VAULT_DIR)) {
43
48
  mkdirSync(VAULT_DIR, { recursive: true });
@@ -49,7 +54,12 @@ export function loadManifest() {
49
54
  requireInit();
50
55
  if (!existsSync(MANIFEST_PATH))
51
56
  return { version: 1, apps: {} };
52
- return JSON.parse(readFileSync(MANIFEST_PATH, 'utf-8'));
57
+ try {
58
+ return JSON.parse(readFileSync(MANIFEST_PATH, 'utf-8'));
59
+ }
60
+ catch {
61
+ return { apps: {} };
62
+ }
53
63
  }
54
64
  export function saveManifest(manifest) {
55
65
  writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n');
@@ -89,27 +99,27 @@ export function removeBackup(app) {
89
99
  }
90
100
  export function ageEncrypt(plaintext) {
91
101
  const pubkey = getPublicKey();
92
- return execSync(`age -r ${pubkey} --armor`, {
93
- input: plaintext,
94
- encoding: 'utf-8',
95
- maxBuffer: 10 * 1024 * 1024,
96
- });
102
+ const r = execSafe('age', ['-r', pubkey, '--armor'], { input: plaintext });
103
+ if (!r.ok)
104
+ throw new SecretsError(`age encrypt failed: ${r.stderr}`);
105
+ return r.stdout;
97
106
  }
98
107
  export function ageDecrypt(ciphertext) {
99
- return execSync(`age -d -i ${KEY_PATH}`, {
100
- input: ciphertext,
101
- encoding: 'utf-8',
102
- maxBuffer: 10 * 1024 * 1024,
103
- });
108
+ const r = execSafe('age', ['-d', '-i', KEY_PATH], { input: ciphertext.toString() });
109
+ if (!r.ok)
110
+ throw new SecretsError(`age decrypt failed: ${r.stderr}`);
111
+ return r.stdout;
104
112
  }
105
113
  export function ageDecryptFile(filePath) {
106
- return execSync(`age -d -i ${KEY_PATH} "${filePath}"`, {
107
- encoding: 'utf-8',
108
- maxBuffer: 10 * 1024 * 1024,
109
- });
114
+ assertFilePath(filePath);
115
+ const r = execSafe('age', ['-d', '-i', KEY_PATH, filePath]);
116
+ if (!r.ok)
117
+ throw new SecretsError(`age decrypt file failed: ${r.stderr}`);
118
+ return r.stdout;
110
119
  }
111
120
  export function sealApp(app, envContent, sourceFile) {
112
121
  requireInit();
122
+ assertAppName(app);
113
123
  const encrypted = ageEncrypt(envContent);
114
124
  const encFile = `${app}.env.age`;
115
125
  writeFileSync(join(VAULT_DIR, encFile), encrypted);
@@ -126,6 +136,7 @@ export function sealApp(app, envContent, sourceFile) {
126
136
  }
127
137
  export function sealDbSecrets(app, secretsMap, sourceDir) {
128
138
  requireInit();
139
+ assertAppName(app);
129
140
  const filenames = Object.keys(secretsMap).sort();
130
141
  const parts = filenames.map(f => `${SECRET_DELIMITER}${f}---\n${secretsMap[f]}`);
131
142
  const bundle = parts.join('\n');
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Self-update check and apply for fleet itself.
3
+ *
4
+ * fleet is installed via `npm link`-style symlink from /usr/local/bin/fleet to
5
+ * /home/matt/fleet/dist/index.js. Updates are produced by:
6
+ * 1. git pull --ff-only origin develop in /home/matt/fleet
7
+ * 2. npm run build (rewrites dist/)
8
+ *
9
+ * checkForUpdate() does a non-blocking `git fetch` + compares HEAD with the
10
+ * remote. applyUpdate() runs the pull + build. Both are pure shell wrappers
11
+ * around execSafe — easy to mock in tests, easy to reason about under sudo.
12
+ */
13
+ export interface UpdateInfo {
14
+ /** True if `git rev-parse @{u}` shows commits ahead of HEAD. */
15
+ available: boolean;
16
+ /** Number of commits HEAD is behind origin. 0 if up-to-date. */
17
+ behind: number;
18
+ /** Short subject of the latest remote commit (or empty string on failure). */
19
+ latestSubject: string;
20
+ /** Branch name in the local repo. */
21
+ branch: string;
22
+ /** Why the check failed, if it did. */
23
+ error?: string;
24
+ }
25
+ export interface UpdateResult {
26
+ ok: boolean;
27
+ pulled: number;
28
+ buildOk: boolean;
29
+ output: string;
30
+ }
31
+ /**
32
+ * Non-blocking check. Does a `git fetch` (timeboxed) then compares.
33
+ * Returns a stable UpdateInfo even on failure (just `available=false`).
34
+ */
35
+ export declare function checkForUpdate(): Promise<UpdateInfo>;
36
+ /**
37
+ * Apply: git pull --ff-only + npm run build. Refuses to run if the working
38
+ * tree is dirty (would clobber uncommitted changes). Returns aggregate output
39
+ * for the toast / TUI to surface.
40
+ */
41
+ export declare function applyUpdate(): Promise<UpdateResult>;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Self-update check and apply for fleet itself.
3
+ *
4
+ * fleet is installed via `npm link`-style symlink from /usr/local/bin/fleet to
5
+ * /home/matt/fleet/dist/index.js. Updates are produced by:
6
+ * 1. git pull --ff-only origin develop in /home/matt/fleet
7
+ * 2. npm run build (rewrites dist/)
8
+ *
9
+ * checkForUpdate() does a non-blocking `git fetch` + compares HEAD with the
10
+ * remote. applyUpdate() runs the pull + build. Both are pure shell wrappers
11
+ * around execSafe — easy to mock in tests, easy to reason about under sudo.
12
+ */
13
+ import { dirname } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { execSafe } from './exec.js';
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+ // dist/core/self-update.js → repo root is two ../
18
+ const FLEET_REPO = process.env.FLEET_REPO_PATH ?? `${__dirname}/../..`;
19
+ /**
20
+ * Non-blocking check. Does a `git fetch` (timeboxed) then compares.
21
+ * Returns a stable UpdateInfo even on failure (just `available=false`).
22
+ */
23
+ export async function checkForUpdate() {
24
+ const branchR = execSafe('git', ['-C', FLEET_REPO, 'rev-parse', '--abbrev-ref', 'HEAD']);
25
+ if (!branchR.ok) {
26
+ return { available: false, behind: 0, latestSubject: '', branch: '?', error: branchR.stderr };
27
+ }
28
+ const branch = branchR.stdout;
29
+ // Fetch quietly, with a short timeout so we never block the TUI launch.
30
+ const fetchR = execSafe('git', ['-C', FLEET_REPO, 'fetch', '--quiet', 'origin', branch], { timeout: 8_000 });
31
+ if (!fetchR.ok) {
32
+ return { available: false, behind: 0, latestSubject: '', branch, error: 'fetch failed' };
33
+ }
34
+ const countR = execSafe('git', ['-C', FLEET_REPO, 'rev-list', '--count', `HEAD..origin/${branch}`]);
35
+ if (!countR.ok) {
36
+ return { available: false, behind: 0, latestSubject: '', branch, error: countR.stderr };
37
+ }
38
+ const behind = parseInt(countR.stdout, 10) || 0;
39
+ let latestSubject = '';
40
+ if (behind > 0) {
41
+ const subR = execSafe('git', ['-C', FLEET_REPO, 'log', '-1', '--pretty=%s', `origin/${branch}`]);
42
+ latestSubject = subR.ok ? subR.stdout : '';
43
+ }
44
+ return { available: behind > 0, behind, latestSubject, branch };
45
+ }
46
+ /**
47
+ * Apply: git pull --ff-only + npm run build. Refuses to run if the working
48
+ * tree is dirty (would clobber uncommitted changes). Returns aggregate output
49
+ * for the toast / TUI to surface.
50
+ */
51
+ export async function applyUpdate() {
52
+ const dirty = execSafe('git', ['-C', FLEET_REPO, 'status', '--porcelain']);
53
+ if (dirty.ok && dirty.stdout.length > 0) {
54
+ return {
55
+ ok: false, pulled: 0, buildOk: false,
56
+ output: 'Refusing to update: working tree is dirty. Commit or stash first.',
57
+ };
58
+ }
59
+ const pre = execSafe('git', ['-C', FLEET_REPO, 'rev-parse', 'HEAD']);
60
+ const pull = execSafe('git', ['-C', FLEET_REPO, 'pull', '--ff-only'], { timeout: 30_000 });
61
+ if (!pull.ok) {
62
+ return { ok: false, pulled: 0, buildOk: false, output: pull.stderr || pull.stdout };
63
+ }
64
+ const post = execSafe('git', ['-C', FLEET_REPO, 'rev-parse', 'HEAD']);
65
+ const pulled = pre.stdout !== post.stdout ? 1 : 0; // 1 = something updated
66
+ const build = execSafe('npm', ['run', 'build'], { cwd: FLEET_REPO, timeout: 120_000 });
67
+ return {
68
+ ok: pull.ok && build.ok,
69
+ pulled,
70
+ buildOk: build.ok,
71
+ output: pulled === 0 ? 'Already up to date.' : (build.ok ? 'Updated + rebuilt.' : build.stderr),
72
+ };
73
+ }
@@ -1,9 +1,10 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
- import { exec } from './exec.js';
2
+ import { execSafe } from './exec.js';
3
+ import { assertServiceName } from './validate.js';
3
4
  let _systemdAvailable = null;
4
5
  export function systemdAvailable() {
5
6
  if (_systemdAvailable === null) {
6
- const result = exec('systemctl is-system-running');
7
+ const result = execSafe('systemctl', ['is-system-running']);
7
8
  // Returns "running", "degraded", etc. when systemd is PID 1.
8
9
  // Returns "offline" when not booted with systemd.
9
10
  _systemdAvailable = result.ok || result.stdout === 'degraded';
@@ -21,7 +22,11 @@ function parseSystemctlShow(output) {
21
22
  return props;
22
23
  }
23
24
  export function getServiceStatus(serviceName) {
24
- const result = exec(`systemctl show ${serviceName}.service --property=ActiveState,UnitFileState,Description --no-pager`);
25
+ assertServiceName(serviceName);
26
+ const result = execSafe('systemctl', [
27
+ 'show', `${serviceName}.service`,
28
+ '--property=ActiveState,UnitFileState,Description', '--no-pager',
29
+ ]);
25
30
  const props = parseSystemctlShow(result.stdout);
26
31
  return {
27
32
  name: serviceName,
@@ -34,8 +39,13 @@ export function getServiceStatus(serviceName) {
34
39
  export function getMultipleServiceStatuses(serviceNames) {
35
40
  if (serviceNames.length === 0)
36
41
  return new Map();
37
- const args = serviceNames.map(n => `${n}.service`).join(' ');
38
- const result = exec(`systemctl show ${args} --property=Id,ActiveState,UnitFileState,Description --no-pager`, { timeout: 15_000 });
42
+ for (const n of serviceNames)
43
+ assertServiceName(n);
44
+ const units = serviceNames.map(n => `${n}.service`);
45
+ const result = execSafe('systemctl', [
46
+ 'show', ...units,
47
+ '--property=Id,ActiveState,UnitFileState,Description', '--no-pager',
48
+ ], { timeout: 15_000 });
39
49
  const map = new Map();
40
50
  if (!result.stdout)
41
51
  return map;
@@ -58,24 +68,29 @@ export function getMultipleServiceStatuses(serviceNames) {
58
68
  return map;
59
69
  }
60
70
  export function startService(serviceName) {
61
- return exec(`systemctl start ${serviceName}.service`, { timeout: 60_000 }).ok;
71
+ assertServiceName(serviceName);
72
+ return execSafe('systemctl', ['start', `${serviceName}.service`], { timeout: 60_000 }).ok;
62
73
  }
63
74
  export function stopService(serviceName) {
64
- return exec(`systemctl stop ${serviceName}.service`, { timeout: 60_000 }).ok;
75
+ assertServiceName(serviceName);
76
+ return execSafe('systemctl', ['stop', `${serviceName}.service`], { timeout: 60_000 }).ok;
65
77
  }
66
78
  export function restartService(serviceName) {
67
- return exec(`systemctl restart ${serviceName}.service`, { timeout: 120_000 }).ok;
79
+ assertServiceName(serviceName);
80
+ return execSafe('systemctl', ['restart', `${serviceName}.service`], { timeout: 120_000 }).ok;
68
81
  }
69
82
  export function enableService(serviceName) {
70
- return exec(`systemctl enable ${serviceName}.service`).ok;
83
+ assertServiceName(serviceName);
84
+ return execSafe('systemctl', ['enable', `${serviceName}.service`]).ok;
71
85
  }
72
86
  export function disableService(serviceName) {
73
- return exec(`systemctl disable ${serviceName}.service`).ok;
87
+ assertServiceName(serviceName);
88
+ return execSafe('systemctl', ['disable', `${serviceName}.service`]).ok;
74
89
  }
75
90
  export function installServiceFile(serviceName, content) {
76
91
  const path = `/etc/systemd/system/${serviceName}.service`;
77
92
  writeFileSync(path, content);
78
- exec('systemctl daemon-reload');
93
+ execSafe('systemctl', ['daemon-reload']);
79
94
  }
80
95
  export function readServiceFile(serviceName) {
81
96
  const path = `/etc/systemd/system/${serviceName}.service`;
@@ -84,7 +99,9 @@ export function readServiceFile(serviceName) {
84
99
  return readFileSync(path, 'utf-8');
85
100
  }
86
101
  export function discoverServices() {
87
- const result = exec('systemctl list-units --type=service --state=active --no-legend --no-pager', { timeout: 10_000 });
102
+ const result = execSafe('systemctl', [
103
+ 'list-units', '--type=service', '--state=active', '--no-legend', '--no-pager',
104
+ ], { timeout: 10_000 });
88
105
  if (!result.ok)
89
106
  return [];
90
107
  return result.stdout.split('\n')
@@ -0,0 +1,6 @@
1
+ export interface TelegramConfig {
2
+ botToken: string;
3
+ chatId: string;
4
+ }
5
+ export declare function loadTelegramConfig(): TelegramConfig | null;
6
+ export declare function sendTelegram(config: TelegramConfig, message: string): Promise<boolean>;