@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
@@ -1,22 +1,19 @@
1
+ import { z } from 'zod';
1
2
  import { load, findApp } from '../core/registry.js';
2
3
  import { startService } from '../core/systemd.js';
3
- import { AppNotFoundError } from '../core/errors.js';
4
- import { success, error } from '../ui/output.js';
5
- export function startCommand(args) {
6
- const appName = args[0];
7
- if (!appName) {
8
- error('Usage: fleet start <app>');
9
- process.exit(1);
10
- }
11
- const reg = load();
12
- const app = findApp(reg, appName);
13
- if (!app)
14
- throw new AppNotFoundError(appName);
15
- if (startService(app.serviceName)) {
16
- success(`Started ${app.name}`);
17
- }
18
- else {
19
- error(`Failed to start ${app.name}`);
20
- process.exit(1);
21
- }
22
- }
4
+ import { defineCommand } from '../registry/registry.js';
5
+ export const startCommand = defineCommand({
6
+ name: 'start',
7
+ summary: 'Start an app via systemctl',
8
+ args: z.object({ app: z.string() }),
9
+ async run(args) {
10
+ const app = findApp(load(), args.app);
11
+ if (!app) {
12
+ return { ok: false, summary: `app not found: ${args.app}`, data: { app: args.app, service: '' } };
13
+ }
14
+ if (!startService(app.serviceName)) {
15
+ return { ok: false, summary: `failed to start ${app.name}`, data: { app: app.name, service: app.serviceName } };
16
+ }
17
+ return { ok: true, summary: `started ${app.name}`, data: { app: app.name, service: app.serviceName } };
18
+ },
19
+ });
@@ -11,4 +11,4 @@ export interface StatusData {
11
11
  unhealthy: number;
12
12
  }
13
13
  export declare function getStatusData(): StatusData;
14
- export declare function statusCommand(args: string[]): void;
14
+ export declare const statusCommand: import("../registry/types.js").CommandDef<StatusData>;
@@ -1,7 +1,8 @@
1
+ import { z } from 'zod';
1
2
  import { load } from '../core/registry.js';
2
3
  import { getMultipleServiceStatuses, systemdAvailable } from '../core/systemd.js';
3
4
  import { listContainers } from '../core/docker.js';
4
- import { c, icon, heading, table, info } from '../ui/output.js';
5
+ import { defineCommand } from '../registry/registry.js';
5
6
  export function getStatusData() {
6
7
  const reg = load();
7
8
  const containers = listContainers();
@@ -47,28 +48,22 @@ export function getStatusData() {
47
48
  unhealthy: apps.filter(a => a.health !== 'healthy').length,
48
49
  };
49
50
  }
50
- export function statusCommand(args) {
51
- const json = args.includes('--json');
52
- const data = getStatusData();
53
- if (json) {
54
- process.stdout.write(JSON.stringify(data, null, 2) + '\n');
55
- return;
56
- }
57
- heading('Fleet Dashboard');
58
- info(`${data.totalApps} apps | ${c.green}${data.healthy} healthy${c.reset} | ${data.unhealthy > 0 ? c.red : c.dim}${data.unhealthy} unhealthy${c.reset}`);
59
- const rows = data.apps.map(app => {
60
- const healthIcon = app.health === 'healthy' ? icon.ok
61
- : app.health === 'frozen' ? icon.info
62
- : app.health === 'degraded' ? icon.warn
63
- : icon.err;
64
- const systemdColor = app.systemd === 'active' ? c.green : c.red;
65
- return [
66
- `${c.bold}${app.name}${c.reset}`,
67
- `${systemdColor}${app.systemd}${c.reset}`,
68
- app.containers,
69
- `${healthIcon} ${app.health}`,
70
- ];
71
- });
72
- table(['APP', 'SYSTEMD', 'CONTAINERS', 'HEALTH'], rows);
73
- process.stdout.write('\n');
74
- }
51
+ export const statusCommand = defineCommand({
52
+ name: 'status',
53
+ summary: 'Dashboard: all apps, systemd state, containers, health',
54
+ args: z.object({}),
55
+ tui: { view: 'dashboard' },
56
+ async run() {
57
+ const data = getStatusData();
58
+ return {
59
+ ok: true,
60
+ summary: `${data.totalApps} apps | ${data.healthy} healthy | ${data.unhealthy} unhealthy`,
61
+ data,
62
+ render: {
63
+ kind: 'table',
64
+ columns: ['APP', 'SYSTEMD', 'CONTAINERS', 'HEALTH'],
65
+ rows: data.apps.map(a => [a.name, a.systemd, a.containers, a.health]),
66
+ },
67
+ };
68
+ },
69
+ });
@@ -1 +1,4 @@
1
- export declare function stopCommand(args: string[]): void;
1
+ export declare const stopCommand: import("../registry/types.js").CommandDef<{
2
+ app: string;
3
+ service: string;
4
+ }>;
@@ -1,22 +1,19 @@
1
+ import { z } from 'zod';
1
2
  import { load, findApp } from '../core/registry.js';
2
3
  import { stopService } from '../core/systemd.js';
3
- import { AppNotFoundError } from '../core/errors.js';
4
- import { success, error } from '../ui/output.js';
5
- export function stopCommand(args) {
6
- const appName = args[0];
7
- if (!appName) {
8
- error('Usage: fleet stop <app>');
9
- process.exit(1);
10
- }
11
- const reg = load();
12
- const app = findApp(reg, appName);
13
- if (!app)
14
- throw new AppNotFoundError(appName);
15
- if (stopService(app.serviceName)) {
16
- success(`Stopped ${app.name}`);
17
- }
18
- else {
19
- error(`Failed to stop ${app.name}`);
20
- process.exit(1);
21
- }
22
- }
4
+ import { defineCommand } from '../registry/registry.js';
5
+ export const stopCommand = defineCommand({
6
+ name: 'stop',
7
+ summary: 'Stop an app via systemctl',
8
+ args: z.object({ app: z.string() }),
9
+ async run(args) {
10
+ const app = findApp(load(), args.app);
11
+ if (!app) {
12
+ return { ok: false, summary: `app not found: ${args.app}`, data: { app: args.app, service: '' } };
13
+ }
14
+ if (!stopService(app.serviceName)) {
15
+ return { ok: false, summary: `failed to stop ${app.name}`, data: { app: app.name, service: app.serviceName } };
16
+ }
17
+ return { ok: true, summary: `stopped ${app.name}`, data: { app: app.name, service: app.serviceName } };
18
+ },
19
+ });
@@ -0,0 +1 @@
1
+ export declare function testflightCommand(args: string[]): Promise<void>;
@@ -0,0 +1,193 @@
1
+ import { resolveAscCredentials, hasAscCredentials } from '../core/testflight/credentials.js';
2
+ import { ghVersion, resolveRepo, repoSecrets, dispatchWorkflow, latestRun, watchRun, } from '../core/testflight/workflow.js';
3
+ import { listBuilds, expireBuild, setWhatsNew, verifyApp } from '../core/testflight/asc.js';
4
+ import { resolveTestflightTarget, appSecretsEnv } from '../core/testflight/resolve.js';
5
+ import { heading, success, error, info, warn, table } from '../ui/output.js';
6
+ // the build workflow this command dispatches by default — a macos-runner
7
+ // workflow committed to the app's repo at .github/workflows/.
8
+ const DEFAULT_WORKFLOW = 'ios-testflight.yml';
9
+ // the actions secrets the build workflow needs to sign and upload an .ipa.
10
+ const REQUIRED_REPO_SECRETS = [
11
+ 'ASC_API_KEY_ID', 'ASC_API_KEY_ISSUER_ID', 'ASC_API_KEY_B64', 'APPLE_TEAM_ID',
12
+ ];
13
+ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
14
+ // `fleet testflight` — publish a mobile app to TestFlight by dispatching its
15
+ // repo's macOS build workflow, and manage its builds through the App Store
16
+ // Connect API. an iOS .ipa can only be built on macOS, so publish runs the
17
+ // build on a github-hosted runner rather than locally.
18
+ export async function testflightCommand(args) {
19
+ switch (args[0]) {
20
+ case 'doctor': return tfDoctor(args.slice(1));
21
+ case 'publish': return tfPublish(args.slice(1));
22
+ case 'builds': return tfBuilds(args.slice(1));
23
+ case 'update': return tfUpdate(args.slice(1));
24
+ case 'delete': return tfDelete(args.slice(1));
25
+ default:
26
+ error('Usage: fleet testflight <doctor|publish|builds|update|delete>');
27
+ process.exit(1);
28
+ }
29
+ }
30
+ function extractFlag(args, flag) {
31
+ const idx = args.indexOf(flag);
32
+ if (idx === -1 || idx + 1 >= args.length)
33
+ return undefined;
34
+ return args[idx + 1];
35
+ }
36
+ async function tfDoctor(args) {
37
+ heading('TestFlight — readiness');
38
+ const gh = ghVersion();
39
+ if (gh)
40
+ success(`GitHub CLI available: ${gh}`);
41
+ else
42
+ error('GitHub CLI (gh) not found — required to dispatch the build workflow');
43
+ const appName = args.find(a => !a.startsWith('-'));
44
+ if (!appName) {
45
+ info('Pass an app name to check its repo + credentials: fleet testflight doctor <app>');
46
+ return;
47
+ }
48
+ const { app, projectPath } = resolveTestflightTarget(appName);
49
+ const repo = resolveRepo(projectPath);
50
+ if (repo) {
51
+ success(`GitHub repo: ${repo}`);
52
+ const secrets = repoSecrets(repo);
53
+ if (secrets) {
54
+ const missing = REQUIRED_REPO_SECRETS.filter(s => !secrets.includes(s));
55
+ if (missing.length === 0)
56
+ success('Repo Actions secrets present — the build workflow can sign and upload');
57
+ else
58
+ warn(`Repo Actions secrets missing: ${missing.join(', ')} — set them with "gh secret set"`);
59
+ }
60
+ else {
61
+ warn('Could not list repo Actions secrets — check "gh auth status"');
62
+ }
63
+ }
64
+ else {
65
+ warn(`No GitHub repo resolved for ${app} — publish needs a gh checkout at ${projectPath}`);
66
+ }
67
+ const env = appSecretsEnv(app);
68
+ if (!hasAscCredentials(env)) {
69
+ error(`App Store Connect credentials missing for ${app}`);
70
+ info('Required vault secrets: ASC_API_KEY_ID, ASC_API_KEY_ISSUER_ID, ASC_API_KEY_B64');
71
+ process.exit(1);
72
+ }
73
+ success('App Store Connect credentials present (builds/update/delete)');
74
+ const ascAppId = env.ASC_APP_ID;
75
+ if (!ascAppId) {
76
+ warn('ASC_APP_ID not set — builds/update/delete need it');
77
+ return;
78
+ }
79
+ try {
80
+ const name = await verifyApp(resolveAscCredentials(env), ascAppId);
81
+ success(`App Store Connect reachable — app: ${name}`);
82
+ }
83
+ catch (err) {
84
+ error(`App Store Connect check failed: ${err.message}`);
85
+ process.exit(1);
86
+ }
87
+ }
88
+ async function tfPublish(args) {
89
+ const appName = args.find(a => !a.startsWith('-'));
90
+ if (!appName) {
91
+ error('Usage: fleet testflight publish <app> [--workflow <file>] [--ref <branch>] [--watch]');
92
+ process.exit(1);
93
+ }
94
+ const workflow = extractFlag(args, '--workflow') ?? DEFAULT_WORKFLOW;
95
+ const ref = extractFlag(args, '--ref');
96
+ const watch = args.includes('--watch');
97
+ const { app, projectPath } = resolveTestflightTarget(appName);
98
+ heading(`TestFlight publish: ${app}`);
99
+ if (!ghVersion()) {
100
+ error('GitHub CLI (gh) not found — required to dispatch the build workflow');
101
+ process.exit(1);
102
+ }
103
+ const repo = resolveRepo(projectPath);
104
+ if (!repo) {
105
+ error(`Could not resolve a GitHub repo for ${app} — is ${projectPath} a gh checkout?`);
106
+ process.exit(1);
107
+ }
108
+ // remember the newest run so the one this dispatch queues can be told
109
+ // apart from it — `gh workflow run` returns no run id of its own.
110
+ const before = latestRun(repo, workflow)?.databaseId ?? 0;
111
+ info(`Dispatching ${workflow} on ${repo}${ref ? ` (${ref})` : ''}...`);
112
+ const dispatch = dispatchWorkflow(repo, workflow, ref);
113
+ if (!dispatch.ok) {
114
+ error(`Workflow dispatch failed: ${dispatch.message}`);
115
+ process.exit(1);
116
+ }
117
+ success('Workflow dispatched — the iOS build runs on a macOS runner (~15-30 min)');
118
+ // the queued run is not addressable straight away; poll briefly for it.
119
+ let run = latestRun(repo, workflow);
120
+ for (let i = 0; i < 10 && (!run || run.databaseId === before); i++) {
121
+ await sleep(3000);
122
+ run = latestRun(repo, workflow);
123
+ }
124
+ if (!run || run.databaseId === before) {
125
+ info(`Track it at https://github.com/${repo}/actions/workflows/${workflow}`);
126
+ return;
127
+ }
128
+ info(`Run: ${run.url}`);
129
+ if (watch) {
130
+ const code = watchRun(repo, run.databaseId);
131
+ if (code !== 0) {
132
+ error('The build workflow failed — see the run log above');
133
+ process.exit(1);
134
+ }
135
+ success(`${app} built and uploaded to TestFlight`);
136
+ }
137
+ }
138
+ async function tfBuilds(args) {
139
+ const appName = args.find(a => !a.startsWith('-'));
140
+ const json = args.includes('--json');
141
+ if (!appName) {
142
+ error('Usage: fleet testflight builds <app> [--app-id <id>] [--json]');
143
+ process.exit(1);
144
+ }
145
+ const { app } = resolveTestflightTarget(appName);
146
+ const env = appSecretsEnv(app);
147
+ const ascAppId = extractFlag(args, '--app-id') ?? env.ASC_APP_ID;
148
+ if (!ascAppId) {
149
+ error('App Store Connect app id required — set ASC_APP_ID or pass --app-id');
150
+ process.exit(1);
151
+ }
152
+ const builds = await listBuilds(resolveAscCredentials(env), ascAppId);
153
+ if (json) {
154
+ process.stdout.write(JSON.stringify(builds, null, 2) + '\n');
155
+ return;
156
+ }
157
+ heading(`TestFlight builds: ${app}`);
158
+ if (builds.length === 0) {
159
+ info('No builds found.');
160
+ return;
161
+ }
162
+ table(['BUILD', 'VERSION', 'STATE', 'EXPIRED', 'UPLOADED'], builds.map(b => [
163
+ b.version,
164
+ b.shortVersion,
165
+ b.processingState,
166
+ b.expired ? 'yes' : 'no',
167
+ b.uploadedDate.slice(0, 10),
168
+ ]));
169
+ process.stdout.write('\n');
170
+ }
171
+ async function tfUpdate(args) {
172
+ const appName = args.find(a => !a.startsWith('-'));
173
+ const buildId = extractFlag(args, '--build');
174
+ const whatsNew = extractFlag(args, '--whats-new');
175
+ if (!appName || !buildId || !whatsNew) {
176
+ error('Usage: fleet testflight update <app> --build <build-id> --whats-new "..."');
177
+ process.exit(1);
178
+ }
179
+ const { app } = resolveTestflightTarget(appName);
180
+ await setWhatsNew(resolveAscCredentials(appSecretsEnv(app)), buildId, whatsNew);
181
+ success(`Updated the "What to Test" notes for build ${buildId}`);
182
+ }
183
+ async function tfDelete(args) {
184
+ const appName = args.find(a => !a.startsWith('-'));
185
+ const buildId = extractFlag(args, '--build');
186
+ if (!appName || !buildId) {
187
+ error('Usage: fleet testflight delete <app> --build <build-id>');
188
+ process.exit(1);
189
+ }
190
+ const { app } = resolveTestflightTarget(appName);
191
+ await expireBuild(resolveAscCredentials(appSecretsEnv(app)), buildId);
192
+ success(`Expired build ${buildId} — it is no longer installable from TestFlight`);
193
+ }
@@ -0,0 +1,16 @@
1
+ export interface UpdateData {
2
+ channel: 'stable' | 'prerelease';
3
+ remoteBranch: string;
4
+ localBranch: string;
5
+ available: boolean;
6
+ behind: number;
7
+ latestSubject: string;
8
+ /** populated only when an apply actually ran. */
9
+ pulled?: number;
10
+ buildOk?: boolean;
11
+ output?: string;
12
+ error?: string;
13
+ }
14
+ /** CLI surface for self-update. equivalent to the TUI banner + U key, but
15
+ * driveable from a dumb terminal, a cron job, or a Claude session. */
16
+ export declare const updateCommand: import("../registry/types.js").CommandDef<UpdateData>;
@@ -0,0 +1,95 @@
1
+ import { z } from 'zod';
2
+ import { checkForUpdate, applyUpdate, resolveChannel } from '../core/self-update.js';
3
+ import { defineCommand } from '../registry/registry.js';
4
+ /** CLI surface for self-update. equivalent to the TUI banner + U key, but
5
+ * driveable from a dumb terminal, a cron job, or a Claude session. */
6
+ export const updateCommand = defineCommand({
7
+ name: 'update',
8
+ summary: 'Check for or install a fleet update from the configured channel',
9
+ args: z.object({
10
+ check: z.boolean().default(false),
11
+ channel: z.enum(['stable', 'prerelease']).optional(),
12
+ branch: z.string().optional(),
13
+ }),
14
+ async run(args, _ctx) {
15
+ // env-var overrides apply for the lifetime of this invocation so the
16
+ // existing resolveChannel logic stays the single source of truth. we
17
+ // restore the prior environment afterward so an in-process MCP call
18
+ // doesn't leak the override into subsequent commands.
19
+ const previous = {
20
+ channel: process.env.FLEET_UPDATE_CHANNEL,
21
+ branch: process.env.FLEET_UPDATE_BRANCH,
22
+ };
23
+ if (args.channel)
24
+ process.env.FLEET_UPDATE_CHANNEL = args.channel;
25
+ if (args.branch)
26
+ process.env.FLEET_UPDATE_BRANCH = args.branch;
27
+ try {
28
+ const info = await checkForUpdate();
29
+ const channelInfo = resolveChannel();
30
+ const base = {
31
+ channel: info.channel,
32
+ remoteBranch: info.remoteBranch,
33
+ localBranch: info.branch,
34
+ available: info.available,
35
+ behind: info.behind,
36
+ latestSubject: info.latestSubject,
37
+ ...(info.error ? { error: info.error } : {}),
38
+ };
39
+ if (info.error) {
40
+ return {
41
+ ok: false,
42
+ summary: `check failed: ${info.error}`,
43
+ data: base,
44
+ };
45
+ }
46
+ if (!info.available) {
47
+ return {
48
+ ok: true,
49
+ summary: `up to date (channel=${channelInfo.channel}, branch=${channelInfo.branch})`,
50
+ data: base,
51
+ };
52
+ }
53
+ if (args.check) {
54
+ const subject = info.latestSubject ? ` — ${info.latestSubject}` : '';
55
+ return {
56
+ ok: true,
57
+ summary: `update available (channel=${channelInfo.channel}): ${info.behind} commit${info.behind === 1 ? '' : 's'} ahead${subject}`,
58
+ data: base,
59
+ };
60
+ }
61
+ const result = await applyUpdate();
62
+ const data = {
63
+ ...base,
64
+ pulled: result.pulled,
65
+ buildOk: result.buildOk,
66
+ output: result.output,
67
+ };
68
+ if (!result.ok) {
69
+ return {
70
+ ok: false,
71
+ summary: `update failed: ${result.output}`,
72
+ data,
73
+ };
74
+ }
75
+ return {
76
+ ok: true,
77
+ summary: result.pulled === 0
78
+ ? 'no changes pulled; rebuild ran'
79
+ : `updated ${result.pulled} commit${result.pulled === 1 ? '' : 's'}, rebuilt`,
80
+ data,
81
+ };
82
+ }
83
+ finally {
84
+ // restore the prior env unconditionally — even if the call threw.
85
+ if (previous.channel === undefined)
86
+ delete process.env.FLEET_UPDATE_CHANNEL;
87
+ else
88
+ process.env.FLEET_UPDATE_CHANNEL = previous.channel;
89
+ if (previous.branch === undefined)
90
+ delete process.env.FLEET_UPDATE_BRANCH;
91
+ else
92
+ process.env.FLEET_UPDATE_BRANCH = previous.branch;
93
+ }
94
+ },
95
+ });
@@ -0,0 +1,4 @@
1
+ import type { AuditCache, AuditRecord } from './types.js';
2
+ export declare function loadAuditCache(path?: string): AuditCache;
3
+ export declare function saveAuditRecord(record: AuditRecord, path?: string): void;
4
+ export declare function auditCachePath(): string;
@@ -0,0 +1,37 @@
1
+ import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ const __dirname = dirname(fileURLToPath(import.meta.url));
5
+ const DEFAULT_CACHE_PATH = join(__dirname, '..', '..', '..', 'data', 'audit-cache.json');
6
+ function emptyCache() {
7
+ return { version: 1, audits: {} };
8
+ }
9
+ // load the audit cache, returning an empty cache when the file is absent or
10
+ // unreadable — a stale/corrupt cache must never block a fresh audit.
11
+ export function loadAuditCache(path = DEFAULT_CACHE_PATH) {
12
+ if (!existsSync(path))
13
+ return emptyCache();
14
+ try {
15
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
16
+ if (!parsed || typeof parsed !== 'object' || !parsed.audits)
17
+ return emptyCache();
18
+ return parsed;
19
+ }
20
+ catch {
21
+ return emptyCache();
22
+ }
23
+ }
24
+ // upsert one audit record, keyed by target, via an atomic tmp-file rename.
25
+ export function saveAuditRecord(record, path = DEFAULT_CACHE_PATH) {
26
+ const cache = loadAuditCache(path);
27
+ cache.audits[record.target] = record;
28
+ const dir = dirname(path);
29
+ if (!existsSync(dir))
30
+ mkdirSync(dir, { recursive: true });
31
+ const tmp = path + '.tmp';
32
+ writeFileSync(tmp, JSON.stringify(cache, null, 2) + '\n');
33
+ renameSync(tmp, path);
34
+ }
35
+ export function auditCachePath() {
36
+ return DEFAULT_CACHE_PATH;
37
+ }
@@ -0,0 +1,5 @@
1
+ import type { AuditConfig } from './types.js';
2
+ export declare function defaultAuditConfig(): AuditConfig;
3
+ export declare function loadAuditConfig(path?: string): AuditConfig;
4
+ export declare function saveAuditConfig(config: AuditConfig, path?: string): void;
5
+ export declare function auditConfigPath(): string;
@@ -0,0 +1,35 @@
1
+ import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ const __dirname = dirname(fileURLToPath(import.meta.url));
5
+ const DEFAULT_CONFIG_PATH = join(__dirname, '..', '..', '..', 'data', 'audit-config.json');
6
+ export function defaultAuditConfig() {
7
+ return { version: 1, ignore: [] };
8
+ }
9
+ // load the audit config (ignore rules). a missing or corrupt file yields the
10
+ // default empty config — a bad config must never abort an audit.
11
+ export function loadAuditConfig(path = DEFAULT_CONFIG_PATH) {
12
+ if (!existsSync(path))
13
+ return defaultAuditConfig();
14
+ try {
15
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
16
+ if (!parsed || !Array.isArray(parsed.ignore))
17
+ return defaultAuditConfig();
18
+ return { version: 1, ignore: parsed.ignore };
19
+ }
20
+ catch {
21
+ return defaultAuditConfig();
22
+ }
23
+ }
24
+ // persist the audit config via an atomic tmp-file rename.
25
+ export function saveAuditConfig(config, path = DEFAULT_CONFIG_PATH) {
26
+ const dir = dirname(path);
27
+ if (!existsSync(dir))
28
+ mkdirSync(dir, { recursive: true });
29
+ const tmp = path + '.tmp';
30
+ writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n');
31
+ renameSync(tmp, path);
32
+ }
33
+ export function auditConfigPath() {
34
+ return DEFAULT_CONFIG_PATH;
35
+ }
@@ -0,0 +1,11 @@
1
+ import type { GreenlightReport } from './types.js';
2
+ export declare const GREENLIGHT_INSTALL_HINT: string;
3
+ export declare function findGreenlight(): string | null;
4
+ export declare function requireGreenlight(): string;
5
+ export declare function greenlightVersion(bin: string): string | null;
6
+ export interface PreflightOptions {
7
+ ipaPath?: string;
8
+ timeoutMs?: number;
9
+ }
10
+ export declare function runPreflight(projectPath: string, opts?: PreflightOptions, bin?: string): GreenlightReport;
11
+ export declare function runGuidelines(args: string[], bin?: string): string;
@@ -0,0 +1,81 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { execSafe } from '../exec.js';
5
+ import { FleetError } from '../errors.js';
6
+ export const GREENLIGHT_INSTALL_HINT = [
7
+ 'greenlight binary not found. Install it with one of:',
8
+ ' go install github.com/RevylAI/greenlight/cmd/greenlight@latest',
9
+ ' brew install revylai/tap/greenlight',
10
+ 'Then re-run, or point GREENLIGHT_BIN at an absolute path.',
11
+ ].join('\n');
12
+ // locate the greenlight binary. honours $GREENLIGHT_BIN first, then anything
13
+ // on $PATH, then the usual `go install` and homebrew destinations. returns the
14
+ // command/path execSafe should invoke, or null when nothing usable is found.
15
+ export function findGreenlight() {
16
+ const override = process.env.GREENLIGHT_BIN;
17
+ if (override)
18
+ return existsSync(override) ? override : null;
19
+ if (execSafe('greenlight', ['--help'], { timeout: 10_000 }).ok) {
20
+ return 'greenlight';
21
+ }
22
+ const candidates = [];
23
+ if (process.env.GOBIN)
24
+ candidates.push(join(process.env.GOBIN, 'greenlight'));
25
+ const gopath = execSafe('go', ['env', 'GOPATH'], { timeout: 10_000 });
26
+ if (gopath.ok && gopath.stdout) {
27
+ candidates.push(join(gopath.stdout.split('\n')[0].trim(), 'bin', 'greenlight'));
28
+ }
29
+ candidates.push(join(homedir(), 'go', 'bin', 'greenlight'), '/opt/homebrew/bin/greenlight', '/usr/local/bin/greenlight');
30
+ for (const candidate of candidates) {
31
+ if (existsSync(candidate))
32
+ return candidate;
33
+ }
34
+ return null;
35
+ }
36
+ // resolve the binary or fail loudly with install guidance.
37
+ export function requireGreenlight() {
38
+ const bin = findGreenlight();
39
+ if (!bin)
40
+ throw new FleetError(GREENLIGHT_INSTALL_HINT);
41
+ return bin;
42
+ }
43
+ // best-effort version string for diagnostics; null if it can't be read.
44
+ export function greenlightVersion(bin) {
45
+ const res = execSafe(bin, ['version'], { timeout: 10_000 });
46
+ if (res.ok && res.stdout)
47
+ return res.stdout.split('\n')[0].trim();
48
+ return null;
49
+ }
50
+ // run `greenlight preflight <project> --format json` and parse the report.
51
+ // greenlight exits 0 for any successful scan regardless of how many issues it
52
+ // finds — a non-zero exit means the scan itself failed (bad path, binary
53
+ // error), so that is the only case treated as an error here.
54
+ export function runPreflight(projectPath, opts = {}, bin = requireGreenlight()) {
55
+ const args = ['preflight', projectPath, '--format', 'json'];
56
+ if (opts.ipaPath)
57
+ args.push('--ipa', opts.ipaPath);
58
+ const res = execSafe(bin, args, { timeout: opts.timeoutMs ?? 120_000 });
59
+ if (!res.ok) {
60
+ throw new FleetError(`greenlight preflight failed: ${res.stderr || res.stdout || 'unknown error'}`);
61
+ }
62
+ // greenlight prints a human banner (project path, scanner list) to stdout
63
+ // before the json document even under --format json, so slice from the
64
+ // first brace. the banner is plain text and never contains one.
65
+ const jsonStart = res.stdout.indexOf('{');
66
+ if (jsonStart === -1) {
67
+ throw new FleetError(`greenlight produced no json report:\n${res.stdout.slice(0, 500)}`);
68
+ }
69
+ try {
70
+ return JSON.parse(res.stdout.slice(jsonStart));
71
+ }
72
+ catch {
73
+ throw new FleetError(`greenlight returned output that is not valid json:\n${res.stdout.slice(0, 500)}`);
74
+ }
75
+ }
76
+ // passthrough to `greenlight guidelines <args>` (list | show <section> |
77
+ // search <term>) — returns the rendered text for the caller to print.
78
+ export function runGuidelines(args, bin = requireGreenlight()) {
79
+ const res = execSafe(bin, ['guidelines', ...args], { timeout: 15_000 });
80
+ return (res.stdout || res.stderr).trim();
81
+ }
@@ -0,0 +1,3 @@
1
+ import type { GreenlightReport } from '../types.js';
2
+ export declare function severityIcon(severity: string): string;
3
+ export declare function formatReport(report: GreenlightReport): string[];