@matthesketh/fleet 1.1.0 → 1.6.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 (217) 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 +43 -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/logs.d.ts +1 -1
  39. package/dist/commands/logs.js +237 -8
  40. package/dist/commands/patch-systemd.d.ts +1 -0
  41. package/dist/commands/patch-systemd.js +126 -0
  42. package/dist/commands/rollback.d.ts +1 -0
  43. package/dist/commands/rollback.js +58 -0
  44. package/dist/commands/routine-run.d.ts +1 -0
  45. package/dist/commands/routine-run.js +122 -0
  46. package/dist/commands/routines.d.ts +1 -0
  47. package/dist/commands/routines.js +25 -0
  48. package/dist/commands/secrets.js +449 -16
  49. package/dist/commands/status.js +7 -3
  50. package/dist/commands/watchdog.d.ts +1 -1
  51. package/dist/commands/watchdog.js +16 -40
  52. package/dist/core/boot-refresh.d.ts +57 -0
  53. package/dist/core/boot-refresh.js +116 -0
  54. package/dist/core/deps/actors/pr-creator.js +11 -9
  55. package/dist/core/deps/collectors/docker-running.js +2 -2
  56. package/dist/core/deps/collectors/github-pr.js +5 -2
  57. package/dist/core/deps/collectors/npm.js +10 -5
  58. package/dist/core/deps/collectors/vulnerability.js +10 -6
  59. package/dist/core/deps/reporters/motd.js +1 -1
  60. package/dist/core/deps/reporters/telegram.js +2 -29
  61. package/dist/core/docker.js +45 -15
  62. package/dist/core/egress.d.ts +41 -0
  63. package/dist/core/egress.js +161 -0
  64. package/dist/core/exec.d.ts +7 -1
  65. package/dist/core/exec.js +25 -17
  66. package/dist/core/git.d.ts +1 -0
  67. package/dist/core/git.js +36 -23
  68. package/dist/core/github.js +27 -8
  69. package/dist/core/health.d.ts +3 -0
  70. package/dist/core/health.js +15 -3
  71. package/dist/core/logs-multi.d.ts +73 -0
  72. package/dist/core/logs-multi.js +163 -0
  73. package/dist/core/logs-policy.d.ts +55 -0
  74. package/dist/core/logs-policy.js +148 -0
  75. package/dist/core/nginx.js +8 -4
  76. package/dist/core/notify.d.ts +15 -0
  77. package/dist/core/notify.js +55 -0
  78. package/dist/core/registry.d.ts +25 -0
  79. package/dist/core/registry.js +57 -10
  80. package/dist/core/routines/cost-queries.d.ts +24 -0
  81. package/dist/core/routines/cost-queries.js +65 -0
  82. package/dist/core/routines/db.d.ts +9 -0
  83. package/dist/core/routines/db.js +126 -0
  84. package/dist/core/routines/defaults.d.ts +2 -0
  85. package/dist/core/routines/defaults.js +72 -0
  86. package/dist/core/routines/engine.d.ts +59 -0
  87. package/dist/core/routines/engine.js +175 -0
  88. package/dist/core/routines/incidents.d.ts +13 -0
  89. package/dist/core/routines/incidents.js +35 -0
  90. package/dist/core/routines/schema.d.ts +418 -0
  91. package/dist/core/routines/schema.js +113 -0
  92. package/dist/core/routines/signals-collector.d.ts +35 -0
  93. package/dist/core/routines/signals-collector.js +114 -0
  94. package/dist/core/routines/store.d.ts +316 -0
  95. package/dist/core/routines/store.js +99 -0
  96. package/dist/core/routines/test-utils.d.ts +2 -0
  97. package/dist/core/routines/test-utils.js +13 -0
  98. package/dist/core/secrets-audit.d.ts +21 -0
  99. package/dist/core/secrets-audit.js +60 -0
  100. package/dist/core/secrets-metadata.d.ts +39 -0
  101. package/dist/core/secrets-metadata.js +82 -0
  102. package/dist/core/secrets-motd.d.ts +20 -0
  103. package/dist/core/secrets-motd.js +72 -0
  104. package/dist/core/secrets-ops.d.ts +3 -1
  105. package/dist/core/secrets-ops.js +78 -13
  106. package/dist/core/secrets-providers.d.ts +50 -0
  107. package/dist/core/secrets-providers.js +291 -0
  108. package/dist/core/secrets-rotation.d.ts +52 -0
  109. package/dist/core/secrets-rotation.js +165 -0
  110. package/dist/core/secrets-snapshots.d.ts +26 -0
  111. package/dist/core/secrets-snapshots.js +95 -0
  112. package/dist/core/secrets-validate.js +2 -1
  113. package/dist/core/secrets.d.ts +12 -1
  114. package/dist/core/secrets.js +35 -24
  115. package/dist/core/self-update.d.ts +41 -0
  116. package/dist/core/self-update.js +73 -0
  117. package/dist/core/systemd.js +29 -12
  118. package/dist/core/telegram.d.ts +6 -0
  119. package/dist/core/telegram.js +32 -0
  120. package/dist/core/validate.d.ts +7 -0
  121. package/dist/core/validate.js +42 -0
  122. package/dist/index.js +0 -4
  123. package/dist/mcp/deps-tools.js +9 -1
  124. package/dist/mcp/git-tools.js +4 -4
  125. package/dist/mcp/server.js +193 -8
  126. package/dist/templates/systemd.js +3 -3
  127. package/dist/templates/unseal.js +5 -1
  128. package/dist/tui/components/Confirm.js +3 -4
  129. package/dist/tui/components/Header.js +37 -8
  130. package/dist/tui/components/KeyHint.js +14 -5
  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/hooks/use-terminal-size.d.ts +1 -0
  135. package/dist/tui/hooks/use-terminal-size.js +1 -0
  136. package/dist/tui/router.js +133 -8
  137. package/dist/tui/routines/RoutinesApp.d.ts +8 -0
  138. package/dist/tui/routines/RoutinesApp.js +277 -0
  139. package/dist/tui/routines/components/AlertsPanel.d.ts +7 -0
  140. package/dist/tui/routines/components/AlertsPanel.js +22 -0
  141. package/dist/tui/routines/components/AlertsPanel.test.d.ts +1 -0
  142. package/dist/tui/routines/components/AlertsPanel.test.js +52 -0
  143. package/dist/tui/routines/components/CommandPalette.d.ts +12 -0
  144. package/dist/tui/routines/components/CommandPalette.js +21 -0
  145. package/dist/tui/routines/components/LiveRunPanel.d.ts +12 -0
  146. package/dist/tui/routines/components/LiveRunPanel.js +107 -0
  147. package/dist/tui/routines/components/RoutineForm.d.ts +8 -0
  148. package/dist/tui/routines/components/RoutineForm.js +254 -0
  149. package/dist/tui/routines/components/SignalsGrid.d.ts +13 -0
  150. package/dist/tui/routines/components/SignalsGrid.js +34 -0
  151. package/dist/tui/routines/components/SignalsGrid.test.d.ts +1 -0
  152. package/dist/tui/routines/components/SignalsGrid.test.js +43 -0
  153. package/dist/tui/routines/format.d.ts +7 -0
  154. package/dist/tui/routines/format.js +51 -0
  155. package/dist/tui/routines/hooks/use-git-fleet.d.ts +33 -0
  156. package/dist/tui/routines/hooks/use-git-fleet.js +82 -0
  157. package/dist/tui/routines/hooks/use-logs-stream.d.ts +13 -0
  158. package/dist/tui/routines/hooks/use-logs-stream.js +64 -0
  159. package/dist/tui/routines/hooks/use-ops-fleet.d.ts +20 -0
  160. package/dist/tui/routines/hooks/use-ops-fleet.js +70 -0
  161. package/dist/tui/routines/hooks/use-repo-detail.d.ts +31 -0
  162. package/dist/tui/routines/hooks/use-repo-detail.js +104 -0
  163. package/dist/tui/routines/hooks/use-security.d.ts +33 -0
  164. package/dist/tui/routines/hooks/use-security.js +110 -0
  165. package/dist/tui/routines/hooks/use-signals.d.ts +9 -0
  166. package/dist/tui/routines/hooks/use-signals.js +60 -0
  167. package/dist/tui/routines/runtime.d.ts +20 -0
  168. package/dist/tui/routines/runtime.js +40 -0
  169. package/dist/tui/routines/tabs/CostTab.d.ts +7 -0
  170. package/dist/tui/routines/tabs/CostTab.js +24 -0
  171. package/dist/tui/routines/tabs/DashboardTab.d.ts +15 -0
  172. package/dist/tui/routines/tabs/DashboardTab.js +10 -0
  173. package/dist/tui/routines/tabs/GitTab.d.ts +6 -0
  174. package/dist/tui/routines/tabs/GitTab.js +39 -0
  175. package/dist/tui/routines/tabs/LogsTab.d.ts +6 -0
  176. package/dist/tui/routines/tabs/LogsTab.js +58 -0
  177. package/dist/tui/routines/tabs/OpsTab.d.ts +6 -0
  178. package/dist/tui/routines/tabs/OpsTab.js +34 -0
  179. package/dist/tui/routines/tabs/RepoDetailView.d.ts +6 -0
  180. package/dist/tui/routines/tabs/RepoDetailView.js +12 -0
  181. package/dist/tui/routines/tabs/RoutinesTab.d.ts +10 -0
  182. package/dist/tui/routines/tabs/RoutinesTab.js +58 -0
  183. package/dist/tui/routines/tabs/ScaffoldTab.d.ts +2 -0
  184. package/dist/tui/routines/tabs/ScaffoldTab.js +127 -0
  185. package/dist/tui/routines/tabs/SecurityTab.d.ts +6 -0
  186. package/dist/tui/routines/tabs/SecurityTab.js +31 -0
  187. package/dist/tui/routines/tabs/SettingsTab.d.ts +6 -0
  188. package/dist/tui/routines/tabs/SettingsTab.js +61 -0
  189. package/dist/tui/routines/tabs/TimelineTab.d.ts +7 -0
  190. package/dist/tui/routines/tabs/TimelineTab.js +26 -0
  191. package/dist/tui/state.js +16 -1
  192. package/dist/tui/tests/flicker.test.d.ts +1 -0
  193. package/dist/tui/tests/flicker.test.js +105 -0
  194. package/dist/tui/tests/keyboard-integration.test.d.ts +1 -0
  195. package/dist/tui/tests/keyboard-integration.test.js +120 -0
  196. package/dist/tui/tests/test-app.d.ts +4 -0
  197. package/dist/tui/tests/test-app.js +79 -0
  198. package/dist/tui/types.d.ts +14 -1
  199. package/dist/tui/views/AppDetail.js +40 -26
  200. package/dist/tui/views/Dashboard.js +34 -9
  201. package/dist/tui/views/HealthView.js +42 -12
  202. package/dist/tui/views/LogsView.js +38 -10
  203. package/dist/tui/views/MultiLogsView.d.ts +2 -0
  204. package/dist/tui/views/MultiLogsView.js +165 -0
  205. package/dist/tui/views/SecretEdit.js +18 -7
  206. package/dist/tui/views/SecretsView.js +55 -39
  207. package/dist/ui/prompt.d.ts +52 -0
  208. package/dist/ui/prompt.js +169 -0
  209. package/package.json +33 -5
  210. package/dist/commands/motd.d.ts +0 -1
  211. package/dist/commands/motd.js +0 -10
  212. package/dist/templates/motd.d.ts +0 -1
  213. package/dist/templates/motd.js +0 -7
  214. package/dist/tui/components/AppList.d.ts +0 -12
  215. package/dist/tui/components/AppList.js +0 -32
  216. package/dist/tui/hooks/use-keyboard.d.ts +0 -1
  217. package/dist/tui/hooks/use-keyboard.js +0 -44
@@ -1,16 +1,28 @@
1
- import { writeFileSync } from 'node:fs';
1
+ import { writeFileSync, chmodSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { execSync } from 'node:child_process';
3
+ import { execSafe } from '../core/exec.js';
4
4
  import { SecretsError } from '../core/errors.js';
5
5
  import { load, findApp } from '../core/registry.js';
6
6
  import { initVault, loadManifest, listSecrets } from '../core/secrets.js';
7
- import { setSecret, getSecret, importEnvFile, importDbSecrets, exportApp, unsealAll, sealFromRuntime, rotateKey, getStatus, detectDrift, } from '../core/secrets-ops.js';
7
+ import { enumerateSecrets, enumerateAllSecrets } from '../core/secrets-metadata.js';
8
+ import { setSecret, getSecret, importEnvFile, importDbSecrets, exportApp, sealFromRuntime, rotateKey, getStatus, detectDrift, } from '../core/secrets-ops.js';
8
9
  import { restoreVaultFile } from '../core/secrets.js';
9
10
  import { generateUnsealService } from '../templates/unseal.js';
10
11
  import { validateApp, validateAll } from '../core/secrets-validate.js';
11
12
  import { confirm } from '../ui/confirm.js';
13
+ import { prompt, promptHidden } from '../ui/prompt.js';
12
14
  import { c, heading, table, success, error, info, warn } from '../ui/output.js';
13
- const DB_SECRETS_DIR = '/home/matt/docker-databases/secrets';
15
+ import { performRotation, validateFormat, checkEntropy, maskNewValue, } from '../core/secrets-rotation.js';
16
+ import { unsealAll } from '../core/secrets-ops.js';
17
+ import { restartService } from '../core/systemd.js';
18
+ import { checkHealth } from '../core/health.js';
19
+ import { listSnapshots, restoreSnapshot, snapshotApp } from '../core/secrets-snapshots.js';
20
+ import { auditLog } from '../core/secrets-audit.js';
21
+ import { summariseSecrets, formatSecretsMotd, generateSecretsMotdScript } from '../core/secrets-motd.js';
22
+ function getDbSecretsDir() {
23
+ const reg = load();
24
+ return join(reg.infrastructure.databases.composePath, 'secrets');
25
+ }
14
26
  export async function secretsCommand(args) {
15
27
  const sub = args[0];
16
28
  const rest = args.slice(1);
@@ -24,13 +36,18 @@ export async function secretsCommand(args) {
24
36
  case 'seal': return secretsSeal(rest);
25
37
  case 'unseal': return secretsUnseal();
26
38
  case 'rotate': return secretsRotate(rest);
39
+ case 'rotate-key': return secretsRotateKey(rest);
40
+ case 'ages': return secretsAges(rest);
27
41
  case 'validate': return secretsValidate(rest);
28
42
  case 'status': return secretsStatus(rest);
29
43
  case 'drift': return secretsDrift(rest);
30
44
  case 'restore': return secretsRestore(rest);
45
+ case 'rollback': return secretsRollback(rest);
46
+ case 'snapshots': return secretsSnapshots(rest);
47
+ case 'motd-init': return secretsMotdInit();
31
48
  case 'seal-runtime': return secretsSeal(rest);
32
49
  default:
33
- error('Usage: fleet secrets <init|list|set|get|import|export|seal|unseal|rotate|validate|status|drift|restore>');
50
+ error('Usage: fleet secrets <init|list|set|get|import|export|seal|unseal|rotate|rotate-key|ages|rollback|snapshots|validate|status|drift|restore>');
34
51
  process.exit(1);
35
52
  }
36
53
  }
@@ -41,8 +58,8 @@ function secretsInit() {
41
58
  const serviceContent = generateUnsealService();
42
59
  const servicePath = '/etc/systemd/system/fleet-unseal.service';
43
60
  writeFileSync(servicePath, serviceContent);
44
- execSync('systemctl daemon-reload');
45
- execSync('systemctl enable fleet-unseal');
61
+ execSafe('systemctl', ['daemon-reload']);
62
+ execSafe('systemctl', ['enable', 'fleet-unseal']);
46
63
  success('Installed fleet-unseal.service');
47
64
  }
48
65
  function secretsList(args) {
@@ -76,14 +93,43 @@ function secretsList(args) {
76
93
  table(['APP', 'TYPE', 'KEYS', 'LAST SEALED'], rows);
77
94
  process.stdout.write('\n');
78
95
  }
79
- function secretsSet(args) {
80
- const [app, key, ...valueParts] = args;
81
- const value = valueParts.join(' ');
82
- if (!app || !key || !value) {
83
- error('Usage: fleet secrets set <app> <KEY> <VALUE>');
96
+ async function secretsSet(args) {
97
+ // Strip flags. Positional layout: <app> <KEY>. Value comes from interactive
98
+ // prompt (default) or stdin (--from-stdin). The legacy 'value as argv' form
99
+ // is REJECTED argv is world-readable via /proc/<pid>/cmdline + lands in
100
+ // shell history, the exact leak class this branch was built to prevent.
101
+ const fromStdin = args.includes('--from-stdin');
102
+ const allowWeak = args.includes('--allow-weak');
103
+ const positional = args.filter(a => !a.startsWith('-'));
104
+ const [app, key, ...rest] = positional;
105
+ if (!app || !key) {
106
+ error('Usage: fleet secrets set <app> <KEY> [--from-stdin] [--allow-weak]');
107
+ error(' (interactive paste is the default — value is NEVER passed in argv)');
108
+ process.exit(1);
109
+ }
110
+ if (rest.length > 0) {
111
+ error('Refusing to take a secret value from argv (visible in /proc/<pid>/cmdline + shell history).');
112
+ error('Use the interactive prompt or pipe via --from-stdin:');
113
+ error(` fleet secrets set ${app} ${key} # interactive`);
114
+ error(` printf '%s' "$NEW_VALUE" | fleet secrets set ${app} ${key} --from-stdin`);
84
115
  process.exit(1);
85
116
  }
86
- setSecret(app, key, value);
117
+ let value;
118
+ if (fromStdin) {
119
+ const chunks = [];
120
+ process.stdin.setEncoding('utf8');
121
+ for await (const chunk of process.stdin)
122
+ chunks.push(chunk);
123
+ value = chunks.join('').replace(/\r?\n$/, ''); // strip trailing newline
124
+ }
125
+ else {
126
+ value = await promptHidden(`Paste new value for ${key} (input hidden)`);
127
+ }
128
+ if (!value) {
129
+ error('Empty value — aborting');
130
+ process.exit(1);
131
+ }
132
+ setSecret(app, key, value, { allowWeak });
87
133
  success(`Set ${key} for ${app}`);
88
134
  }
89
135
  function secretsGet(args) {
@@ -107,7 +153,7 @@ function secretsImport(args) {
107
153
  process.exit(1);
108
154
  }
109
155
  if (app === 'docker-databases') {
110
- const dir = pathArg || DB_SECRETS_DIR;
156
+ const dir = pathArg || getDbSecretsDir();
111
157
  const count = importDbSecrets(app, dir);
112
158
  success(`Imported ${count} secret files from ${dir}`);
113
159
  return;
@@ -148,9 +194,9 @@ function secretsSeal(args) {
148
194
  success(`Sealed ${a}`);
149
195
  }
150
196
  }
151
- async function secretsRotate(args) {
197
+ async function secretsRotateKey(args) {
152
198
  const yes = args.includes('-y') || args.includes('--yes');
153
- if (!yes && !await confirm('Rotate age key? This will re-encrypt all secrets.')) {
199
+ if (!yes && !await confirm('Rotate AGE master key? This will re-encrypt all secrets.')) {
154
200
  info('Cancelled');
155
201
  return;
156
202
  }
@@ -161,6 +207,302 @@ async function secretsRotate(args) {
161
207
  info(`Re-encrypted ${result.appsRotated.length} apps`);
162
208
  warn('Run "fleet secrets unseal" to update runtime secrets');
163
209
  }
210
+ function parseRotateArgs(args) {
211
+ const opts = {
212
+ dryRun: args.includes('--dry-run'),
213
+ noRestart: args.includes('--no-restart'),
214
+ dataMigrated: args.includes('--data-migrated'),
215
+ };
216
+ const positional = args.filter(a => !a.startsWith('-'));
217
+ return { app: positional[0], key: positional[1], opts };
218
+ }
219
+ /**
220
+ * Walk one secret through the interactive rotation flow. Returns true if
221
+ * a rotation was performed (regardless of success/rollback), false on skip.
222
+ */
223
+ async function rotateOneInteractive(app, secret, opts) {
224
+ const provider = secret.provider;
225
+ const sensTag = provider
226
+ ? { critical: c.red, high: c.yellow, medium: c.blue, low: c.dim }[provider.sensitivity] + provider.sensitivity + c.reset
227
+ : `${c.dim}unclassified${c.reset}`;
228
+ process.stdout.write(`\n${c.bold}━━━ ${secret.name} ━━━${c.reset}\n`);
229
+ info(`Current: ${c.dim}${secret.maskedValue}${c.reset} age: ${secret.ageDays ?? '?'}d sens: ${sensTag}`);
230
+ if (provider) {
231
+ info(`Provider: ${provider.name}`);
232
+ info(`Strategy: ${provider.strategy}`);
233
+ if (provider.url)
234
+ info(`Regen URL: ${c.cyan}${provider.url}${c.reset}`);
235
+ }
236
+ const action = await prompt(' [r]otate / [s]kip / [q]uit', 's');
237
+ const a = action.toLowerCase().slice(0, 1);
238
+ if (a === 'q') {
239
+ info('Quitting rotation walkthrough.');
240
+ process.exit(0);
241
+ }
242
+ if (a !== 'r') {
243
+ info(`Skipped ${secret.name}`);
244
+ return { acted: false, succeeded: false };
245
+ }
246
+ // Strategy gates BEFORE asking for a value — saves user effort.
247
+ if (provider?.strategy === 'user-issued') {
248
+ error(`${secret.name} is user-issued. Rotate per-user inside your app, not here.`);
249
+ return { acted: false, succeeded: false };
250
+ }
251
+ if (provider?.strategy === 'at-rest-key' && !opts.dataMigrated) {
252
+ warn(`${secret.name} encrypts data at rest.`);
253
+ warn('Re-encrypt your data first, then re-run with --data-migrated');
254
+ return { acted: false, succeeded: false };
255
+ }
256
+ if (provider?.strategy === 'dual-mode') {
257
+ warn(`Dual-mode rotation: old value will be kept as ${secret.name}_PREVIOUS for the grace period.`);
258
+ warn('Your app MUST read both values for verification, otherwise existing tokens become invalid.');
259
+ if (!await confirm('Has your app been updated to read the _PREVIOUS variant?', false)) {
260
+ info('Skipping — update your app first, then re-run.');
261
+ return { acted: false, succeeded: false };
262
+ }
263
+ }
264
+ if (provider?.instructions) {
265
+ process.stdout.write(`\n${c.bold}Steps:${c.reset}\n`);
266
+ for (const line of provider.instructions.split('\n'))
267
+ process.stdout.write(` ${line}\n`);
268
+ }
269
+ let newValue;
270
+ while (true) {
271
+ newValue = await promptHidden(`Paste new ${secret.name} (input hidden)`);
272
+ if (!newValue) {
273
+ info('Empty value — skipping');
274
+ return { acted: false, succeeded: false };
275
+ }
276
+ const formatErr = validateFormat(newValue, provider);
277
+ const entropyErr = checkEntropy(newValue);
278
+ if (formatErr) {
279
+ error(formatErr);
280
+ if (!await confirm('Try again?', true))
281
+ return { acted: false, succeeded: false };
282
+ continue;
283
+ }
284
+ if (entropyErr) {
285
+ error(entropyErr);
286
+ if (!await confirm('Try again?', true))
287
+ return { acted: false, succeeded: false };
288
+ continue;
289
+ }
290
+ break;
291
+ }
292
+ info(`New value: ${maskNewValue(newValue)}`);
293
+ if (!await confirm('Apply rotation?', false)) {
294
+ info('Cancelled');
295
+ return { acted: false, succeeded: false };
296
+ }
297
+ const result = performRotation(app, secret.name, newValue, {
298
+ dryRun: opts.dryRun,
299
+ dataMigrated: opts.dataMigrated,
300
+ });
301
+ if (result.rolledBack) {
302
+ error(`${secret.name}: rotation FAILED — auto-rolled back. Reason: ${result.reason}`);
303
+ return { acted: true, succeeded: false };
304
+ }
305
+ if (opts.dryRun) {
306
+ success(`${secret.name}: dry-run — vault NOT modified`);
307
+ }
308
+ else {
309
+ success(`${secret.name}: rotated (snapshot: ${result.snapshot.split('/').pop()})`);
310
+ }
311
+ return { acted: true, succeeded: true };
312
+ }
313
+ async function secretsRotate(args) {
314
+ const { app, key, opts } = parseRotateArgs(args);
315
+ if (!app) {
316
+ error('Usage: fleet secrets rotate <app> [<KEY>] [--dry-run] [--data-migrated] [--no-restart]');
317
+ error(' fleet secrets rotate-key (legacy: rotate the AGE master key)');
318
+ process.exit(1);
319
+ }
320
+ const manifest = loadManifest();
321
+ if (!manifest.apps[app]) {
322
+ error(`No app in vault: ${app}`);
323
+ process.exit(1);
324
+ }
325
+ let secrets = enumerateSecrets(app);
326
+ if (key) {
327
+ secrets = secrets.filter(s => s.name === key);
328
+ if (secrets.length === 0) {
329
+ error(`No secret named ${key} in ${app}`);
330
+ process.exit(1);
331
+ }
332
+ }
333
+ heading(`Rotate ${key ? `${key} in ${app}` : `secrets in ${app}`}${opts.dryRun ? ' [DRY-RUN]' : ''}`);
334
+ info(`${secrets.length} secret(s) to walk through. Empty answer = skip; "q" = quit.`);
335
+ let acted = 0;
336
+ let succeeded = 0;
337
+ for (const s of secrets) {
338
+ const r = await rotateOneInteractive(app, s, opts);
339
+ if (r.acted)
340
+ acted++;
341
+ if (r.succeeded)
342
+ succeeded++;
343
+ }
344
+ process.stdout.write('\n');
345
+ if (acted === 0) {
346
+ info('No rotations performed.');
347
+ return;
348
+ }
349
+ if (opts.dryRun) {
350
+ success(`Dry-run complete: ${succeeded}/${acted} would-rotate (no changes made)`);
351
+ return;
352
+ }
353
+ // Apply runtime: re-unseal so /run/fleet-secrets has the new values.
354
+ info('Re-unsealing vault to /run/fleet-secrets...');
355
+ unsealAll();
356
+ success('Runtime updated');
357
+ // Restart + health gate (unless --no-restart).
358
+ if (opts.noRestart) {
359
+ warn('Skipping restart (--no-restart). Restart manually with `fleet restart ' + app + '`');
360
+ return;
361
+ }
362
+ const reg = load();
363
+ const appEntry = findApp(reg, app);
364
+ if (!appEntry) {
365
+ warn(`App ${app} not in registry — skipping restart + health gate.`);
366
+ return;
367
+ }
368
+ info(`Restarting ${app}...`);
369
+ if (!restartService(appEntry.serviceName)) {
370
+ error(`Restart failed for ${app}. Check logs.`);
371
+ return;
372
+ }
373
+ success(`${app} restarted`);
374
+ // Brief health gate.
375
+ info('Waiting 5s then checking health...');
376
+ await new Promise(r => setTimeout(r, 5000));
377
+ try {
378
+ const h = checkHealth(appEntry);
379
+ if (h.containers.every(ct => ct.running && (ct.health === 'healthy' || ct.health === 'none' || ct.health === ''))) {
380
+ success(`${app} healthy after rotation`);
381
+ }
382
+ else {
383
+ warn(`${app} health: not all containers happy. Run: fleet health ${app}`);
384
+ }
385
+ }
386
+ catch (e) {
387
+ warn(`Could not check health: ${e instanceof Error ? e.message : String(e)}`);
388
+ }
389
+ }
390
+ function parseAgesOpts(args) {
391
+ const opts = {
392
+ json: args.includes('--json'),
393
+ staleOnly: args.includes('--stale-only') || args.includes('--stale'),
394
+ };
395
+ const app = args.find(a => !a.startsWith('-'));
396
+ return { app, opts };
397
+ }
398
+ function statusLabel(s) {
399
+ if (!s.provider)
400
+ return `${c.dim}unknown${c.reset}`;
401
+ if (s.stale)
402
+ return `${c.red}${c.bold}STALE${c.reset}`;
403
+ if (s.ageDays != null) {
404
+ const threshold = s.provider.rotationFrequencyDays * 0.8;
405
+ if (s.ageDays >= threshold)
406
+ return `${c.yellow}aging${c.reset}`;
407
+ }
408
+ return `${c.green}fresh${c.reset}`;
409
+ }
410
+ function ageString(days) {
411
+ if (days == null)
412
+ return '?';
413
+ if (days === 0)
414
+ return 'today';
415
+ if (days === 1)
416
+ return '1 day';
417
+ return `${days} days`;
418
+ }
419
+ function secretsAges(args) {
420
+ // --motd → short summary suitable for /etc/update-motd.d/
421
+ if (args.includes('--motd')) {
422
+ const summary = summariseSecrets();
423
+ process.stdout.write(formatSecretsMotd(summary) + '\n');
424
+ return;
425
+ }
426
+ const { app, opts } = parseAgesOpts(args);
427
+ let secrets;
428
+ if (app) {
429
+ secrets = enumerateSecrets(app).map(s => ({ app, ...s }));
430
+ }
431
+ else {
432
+ secrets = enumerateAllSecrets();
433
+ }
434
+ if (opts.staleOnly) {
435
+ secrets = secrets.filter(s => s.stale);
436
+ }
437
+ if (opts.json) {
438
+ // Strip the `provider` ProviderDef object (RegExp inside) for JSON-safety;
439
+ // expose just its id, sensitivity, frequency.
440
+ const out = secrets.map(s => ({
441
+ app: s.app,
442
+ name: s.name,
443
+ lastRotated: s.lastRotated,
444
+ ageDays: s.ageDays,
445
+ stale: s.stale,
446
+ provider: s.provider
447
+ ? {
448
+ id: s.provider.id,
449
+ name: s.provider.name,
450
+ sensitivity: s.provider.sensitivity,
451
+ rotationFrequencyDays: s.provider.rotationFrequencyDays,
452
+ strategy: s.provider.strategy,
453
+ }
454
+ : null,
455
+ }));
456
+ process.stdout.write(JSON.stringify(out, null, 2) + '\n');
457
+ return;
458
+ }
459
+ if (secrets.length === 0) {
460
+ if (opts.staleOnly) {
461
+ success('No stale secrets — everything is within rotation frequency');
462
+ }
463
+ else if (app) {
464
+ warn(`No secrets in ${app}`);
465
+ }
466
+ else {
467
+ warn('No secrets in vault');
468
+ }
469
+ return;
470
+ }
471
+ // Sort: stale first (by sensitivity desc), then aging, then fresh.
472
+ const sensRank = { critical: 0, high: 1, medium: 2, low: 3 };
473
+ secrets.sort((a, b) => {
474
+ if (a.stale !== b.stale)
475
+ return a.stale ? -1 : 1;
476
+ const sa = sensRank[a.provider?.sensitivity ?? 'low'] ?? 99;
477
+ const sb = sensRank[b.provider?.sensitivity ?? 'low'] ?? 99;
478
+ if (sa !== sb)
479
+ return sa - sb;
480
+ if ((b.ageDays ?? 0) !== (a.ageDays ?? 0))
481
+ return (b.ageDays ?? 0) - (a.ageDays ?? 0);
482
+ return a.app.localeCompare(b.app) || a.name.localeCompare(b.name);
483
+ });
484
+ const title = app ? `Secret ages: ${app}` : `Secret ages (${secrets.length} secrets)`;
485
+ heading(title);
486
+ const cols = app
487
+ ? ['SECRET', 'AGE', 'ROTATE EVERY', 'PROVIDER', 'SENS', 'STATUS']
488
+ : ['APP', 'SECRET', 'AGE', 'ROTATE EVERY', 'PROVIDER', 'SENS', 'STATUS'];
489
+ const rows = secrets.map(s => {
490
+ const provider = s.provider?.name ?? `${c.dim}—${c.reset}`;
491
+ const freq = s.provider ? `${s.provider.rotationFrequencyDays}d` : '—';
492
+ const sens = s.provider?.sensitivity ?? '—';
493
+ const ageCol = ageString(s.ageDays);
494
+ const status = statusLabel(s);
495
+ return app
496
+ ? [s.name, ageCol, freq, provider, sens, status]
497
+ : [`${c.bold}${s.app}${c.reset}`, s.name, ageCol, freq, provider, sens, status];
498
+ });
499
+ table(cols, rows);
500
+ process.stdout.write('\n');
501
+ const staleCount = secrets.filter(s => s.stale).length;
502
+ if (staleCount > 0) {
503
+ warn(`${staleCount} secret(s) need rotation. Run: fleet secrets rotate <app>`);
504
+ }
505
+ }
164
506
  function secretsValidate(args) {
165
507
  const json = args.includes('--json');
166
508
  const appName = args.find(a => !a.startsWith('-'));
@@ -252,6 +594,97 @@ function secretsDrift(args) {
252
594
  success('No drift detected');
253
595
  }
254
596
  }
597
+ function secretsMotdInit() {
598
+ const motdPath = '/etc/update-motd.d/99-fleet-secrets';
599
+ const script = generateSecretsMotdScript();
600
+ try {
601
+ writeFileSync(motdPath, script);
602
+ chmodSync(motdPath, 0o755);
603
+ success(`Installed MOTD script: ${motdPath}`);
604
+ info('Will print on next shell login.');
605
+ }
606
+ catch (err) {
607
+ error(`Failed to install MOTD: ${err instanceof Error ? err.message : String(err)}`);
608
+ error('Re-run with sudo if permission denied.');
609
+ process.exit(1);
610
+ }
611
+ }
612
+ function secretsSnapshots(args) {
613
+ const json = args.includes('--json');
614
+ const app = args.find(a => !a.startsWith('-'));
615
+ if (!app) {
616
+ error('Usage: fleet secrets snapshots <app>');
617
+ process.exit(1);
618
+ }
619
+ const snaps = listSnapshots(app);
620
+ if (json) {
621
+ process.stdout.write(JSON.stringify(snaps, null, 2) + '\n');
622
+ return;
623
+ }
624
+ if (snaps.length === 0) {
625
+ info(`No snapshots for ${app}`);
626
+ return;
627
+ }
628
+ heading(`Snapshots for ${app} (${snaps.length})`);
629
+ const rows = snaps.map(s => [
630
+ s.timestamp,
631
+ `${(s.sizeBytes / 1024).toFixed(1)}K`,
632
+ s.path.split('/').slice(-2).join('/'),
633
+ ]);
634
+ table(['TIMESTAMP', 'SIZE', 'PATH'], rows);
635
+ process.stdout.write('\n');
636
+ info(`Restore the newest with: fleet secrets rollback ${app}`);
637
+ info(`Restore a specific one: fleet secrets rollback ${app} --to <TIMESTAMP>`);
638
+ }
639
+ async function secretsRollback(args) {
640
+ const yes = args.includes('-y') || args.includes('--yes');
641
+ const toIdx = args.indexOf('--to');
642
+ const to = toIdx >= 0 ? args[toIdx + 1] : undefined;
643
+ // Bug fix: previous logic excluded args[toIdx + 1] always (even when toIdx
644
+ // was -1, i.e. no --to flag), which silently skipped args[0] and grabbed
645
+ // the wrong positional. Only exclude the timestamp slot if --to was given.
646
+ const app = args.find((a, i) => !a.startsWith('-') && (toIdx < 0 || i !== toIdx + 1));
647
+ if (!app) {
648
+ error('Usage: fleet secrets rollback <app> [--to <TIMESTAMP>]');
649
+ error(' (use `fleet secrets snapshots <app>` to list available)');
650
+ process.exit(1);
651
+ }
652
+ const snaps = listSnapshots(app);
653
+ if (snaps.length === 0) {
654
+ error(`No snapshots for ${app}`);
655
+ process.exit(1);
656
+ }
657
+ const target = to ? snaps.find(s => s.timestamp === to) : snaps[0];
658
+ if (!target) {
659
+ error(`Snapshot not found for ${app}: ${to}`);
660
+ process.exit(1);
661
+ }
662
+ warn(`About to restore ${app} from snapshot ${target.timestamp}`);
663
+ warn('This will OVERWRITE the current vault file.');
664
+ if (!yes && !await confirm('Proceed?', false)) {
665
+ info('Cancelled');
666
+ return;
667
+ }
668
+ // Snapshot the CURRENT state before overwriting (so we can roll the rollback back too).
669
+ const safety = snapshotApp(app);
670
+ info(`Pre-rollback safety snapshot: ${safety.split('/').pop()}`);
671
+ restoreSnapshot(app, target.timestamp);
672
+ auditLog({ op: 'rollback', app, ok: true, details: `to ${target.timestamp}` });
673
+ success(`Restored ${app} from ${target.timestamp}`);
674
+ info('Re-unsealing vault...');
675
+ unsealAll();
676
+ const reg = load();
677
+ const appEntry = findApp(reg, app);
678
+ if (appEntry) {
679
+ info(`Restarting ${app}...`);
680
+ if (restartService(appEntry.serviceName)) {
681
+ success(`${app} restarted`);
682
+ }
683
+ else {
684
+ warn(`Restart failed — restart manually with: fleet restart ${app}`);
685
+ }
686
+ }
687
+ }
255
688
  function secretsRestore(args) {
256
689
  const app = args.find(a => !a.startsWith('-'));
257
690
  if (!app) {
@@ -16,7 +16,10 @@ export function getStatusData() {
16
16
  appContainers.every(ct => ct.health === 'healthy' || ct.health === 'none');
17
17
  const allRunning = appContainers.every(ct => ct.status.startsWith('Up'));
18
18
  let health;
19
- if (svc && !svc.active) {
19
+ if (app.frozenAt) {
20
+ health = 'frozen';
21
+ }
22
+ else if (svc && !svc.active) {
20
23
  // systemd says service is not active — it's down
21
24
  health = 'down';
22
25
  }
@@ -55,8 +58,9 @@ export function statusCommand(args) {
55
58
  info(`${data.totalApps} apps | ${c.green}${data.healthy} healthy${c.reset} | ${data.unhealthy > 0 ? c.red : c.dim}${data.unhealthy} unhealthy${c.reset}`);
56
59
  const rows = data.apps.map(app => {
57
60
  const healthIcon = app.health === 'healthy' ? icon.ok
58
- : app.health === 'degraded' ? icon.warn
59
- : icon.err;
61
+ : app.health === 'frozen' ? icon.info
62
+ : app.health === 'degraded' ? icon.warn
63
+ : icon.err;
60
64
  const systemdColor = app.systemd === 'active' ? c.green : c.red;
61
65
  return [
62
66
  `${c.bold}${app.name}${c.reset}`,
@@ -1 +1 @@
1
- export declare function watchdogCommand(_args: string[]): Promise<void>;
1
+ export declare function watchdogCommand(args: string[]): Promise<void>;
@@ -1,37 +1,9 @@
1
- import { readFileSync, existsSync } from 'node:fs';
1
+ import { readFileSync } from 'node:fs';
2
2
  import { load } from '../core/registry.js';
3
3
  import { checkAllHealth } from '../core/health.js';
4
4
  import { getServiceStatus } from '../core/systemd.js';
5
+ import { loadNotifyConfig, sendNotification } from '../core/notify.js';
5
6
  import { error, success, warn } from '../ui/output.js';
6
- const TELEGRAM_CONFIG_PATH = '/etc/fleet/telegram.json';
7
- function loadTelegramConfig() {
8
- if (!existsSync(TELEGRAM_CONFIG_PATH))
9
- return null;
10
- try {
11
- return JSON.parse(readFileSync(TELEGRAM_CONFIG_PATH, 'utf-8'));
12
- }
13
- catch {
14
- return null;
15
- }
16
- }
17
- async function sendTelegram(config, message) {
18
- try {
19
- const url = `https://api.telegram.org/bot${config.botToken}/sendMessage`;
20
- const res = await fetch(url, {
21
- method: 'POST',
22
- headers: { 'Content-Type': 'application/json' },
23
- body: JSON.stringify({
24
- chat_id: config.chatId,
25
- text: message,
26
- parse_mode: 'HTML',
27
- }),
28
- });
29
- return res.ok;
30
- }
31
- catch {
32
- return false;
33
- }
34
- }
35
7
  function getHostname() {
36
8
  try {
37
9
  return readFileSync('/etc/hostname', 'utf-8').trim();
@@ -40,7 +12,8 @@ function getHostname() {
40
12
  return 'unknown';
41
13
  }
42
14
  }
43
- export async function watchdogCommand(_args) {
15
+ export async function watchdogCommand(args) {
16
+ const isMotd = args.includes('--motd');
44
17
  const failures = [];
45
18
  const hostname = getHostname();
46
19
  // check docker-databases systemd status
@@ -76,25 +49,28 @@ export async function watchdogCommand(_args) {
76
49
  for (const f of failures) {
77
50
  error(` ${f}`);
78
51
  }
79
- // send telegram alert
80
- const config = loadTelegramConfig();
52
+ // MOTD mode: display only, no alerts, always exit 0
53
+ if (isMotd)
54
+ return;
55
+ // send alert via notify adapters
56
+ const config = loadNotifyConfig();
81
57
  if (!config) {
82
- warn('No telegram config at /etc/fleet/telegram.json — alert not sent');
58
+ warn('No notify config at /etc/fleet/notify.json — alert not sent');
83
59
  process.exit(1);
84
60
  }
85
61
  const message = [
86
- `<b>fleet watchdog alert</b>`,
87
- `<b>host:</b> ${hostname}`,
88
- `<b>failures:</b> ${failures.length}`,
62
+ `fleet watchdog alert`,
63
+ `host: ${hostname}`,
64
+ `failures: ${failures.length}`,
89
65
  '',
90
66
  ...failures.map(f => `- ${f}`),
91
67
  ].join('\n');
92
- const sent = await sendTelegram(config, message);
68
+ const sent = await sendNotification(config, message);
93
69
  if (sent) {
94
- success('Telegram alert sent');
70
+ success('Alert sent');
95
71
  }
96
72
  else {
97
- error('Failed to send Telegram alert');
73
+ error('Failed to send alert');
98
74
  }
99
75
  process.exit(1);
100
76
  }