codeant-cli 0.5.8 → 0.5.10
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/findings.md +2 -2
- package/mcp.md +34 -18
- package/package.json +2 -1
- package/scans.md +16 -5
- package/src/commands/scans/dismissed.js +46 -6
- package/src/commands/scans/index.js +24 -6
- package/src/commands/scans/lib/categories.js +2 -0
- package/src/commands/scans/lib/normalize.js +14 -0
- package/src/commands/scans/results.js +3 -1
- package/src/index.js +17 -13
- package/src/mcp/server.js +203 -100
- package/src/mcp/shape.js +83 -0
- package/src/scans/fetchAdvancedScanResults.js +81 -4
- package/src/scans/fetchDismissedAlerts.js +15 -1
- package/src/scans/fetchIssueOverrides.js +36 -0
- package/src/scans/fetchScanResults.js +0 -1
- package/src/scm/azure/client.js +1 -1
- package/src/utils/intOption.js +18 -0
- package/src/utils/loginFlow.js +25 -19
package/src/mcp/shape.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Result shaping for MCP clients, which cap how much tool output they accept.
|
|
2
|
+
// CLI commands keep returning the full payloads; only MCP responses are trimmed.
|
|
3
|
+
|
|
4
|
+
const DEFAULT_MAX_RESULT_CHARS = 80_000;
|
|
5
|
+
|
|
6
|
+
const REPO_FIELDS = ['full_name', 'name', 'private', 'visibility', 'default_branch', 'language', 'description', 'archived', 'pushed_at'];
|
|
7
|
+
const CLOUD_SCAN_DETAIL = ['per_service', 'service_rollup', 'compliance_rollup', 'regions'];
|
|
8
|
+
|
|
9
|
+
export function maxResultChars() {
|
|
10
|
+
const configured = Number(process.env.CODEANT_MCP_MAX_RESULT_CHARS);
|
|
11
|
+
return Number.isInteger(configured) && configured > 0 ? configured : DEFAULT_MAX_RESULT_CHARS;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function page(items, { limit, offset = 0 }) {
|
|
15
|
+
const slice = items.slice(offset, offset + limit);
|
|
16
|
+
const end = offset + slice.length;
|
|
17
|
+
return { items: slice, total: items.length, offset, limit, next_offset: end < items.length ? end : null };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function pick(record, fields) {
|
|
21
|
+
const picked = {};
|
|
22
|
+
for (const field of fields) if (field in record) picked[field] = record[field];
|
|
23
|
+
return Object.keys(picked).length ? picked : record;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function omit(record, fields) {
|
|
27
|
+
const kept = { ...record };
|
|
28
|
+
for (const field of fields) delete kept[field];
|
|
29
|
+
return kept;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function shapeRepos(result, { search, limit = 100, offset = 0, full = false } = {}) {
|
|
33
|
+
let repos = result.repos || [];
|
|
34
|
+
if (search) {
|
|
35
|
+
const query = search.toLowerCase();
|
|
36
|
+
repos = repos.filter((repo) => String(repo.full_name || repo.name || '').toLowerCase().includes(query));
|
|
37
|
+
}
|
|
38
|
+
const { items, ...pagination } = page(repos, { limit, offset });
|
|
39
|
+
return { org: result.org, ...pagination, repos: full ? items : items.map((repo) => pick(repo, REPO_FIELDS)) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function shapePentestHistory(result, { limit = 25, offset = 0, full = false } = {}) {
|
|
43
|
+
const { history = [], ...rest } = result;
|
|
44
|
+
const { items, ...pagination } = page(history, { limit, offset });
|
|
45
|
+
return { ...rest, ...pagination, history: full ? items : items.map((entry) => omit(entry, ['credit_unlock'])) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function shapeCloudScans(result, { limit, offset, full }) {
|
|
49
|
+
if (!Array.isArray(result?.scans)) return result;
|
|
50
|
+
const { items, ...pagination } = page(result.scans, { limit, offset });
|
|
51
|
+
return {
|
|
52
|
+
...result,
|
|
53
|
+
...pagination,
|
|
54
|
+
total: result.total ?? pagination.total,
|
|
55
|
+
scans: full ? items : items.map((scan) => omit(scan, CLOUD_SCAN_DETAIL)),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function shapeCloudHistory(result, { limit = 10, offset = 0, full = false } = {}) {
|
|
60
|
+
if (!result.providers) return shapeCloudScans(result, { limit, offset, full });
|
|
61
|
+
const providers = Object.fromEntries(
|
|
62
|
+
Object.entries(result.providers).map(([provider, value]) => [provider, shapeCloudScans(value, { limit, offset, full })]),
|
|
63
|
+
);
|
|
64
|
+
return { ...result, providers };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// CSPM findings come back unpaged (thousands per scan); VM/container results are
|
|
68
|
+
// already paged by the API, so only the per-finding detail is trimmed for them.
|
|
69
|
+
export function shapeCloudFindings(result, { kind = 'cspm', limit = 25, offset = 0, all = false, full = false } = {}) {
|
|
70
|
+
const { findings = [], ...rest } = result;
|
|
71
|
+
const trim = (items) => (full ? items : items.map((finding) => omit(finding, ['compliance'])));
|
|
72
|
+
if (kind !== 'cspm') return { ...rest, findings: trim(findings) };
|
|
73
|
+
const { items, ...pagination } = page(findings, { limit: all ? findings.length : limit, offset });
|
|
74
|
+
return { ...rest, ...pagination, total: rest.total ?? pagination.total, findings: trim(items) };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function tooLarge(length, hint) {
|
|
78
|
+
const max = maxResultChars();
|
|
79
|
+
return {
|
|
80
|
+
error: `Result too large: ${length} characters (about ${Math.round(length / 4)} tokens), over the ${max}-character limit for one tool result.`,
|
|
81
|
+
hint: `${hint || 'Narrow the request with limit/offset, filters, or fields where the tool supports them.'} Set CODEANT_MCP_MAX_RESULT_CHARS to raise the limit.`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -6,8 +6,23 @@ export const ADVANCED_RESULT_TYPES = {
|
|
|
6
6
|
SECRETS: 'secrets',
|
|
7
7
|
IAC: 'iac',
|
|
8
8
|
DEAD_CODE: 'dead_code',
|
|
9
|
+
DUPLICATE_CODE: 'duplicate_code',
|
|
9
10
|
};
|
|
10
11
|
|
|
12
|
+
const BACKEND_MARKERS = ['is_dismissed', 'reason_for_dismiss', 'comment_for_dismiss', 'is_false_positive'];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Dismissed / false-positive markers the backend stamps on each finding. Kept
|
|
16
|
+
* when a finding is rebuilt so callers can hide or show those findings.
|
|
17
|
+
*/
|
|
18
|
+
function backendMarkers(item) {
|
|
19
|
+
const markers = {};
|
|
20
|
+
for (const key of BACKEND_MARKERS) {
|
|
21
|
+
if (item?.[key] !== undefined) markers[key] = item[key];
|
|
22
|
+
}
|
|
23
|
+
return markers;
|
|
24
|
+
}
|
|
25
|
+
|
|
11
26
|
const EXTRA_DEAD_CODE_MESSAGES = {
|
|
12
27
|
S1481: 'Unused local variable',
|
|
13
28
|
S1854: 'Unused assignment',
|
|
@@ -74,12 +89,16 @@ function flattenDeadCode(deadCodeData) {
|
|
|
74
89
|
name,
|
|
75
90
|
confidence: confMatch ? confMatch[1] : '90',
|
|
76
91
|
check_name: `Unused ${issueType}: ${name}`,
|
|
92
|
+
...backendMarkers(issue),
|
|
77
93
|
});
|
|
78
94
|
});
|
|
79
95
|
});
|
|
80
96
|
|
|
81
|
-
// JS dead code — unused files
|
|
97
|
+
// JS dead code — unused files (plain path strings; the backend lists dismissed ones separately)
|
|
82
98
|
const jsDeadCode = deadCodeData.js_dead_code || {};
|
|
99
|
+
const dismissedFiles = Array.isArray(jsDeadCode.dismissed_unused_files)
|
|
100
|
+
? new Set(jsDeadCode.dismissed_unused_files)
|
|
101
|
+
: null;
|
|
83
102
|
(jsDeadCode.unused_files || []).forEach((filePath) => {
|
|
84
103
|
if (!filePath) return;
|
|
85
104
|
flat.push({
|
|
@@ -90,6 +109,7 @@ function flattenDeadCode(deadCodeData) {
|
|
|
90
109
|
severity: 'warning',
|
|
91
110
|
type: 'unused_file',
|
|
92
111
|
check_name: 'Unused file',
|
|
112
|
+
...(dismissedFiles ? { is_dismissed: dismissedFiles.has(filePath) } : {}),
|
|
93
113
|
});
|
|
94
114
|
});
|
|
95
115
|
|
|
@@ -110,6 +130,7 @@ function flattenDeadCode(deadCodeData) {
|
|
|
110
130
|
type: 'unused_export',
|
|
111
131
|
name: exp.name,
|
|
112
132
|
check_name: `Unused export: ${exp.name || 'unknown'}`,
|
|
133
|
+
...backendMarkers(exp),
|
|
113
134
|
});
|
|
114
135
|
});
|
|
115
136
|
});
|
|
@@ -124,7 +145,8 @@ function flattenDeadCode(deadCodeData) {
|
|
|
124
145
|
const messageId = issue['message-id'] || '';
|
|
125
146
|
const msgKey = messageId.includes(':') ? messageId.split(':')[1] : messageId;
|
|
126
147
|
flat.push({
|
|
127
|
-
|
|
148
|
+
// Keys end with the per-file result name (".../app.py/dead_code.json").
|
|
149
|
+
file_path: extractRelativeFilePath(filePath).replace(/\/[^/]+\.json$/, ''),
|
|
128
150
|
line_number: issue.line_number || 0,
|
|
129
151
|
issue_text: issue.issue_text || EXTRA_DEAD_CODE_MESSAGES[msgKey] || 'Dead code detected',
|
|
130
152
|
message: issue.issue_text || EXTRA_DEAD_CODE_MESSAGES[msgKey] || 'Dead code detected',
|
|
@@ -133,6 +155,7 @@ function flattenDeadCode(deadCodeData) {
|
|
|
133
155
|
rule_id: msgKey,
|
|
134
156
|
confidence: issue.confidence || '90',
|
|
135
157
|
check_name: EXTRA_DEAD_CODE_MESSAGES[msgKey] || 'Dead code detected',
|
|
158
|
+
...backendMarkers(issue),
|
|
136
159
|
});
|
|
137
160
|
});
|
|
138
161
|
});
|
|
@@ -141,6 +164,49 @@ function flattenDeadCode(deadCodeData) {
|
|
|
141
164
|
return flat;
|
|
142
165
|
}
|
|
143
166
|
|
|
167
|
+
/** "[12-40]" → [12, 40]; anything unparsable → []. */
|
|
168
|
+
function parseWindow(window) {
|
|
169
|
+
const numbers = String(window ?? '').match(/\d+/g) || [];
|
|
170
|
+
return numbers.slice(0, 2).map(Number);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Flatten duplicate-code clone groups (each a list of { fileName, window }
|
|
175
|
+
* members) into one finding per member, linked by their group index.
|
|
176
|
+
*/
|
|
177
|
+
function flattenDuplicateCode(groups) {
|
|
178
|
+
if (!Array.isArray(groups)) return [];
|
|
179
|
+
const flat = [];
|
|
180
|
+
groups.forEach((group, groupIndex) => {
|
|
181
|
+
const members = Array.isArray(group) ? group.filter((m) => m && typeof m === 'object') : [];
|
|
182
|
+
const locations = members.map((m) => {
|
|
183
|
+
const [start, end] = parseWindow(m.window);
|
|
184
|
+
return { file_path: extractRelativeFilePath(cleanLeadingSlash(m.fileName)), start, end };
|
|
185
|
+
});
|
|
186
|
+
members.forEach((member, i) => {
|
|
187
|
+
const { file_path, start, end } = locations[i];
|
|
188
|
+
const others = locations
|
|
189
|
+
.filter((_, j) => j !== i)
|
|
190
|
+
.map((o) => (o.start ? `${o.file_path}:${o.start}${o.end ? `-${o.end}` : ''}` : o.file_path));
|
|
191
|
+
const message = `Duplicated block (${members.length} locations)`;
|
|
192
|
+
flat.push({
|
|
193
|
+
file_path,
|
|
194
|
+
line_number: start || 1,
|
|
195
|
+
file_line_range: end ? [start, end] : [start || 1],
|
|
196
|
+
check_id: 'duplicate-code',
|
|
197
|
+
check_name: message,
|
|
198
|
+
issue_text: message,
|
|
199
|
+
message,
|
|
200
|
+
severity: 'low',
|
|
201
|
+
duplicate_group: member.originalIndex ?? groupIndex,
|
|
202
|
+
duplicate_locations: others,
|
|
203
|
+
...backendMarkers(member),
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
return flat;
|
|
208
|
+
}
|
|
209
|
+
|
|
144
210
|
/**
|
|
145
211
|
* Normalize a single advanced issue to a consistent shape.
|
|
146
212
|
*/
|
|
@@ -163,7 +229,8 @@ function normalizeAdvancedIssue(item, resultType) {
|
|
|
163
229
|
item.vulnerability_id || item.cve_id || item.advisory_id ||
|
|
164
230
|
(item.package_name ? `Vulnerability in ${item.package_name}` : null) ||
|
|
165
231
|
item.title || item.description || 'Package vulnerability detected';
|
|
166
|
-
|
|
232
|
+
// SCA findings carry severity in ratings (where Change Severity overrides land).
|
|
233
|
+
normalized.severity = item.severity || item.ratings?.[0]?.severity || 'medium';
|
|
167
234
|
break;
|
|
168
235
|
case ADVANCED_RESULT_TYPES.SBOM:
|
|
169
236
|
normalized.check_name = item.package_name
|
|
@@ -187,6 +254,10 @@ function normalizeAdvancedIssue(item, resultType) {
|
|
|
187
254
|
item.check_name || item.name || item.function_name || item.symbol_name || item.description || 'Unused code detected';
|
|
188
255
|
normalized.severity = item.severity || 'low';
|
|
189
256
|
break;
|
|
257
|
+
case ADVANCED_RESULT_TYPES.DUPLICATE_CODE:
|
|
258
|
+
normalized.check_name = item.check_name || 'Duplicated block';
|
|
259
|
+
normalized.severity = item.severity || 'low';
|
|
260
|
+
break;
|
|
190
261
|
default:
|
|
191
262
|
normalized.check_name = item.description || item.message || item.name || 'Issue detected';
|
|
192
263
|
}
|
|
@@ -195,7 +266,7 @@ function normalizeAdvancedIssue(item, resultType) {
|
|
|
195
266
|
}
|
|
196
267
|
|
|
197
268
|
/**
|
|
198
|
-
* Fetch advanced scan results (SCA, SBOM, secrets, IaC, dead code).
|
|
269
|
+
* Fetch advanced scan results (SCA, SBOM, secrets, IaC, dead code, duplicate code).
|
|
199
270
|
*
|
|
200
271
|
* @param {string} repo - "org/repo-name"
|
|
201
272
|
* @param {string} commitId - 40-char commit SHA
|
|
@@ -237,6 +308,8 @@ export async function fetchAdvancedScanResults(repo, commitId, resultType, opts
|
|
|
237
308
|
resultsData = response.secrets;
|
|
238
309
|
} else if (resultType === ADVANCED_RESULT_TYPES.DEAD_CODE) {
|
|
239
310
|
resultsData = flattenDeadCode(response.dead_code);
|
|
311
|
+
} else if (resultType === ADVANCED_RESULT_TYPES.DUPLICATE_CODE) {
|
|
312
|
+
resultsData = flattenDuplicateCode(response.results);
|
|
240
313
|
} else if (resultType === ADVANCED_RESULT_TYPES.SCA) {
|
|
241
314
|
const scaResults = response.results;
|
|
242
315
|
if (scaResults && typeof scaResults === 'object' && !Array.isArray(scaResults) && scaResults.all_vulnerabilities !== undefined) {
|
|
@@ -268,6 +341,7 @@ export async function fetchAdvancedScanResults(repo, commitId, resultType, opts
|
|
|
268
341
|
guideline: check.guideline || '',
|
|
269
342
|
code_block: check.code_block || [],
|
|
270
343
|
resource: check.resource || '',
|
|
344
|
+
...backendMarkers(check),
|
|
271
345
|
});
|
|
272
346
|
});
|
|
273
347
|
});
|
|
@@ -328,3 +402,6 @@ export const fetchIacResults = (repo, commitId, opts) =>
|
|
|
328
402
|
|
|
329
403
|
export const fetchDeadCodeResults = (repo, commitId, opts) =>
|
|
330
404
|
fetchAdvancedScanResults(repo, commitId, ADVANCED_RESULT_TYPES.DEAD_CODE, opts);
|
|
405
|
+
|
|
406
|
+
export const fetchDuplicateCodeResults = (repo, commitId, opts) =>
|
|
407
|
+
fetchAdvancedScanResults(repo, commitId, ADVANCED_RESULT_TYPES.DUPLICATE_CODE, opts);
|
|
@@ -29,7 +29,21 @@ export async function fetchDismissedAlerts(repo, analysisType = 'security') {
|
|
|
29
29
|
const dismissedAlerts = [];
|
|
30
30
|
|
|
31
31
|
for (const [issueKey, dismissInfo] of Object.entries(dismissData)) {
|
|
32
|
-
if (!issueKey.includes('||::||'))
|
|
32
|
+
if (!issueKey.includes('||::||')) {
|
|
33
|
+
// SCA ("pkg@version[::manifest::cve]") and duplicate-code (group index)
|
|
34
|
+
// keys have no file/line parts; keep them so the count is complete.
|
|
35
|
+
dismissedAlerts.push({
|
|
36
|
+
file_path: '',
|
|
37
|
+
context_code_block: '',
|
|
38
|
+
test_id: '',
|
|
39
|
+
line_number: 0,
|
|
40
|
+
type: '',
|
|
41
|
+
issue_key: issueKey,
|
|
42
|
+
reason_for_dismiss: dismissInfo?.reason_for_dismiss || '',
|
|
43
|
+
comment_for_dismiss: dismissInfo?.comment_for_dismiss || '',
|
|
44
|
+
});
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
33
47
|
|
|
34
48
|
const parts = issueKey.split('||::||');
|
|
35
49
|
let file_path = parts[0] || '';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { fetchApi } from '../utils/fetchApi.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fetch the per-finding user overrides the web app saves for a repository
|
|
5
|
+
* (Mark/Unmark false positive, secrets confidence, Change Severity).
|
|
6
|
+
*
|
|
7
|
+
* Override keys and patches by analysis type:
|
|
8
|
+
* security — "id::<tracking id>|rule::<test_id>||::||<result key>||::||<line>" → { false_positive, severity }
|
|
9
|
+
* secrets — "<path>||::||<hashed_secret or line>||::||<type>" → { confidence_score }
|
|
10
|
+
* iac — "<file_path>||::||<check_id>||::||<start>-<end>" → { false_positive }
|
|
11
|
+
* sca — "<package>@<version>" → { severity }
|
|
12
|
+
*
|
|
13
|
+
* @param {string} repo - "org/repo-name"
|
|
14
|
+
* @param {string} analysisType - security | secrets | iac | sca
|
|
15
|
+
* @returns {Promise<{ success: boolean, overrides?: Object, error?: string }>}
|
|
16
|
+
*/
|
|
17
|
+
export async function fetchIssueOverrides(repo, analysisType) {
|
|
18
|
+
try {
|
|
19
|
+
const response = await fetchApi('/extension/scans2/change-issue-details/get', 'POST', {
|
|
20
|
+
repo,
|
|
21
|
+
analysis_type: analysisType,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
if (!response) {
|
|
25
|
+
return { success: false, error: 'Failed to connect to CodeAnt server' };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (response.status === 'error') {
|
|
29
|
+
return { success: false, error: response.message || 'Failed to fetch issue overrides' };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return { success: true, overrides: response.data || {} };
|
|
33
|
+
} catch (error) {
|
|
34
|
+
return { success: false, error: error.message || 'Failed to fetch issue overrides' };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -70,7 +70,6 @@ export async function fetchScanResults(repo, commitId, resultType, opts = {}) {
|
|
|
70
70
|
filter_dismissed: filterDismissed,
|
|
71
71
|
include_false_positives: includeFalsePositives,
|
|
72
72
|
});
|
|
73
|
-
console.error("dropship: ", JSON.stringify(response, null, 2));
|
|
74
73
|
|
|
75
74
|
if (!response) {
|
|
76
75
|
return { success: false, error: 'Failed to connect to CodeAnt server' };
|
package/src/scm/azure/client.js
CHANGED
|
@@ -7,7 +7,7 @@ export function getConnection() {
|
|
|
7
7
|
if (!token) throw new Error('Azure DevOps token not found. Set AZURE_DEVOPS_TOKEN env var or run `codeant set-token azure <token>`.');
|
|
8
8
|
|
|
9
9
|
const orgUrl = process.env.AZURE_DEVOPS_ORG_URL || getConfigValue('azureDevOpsOrgUrl');
|
|
10
|
-
if (!orgUrl) throw new Error('Azure DevOps org URL not found. Set AZURE_DEVOPS_ORG_URL env var
|
|
10
|
+
if (!orgUrl) throw new Error('Azure DevOps org URL not found. Set the AZURE_DEVOPS_ORG_URL env var (for example https://dev.azure.com/<org>) or add azureDevOpsOrgUrl to ~/.codeant/config.json.');
|
|
11
11
|
|
|
12
12
|
const authHandler = azdev.getPersonalAccessTokenHandler(token);
|
|
13
13
|
return new azdev.WebApi(orgUrl, authHandler);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { InvalidArgumentError } from 'commander';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Commander parser for an integer option within [min, max]. Commander passes the
|
|
5
|
+
* option's default as the parser's second argument, so a bare `parseInt` would
|
|
6
|
+
* use it as the radix (`--limit 30` with default 20 → 60).
|
|
7
|
+
*/
|
|
8
|
+
export function intOption({ min = 0, max = Infinity } = {}) {
|
|
9
|
+
return (value) => {
|
|
10
|
+
const n = Number(value);
|
|
11
|
+
if (!Number.isInteger(n) || n < min || n > max) {
|
|
12
|
+
throw new InvalidArgumentError(
|
|
13
|
+
max === Infinity ? `Must be an integer of at least ${min}.` : `Must be an integer from ${min} to ${max}.`
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
return n;
|
|
17
|
+
};
|
|
18
|
+
}
|
package/src/utils/loginFlow.js
CHANGED
|
@@ -4,6 +4,7 @@ import { getBaseUrl, getDashboardUrl } from './baseUrl.js';
|
|
|
4
4
|
|
|
5
5
|
const DEFAULT_POLL_INTERVAL = 10_000;
|
|
6
6
|
const DEFAULT_TIMEOUT = 10 * 60 * 1000;
|
|
7
|
+
const BROWSER_SPAWN_TIMEOUT = 2_000;
|
|
7
8
|
|
|
8
9
|
export function isAlreadyLoggedIn() {
|
|
9
10
|
return !!getConfigValue('apiKeyV2');
|
|
@@ -16,16 +17,24 @@ export async function startLoginFlow() {
|
|
|
16
17
|
const loginUrl = `${dashboardUrl}?ideLoginToken=${token}`;
|
|
17
18
|
const pollUrl = `${baseUrl}/extension/login/status?apiKey=${token}`;
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
const browserOpened = await openBrowser(loginUrl);
|
|
21
|
+
return { token, loginUrl, pollUrl, browserOpened };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// `open` returns the launcher process without an 'error' listener, so a missing
|
|
25
|
+
// launcher (SSH, containers) would otherwise crash the MCP server.
|
|
26
|
+
async function openBrowser(url) {
|
|
20
27
|
try {
|
|
21
28
|
const { default: open } = await import('open');
|
|
22
|
-
await open(
|
|
23
|
-
|
|
29
|
+
const child = await open(url);
|
|
30
|
+
return await new Promise((resolve) => {
|
|
31
|
+
child.once('error', () => resolve(false));
|
|
32
|
+
child.once('spawn', () => resolve(true));
|
|
33
|
+
setTimeout(() => resolve(true), BROWSER_SPAWN_TIMEOUT).unref?.();
|
|
34
|
+
});
|
|
24
35
|
} catch {
|
|
25
|
-
|
|
36
|
+
return false;
|
|
26
37
|
}
|
|
27
|
-
|
|
28
|
-
return { token, loginUrl, pollUrl, browserOpened };
|
|
29
38
|
}
|
|
30
39
|
|
|
31
40
|
export async function awaitLoginCompletion({
|
|
@@ -47,29 +56,26 @@ export async function awaitLoginCompletion({
|
|
|
47
56
|
try {
|
|
48
57
|
const response = await fetch(pollUrl);
|
|
49
58
|
const data = await response.json();
|
|
50
|
-
if
|
|
59
|
+
// Do not save the token if the login was aborted while this poll was in flight.
|
|
60
|
+
if (data.status === 'yes' && !signal?.aborted) {
|
|
51
61
|
setConfigValue('apiKeyV2', token);
|
|
52
|
-
process.env.CODEANT_API_TOKEN = token;
|
|
53
62
|
return { ok: true, token };
|
|
54
63
|
}
|
|
55
64
|
} catch {
|
|
56
65
|
// Network blip — keep polling.
|
|
57
66
|
}
|
|
58
67
|
|
|
68
|
+
if (signal?.aborted) throw new Error('Login aborted');
|
|
69
|
+
|
|
59
70
|
const remaining = deadline - Date.now();
|
|
60
71
|
if (remaining <= 0) break;
|
|
61
|
-
await new Promise((resolve) =>
|
|
72
|
+
await new Promise((resolve) => {
|
|
73
|
+
const onAbort = () => { clearTimeout(timer); resolve(); };
|
|
74
|
+
const timer = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, Math.min(pollIntervalMs, remaining));
|
|
75
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
76
|
+
});
|
|
62
77
|
}
|
|
63
78
|
|
|
79
|
+
if (signal?.aborted) throw new Error('Login aborted');
|
|
64
80
|
throw new Error('Login timed out. Please try again.');
|
|
65
81
|
}
|
|
66
|
-
|
|
67
|
-
export async function runLoginFlow({
|
|
68
|
-
pollIntervalMs = DEFAULT_POLL_INTERVAL,
|
|
69
|
-
timeoutMs = DEFAULT_TIMEOUT,
|
|
70
|
-
signal,
|
|
71
|
-
} = {}) {
|
|
72
|
-
const { token, loginUrl, pollUrl, browserOpened } = await startLoginFlow();
|
|
73
|
-
const result = await awaitLoginCompletion({ token, pollUrl, pollIntervalMs, timeoutMs, signal });
|
|
74
|
-
return { ...result, loginUrl, browserOpened };
|
|
75
|
-
}
|