@matthesketh/fleet 1.8.1 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. package/README.md +186 -16
  2. package/dist/bin/fleet-agent.d.ts +2 -0
  3. package/dist/bin/fleet-agent.js +7 -0
  4. package/dist/cli.d.ts +5 -0
  5. package/dist/cli.js +73 -31
  6. package/dist/commands/add.d.ts +2 -1
  7. package/dist/commands/add.js +66 -59
  8. package/dist/commands/audit.d.ts +1 -0
  9. package/dist/commands/audit.js +144 -0
  10. package/dist/commands/backup.d.ts +1 -0
  11. package/dist/commands/backup.js +510 -0
  12. package/dist/commands/boot-start.d.ts +3 -1
  13. package/dist/commands/boot-start.js +39 -47
  14. package/dist/commands/completions.d.ts +6 -0
  15. package/dist/commands/completions.js +83 -0
  16. package/dist/commands/config.d.ts +16 -0
  17. package/dist/commands/config.js +96 -0
  18. package/dist/commands/deploy.js +3 -2
  19. package/dist/commands/deps.js +5 -1
  20. package/dist/commands/doctor.d.ts +32 -0
  21. package/dist/commands/doctor.js +186 -0
  22. package/dist/commands/egress.d.ts +1 -1
  23. package/dist/commands/egress.js +13 -10
  24. package/dist/commands/freeze.d.ts +8 -4
  25. package/dist/commands/freeze.js +77 -59
  26. package/dist/commands/git.js +2 -2
  27. package/dist/commands/health.d.ts +2 -1
  28. package/dist/commands/health.js +38 -56
  29. package/dist/commands/init.d.ts +2 -1
  30. package/dist/commands/init.js +83 -73
  31. package/dist/commands/install-mcp.d.ts +3 -1
  32. package/dist/commands/install-mcp.js +53 -34
  33. package/dist/commands/list.d.ts +2 -1
  34. package/dist/commands/list.js +22 -19
  35. package/dist/commands/logs.js +1 -1
  36. package/dist/commands/notify.d.ts +1 -0
  37. package/dist/commands/notify.js +51 -0
  38. package/dist/commands/patch-systemd.d.ts +7 -1
  39. package/dist/commands/patch-systemd.js +71 -31
  40. package/dist/commands/remove.d.ts +3 -1
  41. package/dist/commands/remove.js +37 -26
  42. package/dist/commands/restart.d.ts +4 -1
  43. package/dist/commands/restart.js +17 -20
  44. package/dist/commands/rollback.d.ts +4 -1
  45. package/dist/commands/rollback.js +33 -42
  46. package/dist/commands/secrets.js +157 -9
  47. package/dist/commands/start.d.ts +4 -1
  48. package/dist/commands/start.js +17 -20
  49. package/dist/commands/status.d.ts +1 -1
  50. package/dist/commands/status.js +21 -26
  51. package/dist/commands/stop.d.ts +4 -1
  52. package/dist/commands/stop.js +17 -20
  53. package/dist/commands/testflight.d.ts +1 -0
  54. package/dist/commands/testflight.js +193 -0
  55. package/dist/commands/update.d.ts +16 -0
  56. package/dist/commands/update.js +95 -0
  57. package/dist/core/audit/cache.d.ts +4 -0
  58. package/dist/core/audit/cache.js +37 -0
  59. package/dist/core/audit/config.d.ts +5 -0
  60. package/dist/core/audit/config.js +35 -0
  61. package/dist/core/audit/greenlight.d.ts +11 -0
  62. package/dist/core/audit/greenlight.js +81 -0
  63. package/dist/core/audit/reporters/cli.d.ts +3 -0
  64. package/dist/core/audit/reporters/cli.js +68 -0
  65. package/dist/core/audit/suppress.d.ts +6 -0
  66. package/dist/core/audit/suppress.js +37 -0
  67. package/dist/core/audit/target.d.ts +5 -0
  68. package/dist/core/audit/target.js +26 -0
  69. package/dist/core/audit/types.d.ts +54 -0
  70. package/dist/core/audit/types.js +5 -0
  71. package/dist/core/backup/browser-api.d.ts +66 -0
  72. package/dist/core/backup/browser-api.js +197 -0
  73. package/dist/core/backup/browser-server.d.ts +11 -0
  74. package/dist/core/backup/browser-server.js +241 -0
  75. package/dist/core/backup/browser-ui.d.ts +5 -0
  76. package/dist/core/backup/browser-ui.js +268 -0
  77. package/dist/core/backup/cloudflare.d.ts +7 -0
  78. package/dist/core/backup/cloudflare.js +82 -0
  79. package/dist/core/backup/config.d.ts +9 -0
  80. package/dist/core/backup/config.js +80 -0
  81. package/dist/core/backup/detect.d.ts +11 -0
  82. package/dist/core/backup/detect.js +71 -0
  83. package/dist/core/backup/dump.d.ts +11 -0
  84. package/dist/core/backup/dump.js +82 -0
  85. package/dist/core/backup/index.d.ts +9 -0
  86. package/dist/core/backup/index.js +9 -0
  87. package/dist/core/backup/repo.d.ts +71 -0
  88. package/dist/core/backup/repo.js +256 -0
  89. package/dist/core/backup/schedule.d.ts +17 -0
  90. package/dist/core/backup/schedule.js +90 -0
  91. package/dist/core/backup/sensitive.d.ts +5 -0
  92. package/dist/core/backup/sensitive.js +37 -0
  93. package/dist/core/backup/status.d.ts +3 -0
  94. package/dist/core/backup/status.js +29 -0
  95. package/dist/core/backup/statuspage.d.ts +23 -0
  96. package/dist/core/backup/statuspage.js +145 -0
  97. package/dist/core/backup/system.d.ts +24 -0
  98. package/dist/core/backup/system.js +209 -0
  99. package/dist/core/backup/totp.d.ts +16 -0
  100. package/dist/core/backup/totp.js +116 -0
  101. package/dist/core/backup/types.d.ts +70 -0
  102. package/dist/core/backup/types.js +7 -0
  103. package/dist/core/backup/unlock.d.ts +19 -0
  104. package/dist/core/backup/unlock.js +69 -0
  105. package/dist/core/boot-refresh.d.ts +1 -1
  106. package/dist/core/boot-refresh.js +10 -9
  107. package/dist/core/deps/actors/pr-creator.d.ts +5 -3
  108. package/dist/core/deps/actors/pr-creator.js +71 -18
  109. package/dist/core/deps/collectors/fetch-with-timeout.d.ts +7 -0
  110. package/dist/core/deps/collectors/fetch-with-timeout.js +16 -0
  111. package/dist/core/deps/collectors/npm.js +3 -1
  112. package/dist/core/deps/collectors/vulnerability.d.ts +8 -0
  113. package/dist/core/deps/collectors/vulnerability.js +31 -2
  114. package/dist/core/deps/config.js +6 -0
  115. package/dist/core/deps/scanner.js +1 -1
  116. package/dist/core/deps/types.d.ts +8 -0
  117. package/dist/core/env.d.ts +3 -0
  118. package/dist/core/env.js +11 -0
  119. package/dist/core/exec.d.ts +1 -0
  120. package/dist/core/exec.js +4 -0
  121. package/dist/core/file-lock.d.ts +18 -0
  122. package/dist/core/file-lock.js +44 -0
  123. package/dist/core/git-onboard.js +10 -13
  124. package/dist/core/github.d.ts +3 -1
  125. package/dist/core/github.js +10 -7
  126. package/dist/core/logs-policy.d.ts +5 -0
  127. package/dist/core/logs-policy.js +20 -1
  128. package/dist/core/operator.d.ts +21 -0
  129. package/dist/core/operator.js +54 -0
  130. package/dist/core/registry.d.ts +18 -0
  131. package/dist/core/registry.js +26 -0
  132. package/dist/core/routines/schema.d.ts +11 -11
  133. package/dist/core/routines/schema.js +14 -3
  134. package/dist/core/routines/store.d.ts +8 -8
  135. package/dist/core/secrets-ops.d.ts +31 -6
  136. package/dist/core/secrets-ops.js +208 -102
  137. package/dist/core/secrets-providers.js +2 -2
  138. package/dist/core/secrets-rotation.d.ts +1 -1
  139. package/dist/core/secrets-rotation.js +58 -52
  140. package/dist/core/secrets-v2-cleanup.d.ts +19 -0
  141. package/dist/core/secrets-v2-cleanup.js +94 -0
  142. package/dist/core/secrets-v2-creds.d.ts +9 -0
  143. package/dist/core/secrets-v2-creds.js +44 -0
  144. package/dist/core/secrets-v2-install.d.ts +13 -0
  145. package/dist/core/secrets-v2-install.js +76 -0
  146. package/dist/core/secrets-v2-keypair.d.ts +10 -0
  147. package/dist/core/secrets-v2-keypair.js +31 -0
  148. package/dist/core/secrets-v2-migrate.d.ts +29 -0
  149. package/dist/core/secrets-v2-migrate.js +395 -0
  150. package/dist/core/secrets-v2-ops.d.ts +36 -0
  151. package/dist/core/secrets-v2-ops.js +184 -0
  152. package/dist/core/secrets-v2-protocol.d.ts +19 -0
  153. package/dist/core/secrets-v2-protocol.js +60 -0
  154. package/dist/core/secrets-v2-snapshot.d.ts +36 -0
  155. package/dist/core/secrets-v2-snapshot.js +115 -0
  156. package/dist/core/secrets-v2.d.ts +21 -0
  157. package/dist/core/secrets-v2.js +249 -0
  158. package/dist/core/secrets.d.ts +39 -4
  159. package/dist/core/secrets.js +91 -11
  160. package/dist/core/self-update.d.ts +32 -11
  161. package/dist/core/self-update.js +52 -14
  162. package/dist/core/testflight/asc.d.ts +12 -0
  163. package/dist/core/testflight/asc.js +101 -0
  164. package/dist/core/testflight/credentials.d.ts +3 -0
  165. package/dist/core/testflight/credentials.js +35 -0
  166. package/dist/core/testflight/eas.d.ts +4 -0
  167. package/dist/core/testflight/eas.js +38 -0
  168. package/dist/core/testflight/resolve.d.ts +6 -0
  169. package/dist/core/testflight/resolve.js +44 -0
  170. package/dist/core/testflight/types.d.ts +13 -0
  171. package/dist/core/testflight/types.js +3 -0
  172. package/dist/core/testflight/workflow.d.ts +17 -0
  173. package/dist/core/testflight/workflow.js +65 -0
  174. package/dist/core/validate.d.ts +1 -0
  175. package/dist/core/validate.js +8 -0
  176. package/dist/mcp/audit-tools.d.ts +2 -0
  177. package/dist/mcp/audit-tools.js +94 -0
  178. package/dist/mcp/git-tools.js +1 -1
  179. package/dist/mcp/registry-bridge.d.ts +10 -0
  180. package/dist/mcp/registry-bridge.js +65 -0
  181. package/dist/mcp/secrets-tools.js +2 -2
  182. package/dist/mcp/server.js +16 -82
  183. package/dist/mcp/testflight-tools.d.ts +2 -0
  184. package/dist/mcp/testflight-tools.js +52 -0
  185. package/dist/registry/context.d.ts +7 -0
  186. package/dist/registry/context.js +37 -0
  187. package/dist/registry/index.d.ts +5 -0
  188. package/dist/registry/index.js +44 -0
  189. package/dist/registry/parse-args.d.ts +13 -0
  190. package/dist/registry/parse-args.js +74 -0
  191. package/dist/registry/registry.d.ts +24 -0
  192. package/dist/registry/registry.js +26 -0
  193. package/dist/registry/render.d.ts +3 -0
  194. package/dist/registry/render.js +29 -0
  195. package/dist/registry/types.d.ts +50 -0
  196. package/dist/registry/types.js +1 -0
  197. package/dist/templates/agent-unit.d.ts +5 -0
  198. package/dist/templates/agent-unit.js +40 -0
  199. package/dist/templates/app-unit-edit.d.ts +2 -0
  200. package/dist/templates/app-unit-edit.js +46 -0
  201. package/dist/templates/compose-edit.d.ts +2 -0
  202. package/dist/templates/compose-edit.js +156 -0
  203. package/dist/templates/nginx.js +11 -0
  204. package/dist/templates/systemd.js +6 -0
  205. package/dist/tui/components/ArgForm.d.ts +7 -0
  206. package/dist/tui/components/ArgForm.js +64 -0
  207. package/dist/tui/components/ArgForm.test.d.ts +1 -0
  208. package/dist/tui/components/ArgForm.test.js +19 -0
  209. package/dist/tui/components/KeyHint.js +5 -0
  210. package/dist/tui/hooks/use-secrets.d.ts +8 -8
  211. package/dist/tui/hooks/use-secrets.js +7 -7
  212. package/dist/tui/router.d.ts +1 -0
  213. package/dist/tui/router.js +26 -9
  214. package/dist/tui/router.test.d.ts +1 -0
  215. package/dist/tui/router.test.js +13 -0
  216. package/dist/tui/routines/components/SignalsGrid.test.js +2 -2
  217. package/dist/tui/routines/tabs/ScaffoldTab.js +1 -1
  218. package/dist/tui/tests/redaction-rerender.test.d.ts +1 -0
  219. package/dist/tui/tests/redaction-rerender.test.js +53 -0
  220. package/dist/tui/tests/scroll-flicker-proof.test.d.ts +1 -0
  221. package/dist/tui/tests/scroll-flicker-proof.test.js +145 -0
  222. package/dist/tui/types.d.ts +1 -1
  223. package/dist/tui/views/CommandPalette.d.ts +5 -0
  224. package/dist/tui/views/CommandPalette.js +90 -0
  225. package/dist/tui/views/CommandPalette.test.d.ts +1 -0
  226. package/dist/tui/views/CommandPalette.test.js +117 -0
  227. package/dist/tui/views/Dashboard.js +9 -6
  228. package/dist/tui/views/HealthView.js +9 -4
  229. package/dist/tui/views/SecretEdit.js +15 -16
  230. package/dist/tui/views/SecretEdit.test.d.ts +1 -0
  231. package/dist/tui/views/SecretEdit.test.js +82 -0
  232. package/dist/tui/views/SecretsView.js +26 -16
  233. package/package.json +8 -5
package/README.md CHANGED
@@ -28,32 +28,43 @@ graph TD
28
28
  TUI["TUI Dashboard"]
29
29
  MCP["MCP Server"]
30
30
  BOT["fleet-bot (Go)"]
31
+ Explorer["Backup Explorer<br/>(browser)"]
31
32
 
32
33
  CLI --> Core
33
34
  TUI --> Core
34
35
  MCP --> Core
35
- BOT -->|"via MCP"| Core
36
+ BOT -->|"MCP + exec"| Core
37
+ Explorer --> BackupCore["Backup core"]
36
38
 
37
- subgraph Core["Core Modules"]
39
+ subgraph Core["Core modules"]
38
40
  Registry["Registry"]
39
41
  Docker["Docker Compose"]
40
42
  Systemd["systemd"]
41
43
  Nginx["nginx"]
42
- Secrets["Secrets Vault"]
43
- Health["Health Checks"]
44
+ Secrets["Secrets vault"]
45
+ Agent["Secrets agent v2<br/>(per-app socket)"]
46
+ Health["Health checks"]
44
47
  Git["Git / GitHub"]
45
- Deps["Dependency Monitor"]
48
+ Deps["Dependency monitor"]
49
+ Routines["Routines<br/>(scheduled tasks)"]
50
+ BackupCore
51
+ Guard["Cloudflare guard"]
52
+ Mobile["Mobile (TestFlight + audit)"]
46
53
  end
47
54
 
48
55
  Docker --> Containers["Containers"]
49
- Systemd --> Services["systemd Services"]
50
- Nginx --> Proxy["Reverse Proxy"]
56
+ Systemd --> Services["systemd services"]
57
+ Nginx --> Proxy["Reverse proxy"]
51
58
  Secrets --> Vault["vault/*.age"]
52
59
  Secrets --> Runtime["/run/fleet-secrets"]
60
+ Agent --> Containers
53
61
  Health --> Alerts["Telegram / iMessage"]
62
+ BackupCore --> Restic["restic + age<br/>(off-host)"]
54
63
  ```
55
64
 
56
- Each Docker Compose app is registered with its compose path, domains, port, and container names. Fleet generates systemd units so apps start on boot in the correct order. Secrets are encrypted at rest with [age](https://github.com/FiloSottile/age) and decrypted to a tmpfs on boot.
65
+ Each Docker Compose app is registered with its compose path, domains, port, and container names. Fleet generates systemd units so apps start on boot in the correct order. Secrets are encrypted at rest with [age](https://github.com/FiloSottile/age) and either decrypted to a tmpfs on boot (v1) or served per-app over a Unix socket by the secrets agent (v2).
66
+
67
+ Operator-specific identity (GitHub org, home dir, domain, username) lives in `data/operator.json` — gitignored, instance-local. Copy `data/operator.example.json` to seed it on a fresh install.
57
68
 
58
69
  ## Install
59
70
 
@@ -77,8 +88,24 @@ Requires Node.js 20+, Docker Compose v2, systemd, nginx, and [age](https://githu
77
88
 
78
89
  **Git workflows** -- Onboard apps to GitHub, manage branches, PRs, and releases from the CLI.
79
90
 
91
+ **Off-host backups** -- `fleet backup` runs restic against an append-only REST backend with age-encrypted dumps for databases. Includes `schedule` for systemd-timer-driven recurring backups, `verify` / `integrity` for repository checks, and `serve` for a browser-based restore explorer.
92
+
93
+ **Routines** -- `fleet routines` is a TUI for signal-based scheduled tasks (each routine has a target repo, a trigger condition, and a runner — claude-cli, shell, or mcp). `fleet routine-run --id <id>` is the headless entrypoint for systemd-timer units.
94
+
95
+ **Mobile pipelines** -- `fleet testflight` dispatches the macOS build workflow and publishes to TestFlight; `fleet audit` runs an App Store Review Guidelines audit via greenlight.
96
+
97
+ **Cloudflare guard** -- `fleet guard` installs a watchdog layer (cf-snapshot, dns-drift-watch, cert-expiry-watch, cf-audit-monitor) that detects unauthorised dashboard changes and DNS drift on protected zones.
98
+
80
99
  **Interactive dashboard** -- Run bare `fleet` to launch a full-screen TUI with real-time status.
81
100
 
101
+ **Host preflight** -- `fleet doctor` checks every external requirement the rest of the tool assumes (`age`, `docker compose v2`, `systemd ≥ 240`, `node ≥ 20`), plus the registry, vault, operator config and orphaned-app detection. Run on any fresh server.
102
+
103
+ **Self-update from the CLI** -- `fleet update [--check] [--channel prerelease]` is the non-TUI counterpart to the dashboard banner. Cron-able, ssh-friendly.
104
+
105
+ **Operator config** -- `fleet config show / get <field> / set <field> <value>` and the one-liner `fleet whoami` keep `data/operator.json` (username + home + domain + github org) editable from the CLI.
106
+
107
+ **Shell completions** -- `fleet completions bash | zsh | fish` emits a completion script driven from the command registry, so it stays accurate as the migration finishes.
108
+
82
109
  See the [CLI reference](https://fleet.hesketh.pro/cli/) for the complete command list.
83
110
 
84
111
  ## Secrets Flow
@@ -102,6 +129,16 @@ graph LR
102
129
 
103
130
  Secrets are imported or set individually, encrypted with age, and stored in the vault. On boot (or manually), they are decrypted to a tmpfs mount that Docker containers reference. Sealing writes runtime changes back to the vault. Drift detection compares vault vs runtime to catch unsaved changes.
104
131
 
132
+ ### Per-app secrets agent (v2, opt-in)
133
+
134
+ The v1 model decrypts every app's secrets to `/run/fleet-secrets/<app>/`, which means any process on the host can read the tmpfs file. v2 replaces that with a per-app systemd-templated socket service:
135
+
136
+ - Each app gets its own age keypair; the vault is encrypted to (admin + per-app) recipients.
137
+ - `fleet-secrets-agent@<app>.service` runs under `DynamicUser=yes`, loads the per-app key via `LoadCredentialEncrypted`, and serves the decrypted env over a Unix socket at `/run/fleet-secrets/<app>.sock`.
138
+ - Consumers (the container) fetch secrets via HTTP/1.1 over the socket. The [`@matthesketh/fleet-secrets-client`](https://www.npmjs.com/package/@matthesketh/fleet-secrets-client) package wraps that protocol for Node apps.
139
+ - `fleet secrets migrate-v2 <app>` orchestrates the move: snapshot, generate per-app keypair, re-encrypt to the new recipient set, edit the compose file + app unit, swap, and auto-rollback on any failure.
140
+ - `fleet secrets revert-v2 <app>` rolls back from a snapshot if you need to drop back to v1.
141
+
105
142
  ### Per-secret rotation (v1.6)
106
143
 
107
144
  Each secret carries metadata (`lastRotated`, `provider`, `strategy`) so fleet knows when it's stale and how to safely rotate it.
@@ -166,6 +203,107 @@ v1 is **observe-only** — it never blocks packets, so zero risk of breaking app
166
203
 
167
204
  `enforce` mode (actual default-deny via nftables) is deferred to a future phase — by design, it requires the operator to explicitly promote a shadow-clean app, never auto-promotes.
168
205
 
206
+ ## Backups (off-host)
207
+
208
+ ```
209
+ fleet backup init # initialise the restic repository
210
+ fleet backup register <app> [--paths …] # tell fleet which paths and dbs to capture
211
+ fleet backup register-all # bulk-register every known app
212
+ fleet backup snapshot <app> [--tag …] # take an on-demand snapshot
213
+ fleet backup snapshot-all # one snapshot per registered app
214
+ fleet backup list [<app>] # list snapshots, latest first
215
+ fleet backup restore <app> --snap <id> --target <dir>
216
+ fleet backup prune # apply retention policy
217
+ fleet backup verify # restic check (data integrity)
218
+ fleet backup integrity # repository integrity report
219
+ fleet backup schedule <app> --cron "..." # install a fleet-backup@<app>.timer unit
220
+ fleet backup schedule-all # bulk-install timers from registered apps
221
+ fleet backup unschedule <app> # remove the timer
222
+ fleet backup status [<app>] # last-snapshot age, sizes, append-only state
223
+ fleet backup serve --port <n> # browser-based restore explorer (see below)
224
+ fleet backup test # end-to-end snapshot+restore smoke
225
+ ```
226
+
227
+ Snapshots go to a restic backend mounted with `append-only` mode so a compromised host can't delete history. Database dumps stream straight into the snapshot via `dumpFileSpawn`, never landing on disk in plaintext. Backups are encrypted twice — restic at rest, plus per-app age encryption inside the snapshot for anything classified as sensitive.
228
+
229
+ ### Restore explorer
230
+
231
+ `fleet backup serve --port 7300` starts a localhost-bound HTTP service designed to live behind nginx. It serves:
232
+
233
+ - a read-only status dashboard at `/backups` showing the latest snapshot per app and any retention lag
234
+ - a tree explorer at `/backups/explore` for browsing snapshot contents
235
+ - a per-file streaming endpoint that pipes `restic dump` straight to the browser
236
+ - a one-click restore into a fresh timestamped staging dir under `/var/restore`
237
+
238
+ Authentication is **TOTP only** — paste the secret into your authenticator app on setup (`fleet backup setup-totp`). Sessions are signed cookies (`fleet_backup_session`), `Secure; HttpOnly; SameSite=Strict; Path=/backups`. Sensitive paths (`.env`, age keys, private SSH keys) are classified server-side and refuse view/download regardless of who's logged in.
239
+
240
+ CSRF posture: every `/api/*` request must carry `x-fleet-backup: 1` (a custom header browsers can't set cross-origin without preflight), and write methods (POST / DELETE) must additionally carry an `Origin` header whose host matches the deployment domain exactly. Read methods tolerate a missing `Origin` so curl probes still work.
241
+
242
+ ## Routines
243
+
244
+ `fleet routines` is a TUI for signal-based scheduled tasks. Each routine has:
245
+
246
+ - a **target repo** the routine operates against
247
+ - a **trigger condition** (signals: open issues count, failing checks, branch ahead of remote, custom git-clean signal, scheduled, manual)
248
+ - a **runner**: `claude-cli` (drives a Claude Code session), `shell` (just runs a script), or `mcp-call` (invokes one MCP tool)
249
+ - a **schema** that validates inputs before the runner sees them
250
+
251
+ Routines run from a systemd-timer-driven service called `fleet-routine@<id>.timer` — fleet ships the templates and `fleet routine-run --id <id>` is the headless entrypoint that timer fires. The TUI shows a signals grid (which routines are gated on what), a routine list, and per-routine run history.
252
+
253
+ ```
254
+ fleet routines # interactive TUI
255
+ fleet routine-run --id <id> [--target <repo>] [--trigger scheduled] [--json]
256
+ ```
257
+
258
+ The claude-cli runner serialises through a `proper-lockfile` mutex so two routines targeting the same repo can't race each other, and respects an abort signal so a `systemctl stop` cleans up mid-run rather than orphaning a child process.
259
+
260
+ ## Mobile pipelines
261
+
262
+ For iOS/macOS apps, fleet drives both the build pipeline and the App Store compliance check.
263
+
264
+ ### TestFlight publishing
265
+
266
+ ```
267
+ fleet testflight publish <app> # dispatch the macOS build workflow to TestFlight
268
+ fleet testflight builds <app> # list TestFlight builds for the app
269
+ fleet testflight update <app> --build <id> --whats-new "..."
270
+ fleet testflight delete <app> --build <id> # expire a TestFlight build
271
+ fleet testflight doctor <app> # check gh + App Store Connect credentials
272
+ ```
273
+
274
+ The publish flow goes through a GitHub Actions macOS runner — fleet dispatches the workflow with the app's bundle ID and version, then polls until the build appears in App Store Connect. Auth uses an API key (issuer + key ID + p8) loaded via a fleet-managed secret.
275
+
276
+ ### App Store compliance audit
277
+
278
+ ```
279
+ fleet audit [target] # run greenlight against a mobile project
280
+ fleet audit guidelines # browse App Store Review Guidelines (list/show/search)
281
+ fleet audit doctor # check the greenlight binary is installed
282
+ fleet audit ignore "<title>" --reason "..." # suppress a greenlight false positive
283
+ fleet audit ignores # list audit ignore rules
284
+ ```
285
+
286
+ Greenlight runs a corpus of App Store Review Guideline checks against the project source. Findings classified as "confirmed false positive" are suppressed via the ignore list so the next run is noise-free; everything else fails the audit. Useful as a pre-flight before each TestFlight publish.
287
+
288
+ ## Cloudflare guard
289
+
290
+ `fleet guard` installs a watchdog layer that detects unauthorised dashboard changes and DNS drift on protected Cloudflare zones.
291
+
292
+ ```
293
+ sudo fleet guard install # install scripts + cron + log rotation
294
+ fleet guard status # show what's protected and the last check time
295
+ fleet guard approve <change-id> # acknowledge a flagged change as intentional
296
+ fleet guard reject <change-id> # treat a flagged change as compromise
297
+ ```
298
+
299
+ Components installed under `/usr/local/sbin`:
300
+
301
+ - **`cf-snapshot`** — periodic snapshot of the Cloudflare zone configuration (DNS, page rules, WAF settings) to `/var/lib/cf-snapshots/`
302
+ - **`cf-audit-monitor`** — diffs the latest snapshot against the previous and surfaces unauthorised changes for operator approval
303
+ - **`dns-drift-watch`** — detects when DNS records drift from the snapshot (cron, alerts via the same channels as `fleet watchdog`)
304
+ - **`cert-expiry-watch`** — flags certs approaching expiry across all protected hosts
305
+ - **`fleet-guard` / `fleet-guard-execute`** — the orchestrator + executor pair, run as the `fleet-guard` system user with no shell
306
+
169
307
  ## Deployment Flow
170
308
 
171
309
  ```mermaid
@@ -224,7 +362,7 @@ Any app with `lastBuiltCommit` unset will trigger a full rebuild the first time
224
362
 
225
363
  ## MCP Server
226
364
 
227
- Fleet exposes 36 tools via the [Model Context Protocol](https://modelcontextprotocol.io/) for AI-assisted server management. Run `fleet mcp` to start the stdio server, or install it into Claude Code:
365
+ Fleet exposes 50+ tools via the [Model Context Protocol](https://modelcontextprotocol.io/) for AI-assisted server management — the static surface (server.ts + git / secrets / deps / audit / testflight tool families) plus every migrated registry command exposed through the registry bridge. Run `fleet mcp` to start the stdio server, or install it into Claude Code:
228
366
 
229
367
  ```bash
230
368
  sudo fleet install-mcp
@@ -240,19 +378,48 @@ See the [bot documentation](https://fleet.hesketh.pro/bot/setup/) for setup inst
240
378
 
241
379
  ## Self-update
242
380
 
243
- When `fleet`'s TUI launches it does a non-blocking `git fetch` against `origin/develop`. If the local repo is behind, a banner appears under the header:
381
+ When `fleet`'s TUI launches it does a non-blocking `git fetch` against the configured update channel and compares HEAD to that remote branch. If the local repo is behind, a banner appears under the header:
244
382
 
245
383
  ```
246
384
  ↑ Update available: 3 commits ahead — feat: ... Press U to install.
247
385
  ```
248
386
 
249
- Pressing `U` runs `git pull --ff-only` then `npm run build` (refused if the working tree is dirty). The new binary is live for the next `fleet …` invocation. Recheck happens every 30 minutes for long-running TUI sessions.
387
+ Pressing `U` runs `git pull --ff-only origin <channel-branch>` then `npm run build` (refused if the working tree is dirty). The new binary is live for the next `fleet …` invocation. Recheck happens every 30 minutes for long-running TUI sessions.
388
+
389
+ **Channels**
390
+
391
+ | channel | tracks | who it's for |
392
+ |---|---|---|
393
+ | `stable` (default) | `origin/main` — tagged releases only | everyone |
394
+ | `prerelease` | `origin/develop` — work in flight, may break | operators willing to canary |
395
+
396
+ Opt into prerelease for the current process:
397
+
398
+ ```bash
399
+ FLEET_UPDATE_CHANNEL=prerelease fleet
400
+ ```
401
+
402
+ Or persist it (e.g. in `~/.bashrc` or the systemd unit's `Environment=`):
403
+
404
+ ```bash
405
+ export FLEET_UPDATE_CHANNEL=prerelease
406
+ ```
407
+
408
+ When the banner is on the prerelease channel it labels itself `↑ Update available (prerelease): …` so you can tell at a glance.
409
+
410
+ **Escape hatch — track an arbitrary branch**
411
+
412
+ For forks or release branches, `FLEET_UPDATE_BRANCH=<name>` overrides the channel entirely:
413
+
414
+ ```bash
415
+ FLEET_UPDATE_BRANCH=release/2026.q3 fleet
416
+ ```
250
417
 
251
418
  ## Testing
252
419
 
253
420
  ```bash
254
- npm test # unit + mocked tests (1106 passing)
255
- FLEET_INTEGRATION=1 npm test # also runs boot-refresh integration tests (1156 passing, 0 skipped)
421
+ npm test # unit + mocked tests (~1720 passing)
422
+ FLEET_INTEGRATION=1 npm test # also runs boot-refresh + secrets-v2 integration tests
256
423
  ```
257
424
 
258
425
  Set `FLEET_INTEGRATION=1` to opt into integration tests that hit real systemd / docker. Skipped by default in CI.
@@ -263,11 +430,14 @@ Set `FLEET_INTEGRATION=1` to opt into integration tests that hit real systemd /
263
430
  git clone https://github.com/wrxck/fleet.git
264
431
  cd fleet
265
432
  npm install
266
- npm test # vitest
267
- npm run build # compile TypeScript to dist/
268
- npm run dev # run with tsx (no build needed)
433
+ npm test # vitest
434
+ npm run build # compile TypeScript to dist/
435
+ npm run dev # run with tsx (no build needed)
436
+ npm run changelog # regenerate CHANGELOG.md from git tags
269
437
  ```
270
438
 
439
+ See [CHANGELOG.md](./CHANGELOG.md) for the release history (auto-generated from tags; the GitHub releases page has extra context). The `npm run changelog` script regenerates it after a tag bump.
440
+
271
441
  ## License
272
442
 
273
443
  MIT
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../core/secrets-v2.js';
3
+ main(process.argv.slice(2)).catch((err) => {
4
+ const message = err instanceof Error ? err.message : String(err);
5
+ process.stderr.write(`[fleet-agent] fatal: ${message}\n`);
6
+ process.exit(1);
7
+ });
package/dist/cli.d.ts CHANGED
@@ -1 +1,6 @@
1
+ /**
2
+ * resolves a command from the registry and runs it. returns true when handled,
3
+ * false when the name is unknown (so run() falls through to the legacy switch).
4
+ */
5
+ export declare function dispatchRegistryCommand(command: string, rest: string[], write?: (s: string) => void): Promise<boolean>;
1
6
  export declare function run(argv: string[]): Promise<void>;
package/dist/cli.js CHANGED
@@ -1,29 +1,23 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
- import { statusCommand } from './commands/status.js';
5
- import { listCommand } from './commands/list.js';
6
- import { startCommand } from './commands/start.js';
7
- import { stopCommand } from './commands/stop.js';
8
- import { restartCommand } from './commands/restart.js';
4
+ import { loadRegistry } from './registry/index.js';
5
+ import { getCommand } from './registry/registry.js';
6
+ import { parseArgs } from './registry/parse-args.js';
7
+ import { renderToText } from './registry/render.js';
8
+ import { makeCliContext } from './registry/context.js';
9
9
  import { logsCommand } from './commands/logs.js';
10
10
  import { egressCommand } from './commands/egress.js';
11
- import { healthCommand } from './commands/health.js';
12
- import { addCommand } from './commands/add.js';
13
- import { removeCommand } from './commands/remove.js';
14
11
  import { deployCommand } from './commands/deploy.js';
15
12
  import { nginxCommand } from './commands/nginx.js';
16
13
  import { secretsCommand } from './commands/secrets.js';
17
14
  import { gitCommand } from './commands/git.js';
18
- import { initCommand } from './commands/init.js';
19
15
  import { depsCommand } from './commands/deps.js';
16
+ import { auditCommand } from './commands/audit.js';
17
+ import { testflightCommand } from './commands/testflight.js';
20
18
  import { watchdogCommand } from './commands/watchdog.js';
21
- import { installMcpCommand } from './commands/install-mcp.js';
22
- import { patchSystemdCommand } from './commands/patch-systemd.js';
23
- import { freezeCommand, unfreezeCommand } from './commands/freeze.js';
24
19
  import { guardCommand } from './commands/guard.js';
25
- import { bootStartCommand } from './commands/boot-start.js';
26
- import { rollbackCommand } from './commands/rollback.js';
20
+ import { backupCommand } from './commands/backup.js';
27
21
  import { routineRunCommand } from './commands/routine-run.js';
28
22
  import { routinesCommand } from './commands/routines.js';
29
23
  import { startMcpServer } from './mcp/server.js';
@@ -50,6 +44,16 @@ Commands:
50
44
  deps config Show/set configuration
51
45
  deps ignore <pkg> Suppress a finding
52
46
  deps init Install cron + MOTD for automated scanning
47
+ audit [target] App Store compliance audit of a mobile project (greenlight)
48
+ audit guidelines Browse Apple App Store Review Guidelines (list|show|search)
49
+ audit doctor Check the greenlight binary is installed
50
+ audit ignore "<title>" --reason "..." Suppress a greenlight false positive
51
+ audit ignores List audit ignore rules
52
+ testflight publish <app> Dispatch the macOS build workflow to TestFlight
53
+ testflight builds <app> List TestFlight builds
54
+ testflight update <app> --build <id> --whats-new "..." Set test notes
55
+ testflight delete <app> --build <id> Expire a TestFlight build
56
+ testflight doctor <app> Check gh + App Store Connect credentials
53
57
  add <app-dir> Register existing app
54
58
  remove <app> Stop, disable, deregister
55
59
  nginx add <domain> --port <port> [--type proxy|spa|nextjs]
@@ -91,6 +95,14 @@ Commands:
91
95
  rollback <app> Roll back app to previous image
92
96
  unfreeze <app> Unfreeze and restart a frozen service
93
97
  guard <subcommand> Cloudflare protection layer (install/status/approve/reject/...)
98
+ backup <subcommand> Encrypted off-host backups via restic + age (init/snapshot/list/restore/...)
99
+ update [--check] [--channel stable|prerelease] [--branch <name>]
100
+ Self-update fleet (check / apply, channel selectable)
101
+ doctor Preflight: host requirements, registry, vault, operator config, orphans
102
+ config [show|get|set] [<field>] [<value>]
103
+ Show or update the operator identity (data/operator.json)
104
+ whoami Print operator identity in one line
105
+ completions <shell> Emit shell completion script (bash | zsh | fish)
94
106
 
95
107
  Global flags:
96
108
  --json Output as JSON
@@ -99,6 +111,45 @@ Global flags:
99
111
  -v, --version Show version
100
112
  -h, --help Show this help
101
113
  `;
114
+ /**
115
+ * resolves a command from the registry and runs it. returns true when handled,
116
+ * false when the name is unknown (so run() falls through to the legacy switch).
117
+ */
118
+ export async function dispatchRegistryCommand(command, rest, write = s => process.stdout.write(s)) {
119
+ loadRegistry();
120
+ const def = getCommand(command);
121
+ if (!def)
122
+ return false;
123
+ // --json is an output flag for the registry dispatch path — handled here,
124
+ // not a per-command argument, so it is stripped before the schema parse
125
+ // would reject it as unknown. legacy (non-registry) commands that still
126
+ // live in the switch below parse --json themselves.
127
+ const jsonMode = rest.includes('--json');
128
+ const cmdArgs = rest.filter(arg => arg !== '--json');
129
+ const parsed = parseArgs(def.args, cmdArgs);
130
+ if (parsed.help) {
131
+ // minimal help for now — one-line summary; richer per-command help is future work.
132
+ write(`${def.name} — ${def.summary}\n`);
133
+ return true;
134
+ }
135
+ if (!parsed.ok) {
136
+ process.stderr.write(`error: ${parsed.error}\n`);
137
+ process.exitCode = 1;
138
+ return true;
139
+ }
140
+ const result = await def.run(parsed.values, makeCliContext());
141
+ if (jsonMode) {
142
+ write(JSON.stringify(result.data, null, 2) + '\n');
143
+ }
144
+ else {
145
+ if (result.render)
146
+ write(renderToText(result.render) + '\n');
147
+ write(result.summary + '\n');
148
+ }
149
+ if (!result.ok)
150
+ process.exitCode = 1;
151
+ return true;
152
+ }
102
153
  export async function run(argv) {
103
154
  const args = argv.slice(2);
104
155
  const command = args[0];
@@ -115,40 +166,31 @@ export async function run(argv) {
115
166
  const { launchTui } = await import('./tui/app.js');
116
167
  return launchTui();
117
168
  }
118
- // Commands that require root privileges
169
+ // commands that require root privileges
119
170
  const ROOT_COMMANDS = new Set([
120
171
  'start', 'stop', 'restart', 'deploy', 'freeze', 'unfreeze',
121
- 'nginx', 'secrets', 'patch-systemd', 'init', 'watchdog',
172
+ 'nginx', 'secrets', 'patch-systemd', 'init', 'watchdog', 'backup',
173
+ 'testflight',
122
174
  ]);
123
175
  if (ROOT_COMMANDS.has(command) && process.getuid && process.getuid() !== 0) {
124
176
  error(`'fleet ${command}' requires root privileges. Run with sudo.`);
125
177
  process.exit(1);
126
178
  }
179
+ if (await dispatchRegistryCommand(command, rest))
180
+ return;
127
181
  switch (command) {
128
- case 'status': return statusCommand(rest);
129
- case 'list': return listCommand(rest);
130
- case 'start': return startCommand(rest);
131
- case 'stop': return stopCommand(rest);
132
- case 'restart': return restartCommand(rest);
133
182
  case 'logs': return logsCommand(rest);
134
183
  case 'egress': return egressCommand(rest);
135
- case 'health': return healthCommand(rest);
136
184
  case 'deps': return depsCommand(rest);
137
- case 'add': return addCommand(rest);
138
- case 'remove': return removeCommand(rest);
185
+ case 'audit': return auditCommand(rest);
186
+ case 'testflight': return testflightCommand(rest);
139
187
  case 'deploy': return deployCommand(rest);
140
188
  case 'nginx': return nginxCommand(rest);
141
189
  case 'secrets': return secretsCommand(rest);
142
190
  case 'git': return gitCommand(rest);
143
- case 'init': return initCommand(rest);
144
191
  case 'watchdog': return watchdogCommand(rest);
145
- case 'install-mcp': return installMcpCommand(rest);
146
- case 'patch-systemd': return patchSystemdCommand(rest);
147
- case 'boot-start': return bootStartCommand(rest);
148
- case 'freeze': return freezeCommand(rest);
149
- case 'rollback': return rollbackCommand(rest);
150
- case 'unfreeze': return unfreezeCommand(rest);
151
192
  case 'guard': return guardCommand(rest);
193
+ case 'backup': return backupCommand(rest);
152
194
  case 'mcp': return startMcpServer();
153
195
  case 'tui':
154
196
  case 'dashboard': {
@@ -1 +1,2 @@
1
- export declare function addCommand(args: string[]): Promise<void>;
1
+ import type { AppEntry } from '../core/registry.js';
2
+ export declare const addCommand: import("../registry/types.js").CommandDef<AppEntry | null>;
@@ -1,53 +1,53 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { resolve, basename } from 'node:path';
3
- import { load, save, addApp } from '../core/registry.js';
3
+ import { z } from 'zod';
4
+ import { addApp, withRegistry } from '../core/registry.js';
4
5
  import { getContainersByCompose } from '../core/docker.js';
5
6
  import { installServiceFile, readServiceFile, enableService } from '../core/systemd.js';
6
7
  import { generateServiceFile } from '../templates/systemd.js';
7
- import { FleetError } from '../core/errors.js';
8
- import { success, info, error, warn } from '../ui/output.js';
9
- import { confirm } from '../ui/confirm.js';
10
- export async function addCommand(args) {
11
- const dryRun = args.includes('--dry-run');
12
- const yes = args.includes('-y') || args.includes('--yes');
13
- const appDir = args.find(a => !a.startsWith('-'));
14
- if (!appDir) {
15
- error('Usage: fleet add <app-dir>');
16
- process.exit(1);
17
- }
18
- const fullPath = resolve(appDir);
19
- if (!existsSync(fullPath)) {
20
- throw new FleetError(`Directory not found: ${fullPath}`);
21
- }
22
- const composePath = findComposePath(fullPath);
23
- if (!composePath.path) {
24
- throw new FleetError(`No docker-compose.yml found in ${fullPath} or ${fullPath}/server`);
25
- }
26
- const name = basename(fullPath).toLowerCase().replace(/[^a-z0-9-]/g, '-');
27
- const existingService = readServiceFile(name);
28
- const hasService = existingService !== null;
29
- info(`Registering ${name} from ${fullPath}`);
30
- info(`Compose path: ${composePath.path}`);
31
- info(`Compose file: ${composePath.file ?? 'default'}`);
32
- const containers = getContainersByCompose(composePath.path, composePath.file);
33
- info(`Found containers: ${containers.join(', ') || 'none running'}`);
34
- const app = {
35
- name,
36
- displayName: name,
37
- composePath: composePath.path,
38
- composeFile: composePath.file,
39
- serviceName: name,
40
- domains: [],
41
- port: null,
42
- usesSharedDb: false,
43
- type: 'service',
44
- containers: containers.length > 0 ? containers : [name],
45
- dependsOnDatabases: false,
46
- registeredAt: new Date().toISOString(),
47
- };
48
- if (!hasService) {
49
- info('No systemd service file found');
50
- if (!dryRun && (yes || await confirm('Create systemd service file?'))) {
8
+ import { assertComposeFile } from '../core/validate.js';
9
+ import { defineCommand } from '../registry/registry.js';
10
+ export const addCommand = defineCommand({
11
+ name: 'add',
12
+ summary: 'Register an existing app',
13
+ args: z.object({
14
+ dir: z.string(),
15
+ 'dry-run': z.boolean().default(false),
16
+ yes: z.boolean().default(false),
17
+ }),
18
+ async run(args, ctx) {
19
+ const fullPath = resolve(args.dir);
20
+ if (!existsSync(fullPath)) {
21
+ return { ok: false, summary: `directory not found: ${fullPath}`, data: null };
22
+ }
23
+ const composePath = findComposePath(fullPath);
24
+ if (!composePath.path) {
25
+ return { ok: false, summary: `no docker-compose.yml found in ${fullPath} or ${fullPath}/server`, data: null };
26
+ }
27
+ const name = basename(fullPath).toLowerCase().replace(/[^a-z0-9-]/g, '-');
28
+ const hasService = readServiceFile(name) !== null;
29
+ ctx.log({ level: 'info', message: `registering ${name} from ${fullPath}` });
30
+ const containers = getContainersByCompose(composePath.path, composePath.file);
31
+ const app = {
32
+ name,
33
+ displayName: name,
34
+ composePath: composePath.path,
35
+ composeFile: composePath.file,
36
+ serviceName: name,
37
+ domains: [],
38
+ port: null,
39
+ usesSharedDb: false,
40
+ type: 'service',
41
+ containers: containers.length > 0 ? containers : [name],
42
+ dependsOnDatabases: false,
43
+ registeredAt: new Date().toISOString(),
44
+ };
45
+ const dryRun = args['dry-run'];
46
+ if (!hasService && !dryRun && (args.yes || (await ctx.confirm('Create systemd service file?')))) {
47
+ // defence-in-depth: validate the compose filename before interpolating
48
+ // it into the generated systemd unit.
49
+ if (composePath.file)
50
+ assertComposeFile(composePath.file);
51
51
  const content = generateServiceFile({
52
52
  serviceName: name,
53
53
  description: `${name} Docker Service`,
@@ -57,21 +57,28 @@ export async function addCommand(args) {
57
57
  });
58
58
  installServiceFile(name, content);
59
59
  enableService(name);
60
- success(`Created and enabled ${name}.service`);
60
+ ctx.log({ level: 'info', message: `created and enabled ${name}.service` });
61
61
  }
62
- }
63
- else {
64
- info('Existing systemd service file found');
65
- }
66
- if (dryRun) {
67
- warn('Dry run - no changes saved');
68
- process.stdout.write(JSON.stringify(app, null, 2) + '\n');
69
- return;
70
- }
71
- const reg = load();
72
- save(addApp(reg, app));
73
- success(`Registered ${name}`);
74
- }
62
+ if (dryRun) {
63
+ return {
64
+ ok: true,
65
+ summary: `dry run — ${name} not registered`,
66
+ data: app,
67
+ render: {
68
+ kind: 'keyValue',
69
+ pairs: [
70
+ ['name', app.name],
71
+ ['composePath', app.composePath],
72
+ ['composeFile', app.composeFile ?? '(default)'],
73
+ ['containers', app.containers.join(', ')],
74
+ ],
75
+ },
76
+ };
77
+ }
78
+ await withRegistry(reg => addApp(reg, app));
79
+ return { ok: true, summary: `registered ${name}`, data: app };
80
+ },
81
+ });
75
82
  function findComposePath(dir) {
76
83
  if (existsSync(`${dir}/docker-compose.yml`)) {
77
84
  return { path: dir, file: null };
@@ -0,0 +1 @@
1
+ export declare function auditCommand(args: string[]): Promise<void>;