codeant-cli 0.3.7 → 0.3.9
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/package.json +1 -1
- package/src/commands/secrets.js +63 -15
- package/src/components/SecretsUI.js +93 -45
- package/src/index.js +22 -6
- package/src/reviewHeadless.js +30 -16
- package/src/scm/index.js +7 -0
- package/src/utils/fetchApi.js +5 -0
- package/src/utils/installPushProtectionHook.js +15 -19
- package/src/utils/secretsDetector.js +0 -1
package/package.json
CHANGED
package/src/commands/secrets.js
CHANGED
|
@@ -2,6 +2,8 @@ import { useState, useEffect } from 'react';
|
|
|
2
2
|
import { useApp } from 'ink';
|
|
3
3
|
import SecretsApiHelper from '../utils/secretsApiHelper.js';
|
|
4
4
|
import { detectSecrets } from '../utils/secretsDetector.js';
|
|
5
|
+
import { fetchApi } from '../utils/fetchApi.js';
|
|
6
|
+
import { detectRemote, detectRepoName, detectBaseUrl } from '../scm/index.js';
|
|
5
7
|
import {
|
|
6
8
|
renderInitializing,
|
|
7
9
|
renderFetchingDiff,
|
|
@@ -9,9 +11,10 @@ import {
|
|
|
9
11
|
renderNoFiles,
|
|
10
12
|
renderError,
|
|
11
13
|
renderDone,
|
|
14
|
+
renderBypassPrompt,
|
|
12
15
|
} from '../components/SecretsUI.js';
|
|
13
16
|
|
|
14
|
-
export default function Secrets({ scanType = 'all',
|
|
17
|
+
export default function Secrets({ scanType = 'all', include = [], exclude = [], lastNCommits = 1, baseBranch = null, baseCommit = null, hook = false }) {
|
|
15
18
|
const { exit } = useApp();
|
|
16
19
|
const [status, setStatus] = useState('initializing');
|
|
17
20
|
const [secrets, setSecrets] = useState([]);
|
|
@@ -20,14 +23,6 @@ export default function Secrets({ scanType = 'all', failOn = 'CRITICAL', include
|
|
|
20
23
|
const [scanMeta, setScanMeta] = useState(null);
|
|
21
24
|
const [startTime] = useState(() => Date.now());
|
|
22
25
|
|
|
23
|
-
const shouldFailOn = (confidenceScore) => {
|
|
24
|
-
const score = confidenceScore?.toUpperCase();
|
|
25
|
-
if (score === 'FALSE_POSITIVE') return false;
|
|
26
|
-
if (failOn === 'HIGH') return score === 'HIGH';
|
|
27
|
-
if (failOn === 'MEDIUM') return score === 'HIGH' || score === 'MEDIUM';
|
|
28
|
-
return true;
|
|
29
|
-
};
|
|
30
|
-
|
|
31
26
|
useEffect(() => {
|
|
32
27
|
let cancelled = false;
|
|
33
28
|
|
|
@@ -79,13 +74,65 @@ export default function Secrets({ scanType = 'all', failOn = 'CRITICAL', include
|
|
|
79
74
|
return () => { cancelled = true; };
|
|
80
75
|
}, [scanType, include, exclude, lastNCommits, baseBranch, baseCommit]);
|
|
81
76
|
|
|
77
|
+
// Build secrets payload for API calls
|
|
78
|
+
function buildSecretsPayload() {
|
|
79
|
+
return secrets.flatMap(file =>
|
|
80
|
+
file.secrets.map(s => ({ type: s.type, file_path: file.file_path, line_number: s.line_number }))
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Build remote info using existing scm detection
|
|
85
|
+
function getRemoteBody() {
|
|
86
|
+
const service = detectRemote();
|
|
87
|
+
const repo = detectRepoName();
|
|
88
|
+
if (!service || !repo) return null;
|
|
89
|
+
return { service, repo, base_url: detectBaseUrl() };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Fire-and-forget block event + exit 1
|
|
93
|
+
function reportBlockAndExit() {
|
|
94
|
+
const remote = getRemoteBody();
|
|
95
|
+
if (remote) {
|
|
96
|
+
fetchApi('/extension/push-protection/event', 'POST', {
|
|
97
|
+
...remote,
|
|
98
|
+
secrets: buildSecretsPayload(),
|
|
99
|
+
}).catch(() => {});
|
|
100
|
+
}
|
|
101
|
+
setTimeout(() => { process.exitCode = 1; exit(new Error('Secrets detected')); }, 100);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Handle bypass selection
|
|
105
|
+
async function handleBypassSelect(reason) {
|
|
106
|
+
if (reason === 'cancel') {
|
|
107
|
+
reportBlockAndExit();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const remote = getRemoteBody();
|
|
111
|
+
if (remote) {
|
|
112
|
+
try {
|
|
113
|
+
await fetchApi('/extension/push-protection/bypass', 'POST', {
|
|
114
|
+
...remote,
|
|
115
|
+
secrets: buildSecretsPayload(),
|
|
116
|
+
reason: reason.startsWith('other:') ? 'other' : reason,
|
|
117
|
+
custom_reason: reason.startsWith('other:') ? reason.slice(6) : undefined,
|
|
118
|
+
});
|
|
119
|
+
} catch {
|
|
120
|
+
reportBlockAndExit();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
setTimeout(() => exit(), 100);
|
|
125
|
+
}
|
|
126
|
+
|
|
82
127
|
useEffect(() => {
|
|
83
128
|
if (status === 'done') {
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
129
|
+
const hasSecrets = secrets.some(file => file.secrets.length > 0);
|
|
130
|
+
if (hasSecrets) {
|
|
131
|
+
if (hook && process.stdin.isTTY) {
|
|
132
|
+
setStatus('prompt_bypass');
|
|
133
|
+
} else {
|
|
134
|
+
reportBlockAndExit();
|
|
135
|
+
}
|
|
89
136
|
} else {
|
|
90
137
|
setTimeout(() => exit(), 100);
|
|
91
138
|
}
|
|
@@ -101,7 +148,8 @@ export default function Secrets({ scanType = 'all', failOn = 'CRITICAL', include
|
|
|
101
148
|
if (status === 'scanning') return renderScanning(startTime, fileCount, scanMeta);
|
|
102
149
|
if (status === 'no_files') return renderNoFiles(scanType, lastNCommits, baseBranch, baseCommit);
|
|
103
150
|
if (status === 'error') return renderError(error);
|
|
104
|
-
if (status === '
|
|
151
|
+
if (status === 'prompt_bypass') return renderBypassPrompt(secrets, handleBypassSelect);
|
|
152
|
+
if (status === 'done') return renderDone(secrets, startTime, fileCount, scanMeta);
|
|
105
153
|
|
|
106
154
|
return null;
|
|
107
155
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React, { useState, useEffect } from 'react';
|
|
2
|
-
import { Box, Text } from 'ink';
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
3
|
|
|
4
4
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
5
5
|
|
|
@@ -7,19 +7,8 @@ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧',
|
|
|
7
7
|
const DIVIDER = '─'.repeat(55);
|
|
8
8
|
const STEPS = ['Init', 'Fetch', 'Scan'];
|
|
9
9
|
|
|
10
|
-
const CONFIDENCE_COLORS = {
|
|
11
|
-
HIGH: 'red',
|
|
12
|
-
MEDIUM: 'yellow',
|
|
13
|
-
LOW: 'cyan',
|
|
14
|
-
FALSE_POSITIVE: 'gray',
|
|
15
|
-
};
|
|
16
|
-
|
|
17
10
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
18
11
|
|
|
19
|
-
function getConfidenceColor(score) {
|
|
20
|
-
return CONFIDENCE_COLORS[score?.toUpperCase()] || 'gray';
|
|
21
|
-
}
|
|
22
|
-
|
|
23
12
|
function padEnd(str, len) {
|
|
24
13
|
return str + ' '.repeat(Math.max(0, len - str.length));
|
|
25
14
|
}
|
|
@@ -211,22 +200,13 @@ export function renderNotLoggedIn() {
|
|
|
211
200
|
);
|
|
212
201
|
}
|
|
213
202
|
|
|
214
|
-
export function renderDone(secrets,
|
|
203
|
+
export function renderDone(secrets, startTime, fileCount, meta) {
|
|
215
204
|
const allSecrets = secrets.flatMap(file =>
|
|
216
205
|
file.secrets.map(s => ({ ...s, file_path: file.file_path }))
|
|
217
206
|
);
|
|
218
207
|
|
|
219
|
-
const blockingSecrets = allSecrets.filter(s => shouldFailOn(s.confidence_score));
|
|
220
|
-
const hasBlocking = blockingSecrets.length > 0;
|
|
221
208
|
const elapsed = startTime ? formatElapsed(startTime) : null;
|
|
222
209
|
|
|
223
|
-
// Count by confidence
|
|
224
|
-
const counts = {};
|
|
225
|
-
allSecrets.forEach(s => {
|
|
226
|
-
const conf = s.confidence_score?.toUpperCase() || 'UNKNOWN';
|
|
227
|
-
counts[conf] = (counts[conf] || 0) + 1;
|
|
228
|
-
});
|
|
229
|
-
|
|
230
210
|
// Group by file
|
|
231
211
|
const grouped = {};
|
|
232
212
|
allSecrets.forEach(s => {
|
|
@@ -269,9 +249,6 @@ export function renderDone(secrets, failOn, shouldFailOn, startTime, fileCount,
|
|
|
269
249
|
));
|
|
270
250
|
|
|
271
251
|
fileSecrets.forEach((secret, si) => {
|
|
272
|
-
const conf = secret.confidence_score?.toUpperCase() || 'UNKNOWN';
|
|
273
|
-
const confColor = getConfidenceColor(conf);
|
|
274
|
-
const isBlocking = shouldFailOn(secret.confidence_score);
|
|
275
252
|
const isLast = si === fileSecrets.length - 1;
|
|
276
253
|
const branch = isLast ? '└─' : '├─';
|
|
277
254
|
const lineStr = secret.line_number ? `L${secret.line_number}` : '';
|
|
@@ -279,12 +256,8 @@ export function renderDone(secrets, failOn, shouldFailOn, startTime, fileCount,
|
|
|
279
256
|
els.push(React.createElement(
|
|
280
257
|
Box, { key: `s-${fi}-${si}` },
|
|
281
258
|
React.createElement(Text, { color: 'gray' }, ` ${branch} `),
|
|
282
|
-
React.createElement(Text, {
|
|
283
|
-
color: confColor,
|
|
284
|
-
bold: isBlocking,
|
|
285
|
-
}, padEnd(conf, 16)),
|
|
286
259
|
React.createElement(Text, { color: 'gray' }, padEnd(lineStr, 6)),
|
|
287
|
-
React.createElement(Text, { color:
|
|
260
|
+
React.createElement(Text, { color: 'white' }, secret.type || ''),
|
|
288
261
|
));
|
|
289
262
|
});
|
|
290
263
|
|
|
@@ -330,28 +303,17 @@ export function renderDone(secrets, failOn, shouldFailOn, startTime, fileCount,
|
|
|
330
303
|
|
|
331
304
|
// Secret stats line
|
|
332
305
|
if (allSecrets.length > 0) {
|
|
333
|
-
const stats = [`${allSecrets.length} secret${allSecrets.length !== 1 ? 's' : ''} found`];
|
|
334
|
-
for (const conf of ['HIGH', 'MEDIUM', 'LOW', 'FALSE_POSITIVE']) {
|
|
335
|
-
if (counts[conf]) {
|
|
336
|
-
const label = conf === 'FALSE_POSITIVE' ? 'false positive' : conf.toLowerCase();
|
|
337
|
-
stats.push(`${counts[conf]} ${label}`);
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
306
|
els.push(React.createElement(
|
|
341
|
-
Text, { key: 'stats', color: 'gray' },
|
|
307
|
+
Text, { key: 'stats', color: 'gray' },
|
|
308
|
+
`${allSecrets.length} secret${allSecrets.length !== 1 ? 's' : ''} found`,
|
|
342
309
|
));
|
|
343
310
|
}
|
|
344
311
|
|
|
345
312
|
// Status line
|
|
346
|
-
if (
|
|
313
|
+
if (allSecrets.length > 0) {
|
|
347
314
|
els.push(React.createElement(
|
|
348
315
|
Text, { key: 'status', color: 'red', bold: true },
|
|
349
|
-
`✗ ${
|
|
350
|
-
));
|
|
351
|
-
} else if (allSecrets.length > 0) {
|
|
352
|
-
els.push(React.createElement(
|
|
353
|
-
Text, { key: 'status', color: 'green', bold: true },
|
|
354
|
-
'✓ No blocking secrets found',
|
|
316
|
+
`✗ ${allSecrets.length} secret${allSecrets.length !== 1 ? 's' : ''} found — remove before committing`,
|
|
355
317
|
));
|
|
356
318
|
} else {
|
|
357
319
|
els.push(React.createElement(
|
|
@@ -364,3 +326,89 @@ export function renderDone(secrets, failOn, shouldFailOn, startTime, fileCount,
|
|
|
364
326
|
Box, { flexDirection: 'column', paddingX: 1, paddingY: 1 }, ...els,
|
|
365
327
|
);
|
|
366
328
|
}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
// ── Bypass Prompt ───────────────────────────────────────────────────────────
|
|
332
|
+
|
|
333
|
+
function BypassPrompt({ secrets, onSelect }) {
|
|
334
|
+
const [selected, setSelected] = React.useState(0);
|
|
335
|
+
const [mode, setMode] = React.useState('menu'); // 'menu' | 'typing'
|
|
336
|
+
const [customReason, setCustomReason] = React.useState('');
|
|
337
|
+
const allSecrets = secrets.flatMap(file =>
|
|
338
|
+
file.secrets.map(s => ({ ...s, file_path: file.file_path }))
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
const options = [
|
|
342
|
+
{ label: "It's a false positive", value: 'false_positive' },
|
|
343
|
+
{ label: "It's used in tests", value: 'used_in_tests' },
|
|
344
|
+
{ label: "I'll fix it later", value: 'fix_later' },
|
|
345
|
+
{ label: 'Other (type your reason)', value: 'other' },
|
|
346
|
+
{ label: 'Cancel \u2014 block this push', value: 'cancel' },
|
|
347
|
+
];
|
|
348
|
+
|
|
349
|
+
useInput((input, key) => {
|
|
350
|
+
if (mode === 'menu') {
|
|
351
|
+
if (key.upArrow) {
|
|
352
|
+
setSelected(s => (s - 1 + options.length) % options.length);
|
|
353
|
+
} else if (key.downArrow) {
|
|
354
|
+
setSelected(s => (s + 1) % options.length);
|
|
355
|
+
} else if (key.return) {
|
|
356
|
+
if (options[selected].value === 'other') {
|
|
357
|
+
setMode('typing');
|
|
358
|
+
} else {
|
|
359
|
+
onSelect(options[selected].value);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
} else {
|
|
363
|
+
// typing mode
|
|
364
|
+
if (key.return) {
|
|
365
|
+
const reason = customReason.trim();
|
|
366
|
+
if (reason) {
|
|
367
|
+
onSelect('other:' + reason);
|
|
368
|
+
}
|
|
369
|
+
} else if (key.escape) {
|
|
370
|
+
setMode('menu');
|
|
371
|
+
setCustomReason('');
|
|
372
|
+
} else if (key.backspace || key.delete) {
|
|
373
|
+
setCustomReason(s => s.slice(0, -1));
|
|
374
|
+
} else if (input && !key.ctrl && !key.meta) {
|
|
375
|
+
setCustomReason(s => s + input);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const els = [];
|
|
381
|
+
els.push(React.createElement(Text, { key: 'div-top', color: 'gray', dimColor: true }, DIVIDER));
|
|
382
|
+
els.push(React.createElement(
|
|
383
|
+
Text, { key: 'title', color: 'yellow', bold: true },
|
|
384
|
+
`${allSecrets.length} secret(s) detected. Select an action:`,
|
|
385
|
+
));
|
|
386
|
+
els.push(React.createElement(Text, { key: 'sp' }, ''));
|
|
387
|
+
|
|
388
|
+
if (mode === 'menu') {
|
|
389
|
+
options.forEach((opt, i) => {
|
|
390
|
+
const prefix = i === selected ? '> ' : ' ';
|
|
391
|
+
const color = i === selected ? 'cyan' : 'white';
|
|
392
|
+
els.push(React.createElement(
|
|
393
|
+
Text, { key: `opt-${i}`, color, bold: i === selected },
|
|
394
|
+
`${prefix}${opt.label}`,
|
|
395
|
+
));
|
|
396
|
+
});
|
|
397
|
+
} else {
|
|
398
|
+
els.push(React.createElement(Text, { key: 'typing-label', color: 'cyan' }, ' Type your reason (Enter to submit, Esc to go back):'));
|
|
399
|
+
els.push(React.createElement(Text, { key: 'typing-sp' }, ''));
|
|
400
|
+
els.push(React.createElement(
|
|
401
|
+
Text, { key: 'typing-input', color: 'white' },
|
|
402
|
+
` > ${customReason}\u2588`,
|
|
403
|
+
));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
els.push(React.createElement(Text, { key: 'sp2' }, ''));
|
|
407
|
+
els.push(React.createElement(Text, { key: 'div-bot', color: 'gray', dimColor: true }, DIVIDER));
|
|
408
|
+
|
|
409
|
+
return React.createElement(Box, { flexDirection: 'column', paddingX: 1, paddingY: 1 }, ...els);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export function renderBypassPrompt(secrets, onSelect) {
|
|
413
|
+
return React.createElement(BypassPrompt, { secrets, onSelect });
|
|
414
|
+
}
|
package/src/index.js
CHANGED
|
@@ -10,6 +10,7 @@ import GetBaseUrl from './commands/getBaseUrl.js';
|
|
|
10
10
|
import Login from './commands/login.js';
|
|
11
11
|
import Logout from './commands/logout.js';
|
|
12
12
|
import Review from './commands/review.js';
|
|
13
|
+
import { runReviewHeadless } from './reviewHeadless.js';
|
|
13
14
|
import Welcome from './components/Welcome.js';
|
|
14
15
|
import * as scm from './scm/index.js';
|
|
15
16
|
import { setConfigValue } from './utils/config.js';
|
|
@@ -58,9 +59,9 @@ program
|
|
|
58
59
|
.option('--last-n-commits <n>', 'Scan last n commits (max 5)', parseInt)
|
|
59
60
|
.option('--base <branch>', 'Compare against a specific base branch (e.g. --base develop)')
|
|
60
61
|
.option('--base-commit <commit>', 'Compare against a specific commit (e.g. --base-commit HEAD~3)')
|
|
61
|
-
.option('--fail-on <level>', 'Fail on issues at or above this level: BLOCKER, CRITICAL, MAJOR, MINOR, INFO (default: CRITICAL)', 'CRITICAL')
|
|
62
62
|
.option('--include <paths>', 'Comma-separated list of file paths glob patterns to include')
|
|
63
63
|
.option('--exclude <paths>', 'Comma-separated list of file paths glob patterns to exclude')
|
|
64
|
+
.option('--hook', 'Running from pre-push hook (enables bypass prompt)')
|
|
64
65
|
.action((options) => {
|
|
65
66
|
let scanType = 'all';
|
|
66
67
|
let lastNCommits = 1;
|
|
@@ -95,9 +96,7 @@ program
|
|
|
95
96
|
? (Array.isArray(options.exclude) ? options.exclude : splitGlobs(options.exclude))
|
|
96
97
|
: [];
|
|
97
98
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
render(React.createElement(Secrets, { scanType, failOn, include, exclude, lastNCommits, baseBranch, baseCommit }));
|
|
99
|
+
render(React.createElement(Secrets, { scanType, include, exclude, lastNCommits, baseBranch, baseCommit, hook: options.hook }));
|
|
101
100
|
});
|
|
102
101
|
|
|
103
102
|
program
|
|
@@ -114,7 +113,8 @@ program
|
|
|
114
113
|
.option('--fail-on <level>', 'Fail on issues at or above this level: BLOCKER, CRITICAL, MAJOR, MINOR, INFO (default: CRITICAL)', 'CRITICAL')
|
|
115
114
|
.option('--include <paths>', 'Comma-separated list of file paths glob patterns to include')
|
|
116
115
|
.option('--exclude <paths>', 'Comma-separated list of file paths glob patterns to exclude')
|
|
117
|
-
.
|
|
116
|
+
.option('--headless', 'Output clean JSON with no spinners (for agents and CI)')
|
|
117
|
+
.action(async (options) => {
|
|
118
118
|
let scanType = 'all';
|
|
119
119
|
let lastNCommits = 1;
|
|
120
120
|
let baseBranch = null;
|
|
@@ -150,7 +150,23 @@ program
|
|
|
150
150
|
|
|
151
151
|
const failOn = options.failOn?.toUpperCase() || 'CRITICAL';
|
|
152
152
|
|
|
153
|
-
|
|
153
|
+
if (options.headless) {
|
|
154
|
+
const result = await runReviewHeadless({
|
|
155
|
+
workspacePath: process.cwd(),
|
|
156
|
+
scanType,
|
|
157
|
+
lastNCommits,
|
|
158
|
+
include,
|
|
159
|
+
exclude,
|
|
160
|
+
baseBranch,
|
|
161
|
+
baseCommit,
|
|
162
|
+
onProgress: (msg) => console.error(`[progress] ${msg}`),
|
|
163
|
+
onFilesReady: (files, meta) => console.error(`[files] Reviewing ${files.length} file(s)`),
|
|
164
|
+
});
|
|
165
|
+
console.log(JSON.stringify(result, null, 2));
|
|
166
|
+
process.exit(result.error ? 1 : 0);
|
|
167
|
+
} else {
|
|
168
|
+
render(React.createElement(Review, { scanType, lastNCommits, failOn, include, exclude, baseBranch, baseCommit }));
|
|
169
|
+
}
|
|
154
170
|
});
|
|
155
171
|
|
|
156
172
|
program
|
package/src/reviewHeadless.js
CHANGED
|
@@ -126,9 +126,9 @@ export async function runReviewHeadless(options = {}) {
|
|
|
126
126
|
|
|
127
127
|
onProgress(`Analyzing ${perFileRequests.length} file${perFileRequests.length !== 1 ? 's' : ''} in parallel...`);
|
|
128
128
|
|
|
129
|
-
// ── Per-file agent turn loops (parallel)
|
|
129
|
+
// ── Per-file agent turn loops (parallel, fault-tolerant) ─────────────
|
|
130
130
|
const perFileResults = await Promise.all(
|
|
131
|
-
perFileRequests.map((fileReq) => {
|
|
131
|
+
perFileRequests.map(async (fileReq) => {
|
|
132
132
|
const filename = fileReq._filename;
|
|
133
133
|
delete fileReq._filename;
|
|
134
134
|
|
|
@@ -138,7 +138,15 @@ export async function runReviewHeadless(options = {}) {
|
|
|
138
138
|
}
|
|
139
139
|
delete fileReq.file_contents;
|
|
140
140
|
|
|
141
|
-
|
|
141
|
+
try {
|
|
142
|
+
onProgress(`Reviewing ${filename}...`);
|
|
143
|
+
const result = await runTurnLoop(fileReq, gitRoot, false);
|
|
144
|
+
onProgress(`Done reviewing ${filename}`);
|
|
145
|
+
return result;
|
|
146
|
+
} catch (err) {
|
|
147
|
+
console.error(`[error] Failed to review ${filename}: ${err.message}`);
|
|
148
|
+
return { finalMessage: null, finalOutput: null };
|
|
149
|
+
}
|
|
142
150
|
})
|
|
143
151
|
);
|
|
144
152
|
|
|
@@ -149,20 +157,26 @@ export async function runReviewHeadless(options = {}) {
|
|
|
149
157
|
output: perFileResults[i].finalOutput,
|
|
150
158
|
})).filter(r => r.output?.code_suggestions?.length > 0);
|
|
151
159
|
|
|
152
|
-
|
|
153
|
-
|
|
160
|
+
onProgress(`${perFileWithSuggestions.length} file(s) have suggestions, running reflector...`);
|
|
161
|
+
|
|
162
|
+
// ── Per-file reflector loops (parallel, fault-tolerant) ──────────────
|
|
154
163
|
const reflectorResults = await Promise.all(
|
|
155
|
-
perFileWithSuggestions.map(({ diff_content, suggestions }) =>
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
164
|
+
perFileWithSuggestions.map(async ({ diff_content, suggestions }, i) => {
|
|
165
|
+
try {
|
|
166
|
+
return await runTurnLoop(
|
|
167
|
+
{
|
|
168
|
+
diff_content,
|
|
169
|
+
prompt_template_name: 'reflector',
|
|
170
|
+
extra_variables: { suggestion_str: suggestions },
|
|
171
|
+
},
|
|
172
|
+
gitRoot,
|
|
173
|
+
true
|
|
174
|
+
);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
console.error(`[error] Reflector failed for file ${i}: ${err.message}`);
|
|
177
|
+
return { finalMessage: null, finalOutput: null };
|
|
178
|
+
}
|
|
179
|
+
})
|
|
166
180
|
);
|
|
167
181
|
|
|
168
182
|
// ── Parse results ───────────────────────────────────────────────────
|
package/src/scm/index.js
CHANGED
|
@@ -95,3 +95,10 @@ export function listCodeReviews(opts) { return getProvider(opts.remote).listCode
|
|
|
95
95
|
export function getCodeReview(opts) { return getProvider(opts.remote).getCodeReview(opts); }
|
|
96
96
|
export function searchComments(opts) { return getProvider(opts.remote).searchComments(opts); }
|
|
97
97
|
export function resolveConversation(opts) { return getProvider(opts.remote).resolveConversation(opts); }
|
|
98
|
+
|
|
99
|
+
// Auto-detect base URL (git host) from origin
|
|
100
|
+
export function detectBaseUrl() {
|
|
101
|
+
const origin = getOrigin();
|
|
102
|
+
if (!origin) return '';
|
|
103
|
+
const host = parseOriginUrl(origin); return host ? 'https://' + host : '';
|
|
104
|
+
}
|
package/src/utils/fetchApi.js
CHANGED
|
@@ -23,6 +23,11 @@ const fetchApi = async (endpoint, method = 'GET', body = null) => {
|
|
|
23
23
|
|
|
24
24
|
try {
|
|
25
25
|
const response = await fetch(url, options);
|
|
26
|
+
|
|
27
|
+
if (response.status === 403) {
|
|
28
|
+
throw new Error('Access denied (403). Please run `codeant logout` and then `codeant login` to re-authenticate.');
|
|
29
|
+
}
|
|
30
|
+
|
|
26
31
|
const data = await response.json();
|
|
27
32
|
|
|
28
33
|
if (!response.ok) {
|
|
@@ -3,32 +3,29 @@ import { readFileSync, writeFileSync, chmodSync, existsSync, unlinkSync, mkdirSy
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
|
|
5
5
|
const HOOK_MARKER = '# codeant-push-protection';
|
|
6
|
+
const HOOK_MARKER_END = '# end-codeant-push-protection';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Build the full pre-push hook script (with shebang).
|
|
9
|
-
* @param {string} failOn - Severity threshold (e.g. "HIGH", "CRITICAL")
|
|
10
10
|
*/
|
|
11
|
-
function buildHookScript(
|
|
11
|
+
function buildHookScript() {
|
|
12
12
|
return `#!/bin/sh
|
|
13
|
-
${buildHookBlock(
|
|
13
|
+
${buildHookBlock()}
|
|
14
14
|
`;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Build just the CodeAnt block (no shebang), used when appending to an existing hook.
|
|
19
|
-
* @param {string} failOn - Severity threshold
|
|
20
19
|
*/
|
|
21
|
-
function buildHookBlock(
|
|
20
|
+
function buildHookBlock() {
|
|
22
21
|
return `${HOOK_MARKER}
|
|
23
22
|
# Auto-installed by CodeAnt AI — blocks pushes containing secrets.
|
|
24
23
|
# To disable: delete this hook or run "codeant push-protection disable"
|
|
25
24
|
command -v codeant >/dev/null 2>&1 || exit 0
|
|
26
|
-
codeant secrets --committed --
|
|
25
|
+
codeant secrets --committed --hook
|
|
27
26
|
${HOOK_MARKER_END}`;
|
|
28
27
|
}
|
|
29
28
|
|
|
30
|
-
const HOOK_MARKER_END = '# end-codeant-push-protection';
|
|
31
|
-
|
|
32
29
|
/**
|
|
33
30
|
* Replace the CodeAnt block in a hook file with new content (or remove it).
|
|
34
31
|
*/
|
|
@@ -37,7 +34,6 @@ function replaceCodeAntBlock(fileContent, newBlock) {
|
|
|
37
34
|
let endIdx = fileContent.indexOf(HOOK_MARKER_END);
|
|
38
35
|
if (startIdx === -1) return fileContent;
|
|
39
36
|
if (endIdx === -1) {
|
|
40
|
-
// Legacy hook without end marker — remove from start marker to EOF
|
|
41
37
|
endIdx = fileContent.length;
|
|
42
38
|
} else {
|
|
43
39
|
endIdx += HOOK_MARKER_END.length;
|
|
@@ -85,13 +81,9 @@ function getHooksDir(gitRoot) {
|
|
|
85
81
|
* Install a pre-push hook that runs secret scanning before push.
|
|
86
82
|
*
|
|
87
83
|
* @param {string} workspacePath - Path to the git repository
|
|
88
|
-
* @param {object} [options]
|
|
89
|
-
* @param {string} [options.failOn="HIGH"] - Severity threshold
|
|
90
84
|
* @returns {{ installed: boolean, hookPath: string|null, message: string }}
|
|
91
85
|
*/
|
|
92
|
-
export function installPushProtectionHook(workspacePath
|
|
93
|
-
const { failOn = 'HIGH' } = options;
|
|
94
|
-
|
|
86
|
+
export function installPushProtectionHook(workspacePath) {
|
|
95
87
|
const gitRoot = findGitRoot(workspacePath);
|
|
96
88
|
if (!gitRoot) {
|
|
97
89
|
return { installed: false, hookPath: null, message: 'Not a git repository' };
|
|
@@ -108,19 +100,24 @@ export function installPushProtectionHook(workspacePath, options = {}) {
|
|
|
108
100
|
const existing = readFileSync(hookPath, 'utf-8');
|
|
109
101
|
if (existing.includes(HOOK_MARKER)) {
|
|
110
102
|
// Replace only our block, preserve everything else
|
|
111
|
-
const updated = replaceCodeAntBlock(existing, buildHookBlock(
|
|
103
|
+
const updated = replaceCodeAntBlock(existing, buildHookBlock());
|
|
112
104
|
writeFileSync(hookPath, updated, 'utf-8');
|
|
113
105
|
chmodSync(hookPath, 0o755);
|
|
114
106
|
return { installed: true, hookPath, message: 'Hook updated' };
|
|
115
107
|
}
|
|
116
|
-
// There's a user-managed hook — append
|
|
117
|
-
const
|
|
108
|
+
// There's a user-managed hook — append only for shell hooks to avoid breaking non-shell scripts
|
|
109
|
+
const firstLine = existing.split('\n', 1)[0] || '';
|
|
110
|
+
const isShellHook = firstLine.startsWith('#!') ? /\/(ba|z|k)?sh(\s|$)/.test(firstLine) : true;
|
|
111
|
+
if (!isShellHook) {
|
|
112
|
+
return { installed: false, hookPath, message: 'Existing pre-push hook is non-shell; cannot append CodeAnt block safely' };
|
|
113
|
+
}
|
|
114
|
+
const appended = existing.trimEnd() + '\n\n' + buildHookBlock() + '\n';
|
|
118
115
|
writeFileSync(hookPath, appended, 'utf-8');
|
|
119
116
|
chmodSync(hookPath, 0o755);
|
|
120
117
|
return { installed: true, hookPath, message: 'Hook appended to existing pre-push' };
|
|
121
118
|
}
|
|
122
119
|
|
|
123
|
-
writeFileSync(hookPath, buildHookScript(
|
|
120
|
+
writeFileSync(hookPath, buildHookScript(), 'utf-8');
|
|
124
121
|
chmodSync(hookPath, 0o755);
|
|
125
122
|
return { installed: true, hookPath, message: 'Hook installed' };
|
|
126
123
|
}
|
|
@@ -152,7 +149,6 @@ export function removePushProtectionHook(workspacePath) {
|
|
|
152
149
|
// Remove our block (between start and end markers)
|
|
153
150
|
const remaining = replaceCodeAntBlock(content, '').trim();
|
|
154
151
|
if (!remaining || remaining === '#!/bin/sh') {
|
|
155
|
-
// Nothing left — delete the file
|
|
156
152
|
unlinkSync(hookPath);
|
|
157
153
|
return { removed: true, message: 'Hook removed' };
|
|
158
154
|
}
|