tlc-claude-code 2.0.1 → 2.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.
- package/.claude/agents/builder.md +144 -0
- package/.claude/agents/planner.md +143 -0
- package/.claude/agents/reviewer.md +160 -0
- package/.claude/commands/tlc/build.md +4 -0
- package/.claude/commands/tlc/deploy.md +194 -2
- package/.claude/commands/tlc/e2e-verify.md +214 -0
- package/.claude/commands/tlc/guard.md +191 -0
- package/.claude/commands/tlc/help.md +32 -0
- package/.claude/commands/tlc/init.md +73 -37
- package/.claude/commands/tlc/llm.md +19 -4
- package/.claude/commands/tlc/preflight.md +134 -0
- package/.claude/commands/tlc/review-plan.md +363 -0
- package/.claude/commands/tlc/review.md +172 -57
- package/.claude/commands/tlc/watchci.md +159 -0
- package/.claude/hooks/tlc-block-tools.sh +41 -0
- package/.claude/hooks/tlc-capture-exchange.sh +50 -0
- package/.claude/hooks/tlc-post-build.sh +38 -0
- package/.claude/hooks/tlc-post-push.sh +22 -0
- package/.claude/hooks/tlc-prompt-guard.sh +69 -0
- package/.claude/hooks/tlc-session-init.sh +123 -0
- package/CLAUDE.md +13 -0
- package/bin/install.js +268 -2
- package/bin/postinstall.js +102 -24
- package/bin/setup-autoupdate.js +206 -0
- package/bin/setup-autoupdate.test.js +124 -0
- package/bin/tlc.js +0 -0
- package/dashboard-web/dist/assets/index-CdS5CHqu.css +1 -0
- package/dashboard-web/dist/assets/index-CwNPPVpg.js +483 -0
- package/dashboard-web/dist/assets/index-CwNPPVpg.js.map +1 -0
- package/dashboard-web/dist/index.html +2 -2
- package/docker-compose.dev.yml +18 -12
- package/package.json +4 -2
- package/scripts/project-docs.js +1 -1
- package/server/index.js +228 -2
- package/server/lib/capture-bridge.js +242 -0
- package/server/lib/capture-bridge.test.js +363 -0
- package/server/lib/capture-guard.js +140 -0
- package/server/lib/capture-guard.test.js +182 -0
- package/server/lib/command-runner.js +159 -0
- package/server/lib/command-runner.test.js +92 -0
- package/server/lib/cost-tracker.test.js +49 -12
- package/server/lib/deploy/runners/dependency-runner.js +106 -0
- package/server/lib/deploy/runners/dependency-runner.test.js +148 -0
- package/server/lib/deploy/runners/secrets-runner.js +174 -0
- package/server/lib/deploy/runners/secrets-runner.test.js +127 -0
- package/server/lib/deploy/security-gates.js +11 -24
- package/server/lib/deploy/security-gates.test.js +9 -2
- package/server/lib/deploy-engine.js +182 -0
- package/server/lib/deploy-engine.test.js +147 -0
- package/server/lib/docker-api.js +137 -0
- package/server/lib/docker-api.test.js +202 -0
- package/server/lib/docker-client.js +297 -0
- package/server/lib/docker-client.test.js +308 -0
- package/server/lib/input-sanitizer.js +86 -0
- package/server/lib/input-sanitizer.test.js +117 -0
- package/server/lib/launchd-agent.js +225 -0
- package/server/lib/launchd-agent.test.js +185 -0
- package/server/lib/memory-api.js +3 -1
- package/server/lib/memory-api.test.js +3 -5
- package/server/lib/memory-bridge-e2e.test.js +160 -0
- package/server/lib/memory-committer.js +18 -4
- package/server/lib/memory-committer.test.js +21 -0
- package/server/lib/memory-hooks-capture.test.js +69 -4
- package/server/lib/memory-hooks-integration.test.js +98 -0
- package/server/lib/memory-hooks.js +42 -4
- package/server/lib/memory-store-adapter.js +105 -0
- package/server/lib/memory-store-adapter.test.js +141 -0
- package/server/lib/memory-wiring-e2e.test.js +93 -0
- package/server/lib/nginx-config.js +114 -0
- package/server/lib/nginx-config.test.js +82 -0
- package/server/lib/ollama-health.js +91 -0
- package/server/lib/ollama-health.test.js +74 -0
- package/server/lib/orchestration/agent-dispatcher.js +114 -0
- package/server/lib/orchestration/agent-dispatcher.test.js +110 -0
- package/server/lib/orchestration/orchestrator.js +130 -0
- package/server/lib/orchestration/orchestrator.test.js +192 -0
- package/server/lib/orchestration/tmux-manager.js +101 -0
- package/server/lib/orchestration/tmux-manager.test.js +109 -0
- package/server/lib/orchestration/worktree-manager.js +132 -0
- package/server/lib/orchestration/worktree-manager.test.js +129 -0
- package/server/lib/port-guard.js +44 -0
- package/server/lib/port-guard.test.js +65 -0
- package/server/lib/project-scanner.js +37 -2
- package/server/lib/project-scanner.test.js +152 -0
- package/server/lib/remember-command.js +2 -0
- package/server/lib/remember-command.test.js +23 -0
- package/server/lib/review/plan-reviewer.js +260 -0
- package/server/lib/review/plan-reviewer.test.js +269 -0
- package/server/lib/review/review-schemas.js +173 -0
- package/server/lib/review/review-schemas.test.js +152 -0
- package/server/lib/security/crypto-utils.test.js +2 -2
- package/server/lib/semantic-recall.js +1 -1
- package/server/lib/semantic-recall.test.js +17 -0
- package/server/lib/ssh-client.js +184 -0
- package/server/lib/ssh-client.test.js +127 -0
- package/server/lib/vps-api.js +184 -0
- package/server/lib/vps-api.test.js +208 -0
- package/server/lib/vps-bootstrap.js +124 -0
- package/server/lib/vps-bootstrap.test.js +79 -0
- package/server/lib/vps-monitor.js +126 -0
- package/server/lib/vps-monitor.test.js +98 -0
- package/server/lib/workspace-api.js +182 -1
- package/server/lib/workspace-api.test.js +474 -0
- package/server/package-lock.json +737 -0
- package/server/package.json +3 -0
- package/server/setup.sh +271 -271
- package/dashboard-web/dist/assets/index-Uhc49PE-.css +0 -1
- package/dashboard-web/dist/assets/index-W36XHPC5.js +0 -431
- package/dashboard-web/dist/assets/index-W36XHPC5.js.map +0 -1
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command Runner — execute TLC commands via container, Claude Code, or queue
|
|
3
|
+
* Phase 80 Task 8
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { execSync, spawn } = require('child_process');
|
|
9
|
+
|
|
10
|
+
const VALID_COMMANDS = ['build', 'deploy', 'test', 'plan', 'verify', 'review', 'status'];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Create command runner
|
|
14
|
+
* @param {Object} [options]
|
|
15
|
+
* @param {Function} [options._checkDocker] - Check if tlc-standalone image exists
|
|
16
|
+
* @param {Function} [options._checkClaude] - Check if Claude Code is running
|
|
17
|
+
* @returns {Object} Command runner API
|
|
18
|
+
*/
|
|
19
|
+
function createCommandRunner(options = {}) {
|
|
20
|
+
const checkDocker = options._checkDocker || defaultCheckDocker;
|
|
21
|
+
const checkClaude = options._checkClaude || defaultCheckClaude;
|
|
22
|
+
|
|
23
|
+
async function defaultCheckDocker() {
|
|
24
|
+
try {
|
|
25
|
+
execSync('docker image inspect tlc-standalone 2>/dev/null', { stdio: 'pipe' });
|
|
26
|
+
return true;
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function defaultCheckClaude() {
|
|
33
|
+
try {
|
|
34
|
+
const result = execSync('pgrep -f "claude" 2>/dev/null', { stdio: 'pipe' });
|
|
35
|
+
return result.toString().trim().length > 0;
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Detect best execution method
|
|
43
|
+
* @param {string} projectPath
|
|
44
|
+
* @returns {Promise<string>} 'container' | 'claude-code' | 'queue'
|
|
45
|
+
*/
|
|
46
|
+
async function detectExecutionMethod(projectPath) {
|
|
47
|
+
if (await checkDocker()) return 'container';
|
|
48
|
+
if (checkClaude()) return 'claude-code';
|
|
49
|
+
return 'queue';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Execute command via Docker container
|
|
54
|
+
* @param {string} projectPath
|
|
55
|
+
* @param {string} command
|
|
56
|
+
* @param {Function} onOutput
|
|
57
|
+
* @returns {Promise<{ exitCode: number }>}
|
|
58
|
+
*/
|
|
59
|
+
async function executeViaContainer(projectPath, command, onOutput) {
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
const proc = spawn('docker', [
|
|
62
|
+
'run', '--rm',
|
|
63
|
+
'-v', `${projectPath}:/project`,
|
|
64
|
+
'-w', '/project',
|
|
65
|
+
'tlc-standalone',
|
|
66
|
+
'tlc', command,
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
proc.stdout.on('data', (data) => onOutput && onOutput(data.toString()));
|
|
70
|
+
proc.stderr.on('data', (data) => onOutput && onOutput(data.toString()));
|
|
71
|
+
proc.on('close', (code) => resolve({ exitCode: code }));
|
|
72
|
+
proc.on('error', (err) => reject(err));
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Queue command as task in PLAN.md
|
|
78
|
+
* @param {string} projectPath
|
|
79
|
+
* @param {string} command
|
|
80
|
+
*/
|
|
81
|
+
async function queueCommand(projectPath, command) {
|
|
82
|
+
const timestamp = new Date().toISOString();
|
|
83
|
+
const entry = `\n### Queued: tlc ${command}\n_Queued at ${timestamp}_\n`;
|
|
84
|
+
|
|
85
|
+
// Find most recent plan file
|
|
86
|
+
const planDir = path.join(projectPath, '.planning', 'phases');
|
|
87
|
+
if (fs.existsSync(planDir)) {
|
|
88
|
+
const plans = fs.readdirSync(planDir).filter(f => f.endsWith('-PLAN.md')).sort((a, b) => {
|
|
89
|
+
const numA = parseInt(a.match(/^(\d+)/)?.[1] || '0', 10);
|
|
90
|
+
const numB = parseInt(b.match(/^(\d+)/)?.[1] || '0', 10);
|
|
91
|
+
return numA - numB;
|
|
92
|
+
});
|
|
93
|
+
if (plans.length > 0) {
|
|
94
|
+
const planPath = path.join(planDir, plans[plans.length - 1]);
|
|
95
|
+
fs.appendFileSync(planPath, entry);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Log to history
|
|
100
|
+
logCommand(projectPath, { command, timestamp, method: 'queue' });
|
|
101
|
+
|
|
102
|
+
return { queued: true, method: 'queue', command };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Log a command to history
|
|
107
|
+
*/
|
|
108
|
+
function logCommand(projectPath, entry) {
|
|
109
|
+
const histPath = path.join(projectPath, '.tlc', 'command-history.json');
|
|
110
|
+
const dir = path.dirname(histPath);
|
|
111
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
112
|
+
|
|
113
|
+
let history = [];
|
|
114
|
+
try {
|
|
115
|
+
if (fs.existsSync(histPath)) {
|
|
116
|
+
history = JSON.parse(fs.readFileSync(histPath, 'utf8'));
|
|
117
|
+
}
|
|
118
|
+
} catch {}
|
|
119
|
+
history.push(entry);
|
|
120
|
+
// Keep last 100
|
|
121
|
+
if (history.length > 100) history = history.slice(-100);
|
|
122
|
+
fs.writeFileSync(histPath, JSON.stringify(history, null, 2));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Get command history for a project
|
|
127
|
+
* @param {string} projectPath
|
|
128
|
+
* @returns {Array}
|
|
129
|
+
*/
|
|
130
|
+
function getCommandHistory(projectPath) {
|
|
131
|
+
const histPath = path.join(projectPath, '.tlc', 'command-history.json');
|
|
132
|
+
try {
|
|
133
|
+
if (fs.existsSync(histPath)) {
|
|
134
|
+
return JSON.parse(fs.readFileSync(histPath, 'utf8'));
|
|
135
|
+
}
|
|
136
|
+
} catch {}
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Validate command type
|
|
142
|
+
* @param {string} command
|
|
143
|
+
* @returns {boolean}
|
|
144
|
+
*/
|
|
145
|
+
function validateCommand(command) {
|
|
146
|
+
if (!command || typeof command !== 'string') return false;
|
|
147
|
+
return VALID_COMMANDS.includes(command);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
detectExecutionMethod,
|
|
152
|
+
executeViaContainer,
|
|
153
|
+
queueCommand,
|
|
154
|
+
getCommandHistory,
|
|
155
|
+
validateCommand,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = { createCommandRunner };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
|
|
6
|
+
const { createCommandRunner } = await import('./command-runner.js');
|
|
7
|
+
|
|
8
|
+
describe('CommandRunner', () => {
|
|
9
|
+
let runner;
|
|
10
|
+
let tempDir;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cmd-runner-test-'));
|
|
14
|
+
runner = createCommandRunner({
|
|
15
|
+
_checkDocker: vi.fn().mockResolvedValue(false),
|
|
16
|
+
_checkClaude: vi.fn().mockReturnValue(false),
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('detectExecutionMethod', () => {
|
|
25
|
+
it('returns container when tlc-standalone image exists', async () => {
|
|
26
|
+
const r = createCommandRunner({
|
|
27
|
+
_checkDocker: vi.fn().mockResolvedValue(true),
|
|
28
|
+
_checkClaude: vi.fn().mockReturnValue(false),
|
|
29
|
+
});
|
|
30
|
+
const method = await r.detectExecutionMethod(tempDir);
|
|
31
|
+
expect(method).toBe('container');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('returns claude-code when Claude Code process running', async () => {
|
|
35
|
+
const r = createCommandRunner({
|
|
36
|
+
_checkDocker: vi.fn().mockResolvedValue(false),
|
|
37
|
+
_checkClaude: vi.fn().mockReturnValue(true),
|
|
38
|
+
});
|
|
39
|
+
const method = await r.detectExecutionMethod(tempDir);
|
|
40
|
+
expect(method).toBe('claude-code');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('returns queue as fallback', async () => {
|
|
44
|
+
const method = await runner.detectExecutionMethod(tempDir);
|
|
45
|
+
expect(method).toBe('queue');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('queueCommand', () => {
|
|
50
|
+
it('appends task to PLAN.md', async () => {
|
|
51
|
+
const planDir = path.join(tempDir, '.planning', 'phases');
|
|
52
|
+
fs.mkdirSync(planDir, { recursive: true });
|
|
53
|
+
fs.writeFileSync(path.join(planDir, '1-PLAN.md'), '# Phase 1\n');
|
|
54
|
+
|
|
55
|
+
const result = await runner.queueCommand(tempDir, 'build');
|
|
56
|
+
expect(result.queued).toBe(true);
|
|
57
|
+
expect(result.method).toBe('queue');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('getCommandHistory', () => {
|
|
62
|
+
it('returns recent commands', () => {
|
|
63
|
+
const histDir = path.join(tempDir, '.tlc');
|
|
64
|
+
fs.mkdirSync(histDir, { recursive: true });
|
|
65
|
+
fs.writeFileSync(path.join(histDir, 'command-history.json'), JSON.stringify([
|
|
66
|
+
{ command: 'build', timestamp: '2026-02-18T00:00:00Z', method: 'queue' },
|
|
67
|
+
]));
|
|
68
|
+
|
|
69
|
+
const history = runner.getCommandHistory(tempDir);
|
|
70
|
+
expect(history).toHaveLength(1);
|
|
71
|
+
expect(history[0].command).toBe('build');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('returns empty array when no history', () => {
|
|
75
|
+
const history = runner.getCommandHistory(tempDir);
|
|
76
|
+
expect(history).toEqual([]);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('validateCommand', () => {
|
|
81
|
+
it('accepts valid commands', () => {
|
|
82
|
+
expect(runner.validateCommand('build')).toBe(true);
|
|
83
|
+
expect(runner.validateCommand('deploy')).toBe(true);
|
|
84
|
+
expect(runner.validateCommand('test')).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('rejects invalid commands', () => {
|
|
88
|
+
expect(runner.validateCommand('')).toBe(false);
|
|
89
|
+
expect(runner.validateCommand(null)).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -151,21 +151,58 @@ describe('Cost Tracker', () => {
|
|
|
151
151
|
|
|
152
152
|
describe('getWeeklyCost', () => {
|
|
153
153
|
it('aggregates days in week', () => {
|
|
154
|
-
//
|
|
154
|
+
// Use explicit UTC day strings for Mon–Fri of the current UTC week so
|
|
155
|
+
// the recorded entries are immune to local-vs-UTC boundary differences.
|
|
156
|
+
// We record two entries and then recompute the expected weekly total using
|
|
157
|
+
// the same week-window logic the implementation uses, so the assertion is
|
|
158
|
+
// always self-consistent regardless of timezone or day-of-week.
|
|
155
159
|
const now = new Date();
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
160
|
+
const weekStart = new Date(now);
|
|
161
|
+
weekStart.setDate(now.getDate() - now.getDay());
|
|
162
|
+
weekStart.setHours(0, 0, 0, 0);
|
|
163
|
+
|
|
164
|
+
// Pick up to two UTC day strings from the past 7 days that fall within
|
|
165
|
+
// the implementation's [weekStart, now] window so we always have at
|
|
166
|
+
// least one in-range day (or zero if the implementation window is empty,
|
|
167
|
+
// in which case the expected total is also 0).
|
|
168
|
+
const candidateDays = [];
|
|
169
|
+
for (let offset = 1; offset <= 6; offset++) {
|
|
170
|
+
const d = new Date(now);
|
|
171
|
+
d.setUTCDate(now.getUTCDate() - offset);
|
|
172
|
+
d.setUTCHours(12, 0, 0, 0); // noon UTC avoids any day-rollover at midnight
|
|
173
|
+
const dayStr = d.toISOString().split('T')[0];
|
|
174
|
+
const dayDate = new Date(dayStr);
|
|
175
|
+
if (dayDate >= weekStart && dayDate <= now) {
|
|
176
|
+
candidateDays.push(dayStr);
|
|
177
|
+
if (candidateDays.length >= 2) break;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Always record one entry for the exact current moment so that entry is
|
|
182
|
+
// trivially <= now; whether it passes the weekStart check determines the
|
|
183
|
+
// expected total.
|
|
184
|
+
const nowDayStr = now.toISOString().split('T')[0];
|
|
185
|
+
|
|
186
|
+
const allDays = [...new Set([...candidateDays, nowDayStr])];
|
|
187
|
+
let expectedTotal = 0;
|
|
188
|
+
for (const dayStr of allDays) {
|
|
189
|
+
const dayDate = new Date(dayStr);
|
|
190
|
+
const cost = 1.00;
|
|
191
|
+
recordCost(tracker, {
|
|
192
|
+
agentId: 'agent-1',
|
|
193
|
+
sessionId: 'session-1',
|
|
194
|
+
model: 'claude-3-opus',
|
|
195
|
+
provider: 'anthropic',
|
|
196
|
+
cost,
|
|
197
|
+
timestamp: `${dayStr}T12:00:00.000Z`,
|
|
198
|
+
});
|
|
199
|
+
if (dayDate >= weekStart && dayDate <= now) {
|
|
200
|
+
expectedTotal += cost;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
166
203
|
|
|
167
204
|
const weeklyCost = getWeeklyCost(tracker);
|
|
168
|
-
assert.
|
|
205
|
+
assert.strictEqual(weeklyCost, expectedTotal);
|
|
169
206
|
});
|
|
170
207
|
});
|
|
171
208
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency Runner
|
|
3
|
+
*
|
|
4
|
+
* Scans project dependencies using npm audit.
|
|
5
|
+
* Returns findings with severity, package name, and fix availability.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execFile as defaultExecFile } from 'node:child_process';
|
|
9
|
+
import { promisify } from 'node:util';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
const execFileAsync = promisify(defaultExecFile);
|
|
14
|
+
|
|
15
|
+
/** Severity levels ordered from lowest to highest */
|
|
16
|
+
const SEVERITY_ORDER = ['info', 'low', 'moderate', 'high', 'critical'];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Check if a severity meets or exceeds the threshold
|
|
20
|
+
* @param {string} severity - The vulnerability severity
|
|
21
|
+
* @param {string} threshold - The minimum severity to fail on
|
|
22
|
+
* @returns {boolean} True if severity >= threshold
|
|
23
|
+
*/
|
|
24
|
+
function meetsThreshold(severity, threshold) {
|
|
25
|
+
return SEVERITY_ORDER.indexOf(severity) >= SEVERITY_ORDER.indexOf(threshold);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Create a dependency scanning runner
|
|
30
|
+
* @param {Object} [deps] - Injectable dependencies for testing
|
|
31
|
+
* @param {Function} [deps.exec] - Command executor (replaces execFile)
|
|
32
|
+
* @param {Object} [deps.fs] - File system module
|
|
33
|
+
* @param {string} [deps.severityThreshold] - Minimum severity to fail on (default: 'high')
|
|
34
|
+
* @returns {Function} Runner function: (projectPath, options) => { passed, findings, error? }
|
|
35
|
+
*/
|
|
36
|
+
export function createDependencyRunner(deps = {}) {
|
|
37
|
+
const {
|
|
38
|
+
exec: execFn,
|
|
39
|
+
fs: fsMod = fs,
|
|
40
|
+
severityThreshold = 'high',
|
|
41
|
+
} = deps;
|
|
42
|
+
|
|
43
|
+
return async (projectPath, options = {}) => {
|
|
44
|
+
// Check if package.json exists
|
|
45
|
+
const pkgPath = path.join(projectPath, 'package.json');
|
|
46
|
+
if (!fsMod.existsSync(pkgPath)) {
|
|
47
|
+
return { passed: true, findings: [] };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let stdout;
|
|
51
|
+
try {
|
|
52
|
+
if (execFn) {
|
|
53
|
+
// Injected exec for testing
|
|
54
|
+
const result = await execFn('npm', ['audit', '--json'], { cwd: projectPath });
|
|
55
|
+
stdout = result.stdout;
|
|
56
|
+
} else {
|
|
57
|
+
// Real exec
|
|
58
|
+
try {
|
|
59
|
+
const result = await execFileAsync('npm', ['audit', '--json'], { cwd: projectPath });
|
|
60
|
+
stdout = result.stdout;
|
|
61
|
+
} catch (execErr) {
|
|
62
|
+
// npm audit exits with code 1 when vulnerabilities found — that's expected
|
|
63
|
+
if (execErr.stdout) {
|
|
64
|
+
stdout = execErr.stdout;
|
|
65
|
+
} else {
|
|
66
|
+
throw execErr;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} catch (err) {
|
|
71
|
+
return { passed: false, findings: [], error: err.message };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Parse the JSON output
|
|
75
|
+
let auditData;
|
|
76
|
+
try {
|
|
77
|
+
auditData = JSON.parse(stdout);
|
|
78
|
+
} catch {
|
|
79
|
+
return { passed: false, findings: [], error: 'Failed to parse npm audit output' };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const vulnerabilities = auditData.vulnerabilities || {};
|
|
83
|
+
const findings = [];
|
|
84
|
+
let hasFailingSeverity = false;
|
|
85
|
+
|
|
86
|
+
for (const [name, vuln] of Object.entries(vulnerabilities)) {
|
|
87
|
+
const finding = {
|
|
88
|
+
severity: vuln.severity,
|
|
89
|
+
package: vuln.name || name,
|
|
90
|
+
title: vuln.title || 'Unknown vulnerability',
|
|
91
|
+
url: vuln.url || '',
|
|
92
|
+
fixAvailable: Boolean(vuln.fixAvailable),
|
|
93
|
+
};
|
|
94
|
+
findings.push(finding);
|
|
95
|
+
|
|
96
|
+
if (meetsThreshold(vuln.severity, severityThreshold)) {
|
|
97
|
+
hasFailingSeverity = true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
passed: !hasFailingSeverity,
|
|
103
|
+
findings,
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency Runner Tests
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
5
|
+
import { createDependencyRunner } from './dependency-runner.js';
|
|
6
|
+
|
|
7
|
+
describe('dependency-runner', () => {
|
|
8
|
+
let execMock;
|
|
9
|
+
let fsMock;
|
|
10
|
+
let runner;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
execMock = vi.fn();
|
|
14
|
+
fsMock = { existsSync: vi.fn().mockReturnValue(true) };
|
|
15
|
+
runner = createDependencyRunner({ exec: execMock, fs: fsMock });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('passes when npm audit returns no vulnerabilities', async () => {
|
|
19
|
+
execMock.mockResolvedValue({
|
|
20
|
+
stdout: JSON.stringify({ vulnerabilities: {} }),
|
|
21
|
+
exitCode: 0,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const result = await runner('/test/project', {});
|
|
25
|
+
expect(result.passed).toBe(true);
|
|
26
|
+
expect(result.findings).toEqual([]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('fails when npm audit finds high-severity vulnerability', async () => {
|
|
30
|
+
execMock.mockResolvedValue({
|
|
31
|
+
stdout: JSON.stringify({
|
|
32
|
+
vulnerabilities: {
|
|
33
|
+
'bad-pkg': {
|
|
34
|
+
name: 'bad-pkg',
|
|
35
|
+
severity: 'high',
|
|
36
|
+
title: 'Prototype Pollution',
|
|
37
|
+
url: 'https://npmjs.com/advisories/123',
|
|
38
|
+
fixAvailable: true,
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
}),
|
|
42
|
+
exitCode: 1,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const result = await runner('/test/project', {});
|
|
46
|
+
expect(result.passed).toBe(false);
|
|
47
|
+
expect(result.findings).toHaveLength(1);
|
|
48
|
+
expect(result.findings[0].severity).toBe('high');
|
|
49
|
+
expect(result.findings[0].package).toBe('bad-pkg');
|
|
50
|
+
expect(result.findings[0].title).toBe('Prototype Pollution');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('passes when only low/moderate vulnerabilities found', async () => {
|
|
54
|
+
execMock.mockResolvedValue({
|
|
55
|
+
stdout: JSON.stringify({
|
|
56
|
+
vulnerabilities: {
|
|
57
|
+
'ok-pkg': {
|
|
58
|
+
name: 'ok-pkg',
|
|
59
|
+
severity: 'moderate',
|
|
60
|
+
title: 'ReDoS',
|
|
61
|
+
url: 'https://npmjs.com/advisories/456',
|
|
62
|
+
fixAvailable: true,
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
}),
|
|
66
|
+
exitCode: 1,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const result = await runner('/test/project', {});
|
|
70
|
+
expect(result.passed).toBe(true);
|
|
71
|
+
expect(result.findings).toHaveLength(1);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('fails when critical vulnerability found', async () => {
|
|
75
|
+
execMock.mockResolvedValue({
|
|
76
|
+
stdout: JSON.stringify({
|
|
77
|
+
vulnerabilities: {
|
|
78
|
+
'evil-pkg': {
|
|
79
|
+
name: 'evil-pkg',
|
|
80
|
+
severity: 'critical',
|
|
81
|
+
title: 'RCE',
|
|
82
|
+
url: 'https://npmjs.com/advisories/789',
|
|
83
|
+
fixAvailable: false,
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
}),
|
|
87
|
+
exitCode: 1,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const result = await runner('/test/project', {});
|
|
91
|
+
expect(result.passed).toBe(false);
|
|
92
|
+
expect(result.findings[0].severity).toBe('critical');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('handles missing package.json', async () => {
|
|
96
|
+
fsMock.existsSync.mockReturnValue(false);
|
|
97
|
+
|
|
98
|
+
const result = await runner('/test/project', {});
|
|
99
|
+
expect(result.passed).toBe(true);
|
|
100
|
+
expect(result.findings).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('handles npm audit JSON parse error', async () => {
|
|
104
|
+
execMock.mockResolvedValue({
|
|
105
|
+
stdout: 'not valid json',
|
|
106
|
+
exitCode: 1,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const result = await runner('/test/project', {});
|
|
110
|
+
expect(result.passed).toBe(false);
|
|
111
|
+
expect(result.error).toBeDefined();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('handles npm audit process error', async () => {
|
|
115
|
+
execMock.mockRejectedValue(new Error('npm not found'));
|
|
116
|
+
|
|
117
|
+
const result = await runner('/test/project', {});
|
|
118
|
+
expect(result.passed).toBe(false);
|
|
119
|
+
expect(result.error).toContain('npm not found');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('respects configurable severity threshold', async () => {
|
|
123
|
+
execMock.mockResolvedValue({
|
|
124
|
+
stdout: JSON.stringify({
|
|
125
|
+
vulnerabilities: {
|
|
126
|
+
'bad-pkg': {
|
|
127
|
+
name: 'bad-pkg',
|
|
128
|
+
severity: 'high',
|
|
129
|
+
title: 'Prototype Pollution',
|
|
130
|
+
url: 'https://npmjs.com/advisories/123',
|
|
131
|
+
fixAvailable: true,
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
}),
|
|
135
|
+
exitCode: 1,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const lenientRunner = createDependencyRunner({
|
|
139
|
+
exec: execMock,
|
|
140
|
+
fs: fsMock,
|
|
141
|
+
severityThreshold: 'critical',
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const result = await lenientRunner('/test/project', {});
|
|
145
|
+
expect(result.passed).toBe(true);
|
|
146
|
+
expect(result.findings).toHaveLength(1);
|
|
147
|
+
});
|
|
148
|
+
});
|