@matthesketh/fleet 1.0.0 → 1.2.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 (67) hide show
  1. package/README.md +27 -4
  2. package/dist/cli.js +8 -0
  3. package/dist/commands/deps.d.ts +1 -0
  4. package/dist/commands/deps.js +223 -0
  5. package/dist/commands/motd.d.ts +1 -0
  6. package/dist/commands/motd.js +10 -0
  7. package/dist/core/deps/actors/pr-creator.d.ts +14 -0
  8. package/dist/core/deps/actors/pr-creator.js +103 -0
  9. package/dist/core/deps/cache.d.ts +5 -0
  10. package/dist/core/deps/cache.js +28 -0
  11. package/dist/core/deps/collectors/composer.d.ts +12 -0
  12. package/dist/core/deps/collectors/composer.js +70 -0
  13. package/dist/core/deps/collectors/docker-image.d.ts +18 -0
  14. package/dist/core/deps/collectors/docker-image.js +132 -0
  15. package/dist/core/deps/collectors/docker-running.d.ts +17 -0
  16. package/dist/core/deps/collectors/docker-running.js +55 -0
  17. package/dist/core/deps/collectors/eol.d.ts +16 -0
  18. package/dist/core/deps/collectors/eol.js +139 -0
  19. package/dist/core/deps/collectors/github-pr.d.ts +8 -0
  20. package/dist/core/deps/collectors/github-pr.js +40 -0
  21. package/dist/core/deps/collectors/npm.d.ts +12 -0
  22. package/dist/core/deps/collectors/npm.js +63 -0
  23. package/dist/core/deps/collectors/pip.d.ts +15 -0
  24. package/dist/core/deps/collectors/pip.js +94 -0
  25. package/dist/core/deps/collectors/vulnerability.d.ts +9 -0
  26. package/dist/core/deps/collectors/vulnerability.js +102 -0
  27. package/dist/core/deps/config.d.ts +6 -0
  28. package/dist/core/deps/config.js +55 -0
  29. package/dist/core/deps/reporters/cli.d.ts +4 -0
  30. package/dist/core/deps/reporters/cli.js +123 -0
  31. package/dist/core/deps/reporters/motd.d.ts +3 -0
  32. package/dist/core/deps/reporters/motd.js +64 -0
  33. package/dist/core/deps/reporters/telegram.d.ts +6 -0
  34. package/dist/core/deps/reporters/telegram.js +106 -0
  35. package/dist/core/deps/scanner.d.ts +4 -0
  36. package/dist/core/deps/scanner.js +89 -0
  37. package/dist/core/deps/severity.d.ts +6 -0
  38. package/dist/core/deps/severity.js +45 -0
  39. package/dist/core/deps/types.d.ts +64 -0
  40. package/dist/core/deps/types.js +1 -0
  41. package/dist/mcp/deps-tools.d.ts +2 -0
  42. package/dist/mcp/deps-tools.js +81 -0
  43. package/dist/mcp/server.js +2 -0
  44. package/dist/templates/motd.d.ts +1 -0
  45. package/dist/templates/motd.js +7 -0
  46. package/dist/tui/components/AppList.js +1 -1
  47. package/dist/tui/components/Confirm.js +3 -4
  48. package/dist/tui/components/Header.js +37 -8
  49. package/dist/tui/components/KeyHint.js +4 -5
  50. package/dist/tui/hooks/use-terminal-size.d.ts +1 -0
  51. package/dist/tui/hooks/use-terminal-size.js +1 -0
  52. package/dist/tui/router.js +81 -9
  53. package/dist/tui/state.js +15 -0
  54. package/dist/tui/tests/flicker.test.d.ts +1 -0
  55. package/dist/tui/tests/flicker.test.js +105 -0
  56. package/dist/tui/tests/keyboard-integration.test.d.ts +1 -0
  57. package/dist/tui/tests/keyboard-integration.test.js +117 -0
  58. package/dist/tui/tests/test-app.d.ts +4 -0
  59. package/dist/tui/tests/test-app.js +79 -0
  60. package/dist/tui/types.d.ts +13 -0
  61. package/dist/tui/views/AppDetail.js +41 -26
  62. package/dist/tui/views/Dashboard.js +34 -9
  63. package/dist/tui/views/HealthView.js +36 -12
  64. package/dist/tui/views/LogsView.js +14 -9
  65. package/dist/tui/views/SecretEdit.js +8 -4
  66. package/dist/tui/views/SecretsView.js +49 -36
  67. package/package.json +17 -1
package/README.md CHANGED
@@ -10,7 +10,7 @@
10
10
  [![TypeScript](https://img.shields.io/badge/TypeScript-5.6-blue?logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
11
11
  [![License](https://img.shields.io/github/license/wrxck/fleet)](LICENSE)
12
12
 
13
- Manages Docker Compose applications on a single server with systemd orchestration, nginx configuration, encrypted secrets, Git/GitHub workflows, health monitoring, and Telegram alerts.
13
+ Manages Docker Compose applications on a single server with systemd orchestration, nginx configuration, encrypted secrets, Git/GitHub workflows, health monitoring, dependency tracking, and Telegram alerts.
14
14
 
15
15
  </div>
16
16
 
@@ -24,6 +24,7 @@ fleet CLI (TypeScript/Node.js)
24
24
  ├── MCP Server Claude Code integration (fleet mcp)
25
25
  ├── Registry App inventory (data/registry.json)
26
26
  ├── Secrets Vault age-encrypted secrets (vault/*.age)
27
+ ├── Deps Monitor Dependency health scanning + alerting
27
28
  └── Templates systemd, nginx, gitignore generators
28
29
 
29
30
  fleet-bot (Go)
@@ -126,6 +127,19 @@ fleet watchdog # Check all services, send Telegram alert on fai
126
127
  }
127
128
  ```
128
129
 
130
+ ### Dependency health
131
+
132
+ ```bash
133
+ fleet deps [app] # Summary dashboard or per-app detail
134
+ fleet deps scan # Run fresh dependency scan
135
+ fleet deps fix <app> [--dry-run] # Create PR for fixable dependency updates
136
+ fleet deps config # Show/set configuration
137
+ fleet deps ignore <pkg> --reason .. # Suppress a finding
138
+ fleet deps init # Install cron + MOTD for automated scanning
139
+ ```
140
+
141
+ Scans all registered apps for outdated packages (npm, Composer, pip), Docker image updates, runtime EOL warnings (via endoflife.date), security vulnerabilities (via OSV API), and open dependency PRs on GitHub. Results are cached and surfaced via CLI, MOTD on SSH login, and Telegram notifications. Runs automatically every 6 hours via cron (configurable).
142
+
129
143
  ### Nginx management
130
144
 
131
145
  ```bash
@@ -204,7 +218,7 @@ The `onboard` command handles everything: initialises git if needed, creates a p
204
218
 
205
219
  Running `fleet mcp` starts a stdio-based [Model Context Protocol](https://modelcontextprotocol.io/) server. This exposes all fleet operations as tools that Claude Code (or any MCP client) can call.
206
220
 
207
- ### Available tools (27)
221
+ ### Available tools (33)
208
222
 
209
223
  | Tool | Description |
210
224
  |------|-------------|
@@ -236,6 +250,12 @@ Running `fleet mcp` starts a stdio-based [Model Context Protocol](https://modelc
236
250
  | `fleet_git_pr_create` | Create a pull request |
237
251
  | `fleet_git_pr_list` | List pull requests |
238
252
  | `fleet_git_release` | Create develop -> main release PR |
253
+ | `fleet_deps_status` | Dependency health summary from cache |
254
+ | `fleet_deps_scan` | Run a fresh dependency scan |
255
+ | `fleet_deps_app` | Dependency findings for a specific app |
256
+ | `fleet_deps_fix` | Create PR with dependency updates (dry-run default) |
257
+ | `fleet_deps_ignore` | Add an ignore rule for a finding |
258
+ | `fleet_deps_config` | Get or set dependency monitoring config |
239
259
 
240
260
  ## fleet-bot
241
261
 
@@ -259,6 +279,7 @@ src/
259
279
  ├── commands/ CLI command implementations
260
280
  │ ├── add.ts Register an app
261
281
  │ ├── deploy.ts Full deploy pipeline
282
+ │ ├── deps.ts Dependency health monitoring
262
283
  │ ├── git.ts Git/GitHub operations
263
284
  │ ├── health.ts Health checks
264
285
  │ ├── init.ts Auto-discover apps
@@ -286,11 +307,13 @@ src/
286
307
  │ ├── secrets.ts Vault primitives (age encrypt/decrypt, backup/restore)
287
308
  │ ├── secrets-ops.ts High-level secrets operations (safe seal, drift, validation)
288
309
  │ ├── secrets-validate.ts Compose vs vault validation
289
- └── systemd.ts systemctl operations
310
+ ├── systemd.ts systemctl operations
311
+ │ └── deps/ Dependency health (collectors, reporters, actors)
290
312
  ├── mcp/
291
313
  │ ├── server.ts MCP server setup + tool registration
292
314
  │ ├── git-tools.ts Git-related MCP tools
293
- └── secrets-tools.ts Secrets MCP tools (set, get, seal, drift, restore)
315
+ ├── secrets-tools.ts Secrets MCP tools (set, get, seal, drift, restore)
316
+ │ └── deps-tools.ts Dependency monitoring MCP tools
294
317
  ├── templates/
295
318
  │ ├── gitignore.ts .gitignore generator
296
319
  │ ├── nginx.ts Nginx config generator
package/dist/cli.js CHANGED
@@ -12,6 +12,7 @@ import { nginxCommand } from './commands/nginx.js';
12
12
  import { secretsCommand } from './commands/secrets.js';
13
13
  import { gitCommand } from './commands/git.js';
14
14
  import { initCommand } from './commands/init.js';
15
+ import { depsCommand } from './commands/deps.js';
15
16
  import { watchdogCommand } from './commands/watchdog.js';
16
17
  import { installMcpCommand } from './commands/install-mcp.js';
17
18
  import { startMcpServer } from './mcp/server.js';
@@ -30,6 +31,12 @@ Commands:
30
31
  restart <app> Restart app via systemctl
31
32
  logs <app> [-f] Container logs (follow mode with -f)
32
33
  health [app] Health checks (systemd + container + HTTP)
34
+ deps [app] Dependency health: outdated, CVEs, EOL, Docker
35
+ deps scan Run fresh dependency scan
36
+ deps fix <app> Create PR for fixable dependency updates
37
+ deps config Show/set configuration
38
+ deps ignore <pkg> Suppress a finding
39
+ deps init Install cron + MOTD for automated scanning
33
40
  add <app-dir> Register existing app
34
41
  remove <app> Stop, disable, deregister
35
42
  nginx add <domain> --port <port> [--type proxy|spa|nextjs]
@@ -90,6 +97,7 @@ export async function run(argv) {
90
97
  case 'restart': return restartCommand(rest);
91
98
  case 'logs': return logsCommand(rest);
92
99
  case 'health': return healthCommand(rest);
100
+ case 'deps': return depsCommand(rest);
93
101
  case 'add': return addCommand(rest);
94
102
  case 'remove': return removeCommand(rest);
95
103
  case 'deploy': return deployCommand(rest);
@@ -0,0 +1 @@
1
+ export declare function depsCommand(args: string[]): Promise<void>;
@@ -0,0 +1,223 @@
1
+ import { writeFileSync, chmodSync } from 'node:fs';
2
+ import { load, findApp } from '../core/registry.js';
3
+ import { loadConfig, saveConfig, configPath } from '../core/deps/config.js';
4
+ import { loadCache, saveCache, isCacheStale, cachePath } from '../core/deps/cache.js';
5
+ import { runScan } from '../core/deps/scanner.js';
6
+ import { formatSummary, formatAppDetail } from '../core/deps/reporters/cli.js';
7
+ import { formatMotd, generateMotdScript } from '../core/deps/reporters/motd.js';
8
+ import { sendTelegramNotification, loadNotifiedFindings, saveNotifiedFindings, } from '../core/deps/reporters/telegram.js';
9
+ import { createDepsPr } from '../core/deps/actors/pr-creator.js';
10
+ import { AppNotFoundError } from '../core/errors.js';
11
+ import { heading, success, error, info, warn } from '../ui/output.js';
12
+ export async function depsCommand(args) {
13
+ const sub = args[0];
14
+ switch (sub) {
15
+ case 'scan': return depsScan(args.slice(1));
16
+ case 'fix': return depsFix(args.slice(1));
17
+ case 'config': return depsConfig(args.slice(1));
18
+ case 'ignore': return depsIgnore(args.slice(1));
19
+ case 'unignore': return depsUnignore(args.slice(1));
20
+ case 'init': return depsInit();
21
+ default: return depsShow(args);
22
+ }
23
+ }
24
+ async function depsShow(args) {
25
+ const json = args.includes('--json');
26
+ const motd = args.includes('--motd');
27
+ const severityFilter = extractFlag(args, '--severity');
28
+ const appName = args.find(a => !a.startsWith('-'));
29
+ const config = loadConfig();
30
+ const cache = loadCache();
31
+ const reg = load();
32
+ if (!cache) {
33
+ warn('No scan data found. Run: fleet deps scan');
34
+ return;
35
+ }
36
+ if (isCacheStale(cache, config.scanIntervalHours)) {
37
+ warn(`Scan data is stale (last scan: ${cache.lastScan}). Run: fleet deps scan`);
38
+ }
39
+ if (json) {
40
+ if (appName) {
41
+ const findings = cache.findings.filter(f => f.appName === appName);
42
+ process.stdout.write(JSON.stringify(findings, null, 2) + '\n');
43
+ }
44
+ else {
45
+ process.stdout.write(JSON.stringify(cache, null, 2) + '\n');
46
+ }
47
+ return;
48
+ }
49
+ if (motd) {
50
+ process.stdout.write(formatMotd(cache, reg.apps.length) + '\n');
51
+ return;
52
+ }
53
+ if (appName) {
54
+ const app = findApp(reg, appName);
55
+ if (!app)
56
+ throw new AppNotFoundError(appName);
57
+ let findings = cache.findings.filter(f => f.appName === app.name);
58
+ if (severityFilter) {
59
+ const sevs = severityFilter.split(',');
60
+ findings = findings.filter(f => sevs.includes(f.severity));
61
+ }
62
+ heading(`Deps: ${app.name}`);
63
+ const lines = formatAppDetail(app.name, findings);
64
+ for (const line of lines)
65
+ process.stdout.write(line + '\n');
66
+ process.stdout.write('\n');
67
+ return;
68
+ }
69
+ heading('Dependency Health');
70
+ let findings = cache.findings;
71
+ if (severityFilter) {
72
+ const sevs = severityFilter.split(',');
73
+ findings = findings.filter(f => sevs.includes(f.severity));
74
+ }
75
+ const summaryCache = { ...cache, findings };
76
+ const lines = formatSummary(summaryCache, reg.apps.length);
77
+ for (const line of lines)
78
+ process.stdout.write(line + '\n');
79
+ process.stdout.write('\n');
80
+ }
81
+ async function depsScan(args) {
82
+ const quiet = args.includes('--quiet');
83
+ const reg = load();
84
+ const config = loadConfig();
85
+ if (!quiet)
86
+ info('Scanning dependencies across all apps...');
87
+ const cache = await runScan(reg.apps, config);
88
+ saveCache(cache);
89
+ if (config.notifications.telegram.enabled) {
90
+ const previousFindings = loadNotifiedFindings();
91
+ const sent = await sendTelegramNotification(cache.findings, reg.apps.length, previousFindings, config.notifications.telegram.minSeverity);
92
+ if (sent) {
93
+ saveNotifiedFindings(cache.findings);
94
+ if (!quiet)
95
+ info('Telegram notification sent');
96
+ }
97
+ }
98
+ if (!quiet) {
99
+ success(`Scan complete: ${cache.findings.length} findings across ${reg.apps.length} apps (${cache.scanDurationMs}ms)`);
100
+ if (cache.errors.length > 0) {
101
+ warn(`${cache.errors.length} collector errors`);
102
+ }
103
+ process.stdout.write('\n');
104
+ heading('Dependency Health');
105
+ const lines = formatSummary(cache, reg.apps.length);
106
+ for (const line of lines)
107
+ process.stdout.write(line + '\n');
108
+ process.stdout.write('\n');
109
+ }
110
+ }
111
+ async function depsFix(args) {
112
+ const dryRun = args.includes('--dry-run');
113
+ const appName = args.find(a => !a.startsWith('-'));
114
+ if (!appName) {
115
+ error('Usage: fleet deps fix <app> [--dry-run] [--major]');
116
+ process.exit(1);
117
+ }
118
+ const reg = load();
119
+ const app = findApp(reg, appName);
120
+ if (!app)
121
+ throw new AppNotFoundError(appName);
122
+ const cache = loadCache();
123
+ if (!cache) {
124
+ error('No scan data. Run: fleet deps scan');
125
+ process.exit(1);
126
+ }
127
+ const findings = cache.findings.filter(f => f.appName === app.name && f.fixable);
128
+ if (findings.length === 0) {
129
+ info('No fixable findings for this app');
130
+ return;
131
+ }
132
+ const result = createDepsPr(app, findings, dryRun);
133
+ if (dryRun) {
134
+ heading(`Dry run: ${app.name}`);
135
+ info(`Would create branch: ${result.branch}`);
136
+ for (const bump of result.bumps) {
137
+ info(` ${bump.file}: ${bump.search} -> ${bump.replace}`);
138
+ }
139
+ return;
140
+ }
141
+ if (result.prUrl) {
142
+ success(`PR created: ${result.prUrl}`);
143
+ }
144
+ else {
145
+ success(`Branch ${result.branch} pushed with ${result.bumps.length} updates`);
146
+ }
147
+ }
148
+ async function depsConfig(args) {
149
+ const config = loadConfig();
150
+ if (args.length === 0) {
151
+ process.stdout.write(JSON.stringify(config, null, 2) + '\n');
152
+ return;
153
+ }
154
+ if (args[0] === 'set' && args.length >= 3) {
155
+ const key = args[1];
156
+ const value = args[2];
157
+ const parsed = value === 'true' ? true : value === 'false' ? false : isNaN(Number(value)) ? value : Number(value);
158
+ config[key] = parsed;
159
+ saveConfig(config);
160
+ success(`Set ${key} = ${value}`);
161
+ return;
162
+ }
163
+ error('Usage: fleet deps config [set <key> <value>]');
164
+ }
165
+ async function depsIgnore(args) {
166
+ const pkg = args.find(a => !a.startsWith('-'));
167
+ const appName = extractFlag(args, '--app');
168
+ const reason = extractFlag(args, '--reason');
169
+ const until = extractFlag(args, '--until');
170
+ if (!pkg || !reason) {
171
+ error('Usage: fleet deps ignore <package> --reason "..." [--app <name>] [--until YYYY-MM-DD]');
172
+ process.exit(1);
173
+ }
174
+ const config = loadConfig();
175
+ config.ignore.push({
176
+ package: pkg,
177
+ ...(appName && { appName }),
178
+ reason,
179
+ ...(until && { until }),
180
+ });
181
+ saveConfig(config);
182
+ success(`Ignoring ${pkg}${appName ? ` for ${appName}` : ''}: ${reason}`);
183
+ }
184
+ async function depsUnignore(args) {
185
+ const pkg = args.find(a => !a.startsWith('-'));
186
+ const appName = extractFlag(args, '--app');
187
+ if (!pkg) {
188
+ error('Usage: fleet deps unignore <package> [--app <name>]');
189
+ process.exit(1);
190
+ }
191
+ const config = loadConfig();
192
+ config.ignore = config.ignore.filter(r => {
193
+ if (r.package !== pkg)
194
+ return true;
195
+ if (appName && r.appName !== appName)
196
+ return true;
197
+ return false;
198
+ });
199
+ saveConfig(config);
200
+ success(`Removed ignore rule for ${pkg}`);
201
+ }
202
+ async function depsInit() {
203
+ const config = loadConfig();
204
+ saveConfig(config);
205
+ success(`Config written to ${configPath()}`);
206
+ const motdPath = '/etc/update-motd.d/99-fleet-deps';
207
+ const script = generateMotdScript(cachePath());
208
+ writeFileSync(motdPath, script);
209
+ chmodSync(motdPath, 0o755);
210
+ success(`MOTD script installed at ${motdPath}`);
211
+ const cronLine = `0 */${config.scanIntervalHours} * * * root /usr/local/bin/fleet deps scan --quiet\n`;
212
+ writeFileSync('/etc/cron.d/fleet-deps', cronLine);
213
+ success(`Cron installed: every ${config.scanIntervalHours} hours`);
214
+ info('Running initial scan...');
215
+ await depsScan(['--quiet']);
216
+ success('Initial scan complete. Run: fleet deps');
217
+ }
218
+ function extractFlag(args, flag) {
219
+ const idx = args.indexOf(flag);
220
+ if (idx === -1 || idx + 1 >= args.length)
221
+ return undefined;
222
+ return args[idx + 1];
223
+ }
@@ -0,0 +1 @@
1
+ export declare function motdInstallCommand(): void;
@@ -0,0 +1,10 @@
1
+ import { writeFileSync, chmodSync } from 'node:fs';
2
+ import { generateMotdScript } from '../templates/motd.js';
3
+ import { success } from '../ui/output.js';
4
+ const MOTD_PATH = '/etc/update-motd.d/50-fleet-status';
5
+ export function motdInstallCommand() {
6
+ const script = generateMotdScript();
7
+ writeFileSync(MOTD_PATH, script);
8
+ chmodSync(MOTD_PATH, 0o755);
9
+ success(`Installed MOTD script at ${MOTD_PATH}`);
10
+ }
@@ -0,0 +1,14 @@
1
+ import type { AppEntry } from '../../registry.js';
2
+ import type { Finding } from '../types.js';
3
+ export interface VersionBump {
4
+ file: string;
5
+ search: string;
6
+ replace: string;
7
+ }
8
+ export declare function generateVersionBump(finding: Finding): VersionBump | null;
9
+ export declare function buildPrBody(findings: Finding[]): string;
10
+ export declare function createDepsPr(app: AppEntry, findings: Finding[], dryRun: boolean): {
11
+ branch: string;
12
+ bumps: VersionBump[];
13
+ prUrl?: string;
14
+ };
@@ -0,0 +1,103 @@
1
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { exec } from '../../exec.js';
4
+ export function generateVersionBump(finding) {
5
+ if (!finding.fixable || !finding.package || !finding.currentVersion || !finding.latestVersion) {
6
+ return null;
7
+ }
8
+ switch (finding.source) {
9
+ case 'npm':
10
+ return {
11
+ file: 'package.json',
12
+ search: `"${finding.package}": "${finding.currentVersion}"`,
13
+ replace: `"${finding.package}": "${finding.latestVersion}"`,
14
+ };
15
+ case 'composer':
16
+ return {
17
+ file: 'composer.json',
18
+ search: `"${finding.package}": "${finding.currentVersion}"`,
19
+ replace: `"${finding.package}": "${finding.latestVersion}"`,
20
+ };
21
+ case 'pip':
22
+ return {
23
+ file: 'requirements.txt',
24
+ search: `${finding.package}==${finding.currentVersion}`,
25
+ replace: `${finding.package}==${finding.latestVersion}`,
26
+ };
27
+ case 'docker-image':
28
+ return {
29
+ file: 'Dockerfile',
30
+ search: `${finding.package}:${finding.currentVersion}`,
31
+ replace: `${finding.package}:${finding.latestVersion}`,
32
+ };
33
+ default:
34
+ return null;
35
+ }
36
+ }
37
+ export function buildPrBody(findings) {
38
+ const lines = [];
39
+ lines.push('## Dependency Updates\n');
40
+ lines.push('| Package | Current | Latest | Severity |');
41
+ lines.push('|---------|---------|--------|----------|');
42
+ for (const f of findings) {
43
+ lines.push(`| ${f.package ?? f.title} | ${f.currentVersion ?? '-'} | ${f.latestVersion ?? '-'} | ${f.severity} |`);
44
+ }
45
+ lines.push('');
46
+ lines.push('## Post-merge steps');
47
+ lines.push('');
48
+ const hasNpm = findings.some(f => f.source === 'npm');
49
+ const hasComposer = findings.some(f => f.source === 'composer');
50
+ const hasPip = findings.some(f => f.source === 'pip');
51
+ const hasDocker = findings.some(f => f.source === 'docker-image');
52
+ if (hasNpm)
53
+ lines.push('- [ ] Run `npm install` to update lockfile');
54
+ if (hasComposer)
55
+ lines.push('- [ ] Run `composer update` to update lockfile');
56
+ if (hasPip)
57
+ lines.push('- [ ] Run `pip install -r requirements.txt` to verify');
58
+ if (hasDocker)
59
+ lines.push('- [ ] Rebuild Docker image and test');
60
+ lines.push('- [ ] Run tests');
61
+ lines.push('');
62
+ lines.push('---');
63
+ lines.push('Generated by `fleet deps fix`');
64
+ return lines.join('\n');
65
+ }
66
+ export function createDepsPr(app, findings, dryRun) {
67
+ const fixable = findings.filter(f => f.fixable);
68
+ const bumps = fixable.map(generateVersionBump).filter((b) => b !== null);
69
+ if (bumps.length === 0) {
70
+ return { branch: '', bumps: [] };
71
+ }
72
+ const date = new Date().toISOString().split('T')[0];
73
+ const branch = `deps/${app.name}/${date}`;
74
+ if (dryRun) {
75
+ return { branch, bumps };
76
+ }
77
+ const sshEnv = { SSH_AUTH_SOCK: '/tmp/fleet-ssh-agent.sock' };
78
+ exec('git checkout develop', { cwd: app.composePath });
79
+ exec('git pull', { cwd: app.composePath, env: sshEnv });
80
+ exec(`git checkout -b ${branch}`, { cwd: app.composePath });
81
+ for (const bump of bumps) {
82
+ const filePath = join(app.composePath, bump.file);
83
+ if (!existsSync(filePath))
84
+ continue;
85
+ let content = readFileSync(filePath, 'utf-8');
86
+ content = content.replace(bump.search, bump.replace);
87
+ writeFileSync(filePath, content);
88
+ }
89
+ const files = [...new Set(bumps.map(b => b.file))];
90
+ exec(`git add ${files.join(' ')}`, { cwd: app.composePath });
91
+ const commitMsg = bumps.length === 1
92
+ ? `chore(deps): update ${fixable[0].package} from ${fixable[0].currentVersion} to ${fixable[0].latestVersion}`
93
+ : `chore(deps): update ${bumps.length} dependencies`;
94
+ exec(`git commit -m "${commitMsg}"`, { cwd: app.composePath });
95
+ exec(`git push -u origin ${branch}`, { cwd: app.composePath, env: sshEnv });
96
+ if (!app.gitRepo)
97
+ return { branch, bumps };
98
+ const prBody = buildPrBody(fixable);
99
+ const prTitle = `chore(deps): update dependencies (${date})`;
100
+ const prResult = exec(`gh pr create --repo ${app.gitRepo} --title "${prTitle}" --body "${prBody.replace(/"/g, '\\"')}" --base develop`, { cwd: app.composePath, env: sshEnv });
101
+ const prUrl = prResult.ok ? prResult.stdout.trim() : undefined;
102
+ return { branch, bumps, prUrl };
103
+ }
@@ -0,0 +1,5 @@
1
+ import type { DepsCache } from './types.js';
2
+ export declare function loadCache(path?: string): DepsCache | null;
3
+ export declare function saveCache(cache: DepsCache, path?: string): void;
4
+ export declare function isCacheStale(cache: DepsCache | null, intervalHours: number): boolean;
5
+ export declare function cachePath(): string;
@@ -0,0 +1,28 @@
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', 'deps-cache.json');
6
+ export function loadCache(path = DEFAULT_CACHE_PATH) {
7
+ if (!existsSync(path))
8
+ return null;
9
+ const raw = readFileSync(path, 'utf-8');
10
+ return JSON.parse(raw);
11
+ }
12
+ export function saveCache(cache, path = DEFAULT_CACHE_PATH) {
13
+ const dir = dirname(path);
14
+ if (!existsSync(dir))
15
+ mkdirSync(dir, { recursive: true });
16
+ const tmpPath = path + '.tmp';
17
+ writeFileSync(tmpPath, JSON.stringify(cache, null, 2) + '\n');
18
+ renameSync(tmpPath, path);
19
+ }
20
+ export function isCacheStale(cache, intervalHours) {
21
+ if (!cache)
22
+ return true;
23
+ const age = Date.now() - new Date(cache.lastScan).getTime();
24
+ return age > intervalHours * 60 * 60 * 1000;
25
+ }
26
+ export function cachePath() {
27
+ return DEFAULT_CACHE_PATH;
28
+ }
@@ -0,0 +1,12 @@
1
+ import type { AppEntry } from '../../registry.js';
2
+ import type { Collector, Finding, DepsConfig } from '../types.js';
3
+ type SeverityOverrides = DepsConfig['severityOverrides'];
4
+ export declare class ComposerCollector implements Collector {
5
+ private overrides;
6
+ type: "composer";
7
+ constructor(overrides: SeverityOverrides);
8
+ detect(appPath: string): boolean;
9
+ collect(app: AppEntry): Promise<Finding[]>;
10
+ private checkPackage;
11
+ }
12
+ export {};
@@ -0,0 +1,70 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { severityFromVersionDelta } from '../severity.js';
4
+ export class ComposerCollector {
5
+ overrides;
6
+ type = 'composer';
7
+ constructor(overrides) {
8
+ this.overrides = overrides;
9
+ }
10
+ detect(appPath) {
11
+ return existsSync(join(appPath, 'composer.json'));
12
+ }
13
+ async collect(app) {
14
+ const composerPath = join(app.composePath, 'composer.json');
15
+ if (!existsSync(composerPath))
16
+ return [];
17
+ const raw = readFileSync(composerPath, 'utf-8');
18
+ const composer = JSON.parse(raw);
19
+ const allDeps = {
20
+ ...composer.require,
21
+ ...composer['require-dev'],
22
+ };
23
+ const packages = Object.entries(allDeps).filter(([name]) => !name.startsWith('php') && !name.startsWith('ext-') && !name.startsWith('lib-'));
24
+ const findings = [];
25
+ const results = await Promise.allSettled(packages.map(([name, version]) => this.checkPackage(app.name, name, version)));
26
+ for (const result of results) {
27
+ if (result.status === 'fulfilled' && result.value) {
28
+ findings.push(result.value);
29
+ }
30
+ }
31
+ return findings;
32
+ }
33
+ async checkPackage(appName, name, currentRaw) {
34
+ const current = currentRaw.replace(/^[\^~>=<*]/, '').replace(/\.\*$/, '.0');
35
+ try {
36
+ const res = await fetch(`https://repo.packagist.org/p2/${name}.json`);
37
+ if (!res.ok)
38
+ return null;
39
+ const data = await res.json();
40
+ const versions = data.packages[name];
41
+ if (!versions?.length)
42
+ return null;
43
+ const stable = versions.find(v => /^\d+\.\d+\.\d+$/.test(v.version) || /^v\d+\.\d+\.\d+$/.test(v.version));
44
+ if (!stable)
45
+ return null;
46
+ const latest = stable.version.replace(/^v/, '');
47
+ if (current === latest)
48
+ return null;
49
+ const severity = severityFromVersionDelta(current, latest, this.overrides);
50
+ if (severity === 'info')
51
+ return null;
52
+ return {
53
+ appName,
54
+ source: 'composer',
55
+ severity,
56
+ category: 'outdated-dep',
57
+ title: `${name} ${current} -> ${latest}`,
58
+ detail: `Composer package ${name} can be updated from ${current} to ${latest}`,
59
+ package: name,
60
+ currentVersion: current,
61
+ latestVersion: latest,
62
+ fixable: true,
63
+ updatedAt: new Date().toISOString(),
64
+ };
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ }
70
+ }
@@ -0,0 +1,18 @@
1
+ import type { AppEntry } from '../../registry.js';
2
+ import type { Collector, Finding, DepsConfig } from '../types.js';
3
+ type SeverityOverrides = DepsConfig['severityOverrides'];
4
+ export interface ImageRef {
5
+ image: string;
6
+ tag: string;
7
+ }
8
+ export declare class DockerImageCollector implements Collector {
9
+ private overrides;
10
+ type: "docker-image";
11
+ constructor(overrides: SeverityOverrides);
12
+ detect(appPath: string): boolean;
13
+ collect(app: AppEntry): Promise<Finding[]>;
14
+ parseDockerfile(content: string): ImageRef[];
15
+ parseComposeImages(content: string): ImageRef[];
16
+ private checkImage;
17
+ }
18
+ export {};