codeant-cli 0.3.8 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeant-cli",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
4
4
  "description": "Code review CLI tool",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- setTimeout(() => { process.exitCode = 1; exit(new Error('Secrets detected')); }, 100);
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;
@@ -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
@@ -61,6 +61,7 @@ program
61
61
  .option('--base-commit <commit>', 'Compare against a specific commit (e.g. --base-commit HEAD~3)')
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,7 +96,7 @@ program
95
96
  ? (Array.isArray(options.exclude) ? options.exclude : splitGlobs(options.exclude))
96
97
  : [];
97
98
 
98
- render(React.createElement(Secrets, { scanType, include, exclude, lastNCommits, baseBranch, baseCommit }));
99
+ render(React.createElement(Secrets, { scanType, include, exclude, lastNCommits, baseBranch, baseCommit, hook: options.hook }));
99
100
  });
100
101
 
101
102
  program
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