@matthesketh/fleet 1.8.0 → 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 +10 -7
  228. package/dist/tui/views/HealthView.js +14 -5
  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 +9 -6
@@ -0,0 +1,17 @@
1
+ export declare function ghVersion(): string | null;
2
+ export declare function resolveRepo(projectPath: string): string | null;
3
+ export declare function repoSecrets(repo: string): string[] | null;
4
+ export interface WorkflowDispatch {
5
+ ok: boolean;
6
+ message: string;
7
+ }
8
+ export declare function dispatchWorkflow(repo: string, workflow: string, ref?: string): WorkflowDispatch;
9
+ export interface WorkflowRun {
10
+ databaseId: number;
11
+ status: string;
12
+ conclusion: string | null;
13
+ url: string;
14
+ createdAt: string;
15
+ }
16
+ export declare function latestRun(repo: string, workflow: string): WorkflowRun | null;
17
+ export declare function watchRun(repo: string, runId: number): number;
@@ -0,0 +1,65 @@
1
+ import { execSafe, execLive } from '../exec.js';
2
+ // an ios .ipa can only be built on macos, so `fleet testflight publish`
3
+ // does not build locally — it dispatches the repo's testflight workflow,
4
+ // which runs on a github-hosted macos runner. every operation here is the
5
+ // github cli driving that workflow.
6
+ // version line of the github cli, or null when it isn't installed.
7
+ export function ghVersion() {
8
+ const res = execSafe('gh', ['--version'], { timeout: 30_000 });
9
+ if (!res.ok || !res.stdout)
10
+ return null;
11
+ return res.stdout.split('\n').map(l => l.trim()).filter(Boolean)[0] ?? null;
12
+ }
13
+ // owner/name of the github repo backing a project directory, or null when
14
+ // the directory is not a github checkout the gh cli recognises.
15
+ export function resolveRepo(projectPath) {
16
+ const res = execSafe('gh', ['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner'], { cwd: projectPath, timeout: 30_000 });
17
+ if (!res.ok)
18
+ return null;
19
+ return res.stdout.trim() || null;
20
+ }
21
+ // names of the actions secrets configured on a repo, or null when they
22
+ // cannot be listed (gh not authenticated, or no access to the repo).
23
+ export function repoSecrets(repo) {
24
+ const res = execSafe('gh', ['secret', 'list', '--repo', repo], { timeout: 30_000 });
25
+ if (!res.ok)
26
+ return null;
27
+ return res.stdout
28
+ .split('\n')
29
+ .map(l => l.trim().split(/\s+/)[0])
30
+ .filter(Boolean);
31
+ }
32
+ // dispatch the testflight build workflow. `gh workflow run` queues a
33
+ // workflow_dispatch event and returns no run id, so the caller resolves the
34
+ // resulting run separately via latestRun.
35
+ export function dispatchWorkflow(repo, workflow, ref) {
36
+ const args = ['workflow', 'run', workflow, '--repo', repo];
37
+ if (ref)
38
+ args.push('--ref', ref);
39
+ const res = execSafe('gh', args, { timeout: 60_000 });
40
+ return {
41
+ ok: res.ok,
42
+ message: (res.ok ? res.stdout : res.stderr).trim() || (res.ok ? 'dispatched' : 'dispatch failed'),
43
+ };
44
+ }
45
+ // the most recent run of a workflow, or null when it has never run.
46
+ export function latestRun(repo, workflow) {
47
+ const res = execSafe('gh', [
48
+ 'run', 'list', '--repo', repo, '--workflow', workflow,
49
+ '--limit', '1', '--json', 'databaseId,status,conclusion,url,createdAt',
50
+ ], { timeout: 30_000 });
51
+ if (!res.ok || !res.stdout)
52
+ return null;
53
+ try {
54
+ const runs = JSON.parse(res.stdout);
55
+ return runs[0] ?? null;
56
+ }
57
+ catch {
58
+ return null;
59
+ }
60
+ }
61
+ // stream a workflow run to completion, inheriting stdio so progress shows
62
+ // live. returns the exit code — non-zero when the run failed.
63
+ export function watchRun(repo, runId) {
64
+ return execLive('gh', ['run', 'watch', String(runId), '--repo', repo, '--exit-status']);
65
+ }
@@ -4,4 +4,5 @@ export declare function assertDomain(domain: string): void;
4
4
  export declare function assertBranch(branch: string): void;
5
5
  export declare function assertHealthPath(path: string): void;
6
6
  export declare function assertFilePath(path: string): void;
7
+ export declare function assertComposeFile(name: string): void;
7
8
  export declare function assertSecretKey(key: string): void;
@@ -5,6 +5,11 @@ const HEALTH_PATH_RE = /^\/[a-zA-Z0-9/_.-]*$/;
5
5
  const SERVICE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9@._-]*$/;
6
6
  // Secret keys must be valid env var names (alphanumeric + underscore, no leading digit)
7
7
  const SECRET_KEY_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
8
+ // Compose filenames must be a bare filename (no path separators) ending in
9
+ // .yml or .yaml. The value is interpolated into systemd ExecStart directives,
10
+ // so it must not contain quotes, spaces, newlines, or any shell-meaningful
11
+ // character that would let an attacker inject extra `-f` flags or commands.
12
+ const COMPOSE_FILE_RE = /^[A-Za-z0-9_.-]+\.ya?ml$/;
8
13
  function assert(value, label, pattern) {
9
14
  if (!pattern.test(value)) {
10
15
  throw new Error(`Invalid ${label}: "${value}" does not match ${pattern}`);
@@ -37,6 +42,9 @@ export function assertHealthPath(path) {
37
42
  export function assertFilePath(path) {
38
43
  assertNoTraversal(path, 'file path');
39
44
  }
45
+ export function assertComposeFile(name) {
46
+ assert(name, 'compose filename', COMPOSE_FILE_RE);
47
+ }
40
48
  export function assertSecretKey(key) {
41
49
  assert(key, 'secret key', SECRET_KEY_RE);
42
50
  }
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerAuditTools(server: McpServer): void;
@@ -0,0 +1,94 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { z } from 'zod';
4
+ import { findGreenlight, runPreflight, runGuidelines } from '../core/audit/greenlight.js';
5
+ import { resolveAuditTarget } from '../core/audit/target.js';
6
+ import { loadAuditCache, saveAuditRecord } from '../core/audit/cache.js';
7
+ import { loadAuditConfig, saveAuditConfig } from '../core/audit/config.js';
8
+ import { applySuppressions } from '../core/audit/suppress.js';
9
+ function text(msg) {
10
+ return { content: [{ type: 'text', text: msg }] };
11
+ }
12
+ const INSTALL_NOTE = 'greenlight binary not found. Install it with: ' +
13
+ 'go install github.com/RevylAI/greenlight/cmd/greenlight@latest ' +
14
+ '(or set GREENLIGHT_BIN to an absolute path).';
15
+ export function registerAuditTools(server) {
16
+ server.tool('fleet_audit_run', 'Run an App Store compliance audit on a mobile app project via greenlight preflight. ' +
17
+ 'Scans source code, the privacy manifest, and metadata for Apple App Store rejection ' +
18
+ 'risks. Target is a registered fleet app name or a path to a mobile project directory. ' +
19
+ 'Returns the full report (findings grouped by CRITICAL/WARN/INFO plus a pass/fail summary).', {
20
+ target: z.string().describe('Registered app name or path to a mobile project directory'),
21
+ ipaPath: z.string().optional().describe('Optional path to a built .ipa for binary inspection'),
22
+ }, async ({ target, ipaPath }) => {
23
+ if (!findGreenlight())
24
+ return text(INSTALL_NOTE);
25
+ const { target: resolved, projectPath } = resolveAuditTarget(target);
26
+ let ipa;
27
+ if (ipaPath) {
28
+ ipa = resolve(ipaPath);
29
+ if (!existsSync(ipa))
30
+ return text(`IPA file not found: ${ipa}`);
31
+ }
32
+ const raw = runPreflight(projectPath, { ipaPath: ipa });
33
+ const { report, suppressed } = applySuppressions(raw, resolved, loadAuditConfig().ignore);
34
+ const record = {
35
+ target: resolved,
36
+ projectPath,
37
+ ...(ipa && { ipaPath: ipa }),
38
+ ranAt: new Date().toISOString(),
39
+ report,
40
+ };
41
+ saveAuditRecord(record);
42
+ return text(JSON.stringify({ ...record, suppressed }, null, 2));
43
+ });
44
+ server.tool('fleet_audit_status', 'Show the most recent App Store audit results from cache without re-running a scan. ' +
45
+ 'Returns cached audit records (summary plus findings). Use as a cheap first pass before ' +
46
+ 'fleet_audit_run.', { target: z.string().optional().describe('App name or path (omit for all cached audits)') }, async ({ target }) => {
47
+ const cache = loadAuditCache();
48
+ const all = Object.values(cache.audits);
49
+ if (all.length === 0)
50
+ return text('No audits cached. Run fleet_audit_run first.');
51
+ if (target) {
52
+ const rec = cache.audits[target] ?? all.find(a => a.projectPath === resolve(target));
53
+ if (!rec)
54
+ return text(`No cached audit for "${target}". Run fleet_audit_run.`);
55
+ return text(JSON.stringify(rec, null, 2));
56
+ }
57
+ return text(JSON.stringify(all, null, 2));
58
+ });
59
+ server.tool('fleet_audit_ignore', 'Suppress a confirmed greenlight false positive from future audits. The finding is ' +
60
+ 'matched by its exact title, optionally narrowed to a target and to findings whose file ' +
61
+ 'or code contains a substring. Every rule must carry a reason. Suppressed findings are ' +
62
+ 'dropped and the pass/fail summary is recomputed on subsequent fleet_audit_run calls.', {
63
+ title: z.string().describe('Exact greenlight finding title to suppress'),
64
+ reason: z.string().describe('Why this finding is a false positive'),
65
+ target: z.string().optional().describe('Limit the rule to one audit target'),
66
+ contains: z.string().optional().describe('Only suppress findings whose file/code contains this substring'),
67
+ }, async ({ title, reason, target, contains }) => {
68
+ const config = loadAuditConfig();
69
+ config.ignore.push({
70
+ ...(target && { target }),
71
+ title,
72
+ ...(contains && { contains }),
73
+ reason,
74
+ addedAt: new Date().toISOString(),
75
+ });
76
+ saveAuditConfig(config);
77
+ return text(`Ignoring "${title}"${target ? ` for ${target}` : ''}: ${reason}`);
78
+ });
79
+ server.tool('fleet_audit_guidelines', 'Look up Apple App Store Review Guidelines via greenlight. action "list" returns all ' +
80
+ 'sections, "show" returns one section (query is a section number like "2.1"), "search" ' +
81
+ 'matches a keyword (query is the term). Use to interpret a finding\'s guideline reference ' +
82
+ 'or to guide a fix.', {
83
+ action: z.enum(['list', 'show', 'search']).describe('list, show, or search'),
84
+ query: z.string().optional().describe('Section number for show (e.g. "2.1") or keyword for search'),
85
+ }, async ({ action, query }) => {
86
+ if (!findGreenlight())
87
+ return text(INSTALL_NOTE);
88
+ if ((action === 'show' || action === 'search') && !query) {
89
+ return text(`'${action}' requires a query argument`);
90
+ }
91
+ const args = action === 'list' ? ['list'] : [action, query];
92
+ return text(runGuidelines(args));
93
+ });
94
+ }
@@ -49,7 +49,7 @@ export function registerGitTools(server) {
49
49
  const plan = describeOnboardPlan(scenario, app.name, status);
50
50
  return text(`Scenario: ${scenario}\nRoot: ${root}\n\nPlan:\n${plan.map((s, i) => `${i + 1}. ${s}`).join('\n')}`);
51
51
  }
52
- const result = executeOnboard(scenario, root, app.name, app.name, status);
52
+ const result = await executeOnboard(scenario, root, app.name, app.name, status);
53
53
  return text(`Onboarded ${app.name} (${result.scenario})\n\nSteps:\n${result.steps.map(s => `- ${s}`).join('\n')}\n\nRepo: ${result.repoUrl}`);
54
54
  });
55
55
  server.tool('fleet_git_branch', 'Create a feature branch from develop (or other base) and push it', {
@@ -0,0 +1,10 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export interface BridgeTool {
3
+ toolName: string;
4
+ summary: string;
5
+ cliOnly?: boolean;
6
+ }
7
+ /** registry commands as flat tool descriptors, for inspection and tests. */
8
+ export declare function collectRegistryTools(): BridgeTool[];
9
+ /** registers every non-cliOnly registry command as an mcp tool. */
10
+ export declare function registerRegistryTools(server: McpServer): void;
@@ -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;