@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
@@ -2,24 +2,45 @@
2
2
  * Self-update check and apply for fleet itself.
3
3
  *
4
4
  * fleet is installed via `npm link`-style symlink from /usr/local/bin/fleet to
5
- * /home/matt/fleet/dist/index.js. Updates are produced by:
6
- * 1. git pull --ff-only origin develop in /home/matt/fleet
7
- * 2. npm run build (rewrites dist/)
5
+ * the repo's dist/index.js. Updates are produced by:
6
+ * 1. git fetch origin <channel> (channel = main by default, develop on opt-in)
7
+ * 2. git pull --ff-only origin <channel> in the fleet checkout
8
+ * 3. npm run build (rewrites dist/)
9
+ *
10
+ * Channel selection:
11
+ * - default: 'stable' → tracks origin/main (tagged releases only).
12
+ * - FLEET_UPDATE_CHANNEL=prerelease → tracks origin/develop (work in flight).
13
+ * - FLEET_UPDATE_BRANCH=<name> → arbitrary branch (escape hatch for forks).
14
+ *
15
+ * The check intentionally compares against the configured remote branch, not
16
+ * the local HEAD's tracking branch — so even if the local checkout is on
17
+ * `develop` the operator can opt back to the stable channel without first
18
+ * switching branches.
8
19
  *
9
20
  * checkForUpdate() does a non-blocking `git fetch` + compares HEAD with the
10
21
  * remote. applyUpdate() runs the pull + build. Both are pure shell wrappers
11
22
  * around execSafe — easy to mock in tests, easy to reason about under sudo.
12
23
  */
24
+ export type UpdateChannel = 'stable' | 'prerelease';
25
+ /** resolve the remote branch to track based on env vars. */
26
+ export declare function resolveChannel(): {
27
+ channel: UpdateChannel;
28
+ branch: string;
29
+ };
13
30
  export interface UpdateInfo {
14
- /** True if `git rev-parse @{u}` shows commits ahead of HEAD. */
31
+ /** true if `git rev-parse @{u}` shows commits ahead of HEAD. */
15
32
  available: boolean;
16
- /** Number of commits HEAD is behind origin. 0 if up-to-date. */
33
+ /** number of commits HEAD is behind the configured remote branch. */
17
34
  behind: number;
18
- /** Short subject of the latest remote commit (or empty string on failure). */
35
+ /** short subject of the latest remote commit (or empty string on failure). */
19
36
  latestSubject: string;
20
- /** Branch name in the local repo. */
37
+ /** local branch in the working tree. */
21
38
  branch: string;
22
- /** Why the check failed, if it did. */
39
+ /** remote branch being tracked for updates (e.g. 'main' or 'develop'). */
40
+ remoteBranch: string;
41
+ /** stable = main (tagged releases), prerelease = develop (work in flight). */
42
+ channel: UpdateChannel;
43
+ /** why the check failed, if it did. */
23
44
  error?: string;
24
45
  }
25
46
  export interface UpdateResult {
@@ -34,8 +55,8 @@ export interface UpdateResult {
34
55
  */
35
56
  export declare function checkForUpdate(): Promise<UpdateInfo>;
36
57
  /**
37
- * Apply: git pull --ff-only + npm run build. Refuses to run if the working
38
- * tree is dirty (would clobber uncommitted changes). Returns aggregate output
39
- * for the toast / TUI to surface.
58
+ * Apply: git pull --ff-only origin <channel-branch> + npm run build. Refuses
59
+ * to run if the working tree is dirty (would clobber uncommitted changes).
60
+ * Returns aggregate output for the toast / TUI to surface.
40
61
  */
41
62
  export declare function applyUpdate(): Promise<UpdateResult>;
@@ -2,9 +2,20 @@
2
2
  * Self-update check and apply for fleet itself.
3
3
  *
4
4
  * fleet is installed via `npm link`-style symlink from /usr/local/bin/fleet to
5
- * /home/matt/fleet/dist/index.js. Updates are produced by:
6
- * 1. git pull --ff-only origin develop in /home/matt/fleet
7
- * 2. npm run build (rewrites dist/)
5
+ * the repo's dist/index.js. Updates are produced by:
6
+ * 1. git fetch origin <channel> (channel = main by default, develop on opt-in)
7
+ * 2. git pull --ff-only origin <channel> in the fleet checkout
8
+ * 3. npm run build (rewrites dist/)
9
+ *
10
+ * Channel selection:
11
+ * - default: 'stable' → tracks origin/main (tagged releases only).
12
+ * - FLEET_UPDATE_CHANNEL=prerelease → tracks origin/develop (work in flight).
13
+ * - FLEET_UPDATE_BRANCH=<name> → arbitrary branch (escape hatch for forks).
14
+ *
15
+ * The check intentionally compares against the configured remote branch, not
16
+ * the local HEAD's tracking branch — so even if the local checkout is on
17
+ * `develop` the operator can opt back to the stable channel without first
18
+ * switching branches.
8
19
  *
9
20
  * checkForUpdate() does a non-blocking `git fetch` + compares HEAD with the
10
21
  * remote. applyUpdate() runs the pull + build. Both are pure shell wrappers
@@ -16,39 +27,66 @@ import { execSafe } from './exec.js';
16
27
  const __dirname = dirname(fileURLToPath(import.meta.url));
17
28
  // dist/core/self-update.js → repo root is two ../
18
29
  const FLEET_REPO = process.env.FLEET_REPO_PATH ?? `${__dirname}/../..`;
30
+ /** resolve the remote branch to track based on env vars. */
31
+ export function resolveChannel() {
32
+ // explicit branch override wins — for forks or custom workflows.
33
+ const explicit = process.env.FLEET_UPDATE_BRANCH;
34
+ if (explicit) {
35
+ const channel = explicit === 'develop' ? 'prerelease' : 'stable';
36
+ return { channel, branch: explicit };
37
+ }
38
+ if (process.env.FLEET_UPDATE_CHANNEL === 'prerelease') {
39
+ return { channel: 'prerelease', branch: 'develop' };
40
+ }
41
+ return { channel: 'stable', branch: 'main' };
42
+ }
19
43
  /**
20
44
  * Non-blocking check. Does a `git fetch` (timeboxed) then compares.
21
45
  * Returns a stable UpdateInfo even on failure (just `available=false`).
22
46
  */
23
47
  export async function checkForUpdate() {
48
+ const { channel, branch: remoteBranch } = resolveChannel();
24
49
  const branchR = execSafe('git', ['-C', FLEET_REPO, 'rev-parse', '--abbrev-ref', 'HEAD']);
25
50
  if (!branchR.ok) {
26
- return { available: false, behind: 0, latestSubject: '', branch: '?', error: branchR.stderr };
51
+ return {
52
+ available: false, behind: 0, latestSubject: '',
53
+ branch: '?', remoteBranch, channel,
54
+ error: branchR.stderr,
55
+ };
27
56
  }
28
57
  const branch = branchR.stdout;
29
58
  // Fetch quietly, with a short timeout so we never block the TUI launch.
30
- const fetchR = execSafe('git', ['-C', FLEET_REPO, 'fetch', '--quiet', 'origin', branch], { timeout: 8_000 });
59
+ const fetchR = execSafe('git', ['-C', FLEET_REPO, 'fetch', '--quiet', 'origin', remoteBranch], { timeout: 8_000 });
31
60
  if (!fetchR.ok) {
32
- return { available: false, behind: 0, latestSubject: '', branch, error: 'fetch failed' };
61
+ return {
62
+ available: false, behind: 0, latestSubject: '',
63
+ branch, remoteBranch, channel,
64
+ error: 'fetch failed',
65
+ };
33
66
  }
34
- const countR = execSafe('git', ['-C', FLEET_REPO, 'rev-list', '--count', `HEAD..origin/${branch}`]);
67
+ const countR = execSafe('git', ['-C', FLEET_REPO, 'rev-list', '--count', `HEAD..origin/${remoteBranch}`]);
35
68
  if (!countR.ok) {
36
- return { available: false, behind: 0, latestSubject: '', branch, error: countR.stderr };
69
+ return {
70
+ available: false, behind: 0, latestSubject: '',
71
+ branch, remoteBranch, channel,
72
+ error: countR.stderr,
73
+ };
37
74
  }
38
75
  const behind = parseInt(countR.stdout, 10) || 0;
39
76
  let latestSubject = '';
40
77
  if (behind > 0) {
41
- const subR = execSafe('git', ['-C', FLEET_REPO, 'log', '-1', '--pretty=%s', `origin/${branch}`]);
78
+ const subR = execSafe('git', ['-C', FLEET_REPO, 'log', '-1', '--pretty=%s', `origin/${remoteBranch}`]);
42
79
  latestSubject = subR.ok ? subR.stdout : '';
43
80
  }
44
- return { available: behind > 0, behind, latestSubject, branch };
81
+ return { available: behind > 0, behind, latestSubject, branch, remoteBranch, channel };
45
82
  }
46
83
  /**
47
- * Apply: git pull --ff-only + npm run build. Refuses to run if the working
48
- * tree is dirty (would clobber uncommitted changes). Returns aggregate output
49
- * for the toast / TUI to surface.
84
+ * Apply: git pull --ff-only origin <channel-branch> + npm run build. Refuses
85
+ * to run if the working tree is dirty (would clobber uncommitted changes).
86
+ * Returns aggregate output for the toast / TUI to surface.
50
87
  */
51
88
  export async function applyUpdate() {
89
+ const { branch: remoteBranch } = resolveChannel();
52
90
  const dirty = execSafe('git', ['-C', FLEET_REPO, 'status', '--porcelain']);
53
91
  if (dirty.ok && dirty.stdout.length > 0) {
54
92
  return {
@@ -57,7 +95,7 @@ export async function applyUpdate() {
57
95
  };
58
96
  }
59
97
  const pre = execSafe('git', ['-C', FLEET_REPO, 'rev-parse', 'HEAD']);
60
- const pull = execSafe('git', ['-C', FLEET_REPO, 'pull', '--ff-only'], { timeout: 30_000 });
98
+ const pull = execSafe('git', ['-C', FLEET_REPO, 'pull', '--ff-only', 'origin', remoteBranch], { timeout: 30_000 });
61
99
  if (!pull.ok) {
62
100
  return { ok: false, pulled: 0, buildOk: false, output: pull.stderr || pull.stdout };
63
101
  }
@@ -0,0 +1,12 @@
1
+ import type { AscCredentials, TestflightBuild } from './types.js';
2
+ export declare function ascJwt(creds: AscCredentials, now?: number): string;
3
+ interface AscRequestOptions {
4
+ method?: string;
5
+ body?: unknown;
6
+ }
7
+ export declare function ascRequest(creds: AscCredentials, path: string, opts?: AscRequestOptions): Promise<unknown>;
8
+ export declare function listBuilds(creds: AscCredentials, ascAppId: string, limit?: number): Promise<TestflightBuild[]>;
9
+ export declare function expireBuild(creds: AscCredentials, buildId: string): Promise<void>;
10
+ export declare function setWhatsNew(creds: AscCredentials, buildId: string, whatsNew: string, locale?: string): Promise<void>;
11
+ export declare function verifyApp(creds: AscCredentials, ascAppId: string): Promise<string>;
12
+ export {};
@@ -0,0 +1,101 @@
1
+ import { createSign } from 'node:crypto';
2
+ import { FleetError } from '../errors.js';
3
+ const ASC_BASE = 'https://api.appstoreconnect.apple.com';
4
+ function base64url(input) {
5
+ return Buffer.from(input)
6
+ .toString('base64')
7
+ .replace(/\+/g, '-')
8
+ .replace(/\//g, '_')
9
+ .replace(/=+$/, '');
10
+ }
11
+ // sign a short-lived ES256 jwt for the app store connect api. the lifetime
12
+ // is held well under apple's 20-minute ceiling.
13
+ export function ascJwt(creds, now = Date.now()) {
14
+ const iat = Math.floor(now / 1000);
15
+ const header = { alg: 'ES256', kid: creds.keyId, typ: 'JWT' };
16
+ const payload = { iss: creds.issuerId, iat, exp: iat + 600, aud: 'appstoreconnect-v1' };
17
+ const signingInput = `${base64url(JSON.stringify(header))}.${base64url(JSON.stringify(payload))}`;
18
+ const signer = createSign('SHA256');
19
+ signer.update(signingInput);
20
+ // apple expects the raw r||s signature (ieee-p1363), not asn.1/der.
21
+ const signature = signer.sign({ key: creds.privateKey, dsaEncoding: 'ieee-p1363' });
22
+ return `${signingInput}.${base64url(signature)}`;
23
+ }
24
+ // perform an authenticated app store connect api request. a non-2xx response
25
+ // is surfaced as a FleetError carrying apple's first error detail.
26
+ export async function ascRequest(creds, path, opts = {}) {
27
+ const res = await fetch(`${ASC_BASE}${path}`, {
28
+ method: opts.method ?? 'GET',
29
+ headers: {
30
+ Authorization: `Bearer ${ascJwt(creds)}`,
31
+ 'Content-Type': 'application/json',
32
+ },
33
+ ...(opts.body !== undefined && { body: JSON.stringify(opts.body) }),
34
+ });
35
+ if (res.status === 204)
36
+ return null;
37
+ const text = await res.text();
38
+ const json = text ? JSON.parse(text) : null;
39
+ if (!res.ok) {
40
+ const detail = json?.errors?.[0]?.detail;
41
+ throw new FleetError(`App Store Connect API ${res.status}: ${detail ?? text.slice(0, 200)}`);
42
+ }
43
+ return json;
44
+ }
45
+ // list builds for an app store connect app, newest upload first.
46
+ export async function listBuilds(creds, ascAppId, limit = 20) {
47
+ const query = `filter[app]=${encodeURIComponent(ascAppId)}&sort=-uploadedDate` +
48
+ `&limit=${limit}&include=preReleaseVersion`;
49
+ const res = (await ascRequest(creds, `/v1/builds?${query}`));
50
+ const preReleaseVersions = new Map((res.included ?? [])
51
+ .filter(i => i.type === 'preReleaseVersions')
52
+ .map(i => [i.id, i.attributes?.version ?? '']));
53
+ return (res.data ?? []).map(b => ({
54
+ id: b.id,
55
+ version: b.attributes?.version ?? '',
56
+ shortVersion: preReleaseVersions.get(b.relationships?.preReleaseVersion?.data?.id ?? '') ?? '',
57
+ processingState: b.attributes?.processingState ?? 'UNKNOWN',
58
+ expired: b.attributes?.expired ?? false,
59
+ uploadedDate: b.attributes?.uploadedDate ?? '',
60
+ }));
61
+ }
62
+ // expire a build — the closest the api offers to "delete". an expired build
63
+ // leaves testflight and can no longer be installed by testers.
64
+ export async function expireBuild(creds, buildId) {
65
+ await ascRequest(creds, `/v1/builds/${buildId}`, {
66
+ method: 'PATCH',
67
+ body: { data: { type: 'builds', id: buildId, attributes: { expired: true } } },
68
+ });
69
+ }
70
+ // set the "what to test" notes for a build, creating the beta localisation
71
+ // when the build has none for the requested locale yet.
72
+ export async function setWhatsNew(creds, buildId, whatsNew, locale = 'en-GB') {
73
+ const existing = (await ascRequest(creds, `/v1/builds/${buildId}/betaBuildLocalizations`));
74
+ const match = (existing.data ?? []).find(l => l.attributes?.locale === locale) ??
75
+ (existing.data ?? [])[0];
76
+ if (match) {
77
+ await ascRequest(creds, `/v1/betaBuildLocalizations/${match.id}`, {
78
+ method: 'PATCH',
79
+ body: {
80
+ data: { type: 'betaBuildLocalizations', id: match.id, attributes: { whatsNew } },
81
+ },
82
+ });
83
+ return;
84
+ }
85
+ await ascRequest(creds, '/v1/betaBuildLocalizations', {
86
+ method: 'POST',
87
+ body: {
88
+ data: {
89
+ type: 'betaBuildLocalizations',
90
+ attributes: { locale, whatsNew },
91
+ relationships: { build: { data: { type: 'builds', id: buildId } } },
92
+ },
93
+ },
94
+ });
95
+ }
96
+ // fetch an app's name — a cheap call used to verify credentials and the
97
+ // configured app id resolve.
98
+ export async function verifyApp(creds, ascAppId) {
99
+ const res = (await ascRequest(creds, `/v1/apps/${ascAppId}`));
100
+ return res.data?.attributes?.name ?? '(unknown)';
101
+ }
@@ -0,0 +1,3 @@
1
+ import type { AscCredentials } from './types.js';
2
+ export declare function resolveAscCredentials(env: NodeJS.ProcessEnv): AscCredentials;
3
+ export declare function hasAscCredentials(env: NodeJS.ProcessEnv): boolean;
@@ -0,0 +1,35 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { FleetError } from '../errors.js';
3
+ // resolve app store connect api credentials from an environment map. the
4
+ // private key is supplied either inline-base64 (ASC_API_KEY_B64) or as a path
5
+ // to a .p8 file (ASC_API_KEY_PATH) — base64 is preferred so the key lives in
6
+ // the fleet secrets vault rather than as a loose file on disk.
7
+ export function resolveAscCredentials(env) {
8
+ const keyId = env.ASC_API_KEY_ID;
9
+ const issuerId = env.ASC_API_KEY_ISSUER_ID;
10
+ if (!keyId || !issuerId) {
11
+ throw new FleetError('App Store Connect credentials missing — set ASC_API_KEY_ID and ASC_API_KEY_ISSUER_ID.');
12
+ }
13
+ let privateKey;
14
+ if (env.ASC_API_KEY_B64) {
15
+ privateKey = Buffer.from(env.ASC_API_KEY_B64, 'base64').toString('utf-8');
16
+ }
17
+ else if (env.ASC_API_KEY_PATH && existsSync(env.ASC_API_KEY_PATH)) {
18
+ privateKey = readFileSync(env.ASC_API_KEY_PATH, 'utf-8');
19
+ }
20
+ if (!privateKey || !privateKey.includes('PRIVATE KEY')) {
21
+ throw new FleetError('App Store Connect private key missing — set ASC_API_KEY_B64 (base64 of the .p8) ' +
22
+ 'or ASC_API_KEY_PATH (path to the .p8 file).');
23
+ }
24
+ return { keyId, issuerId, privateKey };
25
+ }
26
+ // true when full app store connect credentials are present in `env`.
27
+ export function hasAscCredentials(env) {
28
+ try {
29
+ resolveAscCredentials(env);
30
+ return true;
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
@@ -0,0 +1,6 @@
1
+ export interface TestflightTarget {
2
+ app: string;
3
+ projectPath: string;
4
+ }
5
+ export declare function resolveTestflightTarget(target: string): TestflightTarget;
6
+ export declare function appSecretsEnv(app: string): NodeJS.ProcessEnv;
@@ -0,0 +1,44 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { load, findApp } from '../registry.js';
4
+ import { FleetError } from '../errors.js';
5
+ const SECRETS_BASE = '/run/fleet-secrets';
6
+ // resolve a registered fleet app to its mobile project directory. testflight
7
+ // targets must be registered apps — the credentials live in the app's vault.
8
+ export function resolveTestflightTarget(target) {
9
+ const app = findApp(load(), target);
10
+ if (!app) {
11
+ throw new FleetError(`Unknown app "${target}" — not in the fleet registry.`);
12
+ }
13
+ const mobileDir = join(app.composePath, 'mobile');
14
+ return {
15
+ app: app.name,
16
+ projectPath: existsSync(mobileDir) ? mobileDir : app.composePath,
17
+ };
18
+ }
19
+ // minimal .env reader for an app's unsealed fleet secrets.
20
+ function readEnvFile(path) {
21
+ if (!existsSync(path))
22
+ return {};
23
+ const vars = {};
24
+ for (const line of readFileSync(path, 'utf-8').split('\n')) {
25
+ const trimmed = line.trim();
26
+ if (!trimmed || trimmed.startsWith('#'))
27
+ continue;
28
+ const eq = trimmed.indexOf('=');
29
+ if (eq < 1)
30
+ continue;
31
+ let val = trimmed.slice(eq + 1);
32
+ if ((val.startsWith('"') && val.endsWith('"')) ||
33
+ (val.startsWith("'") && val.endsWith("'"))) {
34
+ val = val.slice(1, -1);
35
+ }
36
+ vars[trimmed.slice(0, eq)] = val;
37
+ }
38
+ return vars;
39
+ }
40
+ // an app's unsealed fleet secrets layered over the current process env. the
41
+ // vault holds the App Store Connect / Expo credentials testflight needs.
42
+ export function appSecretsEnv(app) {
43
+ return { ...process.env, ...readEnvFile(join(SECRETS_BASE, app, '.env')) };
44
+ }
@@ -0,0 +1,13 @@
1
+ export interface AscCredentials {
2
+ keyId: string;
3
+ issuerId: string;
4
+ privateKey: string;
5
+ }
6
+ export interface TestflightBuild {
7
+ id: string;
8
+ version: string;
9
+ shortVersion: string;
10
+ processingState: string;
11
+ expired: boolean;
12
+ uploadedDate: string;
13
+ }
@@ -0,0 +1,3 @@
1
+ // shapes for the testflight publishing pipeline. the asc types mirror the
2
+ // json:api responses consumed from the app store connect api.
3
+ export {};
@@ -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
  }
package/dist/index.js CHANGED
File without changes
@@ -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;