codeant-cli 0.3.8 → 0.4.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/package.json +1 -1
- package/src/commands/getApiKey.js +30 -0
- package/src/commands/secrets.js +61 -5
- package/src/commands/setApiKey.js +37 -0
- package/src/components/SecretsUI.js +87 -1
- package/src/index.js +18 -1
- package/src/scm/index.js +7 -0
- package/src/utils/installPushProtectionHook.js +1 -1
package/package.json
CHANGED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import React, { useEffect } from 'react';
|
|
2
|
+
import { Text, Box, useApp } from 'ink';
|
|
3
|
+
import { getConfigValue } from '../utils/config.js';
|
|
4
|
+
|
|
5
|
+
export default function GetApiKey() {
|
|
6
|
+
const { exit } = useApp();
|
|
7
|
+
|
|
8
|
+
const apiKey = getConfigValue('apiKeyV2');
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
exit();
|
|
12
|
+
}, []);
|
|
13
|
+
|
|
14
|
+
if (!apiKey) {
|
|
15
|
+
return React.createElement(
|
|
16
|
+
Box,
|
|
17
|
+
{ flexDirection: 'column', padding: 1 },
|
|
18
|
+
React.createElement(Text, { color: 'yellow' }, 'No CodeAnt API key configured.'),
|
|
19
|
+
React.createElement(Text, { color: 'gray' }, 'Set one with: codeant set-codeant-api-key <key>')
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const masked = apiKey.slice(0, 4) + '…' + apiKey.slice(-4);
|
|
24
|
+
|
|
25
|
+
return React.createElement(
|
|
26
|
+
Box,
|
|
27
|
+
{ flexDirection: 'column', padding: 1 },
|
|
28
|
+
React.createElement(Text, { bold: true }, 'CodeAnt API Key: ', masked)
|
|
29
|
+
);
|
|
30
|
+
}
|
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', include = [], exclude = [], lastNCommits = 1, baseBranch = null, baseCommit = null }) {
|
|
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([]);
|
|
@@ -71,13 +74,65 @@ export default function Secrets({ scanType = 'all', include = [], exclude = [],
|
|
|
71
74
|
return () => { cancelled = true; };
|
|
72
75
|
}, [scanType, include, exclude, lastNCommits, baseBranch, baseCommit]);
|
|
73
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
|
+
|
|
74
127
|
useEffect(() => {
|
|
75
128
|
if (status === 'done') {
|
|
76
|
-
const hasSecrets = secrets.some(file =>
|
|
77
|
-
file.secrets.length > 0
|
|
78
|
-
);
|
|
129
|
+
const hasSecrets = secrets.some(file => file.secrets.length > 0);
|
|
79
130
|
if (hasSecrets) {
|
|
80
|
-
|
|
131
|
+
if (hook && process.stdin.isTTY) {
|
|
132
|
+
setStatus('prompt_bypass');
|
|
133
|
+
} else {
|
|
134
|
+
reportBlockAndExit();
|
|
135
|
+
}
|
|
81
136
|
} else {
|
|
82
137
|
setTimeout(() => exit(), 100);
|
|
83
138
|
}
|
|
@@ -93,6 +148,7 @@ export default function Secrets({ scanType = 'all', include = [], exclude = [],
|
|
|
93
148
|
if (status === 'scanning') return renderScanning(startTime, fileCount, scanMeta);
|
|
94
149
|
if (status === 'no_files') return renderNoFiles(scanType, lastNCommits, baseBranch, baseCommit);
|
|
95
150
|
if (status === 'error') return renderError(error);
|
|
151
|
+
if (status === 'prompt_bypass') return renderBypassPrompt(secrets, handleBypassSelect);
|
|
96
152
|
if (status === 'done') return renderDone(secrets, startTime, fileCount, scanMeta);
|
|
97
153
|
|
|
98
154
|
return null;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import React, { useEffect } from 'react';
|
|
2
|
+
import { Text, Box, useApp } from 'ink';
|
|
3
|
+
import { setConfigValue, CONFIG_FILE } from '../utils/config.js';
|
|
4
|
+
|
|
5
|
+
export default function SetApiKey({ apiKey }) {
|
|
6
|
+
const { exit } = useApp();
|
|
7
|
+
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
if (!apiKey) {
|
|
10
|
+
exit(new Error('API key is required'));
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
setConfigValue('apiKeyV2', apiKey);
|
|
16
|
+
exit();
|
|
17
|
+
} catch (err) {
|
|
18
|
+
exit(err);
|
|
19
|
+
}
|
|
20
|
+
}, []);
|
|
21
|
+
|
|
22
|
+
if (!apiKey) {
|
|
23
|
+
return React.createElement(
|
|
24
|
+
Box,
|
|
25
|
+
{ flexDirection: 'column', padding: 1 },
|
|
26
|
+
React.createElement(Text, { color: 'red' }, '✗ Error: API key is required'),
|
|
27
|
+
React.createElement(Text, { color: 'gray' }, 'Usage: codeant set-codeant-api-key <key>')
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return React.createElement(
|
|
32
|
+
Box,
|
|
33
|
+
{ flexDirection: 'column', padding: 1 },
|
|
34
|
+
React.createElement(Text, { color: 'green' }, '✓ CodeAnt API key saved.'),
|
|
35
|
+
React.createElement(Text, { color: 'gray' }, 'Saved to: ', CONFIG_FILE)
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -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
|
|
|
@@ -326,3 +326,89 @@ export function renderDone(secrets, startTime, fileCount, meta) {
|
|
|
326
326
|
Box, { flexDirection: 'column', paddingX: 1, paddingY: 1 }, ...els,
|
|
327
327
|
);
|
|
328
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
|
@@ -7,6 +7,8 @@ import { createRequire } from 'module';
|
|
|
7
7
|
import Secrets from './commands/secrets.js';
|
|
8
8
|
import SetBaseUrl from './commands/setBaseUrl.js';
|
|
9
9
|
import GetBaseUrl from './commands/getBaseUrl.js';
|
|
10
|
+
import SetApiKey from './commands/setApiKey.js';
|
|
11
|
+
import GetApiKey from './commands/getApiKey.js';
|
|
10
12
|
import Login from './commands/login.js';
|
|
11
13
|
import Logout from './commands/logout.js';
|
|
12
14
|
import Review from './commands/review.js';
|
|
@@ -61,6 +63,7 @@ program
|
|
|
61
63
|
.option('--base-commit <commit>', 'Compare against a specific commit (e.g. --base-commit HEAD~3)')
|
|
62
64
|
.option('--include <paths>', 'Comma-separated list of file paths glob patterns to include')
|
|
63
65
|
.option('--exclude <paths>', 'Comma-separated list of file paths glob patterns to exclude')
|
|
66
|
+
.option('--hook', 'Running from pre-push hook (enables bypass prompt)')
|
|
64
67
|
.action((options) => {
|
|
65
68
|
let scanType = 'all';
|
|
66
69
|
let lastNCommits = 1;
|
|
@@ -95,7 +98,7 @@ program
|
|
|
95
98
|
? (Array.isArray(options.exclude) ? options.exclude : splitGlobs(options.exclude))
|
|
96
99
|
: [];
|
|
97
100
|
|
|
98
|
-
render(React.createElement(Secrets, { scanType, include, exclude, lastNCommits, baseBranch, baseCommit }));
|
|
101
|
+
render(React.createElement(Secrets, { scanType, include, exclude, lastNCommits, baseBranch, baseCommit, hook: options.hook }));
|
|
99
102
|
});
|
|
100
103
|
|
|
101
104
|
program
|
|
@@ -182,6 +185,20 @@ program
|
|
|
182
185
|
render(React.createElement(GetBaseUrl));
|
|
183
186
|
});
|
|
184
187
|
|
|
188
|
+
program
|
|
189
|
+
.command('set-codeant-api-key <key>')
|
|
190
|
+
.description('Set the CodeAnt API key')
|
|
191
|
+
.action((key) => {
|
|
192
|
+
render(React.createElement(SetApiKey, { apiKey: key }));
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
program
|
|
196
|
+
.command('get-codeant-api-key')
|
|
197
|
+
.description('Show the current CodeAnt API key')
|
|
198
|
+
.action(() => {
|
|
199
|
+
render(React.createElement(GetApiKey));
|
|
200
|
+
});
|
|
201
|
+
|
|
185
202
|
program
|
|
186
203
|
.command('login')
|
|
187
204
|
.description('Login to CodeAnt')
|
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
|
+
}
|
|
@@ -22,7 +22,7 @@ function buildHookBlock() {
|
|
|
22
22
|
# Auto-installed by CodeAnt AI — blocks pushes containing secrets.
|
|
23
23
|
# To disable: delete this hook or run "codeant push-protection disable"
|
|
24
24
|
command -v codeant >/dev/null 2>&1 || exit 0
|
|
25
|
-
codeant secrets --committed
|
|
25
|
+
codeant secrets --committed --hook
|
|
26
26
|
${HOOK_MARKER_END}`;
|
|
27
27
|
}
|
|
28
28
|
|