@matthesketh/fleet 1.8.1 → 1.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (230) hide show
  1. package/README.md +186 -16
  2. package/dist/bin/fleet-agent.d.ts +2 -0
  3. package/dist/bin/fleet-agent.js +7 -0
  4. package/dist/cli.d.ts +5 -0
  5. package/dist/cli.js +73 -31
  6. package/dist/commands/add.d.ts +2 -1
  7. package/dist/commands/add.js +66 -59
  8. package/dist/commands/audit.d.ts +1 -0
  9. package/dist/commands/audit.js +144 -0
  10. package/dist/commands/backup.d.ts +1 -0
  11. package/dist/commands/backup.js +510 -0
  12. package/dist/commands/boot-start.d.ts +3 -1
  13. package/dist/commands/boot-start.js +39 -47
  14. package/dist/commands/completions.d.ts +6 -0
  15. package/dist/commands/completions.js +83 -0
  16. package/dist/commands/config.d.ts +16 -0
  17. package/dist/commands/config.js +96 -0
  18. package/dist/commands/deploy.js +3 -2
  19. package/dist/commands/deps.js +5 -1
  20. package/dist/commands/doctor.d.ts +32 -0
  21. package/dist/commands/doctor.js +186 -0
  22. package/dist/commands/egress.d.ts +1 -1
  23. package/dist/commands/egress.js +13 -10
  24. package/dist/commands/freeze.d.ts +8 -4
  25. package/dist/commands/freeze.js +77 -59
  26. package/dist/commands/git.js +2 -2
  27. package/dist/commands/health.d.ts +2 -1
  28. package/dist/commands/health.js +38 -56
  29. package/dist/commands/init.d.ts +2 -1
  30. package/dist/commands/init.js +83 -73
  31. package/dist/commands/install-mcp.d.ts +3 -1
  32. package/dist/commands/install-mcp.js +53 -34
  33. package/dist/commands/list.d.ts +2 -1
  34. package/dist/commands/list.js +22 -19
  35. package/dist/commands/logs.js +1 -1
  36. package/dist/commands/patch-systemd.d.ts +7 -1
  37. package/dist/commands/patch-systemd.js +71 -31
  38. package/dist/commands/remove.d.ts +3 -1
  39. package/dist/commands/remove.js +37 -26
  40. package/dist/commands/restart.d.ts +4 -1
  41. package/dist/commands/restart.js +17 -20
  42. package/dist/commands/rollback.d.ts +4 -1
  43. package/dist/commands/rollback.js +33 -42
  44. package/dist/commands/secrets.js +157 -9
  45. package/dist/commands/start.d.ts +4 -1
  46. package/dist/commands/start.js +17 -20
  47. package/dist/commands/status.d.ts +1 -1
  48. package/dist/commands/status.js +21 -26
  49. package/dist/commands/stop.d.ts +4 -1
  50. package/dist/commands/stop.js +17 -20
  51. package/dist/commands/testflight.d.ts +1 -0
  52. package/dist/commands/testflight.js +193 -0
  53. package/dist/commands/update.d.ts +16 -0
  54. package/dist/commands/update.js +95 -0
  55. package/dist/core/audit/cache.d.ts +4 -0
  56. package/dist/core/audit/cache.js +37 -0
  57. package/dist/core/audit/config.d.ts +5 -0
  58. package/dist/core/audit/config.js +35 -0
  59. package/dist/core/audit/greenlight.d.ts +11 -0
  60. package/dist/core/audit/greenlight.js +81 -0
  61. package/dist/core/audit/reporters/cli.d.ts +3 -0
  62. package/dist/core/audit/reporters/cli.js +68 -0
  63. package/dist/core/audit/suppress.d.ts +6 -0
  64. package/dist/core/audit/suppress.js +37 -0
  65. package/dist/core/audit/target.d.ts +5 -0
  66. package/dist/core/audit/target.js +26 -0
  67. package/dist/core/audit/types.d.ts +54 -0
  68. package/dist/core/audit/types.js +5 -0
  69. package/dist/core/backup/browser-api.d.ts +66 -0
  70. package/dist/core/backup/browser-api.js +197 -0
  71. package/dist/core/backup/browser-server.d.ts +11 -0
  72. package/dist/core/backup/browser-server.js +241 -0
  73. package/dist/core/backup/browser-ui.d.ts +5 -0
  74. package/dist/core/backup/browser-ui.js +268 -0
  75. package/dist/core/backup/cloudflare.d.ts +7 -0
  76. package/dist/core/backup/cloudflare.js +82 -0
  77. package/dist/core/backup/config.d.ts +9 -0
  78. package/dist/core/backup/config.js +80 -0
  79. package/dist/core/backup/detect.d.ts +11 -0
  80. package/dist/core/backup/detect.js +71 -0
  81. package/dist/core/backup/dump.d.ts +11 -0
  82. package/dist/core/backup/dump.js +82 -0
  83. package/dist/core/backup/index.d.ts +9 -0
  84. package/dist/core/backup/index.js +9 -0
  85. package/dist/core/backup/repo.d.ts +71 -0
  86. package/dist/core/backup/repo.js +256 -0
  87. package/dist/core/backup/schedule.d.ts +17 -0
  88. package/dist/core/backup/schedule.js +90 -0
  89. package/dist/core/backup/sensitive.d.ts +5 -0
  90. package/dist/core/backup/sensitive.js +37 -0
  91. package/dist/core/backup/status.d.ts +3 -0
  92. package/dist/core/backup/status.js +29 -0
  93. package/dist/core/backup/statuspage.d.ts +23 -0
  94. package/dist/core/backup/statuspage.js +145 -0
  95. package/dist/core/backup/system.d.ts +24 -0
  96. package/dist/core/backup/system.js +209 -0
  97. package/dist/core/backup/totp.d.ts +16 -0
  98. package/dist/core/backup/totp.js +116 -0
  99. package/dist/core/backup/types.d.ts +70 -0
  100. package/dist/core/backup/types.js +7 -0
  101. package/dist/core/backup/unlock.d.ts +19 -0
  102. package/dist/core/backup/unlock.js +69 -0
  103. package/dist/core/boot-refresh.d.ts +1 -1
  104. package/dist/core/boot-refresh.js +10 -9
  105. package/dist/core/deps/actors/pr-creator.d.ts +5 -3
  106. package/dist/core/deps/actors/pr-creator.js +71 -18
  107. package/dist/core/deps/collectors/fetch-with-timeout.d.ts +7 -0
  108. package/dist/core/deps/collectors/fetch-with-timeout.js +16 -0
  109. package/dist/core/deps/collectors/npm.js +3 -1
  110. package/dist/core/deps/collectors/vulnerability.d.ts +8 -0
  111. package/dist/core/deps/collectors/vulnerability.js +31 -2
  112. package/dist/core/deps/config.js +6 -0
  113. package/dist/core/deps/scanner.js +1 -1
  114. package/dist/core/deps/types.d.ts +8 -0
  115. package/dist/core/env.d.ts +3 -0
  116. package/dist/core/env.js +11 -0
  117. package/dist/core/exec.d.ts +1 -0
  118. package/dist/core/exec.js +4 -0
  119. package/dist/core/file-lock.d.ts +18 -0
  120. package/dist/core/file-lock.js +44 -0
  121. package/dist/core/git-onboard.js +10 -13
  122. package/dist/core/github.d.ts +3 -1
  123. package/dist/core/github.js +10 -7
  124. package/dist/core/logs-policy.d.ts +5 -0
  125. package/dist/core/logs-policy.js +20 -1
  126. package/dist/core/operator.d.ts +21 -0
  127. package/dist/core/operator.js +54 -0
  128. package/dist/core/registry.d.ts +18 -0
  129. package/dist/core/registry.js +26 -0
  130. package/dist/core/routines/schema.d.ts +11 -11
  131. package/dist/core/routines/schema.js +14 -3
  132. package/dist/core/routines/store.d.ts +8 -8
  133. package/dist/core/secrets-ops.d.ts +31 -6
  134. package/dist/core/secrets-ops.js +208 -102
  135. package/dist/core/secrets-providers.js +2 -2
  136. package/dist/core/secrets-rotation.d.ts +1 -1
  137. package/dist/core/secrets-rotation.js +58 -52
  138. package/dist/core/secrets-v2-cleanup.d.ts +19 -0
  139. package/dist/core/secrets-v2-cleanup.js +94 -0
  140. package/dist/core/secrets-v2-creds.d.ts +9 -0
  141. package/dist/core/secrets-v2-creds.js +44 -0
  142. package/dist/core/secrets-v2-install.d.ts +13 -0
  143. package/dist/core/secrets-v2-install.js +76 -0
  144. package/dist/core/secrets-v2-keypair.d.ts +10 -0
  145. package/dist/core/secrets-v2-keypair.js +31 -0
  146. package/dist/core/secrets-v2-migrate.d.ts +29 -0
  147. package/dist/core/secrets-v2-migrate.js +395 -0
  148. package/dist/core/secrets-v2-ops.d.ts +36 -0
  149. package/dist/core/secrets-v2-ops.js +184 -0
  150. package/dist/core/secrets-v2-protocol.d.ts +19 -0
  151. package/dist/core/secrets-v2-protocol.js +60 -0
  152. package/dist/core/secrets-v2-snapshot.d.ts +36 -0
  153. package/dist/core/secrets-v2-snapshot.js +115 -0
  154. package/dist/core/secrets-v2.d.ts +21 -0
  155. package/dist/core/secrets-v2.js +249 -0
  156. package/dist/core/secrets.d.ts +39 -4
  157. package/dist/core/secrets.js +91 -11
  158. package/dist/core/self-update.d.ts +32 -11
  159. package/dist/core/self-update.js +52 -14
  160. package/dist/core/testflight/asc.d.ts +12 -0
  161. package/dist/core/testflight/asc.js +101 -0
  162. package/dist/core/testflight/credentials.d.ts +3 -0
  163. package/dist/core/testflight/credentials.js +35 -0
  164. package/dist/core/testflight/resolve.d.ts +6 -0
  165. package/dist/core/testflight/resolve.js +44 -0
  166. package/dist/core/testflight/types.d.ts +13 -0
  167. package/dist/core/testflight/types.js +3 -0
  168. package/dist/core/testflight/workflow.d.ts +17 -0
  169. package/dist/core/testflight/workflow.js +65 -0
  170. package/dist/core/validate.d.ts +1 -0
  171. package/dist/core/validate.js +8 -0
  172. package/dist/index.js +0 -0
  173. package/dist/mcp/audit-tools.d.ts +2 -0
  174. package/dist/mcp/audit-tools.js +94 -0
  175. package/dist/mcp/git-tools.js +1 -1
  176. package/dist/mcp/registry-bridge.d.ts +10 -0
  177. package/dist/mcp/registry-bridge.js +65 -0
  178. package/dist/mcp/secrets-tools.js +2 -2
  179. package/dist/mcp/server.js +16 -82
  180. package/dist/mcp/testflight-tools.d.ts +2 -0
  181. package/dist/mcp/testflight-tools.js +52 -0
  182. package/dist/registry/context.d.ts +7 -0
  183. package/dist/registry/context.js +37 -0
  184. package/dist/registry/index.d.ts +5 -0
  185. package/dist/registry/index.js +44 -0
  186. package/dist/registry/parse-args.d.ts +13 -0
  187. package/dist/registry/parse-args.js +74 -0
  188. package/dist/registry/registry.d.ts +24 -0
  189. package/dist/registry/registry.js +26 -0
  190. package/dist/registry/render.d.ts +3 -0
  191. package/dist/registry/render.js +29 -0
  192. package/dist/registry/types.d.ts +50 -0
  193. package/dist/registry/types.js +1 -0
  194. package/dist/templates/agent-unit.d.ts +5 -0
  195. package/dist/templates/agent-unit.js +40 -0
  196. package/dist/templates/app-unit-edit.d.ts +2 -0
  197. package/dist/templates/app-unit-edit.js +46 -0
  198. package/dist/templates/compose-edit.d.ts +2 -0
  199. package/dist/templates/compose-edit.js +156 -0
  200. package/dist/templates/nginx.js +11 -0
  201. package/dist/templates/systemd.js +6 -0
  202. package/dist/tui/components/ArgForm.d.ts +7 -0
  203. package/dist/tui/components/ArgForm.js +64 -0
  204. package/dist/tui/components/ArgForm.test.d.ts +1 -0
  205. package/dist/tui/components/ArgForm.test.js +19 -0
  206. package/dist/tui/components/KeyHint.js +5 -0
  207. package/dist/tui/hooks/use-secrets.d.ts +8 -8
  208. package/dist/tui/hooks/use-secrets.js +7 -7
  209. package/dist/tui/router.d.ts +1 -0
  210. package/dist/tui/router.js +26 -9
  211. package/dist/tui/router.test.d.ts +1 -0
  212. package/dist/tui/router.test.js +13 -0
  213. package/dist/tui/routines/components/SignalsGrid.test.js +2 -2
  214. package/dist/tui/routines/tabs/ScaffoldTab.js +1 -1
  215. package/dist/tui/tests/redaction-rerender.test.d.ts +1 -0
  216. package/dist/tui/tests/redaction-rerender.test.js +53 -0
  217. package/dist/tui/tests/scroll-flicker-proof.test.d.ts +1 -0
  218. package/dist/tui/tests/scroll-flicker-proof.test.js +145 -0
  219. package/dist/tui/types.d.ts +1 -1
  220. package/dist/tui/views/CommandPalette.d.ts +5 -0
  221. package/dist/tui/views/CommandPalette.js +90 -0
  222. package/dist/tui/views/CommandPalette.test.d.ts +1 -0
  223. package/dist/tui/views/CommandPalette.test.js +117 -0
  224. package/dist/tui/views/Dashboard.js +9 -6
  225. package/dist/tui/views/HealthView.js +9 -4
  226. package/dist/tui/views/SecretEdit.js +15 -16
  227. package/dist/tui/views/SecretEdit.test.d.ts +1 -0
  228. package/dist/tui/views/SecretEdit.test.js +82 -0
  229. package/dist/tui/views/SecretsView.js +26 -16
  230. package/package.json +8 -5
@@ -0,0 +1,65 @@
1
+ import { loadRegistry } from '../registry/index.js';
2
+ import { allCommands } from '../registry/registry.js';
3
+ import { makeMcpContext } from '../registry/context.js';
4
+ /**
5
+ * the mcp tool name for a registry command. ':' namespacing is not legal in
6
+ * tool names, so subcommands collapse to underscores. this string is the
7
+ * cross-surface contract, so both bridge functions derive it the same way.
8
+ */
9
+ function toMcpToolName(commandName) {
10
+ return 'fleet_' + commandName.replace(/:/g, '_');
11
+ }
12
+ /** registry commands as flat tool descriptors, for inspection and tests. */
13
+ export function collectRegistryTools() {
14
+ loadRegistry();
15
+ return allCommands()
16
+ .filter(def => !def.cliOnly)
17
+ .map(def => ({ toolName: toMcpToolName(def.name), summary: def.summary }));
18
+ }
19
+ /** registers every non-cliOnly registry command as an mcp tool. */
20
+ export function registerRegistryTools(server) {
21
+ loadRegistry();
22
+ for (const def of allCommands()) {
23
+ if (def.cliOnly)
24
+ continue;
25
+ server.tool(toMcpToolName(def.name), def.summary, def.args.shape, async (args) => {
26
+ // `confirm` is mcp surface plumbing, not a command arg — read it from the
27
+ // raw input before the schema parse below strips unknown keys.
28
+ const ctx = makeMcpContext(args.confirm === true);
29
+ try {
30
+ // validate, coerce and default args against the command schema — the
31
+ // same safeParse the cli dispatcher runs via parseArgs, so both
32
+ // surfaces invoke `run` with an identically-validated args shape.
33
+ const parsed = def.args.safeParse(args);
34
+ if (!parsed.success) {
35
+ const detail = parsed.error.issues
36
+ .map(iss => `${iss.path.join('.')}: ${iss.message}`)
37
+ .join('; ');
38
+ return {
39
+ content: [{ type: 'text', text: `invalid arguments: ${detail}` }],
40
+ isError: true,
41
+ };
42
+ }
43
+ const result = await def.run(parsed.data, ctx);
44
+ return {
45
+ content: [{ type: 'text', text: result.summary }],
46
+ // structuredContent must be an object — only attach it when the
47
+ // command actually returned one, otherwise the sdk rejects the shape.
48
+ ...(result.data && typeof result.data === 'object'
49
+ ? { structuredContent: result.data }
50
+ : {}),
51
+ isError: !result.ok,
52
+ };
53
+ }
54
+ catch (err) {
55
+ // the bridge is the single funnel for every migrated command — a
56
+ // thrown handler must still surface as a structured tool failure,
57
+ // not an opaque transport-level rejection.
58
+ return {
59
+ content: [{ type: 'text', text: err instanceof Error ? err.message : String(err) }],
60
+ isError: true,
61
+ };
62
+ }
63
+ });
64
+ }
65
+ }
@@ -19,7 +19,7 @@ export function registerSecretsTools(server) {
19
19
  value: z.string().describe('Secret value'),
20
20
  }, async ({ app, key, value }) => {
21
21
  requireVault();
22
- setSecret(app, key, value);
22
+ await setSecret(app, key, value);
23
23
  return text(`Set ${key} for ${app} in vault. Run fleet_secrets_unseal + restart the app to apply at runtime.`);
24
24
  });
25
25
  server.tool('fleet_secrets_get', 'Get a single decrypted secret value from the vault. ' +
@@ -41,7 +41,7 @@ export function registerSecretsTools(server) {
41
41
  app: z.string().optional().describe('App name (omit to seal all apps)'),
42
42
  }, async ({ app }) => {
43
43
  requireVault();
44
- const sealed = sealFromRuntime(app);
44
+ const sealed = await sealFromRuntime(app);
45
45
  return text(`Sealed ${sealed.length} app(s): ${sealed.join(', ')}. Changes will now persist across reboots.`);
46
46
  });
47
47
  server.tool('fleet_secrets_drift', 'Detect drift between vault (encrypted, survives reboot) and runtime (/run/fleet-secrets/, lost on reboot). ' +
@@ -4,26 +4,25 @@ import { fileURLToPath } from 'node:url';
4
4
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
5
5
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
6
6
  import { z } from 'zod';
7
- import { getStatusData } from '../commands/status.js';
8
- import { load, findApp, save, addApp } from '../core/registry.js';
9
- import { startService, stopService, restartService } from '../core/systemd.js';
7
+ import { load, findApp, addApp, withRegistry } from '../core/registry.js';
8
+ import { restartService } from '../core/systemd.js';
10
9
  import { getContainerLogs, getContainersByCompose } from '../core/docker.js';
11
- import { checkHealth, checkAllHealth } from '../core/health.js';
12
10
  import { listSites, installConfig, testConfig, reload, removeConfig } from '../core/nginx.js';
13
11
  import { generateNginxConfig } from '../templates/nginx.js';
14
12
  import { composeBuild } from '../core/docker.js';
15
- import { execSafe } from '../core/exec.js';
16
13
  import { AppNotFoundError } from '../core/errors.js';
17
- import { assertAppName, assertServiceName, assertFilePath, assertDomain } from '../core/validate.js';
14
+ import { assertAppName, assertServiceName, assertFilePath, assertDomain, assertComposeFile } from '../core/validate.js';
18
15
  import { loadManifest, listSecrets, isInitialized } from '../core/secrets.js';
19
16
  import { unsealAll, getStatus as getSecretsStatus } from '../core/secrets-ops.js';
20
17
  import { validateApp, validateAll } from '../core/secrets-validate.js';
21
- import { freezeApp, unfreezeApp } from '../commands/freeze.js';
22
18
  import { registerGitTools } from './git-tools.js';
23
19
  import { registerSecretsTools } from './secrets-tools.js';
20
+ import { registerRegistryTools } from './registry-bridge.js';
24
21
  import { readContainerLogs, getLogStatus, effectivePolicy } from '../core/logs-policy.js';
25
22
  import { snapshotEgress } from '../core/egress.js';
26
23
  import { registerDepsTools } from './deps-tools.js';
24
+ import { registerAuditTools } from './audit-tools.js';
25
+ import { registerTestflightTools } from './testflight-tools.js';
27
26
  function requireApp(name) {
28
27
  const reg = load();
29
28
  const app = findApp(reg, name);
@@ -41,29 +40,7 @@ export async function startMcpServer() {
41
40
  name: 'fleet',
42
41
  version: pkg.version,
43
42
  });
44
- server.tool('fleet_status', 'Dashboard data for all apps: systemd state, containers, health', async () => {
45
- const data = getStatusData();
46
- return text(JSON.stringify(data, null, 2));
47
- });
48
- server.tool('fleet_list', 'List all registered apps with their configuration', async () => {
49
- const reg = load();
50
- return text(JSON.stringify(reg.apps, null, 2));
51
- });
52
- server.tool('fleet_start', 'Start an app via systemctl', { app: z.string().describe('App name') }, async ({ app }) => {
53
- const entry = requireApp(app);
54
- const ok = startService(entry.serviceName);
55
- return text(ok ? `Started ${entry.name}` : `Failed to start ${entry.name}`);
56
- });
57
- server.tool('fleet_stop', 'Stop an app via systemctl', { app: z.string().describe('App name') }, async ({ app }) => {
58
- const entry = requireApp(app);
59
- const ok = stopService(entry.serviceName);
60
- return text(ok ? `Stopped ${entry.name}` : `Failed to stop ${entry.name}`);
61
- });
62
- server.tool('fleet_restart', 'Restart an app via systemctl', { app: z.string().describe('App name') }, async ({ app }) => {
63
- const entry = requireApp(app);
64
- const ok = restartService(entry.serviceName);
65
- return text(ok ? `Restarted ${entry.name}` : `Failed to restart ${entry.name}`);
66
- });
43
+ registerRegistryTools(server);
67
44
  server.tool('fleet_logs', 'DEPRECATED — prefer fleet_logs_recent (token-conservative defaults) or fleet_logs_summary. Get recent container logs for an app.', {
68
45
  app: z.string().describe('App name'),
69
46
  container: z.string().optional().describe('Container name (omit to list available containers, or get logs from first)'),
@@ -196,18 +173,6 @@ export async function startMcpServer() {
196
173
  }
197
174
  return text(JSON.stringify(out, null, 2));
198
175
  });
199
- server.tool('fleet_health', 'Run health checks for one or all apps', { app: z.string().optional().describe('App name (omit for all apps)') }, async ({ app }) => {
200
- const reg = load();
201
- if (app) {
202
- const entry = findApp(reg, app);
203
- if (!entry)
204
- throw new AppNotFoundError(app);
205
- const result = checkHealth(entry);
206
- return text(JSON.stringify(result, null, 2));
207
- }
208
- const results = checkAllHealth(reg.apps);
209
- return text(JSON.stringify(results, null, 2));
210
- });
211
176
  server.tool('fleet_deploy', 'Deploy an app: build and restart', { app: z.string().describe('App name') }, async ({ app }) => {
212
177
  const entry = requireApp(app);
213
178
  const buildOk = composeBuild(entry.composePath, entry.composeFile, entry.name);
@@ -287,7 +252,7 @@ export async function startMcpServer() {
287
252
  if (params.serviceName)
288
253
  assertServiceName(params.serviceName);
289
254
  if (params.composeFile)
290
- assertFilePath(params.composeFile);
255
+ assertComposeFile(params.composeFile);
291
256
  for (const d of (params.domains ?? []))
292
257
  assertDomain(d);
293
258
  }
@@ -297,8 +262,6 @@ export async function startMcpServer() {
297
262
  if (!existsSync(params.composePath)) {
298
263
  return text(`Error: composePath does not exist: ${params.composePath}`);
299
264
  }
300
- const reg = load();
301
- const existing = findApp(reg, params.name);
302
265
  let containers = params.containers;
303
266
  if (!containers || containers.length === 0) {
304
267
  containers = getContainersByCompose(params.composePath, params.composeFile ?? null);
@@ -319,48 +282,19 @@ export async function startMcpServer() {
319
282
  dependsOnDatabases: params.dependsOnDatabases,
320
283
  registeredAt: new Date().toISOString(),
321
284
  };
322
- save(addApp(reg, entry));
323
- const action = existing ? 'Updated' : 'Registered';
285
+ let existed = false;
286
+ await withRegistry(reg => {
287
+ existed = !!findApp(reg, params.name);
288
+ return addApp(reg, entry);
289
+ });
290
+ const action = existed ? 'Updated' : 'Registered';
324
291
  return text(`${action} app "${params.name}":\n${JSON.stringify(entry, null, 2)}`);
325
292
  });
326
- server.tool('fleet_freeze', 'Freeze a crash-looping service: stop it, disable it, and mark it frozen in the registry. Requires manual unfreezing.', {
327
- app: z.string().describe('App name'),
328
- reason: z.string().optional().describe('Reason for freezing'),
329
- }, async ({ app, reason }) => {
330
- freezeApp(app, reason);
331
- return text(`Frozen ${app}${reason ? `: ${reason}` : ''}`);
332
- });
333
- server.tool('fleet_unfreeze', 'Unfreeze a frozen service: clear frozen state, enable and start the service.', { app: z.string().describe('App name') }, async ({ app }) => {
334
- unfreezeApp(app);
335
- return text(`Unfrozen ${app} — service enabled and started`);
336
- });
337
- server.tool('fleet_rollback', 'Roll back an app to its previous image (tagged <repo>:fleet-previous before the last build) and restart the service. Use this when a recent deploy or boot-refresh produced a broken image.', { app: z.string().describe('App name') }, async ({ app }) => {
338
- const entry = requireApp(app);
339
- // Resolve current image name via compose config
340
- const config = execSafe('docker', ['compose', ...(entry.composeFile ? ['-f', entry.composeFile] : []), 'config', '--images'], { cwd: entry.composePath, timeout: 15_000 });
341
- if (!config.ok)
342
- return text(`Could not resolve image name for ${entry.name}: ${config.stderr}`);
343
- const latest = config.stdout.split('\n').filter(Boolean)[0];
344
- if (!latest)
345
- return text(`Could not resolve image name for ${entry.name}`);
346
- // Compute previous tag via lastIndexOf (handles registry:port/repo:tag)
347
- const lastColon = latest.lastIndexOf(':');
348
- const base = lastColon > 0 ? latest.slice(0, lastColon) : latest;
349
- const previous = `${base}:fleet-previous`;
350
- if (!execSafe('docker', ['image', 'inspect', previous], { timeout: 10_000 }).ok) {
351
- return text(`No previous image found (${previous}) — nothing to roll back to`);
352
- }
353
- const tag = execSafe('docker', ['tag', previous, latest], { timeout: 10_000 });
354
- if (!tag.ok)
355
- return text(`docker tag failed: ${tag.stderr}`);
356
- const ok = restartService(entry.serviceName);
357
- return text(ok
358
- ? `Rolled back ${entry.name} to ${previous}`
359
- : `Tag flipped but service restart failed for ${entry.serviceName}`);
360
- });
361
293
  registerGitTools(server);
362
294
  registerSecretsTools(server);
363
295
  registerDepsTools(server);
296
+ registerAuditTools(server);
297
+ registerTestflightTools(server);
364
298
  const transport = new StdioServerTransport();
365
299
  await server.connect(transport);
366
300
  }
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerTestflightTools(server: McpServer): void;
@@ -0,0 +1,52 @@
1
+ import { z } from 'zod';
2
+ import { resolveTestflightTarget, appSecretsEnv } from '../core/testflight/resolve.js';
3
+ import { resolveAscCredentials, hasAscCredentials } from '../core/testflight/credentials.js';
4
+ import { listBuilds, verifyApp } from '../core/testflight/asc.js';
5
+ import { ghVersion, resolveRepo } from '../core/testflight/workflow.js';
6
+ function text(msg) {
7
+ return { content: [{ type: 'text', text: msg }] };
8
+ }
9
+ export function registerTestflightTools(server) {
10
+ server.tool('fleet_testflight_builds', "List an app's TestFlight builds via the App Store Connect API — build number, " +
11
+ 'version, processing state and expiry. Requires the app\'s ASC credentials and ' +
12
+ 'ASC_APP_ID in its fleet secrets.', { app: z.string().describe('Registered fleet app name') }, async ({ app }) => {
13
+ const { app: name } = resolveTestflightTarget(app);
14
+ const env = appSecretsEnv(name);
15
+ if (!hasAscCredentials(env)) {
16
+ return text(`App Store Connect credentials missing for ${name}.`);
17
+ }
18
+ const ascAppId = env.ASC_APP_ID;
19
+ if (!ascAppId)
20
+ return text(`ASC_APP_ID not set for ${name}.`);
21
+ const builds = await listBuilds(resolveAscCredentials(env), ascAppId);
22
+ return text(JSON.stringify(builds, null, 2));
23
+ });
24
+ server.tool('fleet_testflight_doctor', 'Check TestFlight publishing readiness for an app: GitHub CLI availability, the ' +
25
+ 'GitHub repo backing the build workflow, App Store Connect credentials, and — when ' +
26
+ 'ASC_APP_ID is set — that the ASC API is reachable.', { app: z.string().describe('Registered fleet app name') }, async ({ app }) => {
27
+ const { app: name, projectPath } = resolveTestflightTarget(app);
28
+ const env = appSecretsEnv(name);
29
+ const lines = [
30
+ `gh cli: ${ghVersion() ?? 'not found'}`,
31
+ `github repo: ${resolveRepo(projectPath) ?? 'not resolved'}`,
32
+ ];
33
+ if (!hasAscCredentials(env)) {
34
+ lines.push('asc credentials: missing — need ASC_API_KEY_ID, ASC_API_KEY_ISSUER_ID, ASC_API_KEY_B64');
35
+ return text(lines.join('\n'));
36
+ }
37
+ lines.push('asc credentials: present');
38
+ if (env.ASC_APP_ID) {
39
+ try {
40
+ const appName = await verifyApp(resolveAscCredentials(env), env.ASC_APP_ID);
41
+ lines.push(`asc api: reachable — app "${appName}"`);
42
+ }
43
+ catch (err) {
44
+ lines.push(`asc api: check failed — ${err.message}`);
45
+ }
46
+ }
47
+ else {
48
+ lines.push('asc app id: not set');
49
+ }
50
+ return text(lines.join('\n'));
51
+ });
52
+ }
@@ -0,0 +1,7 @@
1
+ import type { CommandContext } from './types.js';
2
+ /** cli context: confirm prompts on stdin, log writes to stderr. */
3
+ export declare function makeCliContext(): CommandContext;
4
+ /** mcp context: confirm is pre-resolved from the tool's `confirm` argument.
5
+ * per-event logs are silently dropped — the command result's `summary` is
6
+ * the sole output channel on the mcp surface. */
7
+ export declare function makeMcpContext(confirmGranted: boolean): CommandContext;
@@ -0,0 +1,37 @@
1
+ import { createInterface } from 'node:readline';
2
+ /** cli context: confirm prompts on stdin, log writes to stderr. */
3
+ export function makeCliContext() {
4
+ return {
5
+ env: process.env,
6
+ log(event) {
7
+ process.stderr.write(`[${event.level}] ${event.message}\n`);
8
+ },
9
+ confirm(prompt) {
10
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
11
+ return new Promise(resolve => {
12
+ // stdin EOF / SIGINT closes the interface — treat that as a "no".
13
+ rl.on('close', () => resolve(false));
14
+ rl.question(`${prompt} [y/N] `, answer => {
15
+ // resolve before close: the first resolve wins, so the close
16
+ // handler firing afterwards is a harmless no-op.
17
+ resolve(/^y(es)?$/i.test(answer.trim()));
18
+ rl.close();
19
+ });
20
+ });
21
+ },
22
+ };
23
+ }
24
+ /** mcp context: confirm is pre-resolved from the tool's `confirm` argument.
25
+ * per-event logs are silently dropped — the command result's `summary` is
26
+ * the sole output channel on the mcp surface. */
27
+ export function makeMcpContext(confirmGranted) {
28
+ return {
29
+ env: process.env,
30
+ log() {
31
+ // mcp surfaces collect output via the result; per-event logs are dropped.
32
+ },
33
+ async confirm() {
34
+ return confirmGranted;
35
+ },
36
+ };
37
+ }
@@ -0,0 +1,5 @@
1
+ /** registers every CommandDef. idempotent — safe to call from each surface. */
2
+ export declare function loadRegistry(): void;
3
+ /** test-only: resets the loaded flag and clears the registry, so a test can
4
+ * re-run loadRegistry from a clean state. */
5
+ export declare function _resetLoader(): void;
@@ -0,0 +1,44 @@
1
+ import { register, _resetRegistry } from './registry.js';
2
+ import { addCommand } from '../commands/add.js';
3
+ import { listCommand } from '../commands/list.js';
4
+ import { statusCommand } from '../commands/status.js';
5
+ import { startCommand } from '../commands/start.js';
6
+ import { stopCommand } from '../commands/stop.js';
7
+ import { restartCommand } from '../commands/restart.js';
8
+ import { healthCommand } from '../commands/health.js';
9
+ import { freezeCommand, unfreezeCommand } from '../commands/freeze.js';
10
+ import { rollbackCommand } from '../commands/rollback.js';
11
+ import { removeCommand } from '../commands/remove.js';
12
+ import { initCommand } from '../commands/init.js';
13
+ import { patchSystemdCommand } from '../commands/patch-systemd.js';
14
+ import { bootStartCommand } from '../commands/boot-start.js';
15
+ import { installMcpCommand } from '../commands/install-mcp.js';
16
+ import { updateCommand } from '../commands/update.js';
17
+ import { doctorCommand } from '../commands/doctor.js';
18
+ import { configCommand, whoamiCommand } from '../commands/config.js';
19
+ import { completionsCommand } from '../commands/completions.js';
20
+ /** every command definition. commands are added here as they are migrated
21
+ * onto the registry. */
22
+ const ALL = [
23
+ addCommand, statusCommand, listCommand, startCommand, stopCommand, restartCommand,
24
+ healthCommand, freezeCommand, unfreezeCommand, rollbackCommand, removeCommand,
25
+ initCommand, patchSystemdCommand, bootStartCommand, installMcpCommand,
26
+ updateCommand, doctorCommand, configCommand, whoamiCommand, completionsCommand,
27
+ ];
28
+ let loaded = false;
29
+ /** registers every CommandDef. idempotent — safe to call from each surface. */
30
+ export function loadRegistry() {
31
+ if (loaded)
32
+ return;
33
+ _resetRegistry();
34
+ for (const def of ALL) {
35
+ register(def);
36
+ }
37
+ loaded = true;
38
+ }
39
+ /** test-only: resets the loaded flag and clears the registry, so a test can
40
+ * re-run loadRegistry from a clean state. */
41
+ export function _resetLoader() {
42
+ loaded = false;
43
+ _resetRegistry();
44
+ }
@@ -0,0 +1,13 @@
1
+ import { z } from 'zod';
2
+ export type ParseResult = {
3
+ help: true;
4
+ } | {
5
+ help: false;
6
+ ok: true;
7
+ values: Record<string, unknown>;
8
+ } | {
9
+ help: false;
10
+ ok: false;
11
+ error: string;
12
+ };
13
+ export declare function parseArgs(schema: z.ZodObject<z.ZodRawShape>, argv: string[]): ParseResult;
@@ -0,0 +1,74 @@
1
+ import { z } from 'zod';
2
+ /** single-dash short flags, mapped to the long-form (schema) field they set.
3
+ * short flags only ever set a boolean field true. */
4
+ const SHORT_FLAGS = { y: 'yes' };
5
+ /** true when a schema field (unwrapping optional/default) is a boolean. */
6
+ function isBooleanField(schema) {
7
+ let s = schema;
8
+ while (s instanceof z.ZodOptional || s instanceof z.ZodDefault) {
9
+ s = s._def.innerType;
10
+ }
11
+ return s instanceof z.ZodBoolean;
12
+ }
13
+ export function parseArgs(schema, argv) {
14
+ if (argv.includes('--help') || argv.includes('-h')) {
15
+ return { help: true };
16
+ }
17
+ const shape = schema.shape;
18
+ const fieldNames = Object.keys(shape);
19
+ const booleanFields = new Set(fieldNames.filter(n => isBooleanField(shape[n])));
20
+ const values = {};
21
+ const positionals = [];
22
+ for (let i = 0; i < argv.length; i++) {
23
+ const token = argv[i];
24
+ if (token.startsWith('--')) {
25
+ const body = token.slice(2);
26
+ const eq = body.indexOf('=');
27
+ if (eq !== -1) {
28
+ // --key=value form
29
+ values[body.slice(0, eq)] = body.slice(eq + 1);
30
+ continue;
31
+ }
32
+ if (booleanFields.has(body)) {
33
+ values[body] = true;
34
+ continue;
35
+ }
36
+ const next = argv[i + 1];
37
+ if (next === undefined || next.startsWith('--')) {
38
+ return { help: false, ok: false, error: `flag --${body} requires a value` };
39
+ }
40
+ values[body] = next;
41
+ i++;
42
+ }
43
+ else if (/^-[A-Za-z]$/.test(token)) {
44
+ // single-dash short flag (e.g. -y) — resolve via the alias table
45
+ const long = SHORT_FLAGS[token.slice(1)];
46
+ if (long === undefined || !(long in shape)) {
47
+ return { help: false, ok: false, error: `unknown flag: ${token}` };
48
+ }
49
+ values[long] = true;
50
+ }
51
+ else {
52
+ positionals.push(token);
53
+ }
54
+ }
55
+ // reject flags that aren't in the schema — a mistyped flag must not be dropped silently
56
+ const unknownFlags = Object.keys(values).filter(k => !(k in shape));
57
+ if (unknownFlags.length > 0) {
58
+ return { help: false, ok: false, error: `unknown flag(s): ${unknownFlags.join(', ')}` };
59
+ }
60
+ // assign leftover positionals to non-boolean fields in declaration order
61
+ const positionalFields = fieldNames.filter(n => !booleanFields.has(n) && !(n in values));
62
+ for (let i = 0; i < positionals.length && i < positionalFields.length; i++) {
63
+ values[positionalFields[i]] = positionals[i];
64
+ }
65
+ const parsed = schema.safeParse(values);
66
+ if (!parsed.success) {
67
+ return {
68
+ help: false,
69
+ ok: false,
70
+ error: parsed.error.issues.map(iss => `${iss.path.join('.')}: ${iss.message}`).join('; '),
71
+ };
72
+ }
73
+ return { help: false, ok: true, values: parsed.data };
74
+ }
@@ -0,0 +1,24 @@
1
+ import type { z } from 'zod';
2
+ import type { CommandContext, CommandDef, CommandResult } from './types.js';
3
+ export declare function register(def: CommandDef): void;
4
+ /**
5
+ * defines a command with full argument-type inference: the `args` schema and
6
+ * the `run` handler's first parameter are tied through the shape generic `S`.
7
+ * returns the erased `CommandDef` the registry stores.
8
+ */
9
+ export declare function defineCommand<S extends z.ZodRawShape, D>(def: {
10
+ name: string;
11
+ summary: string;
12
+ args: z.ZodObject<S>;
13
+ destructive?: boolean;
14
+ cliOnly?: boolean;
15
+ tui?: 'palette' | {
16
+ view: string;
17
+ };
18
+ run(args: z.infer<z.ZodObject<S>>, ctx: CommandContext): Promise<CommandResult<D>>;
19
+ }): CommandDef<D>;
20
+ export declare function getCommand(name: string): CommandDef | undefined;
21
+ export declare function allCommands(): CommandDef[];
22
+ /** test-only: clears the registry between tests. prefer _resetLoader() from
23
+ * registry/index.ts if you also need to reset the loadRegistry guard. */
24
+ export declare function _resetRegistry(): void;
@@ -0,0 +1,26 @@
1
+ const registry = new Map();
2
+ export function register(def) {
3
+ if (registry.has(def.name)) {
4
+ throw new Error(`duplicate command registration: ${def.name}`);
5
+ }
6
+ registry.set(def.name, def);
7
+ }
8
+ /**
9
+ * defines a command with full argument-type inference: the `args` schema and
10
+ * the `run` handler's first parameter are tied through the shape generic `S`.
11
+ * returns the erased `CommandDef` the registry stores.
12
+ */
13
+ export function defineCommand(def) {
14
+ return def;
15
+ }
16
+ export function getCommand(name) {
17
+ return registry.get(name);
18
+ }
19
+ export function allCommands() {
20
+ return [...registry.values()].sort((a, b) => a.name.localeCompare(b.name));
21
+ }
22
+ /** test-only: clears the registry between tests. prefer _resetLoader() from
23
+ * registry/index.ts if you also need to reset the loadRegistry guard. */
24
+ export function _resetRegistry() {
25
+ registry.clear();
26
+ }
@@ -0,0 +1,3 @@
1
+ import type { RenderModel } from './types.js';
2
+ /** turns a RenderModel into a plain-text block for cli output. */
3
+ export declare function renderToText(model: RenderModel): string;
@@ -0,0 +1,29 @@
1
+ /** turns a RenderModel into a plain-text block for cli output. */
2
+ export function renderToText(model) {
3
+ switch (model.kind) {
4
+ case 'lines':
5
+ return model.lines.join('\n');
6
+ case 'keyValue': {
7
+ const width = Math.max(0, ...model.pairs.map(([k]) => k.length));
8
+ return model.pairs.map(([k, v]) => `${k.padEnd(width + 2)}${v}`).join('\n');
9
+ }
10
+ case 'table': {
11
+ const all = [model.columns, ...model.rows];
12
+ const widths = model.columns.map((_, i) => Math.max(...all.map(row => (row[i] ?? '').length)));
13
+ const fmt = (row) => row
14
+ .slice(0, model.columns.length)
15
+ .map((cell, i) => (i === model.columns.length - 1 ? cell : cell.padEnd(widths[i] + 2)))
16
+ .join('');
17
+ return all.map(fmt).join('\n');
18
+ }
19
+ case 'tree':
20
+ return treeLines(model.root, 0).join('\n');
21
+ }
22
+ }
23
+ function treeLines(node, depth) {
24
+ const out = [' '.repeat(depth) + node.label];
25
+ for (const child of node.children ?? []) {
26
+ out.push(...treeLines(child, depth + 1));
27
+ }
28
+ return out;
29
+ }
@@ -0,0 +1,50 @@
1
+ import type { z } from 'zod';
2
+ /** a renderable model each surface turns into text (cli) or components (tui). */
3
+ export type RenderModel = {
4
+ kind: 'lines';
5
+ lines: string[];
6
+ } | {
7
+ kind: 'keyValue';
8
+ pairs: Array<[string, string]>;
9
+ } | {
10
+ kind: 'table';
11
+ columns: string[];
12
+ rows: string[][];
13
+ } | {
14
+ kind: 'tree';
15
+ root: TreeNode;
16
+ };
17
+ export interface TreeNode {
18
+ label: string;
19
+ children?: TreeNode[];
20
+ }
21
+ /** the surface-agnostic result every command handler returns. */
22
+ export interface CommandResult<D = unknown> {
23
+ ok: boolean;
24
+ summary: string;
25
+ data: D;
26
+ render?: RenderModel;
27
+ }
28
+ /** surface-neutral services passed to every handler. */
29
+ export interface CommandContext {
30
+ confirm(prompt: string): Promise<boolean>;
31
+ log(event: {
32
+ level: 'info' | 'warn' | 'error';
33
+ message: string;
34
+ }): void;
35
+ env: NodeJS.ProcessEnv;
36
+ }
37
+ /** a command defined once; all three surfaces are derived from this. the
38
+ * registry stores the erased form — `run` receives the already-parsed args
39
+ * as a plain record. use `defineCommand` for inference at definition sites. */
40
+ export interface CommandDef<D = unknown> {
41
+ name: string;
42
+ summary: string;
43
+ args: z.ZodObject<z.ZodRawShape>;
44
+ destructive?: boolean;
45
+ cliOnly?: boolean;
46
+ tui?: 'palette' | {
47
+ view: string;
48
+ };
49
+ run(args: Record<string, unknown>, ctx: CommandContext): Promise<CommandResult<D>>;
50
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,5 @@
1
+ /** build the templated systemd unit for fleet-secrets-agent@%i.service.
2
+ * the vault path comes from the caller — production code passes whatever
3
+ * FLEET_VAULT_DIR or the repo-local default resolves to, so this template
4
+ * never carries an operator-specific assumption. */
5
+ export declare function generateAgentUnit(vaultPath: string): string;