codeant-cli 0.3.6 → 0.3.8
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 +3 -2
- package/src/commands/secrets.js +5 -13
- package/src/components/SecretsUI.js +6 -44
- package/src/index.js +21 -6
- package/src/reviewHeadless.js +30 -16
- package/src/utils/fetchApi.js +5 -0
- package/src/utils/installPushProtectionHook.js +159 -0
- package/src/utils/secretsDetector.js +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codeant-cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.8",
|
|
4
4
|
"description": "Code review CLI tool",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
"main": "./src/reviewHeadless.js",
|
|
26
26
|
"exports": {
|
|
27
27
|
".": "./src/reviewHeadless.js",
|
|
28
|
-
"./review": "./src/reviewHeadless.js"
|
|
28
|
+
"./review": "./src/reviewHeadless.js",
|
|
29
|
+
"./push-protection": "./src/utils/installPushProtectionHook.js"
|
|
29
30
|
},
|
|
30
31
|
"files": [
|
|
31
32
|
"src"
|
package/src/commands/secrets.js
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
renderDone,
|
|
12
12
|
} from '../components/SecretsUI.js';
|
|
13
13
|
|
|
14
|
-
export default function Secrets({ scanType = 'all',
|
|
14
|
+
export default function Secrets({ scanType = 'all', include = [], exclude = [], lastNCommits = 1, baseBranch = null, baseCommit = null }) {
|
|
15
15
|
const { exit } = useApp();
|
|
16
16
|
const [status, setStatus] = useState('initializing');
|
|
17
17
|
const [secrets, setSecrets] = useState([]);
|
|
@@ -20,14 +20,6 @@ export default function Secrets({ scanType = 'all', failOn = 'CRITICAL', include
|
|
|
20
20
|
const [scanMeta, setScanMeta] = useState(null);
|
|
21
21
|
const [startTime] = useState(() => Date.now());
|
|
22
22
|
|
|
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
23
|
useEffect(() => {
|
|
32
24
|
let cancelled = false;
|
|
33
25
|
|
|
@@ -81,10 +73,10 @@ export default function Secrets({ scanType = 'all', failOn = 'CRITICAL', include
|
|
|
81
73
|
|
|
82
74
|
useEffect(() => {
|
|
83
75
|
if (status === 'done') {
|
|
84
|
-
const
|
|
85
|
-
file.secrets.
|
|
76
|
+
const hasSecrets = secrets.some(file =>
|
|
77
|
+
file.secrets.length > 0
|
|
86
78
|
);
|
|
87
|
-
if (
|
|
79
|
+
if (hasSecrets) {
|
|
88
80
|
setTimeout(() => { process.exitCode = 1; exit(new Error('Secrets detected')); }, 100);
|
|
89
81
|
} else {
|
|
90
82
|
setTimeout(() => exit(), 100);
|
|
@@ -101,7 +93,7 @@ export default function Secrets({ scanType = 'all', failOn = 'CRITICAL', include
|
|
|
101
93
|
if (status === 'scanning') return renderScanning(startTime, fileCount, scanMeta);
|
|
102
94
|
if (status === 'no_files') return renderNoFiles(scanType, lastNCommits, baseBranch, baseCommit);
|
|
103
95
|
if (status === 'error') return renderError(error);
|
|
104
|
-
if (status === 'done') return renderDone(secrets,
|
|
96
|
+
if (status === 'done') return renderDone(secrets, startTime, fileCount, scanMeta);
|
|
105
97
|
|
|
106
98
|
return null;
|
|
107
99
|
}
|
|
@@ -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(
|
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,7 +59,6 @@ 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
64
|
.action((options) => {
|
|
@@ -95,9 +95,7 @@ program
|
|
|
95
95
|
? (Array.isArray(options.exclude) ? options.exclude : splitGlobs(options.exclude))
|
|
96
96
|
: [];
|
|
97
97
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
render(React.createElement(Secrets, { scanType, failOn, include, exclude, lastNCommits, baseBranch, baseCommit }));
|
|
98
|
+
render(React.createElement(Secrets, { scanType, include, exclude, lastNCommits, baseBranch, baseCommit }));
|
|
101
99
|
});
|
|
102
100
|
|
|
103
101
|
program
|
|
@@ -114,7 +112,8 @@ program
|
|
|
114
112
|
.option('--fail-on <level>', 'Fail on issues at or above this level: BLOCKER, CRITICAL, MAJOR, MINOR, INFO (default: CRITICAL)', 'CRITICAL')
|
|
115
113
|
.option('--include <paths>', 'Comma-separated list of file paths glob patterns to include')
|
|
116
114
|
.option('--exclude <paths>', 'Comma-separated list of file paths glob patterns to exclude')
|
|
117
|
-
.
|
|
115
|
+
.option('--headless', 'Output clean JSON with no spinners (for agents and CI)')
|
|
116
|
+
.action(async (options) => {
|
|
118
117
|
let scanType = 'all';
|
|
119
118
|
let lastNCommits = 1;
|
|
120
119
|
let baseBranch = null;
|
|
@@ -150,7 +149,23 @@ program
|
|
|
150
149
|
|
|
151
150
|
const failOn = options.failOn?.toUpperCase() || 'CRITICAL';
|
|
152
151
|
|
|
153
|
-
|
|
152
|
+
if (options.headless) {
|
|
153
|
+
const result = await runReviewHeadless({
|
|
154
|
+
workspacePath: process.cwd(),
|
|
155
|
+
scanType,
|
|
156
|
+
lastNCommits,
|
|
157
|
+
include,
|
|
158
|
+
exclude,
|
|
159
|
+
baseBranch,
|
|
160
|
+
baseCommit,
|
|
161
|
+
onProgress: (msg) => console.error(`[progress] ${msg}`),
|
|
162
|
+
onFilesReady: (files, meta) => console.error(`[files] Reviewing ${files.length} file(s)`),
|
|
163
|
+
});
|
|
164
|
+
console.log(JSON.stringify(result, null, 2));
|
|
165
|
+
process.exit(result.error ? 1 : 0);
|
|
166
|
+
} else {
|
|
167
|
+
render(React.createElement(Review, { scanType, lastNCommits, failOn, include, exclude, baseBranch, baseCommit }));
|
|
168
|
+
}
|
|
154
169
|
});
|
|
155
170
|
|
|
156
171
|
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/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) {
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import { readFileSync, writeFileSync, chmodSync, existsSync, unlinkSync, mkdirSync } from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
const HOOK_MARKER = '# codeant-push-protection';
|
|
6
|
+
const HOOK_MARKER_END = '# end-codeant-push-protection';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Build the full pre-push hook script (with shebang).
|
|
10
|
+
*/
|
|
11
|
+
function buildHookScript() {
|
|
12
|
+
return `#!/bin/sh
|
|
13
|
+
${buildHookBlock()}
|
|
14
|
+
`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Build just the CodeAnt block (no shebang), used when appending to an existing hook.
|
|
19
|
+
*/
|
|
20
|
+
function buildHookBlock() {
|
|
21
|
+
return `${HOOK_MARKER}
|
|
22
|
+
# Auto-installed by CodeAnt AI — blocks pushes containing secrets.
|
|
23
|
+
# To disable: delete this hook or run "codeant push-protection disable"
|
|
24
|
+
command -v codeant >/dev/null 2>&1 || exit 0
|
|
25
|
+
codeant secrets --committed
|
|
26
|
+
${HOOK_MARKER_END}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Replace the CodeAnt block in a hook file with new content (or remove it).
|
|
31
|
+
*/
|
|
32
|
+
function replaceCodeAntBlock(fileContent, newBlock) {
|
|
33
|
+
const startIdx = fileContent.indexOf(HOOK_MARKER);
|
|
34
|
+
let endIdx = fileContent.indexOf(HOOK_MARKER_END);
|
|
35
|
+
if (startIdx === -1) return fileContent;
|
|
36
|
+
if (endIdx === -1) {
|
|
37
|
+
endIdx = fileContent.length;
|
|
38
|
+
} else {
|
|
39
|
+
endIdx += HOOK_MARKER_END.length;
|
|
40
|
+
}
|
|
41
|
+
const before = fileContent.slice(0, startIdx);
|
|
42
|
+
const after = fileContent.slice(endIdx);
|
|
43
|
+
return (before + newBlock + after).replace(/\n{3,}/g, '\n\n');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Find the git root directory for a given workspace path.
|
|
48
|
+
*/
|
|
49
|
+
function findGitRoot(workspacePath) {
|
|
50
|
+
try {
|
|
51
|
+
return execSync('git rev-parse --show-toplevel', {
|
|
52
|
+
cwd: workspacePath,
|
|
53
|
+
encoding: 'utf-8',
|
|
54
|
+
timeout: 5000,
|
|
55
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
56
|
+
}).trim();
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get the effective hooks directory (respects core.hooksPath).
|
|
64
|
+
*/
|
|
65
|
+
function getHooksDir(gitRoot) {
|
|
66
|
+
try {
|
|
67
|
+
const custom = execSync('git config --get core.hooksPath', {
|
|
68
|
+
cwd: gitRoot,
|
|
69
|
+
encoding: 'utf-8',
|
|
70
|
+
timeout: 5000,
|
|
71
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
72
|
+
}).trim();
|
|
73
|
+
if (custom) return path.resolve(gitRoot, custom);
|
|
74
|
+
} catch {
|
|
75
|
+
// No custom hooksPath — use default
|
|
76
|
+
}
|
|
77
|
+
return path.join(gitRoot, '.git', 'hooks');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Install a pre-push hook that runs secret scanning before push.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} workspacePath - Path to the git repository
|
|
84
|
+
* @returns {{ installed: boolean, hookPath: string|null, message: string }}
|
|
85
|
+
*/
|
|
86
|
+
export function installPushProtectionHook(workspacePath) {
|
|
87
|
+
const gitRoot = findGitRoot(workspacePath);
|
|
88
|
+
if (!gitRoot) {
|
|
89
|
+
return { installed: false, hookPath: null, message: 'Not a git repository' };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const hooksDir = getHooksDir(gitRoot);
|
|
93
|
+
if (!existsSync(hooksDir)) {
|
|
94
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
95
|
+
}
|
|
96
|
+
const hookPath = path.join(hooksDir, 'pre-push');
|
|
97
|
+
|
|
98
|
+
// If hook already exists, check if it's ours
|
|
99
|
+
if (existsSync(hookPath)) {
|
|
100
|
+
const existing = readFileSync(hookPath, 'utf-8');
|
|
101
|
+
if (existing.includes(HOOK_MARKER)) {
|
|
102
|
+
// Replace only our block, preserve everything else
|
|
103
|
+
const updated = replaceCodeAntBlock(existing, buildHookBlock());
|
|
104
|
+
writeFileSync(hookPath, updated, 'utf-8');
|
|
105
|
+
chmodSync(hookPath, 0o755);
|
|
106
|
+
return { installed: true, hookPath, message: 'Hook updated' };
|
|
107
|
+
}
|
|
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';
|
|
115
|
+
writeFileSync(hookPath, appended, 'utf-8');
|
|
116
|
+
chmodSync(hookPath, 0o755);
|
|
117
|
+
return { installed: true, hookPath, message: 'Hook appended to existing pre-push' };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
writeFileSync(hookPath, buildHookScript(), 'utf-8');
|
|
121
|
+
chmodSync(hookPath, 0o755);
|
|
122
|
+
return { installed: true, hookPath, message: 'Hook installed' };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Remove the CodeAnt pre-push hook (or just our section if appended).
|
|
127
|
+
*
|
|
128
|
+
* @param {string} workspacePath
|
|
129
|
+
* @returns {{ removed: boolean, message: string }}
|
|
130
|
+
*/
|
|
131
|
+
export function removePushProtectionHook(workspacePath) {
|
|
132
|
+
const gitRoot = findGitRoot(workspacePath);
|
|
133
|
+
if (!gitRoot) {
|
|
134
|
+
return { removed: false, message: 'Not a git repository' };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const hooksDir = getHooksDir(gitRoot);
|
|
138
|
+
const hookPath = path.join(hooksDir, 'pre-push');
|
|
139
|
+
|
|
140
|
+
if (!existsSync(hookPath)) {
|
|
141
|
+
return { removed: false, message: 'No pre-push hook found' };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const content = readFileSync(hookPath, 'utf-8');
|
|
145
|
+
if (!content.includes(HOOK_MARKER)) {
|
|
146
|
+
return { removed: false, message: 'Hook is not managed by CodeAnt' };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Remove our block (between start and end markers)
|
|
150
|
+
const remaining = replaceCodeAntBlock(content, '').trim();
|
|
151
|
+
if (!remaining || remaining === '#!/bin/sh') {
|
|
152
|
+
unlinkSync(hookPath);
|
|
153
|
+
return { removed: true, message: 'Hook removed' };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
writeFileSync(hookPath, remaining + '\n', 'utf-8');
|
|
157
|
+
chmodSync(hookPath, 0o755);
|
|
158
|
+
return { removed: true, message: 'CodeAnt section removed from hook' };
|
|
159
|
+
}
|