@aikidosec/ci-api-client 1.0.17 → 1.0.19

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.
@@ -0,0 +1,342 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import ignore, { Ignore } from 'ignore';
4
+
5
+ // Always skip these, even if .gitignore does not list them.
6
+ const HARDCODED_IGNORES = [
7
+ '.git/',
8
+ 'node_modules/',
9
+ 'vendor/',
10
+ 'coverage/',
11
+ 'dist/',
12
+ 'build/',
13
+ '.venv/',
14
+ 'venv/',
15
+ '__pycache__/',
16
+ '.eggs/',
17
+ '*.egg-info/',
18
+ '.tox/',
19
+ '.mypy_cache/',
20
+ '.pytest_cache/',
21
+ '.next/',
22
+ '.nuxt/',
23
+ '.turbo/',
24
+ '.cache/',
25
+ 'bower_components/',
26
+ '*.png',
27
+ '*.gif',
28
+ '*.jpg',
29
+ '*.jpeg',
30
+ '*.webp',
31
+ '*.ico',
32
+ '*.woff',
33
+ '*.woff2',
34
+ '*.ttf',
35
+ '*.eot',
36
+ '*.mp4',
37
+ '*.mp3',
38
+ '.nyc_output/',
39
+ 'jspm_packages/',
40
+ '.yarn/cache/',
41
+ '.pnpm-store/',
42
+ 'target/', // Java/Rust
43
+ 'Pods/', // Swift
44
+ ];
45
+
46
+ type TIgnoreStackEntry = {
47
+ base: string;
48
+ ig: Ignore;
49
+ };
50
+
51
+ export type TProjectFiles = {
52
+ root: string;
53
+ files: string[];
54
+ };
55
+
56
+ // Walk the repo and return relative file paths, applying hardcoded ignores + .gitignore.
57
+ export async function loadProjectFiles(): Promise<TProjectFiles | null> {
58
+ const root = await findGitRoot(process.cwd());
59
+ const ignoreManager = ignore().add(HARDCODED_IGNORES);
60
+
61
+ try {
62
+ ignoreManager.add(await fs.readFile(path.join(root, '.gitignore'), 'utf8'));
63
+ } catch {
64
+ // No root .gitignore — hardcoded rules still apply.
65
+ }
66
+
67
+ const files: string[] = [];
68
+ await collectFiles(root, '', [{ base: '', ig: ignoreManager }], files);
69
+
70
+ const projectFiles = files.filter(isLikelySourceFile);
71
+ return projectFiles.length === 0 ? null : { root, files: projectFiles };
72
+ }
73
+
74
+ async function findGitRoot(startDir: string): Promise<string> {
75
+ const start = path.resolve(startDir);
76
+ let dir = start;
77
+
78
+ while (path.dirname(dir) !== dir) {
79
+ try {
80
+ await fs.access(path.join(dir, '.git'));
81
+ return dir;
82
+ } catch {
83
+ dir = path.dirname(dir);
84
+ }
85
+ }
86
+
87
+ try {
88
+ await fs.access(path.join(dir, '.git'));
89
+ return dir;
90
+ } catch {
91
+ return start;
92
+ }
93
+ }
94
+
95
+ // Skip coverage report files and .gitignore — not useful as source matches.
96
+ // Not in HARDCODED_IGNORES because these often live at the repo root and are not gitignored.
97
+ function isLikelySourceFile(relativePath: string): boolean {
98
+ const base = relativePath.slice(relativePath.lastIndexOf('/') + 1);
99
+ const lower = base.toLowerCase();
100
+
101
+ if (
102
+ lower === '.gitignore' ||
103
+ lower.endsWith('.lcov') ||
104
+ lower === 'lcov.info' ||
105
+ (lower.endsWith('.info') && lower.includes('lcov')) ||
106
+ lower === 'coverage-final.json' ||
107
+ lower === 'clover.xml' ||
108
+ lower === 'cobertura.xml' ||
109
+ (lower.endsWith('.xml') && lower.includes('cobertura'))
110
+ ) {
111
+ return false;
112
+ }
113
+
114
+ return true;
115
+ }
116
+
117
+ // Recursively list files. ignoreStack is root → nested .gitignore rules for this path.
118
+ async function collectFiles(
119
+ absoluteDir: string,
120
+ relativeDir: string,
121
+ ignoreStack: TIgnoreStackEntry[],
122
+ files: string[]
123
+ ): Promise<void> {
124
+ let stack = ignoreStack;
125
+
126
+ if (relativeDir) {
127
+ try {
128
+ const nested = ignore().add(
129
+ await fs.readFile(path.join(absoluteDir, '.gitignore'), 'utf8')
130
+ );
131
+ stack = [...ignoreStack, { base: relativeDir, ig: nested }];
132
+ } catch {
133
+ // No nested .gitignore for this directory.
134
+ }
135
+ }
136
+
137
+ let entries;
138
+ try {
139
+ entries = await fs.readdir(absoluteDir, { withFileTypes: true });
140
+ } catch {
141
+ return;
142
+ }
143
+
144
+ for (const entry of entries) {
145
+ const relativePath = relativeDir
146
+ ? `${relativeDir}/${entry.name}`
147
+ : entry.name;
148
+
149
+ if (isIgnored(relativePath, entry.isDirectory(), stack)) {
150
+ continue;
151
+ }
152
+
153
+ if (entry.isDirectory()) {
154
+ await collectFiles(
155
+ path.join(absoluteDir, entry.name),
156
+ relativePath,
157
+ stack,
158
+ files
159
+ );
160
+ } else if (entry.isFile() || entry.isSymbolicLink()) {
161
+ files.push(relativePath);
162
+ }
163
+ }
164
+ }
165
+
166
+ // Git last-match: walk root → nested; a later matching rule (including !negation) wins.
167
+ function isIgnored(
168
+ relativePath: string,
169
+ isDirectory: boolean,
170
+ stack: TIgnoreStackEntry[]
171
+ ): boolean {
172
+ let ignored = false;
173
+
174
+ for (const { base, ig } of stack) {
175
+ const testPath = base ? relativePath.slice(base.length + 1) : relativePath;
176
+
177
+ if (!testPath) {
178
+ continue;
179
+ }
180
+
181
+ const result = ignoreDecision(ig, testPath, isDirectory);
182
+
183
+ if (result.ignored) {
184
+ ignored = true;
185
+ } else if (result.unignored) {
186
+ ignored = false;
187
+ }
188
+ }
189
+
190
+ return ignored;
191
+ }
192
+
193
+ // Directory patterns end with "/"; node-ignore treats "foo" as a file unless we also test "foo/".
194
+ function ignoreDecision(
195
+ ig: Ignore,
196
+ testPath: string,
197
+ isDirectory: boolean
198
+ ): { ignored: boolean; unignored: boolean } {
199
+ const result = ig.test(testPath);
200
+
201
+ if (isDirectory) {
202
+ const directoryResult = ig.test(`${testPath}/`);
203
+ if (directoryResult.ignored || directoryResult.unignored) {
204
+ return directoryResult;
205
+ }
206
+ }
207
+
208
+ return result;
209
+ }
210
+
211
+ // Drop the file extension: "src/foo.ts" → "src/foo".
212
+ export function pathStem(sourcePath: string): string {
213
+ const lastSlashIndex = sourcePath.lastIndexOf('/');
214
+ const lastDotIndex = sourcePath.lastIndexOf('.');
215
+
216
+ if (lastDotIndex > lastSlashIndex) {
217
+ return sourcePath.slice(0, lastDotIndex);
218
+ }
219
+
220
+ return sourcePath;
221
+ }
222
+
223
+ function normalizePath(filePath: string): string {
224
+ return filePath.trim().replace(/\\/g, '/').replace(/^\.\//, '');
225
+ }
226
+
227
+ // True if paths are equal, or one ends with the other after a "/".
228
+ // e.g. "app/src/util.js" matches "src/util.js"; "src/utils.js" does not match "util.js".
229
+ function isSamePathOrSuffix(fullPath: string, possibleSuffix: string): boolean {
230
+ const normalizedFullPath = fullPath.toLowerCase();
231
+ const suffix = possibleSuffix.toLowerCase();
232
+ return (
233
+ normalizedFullPath === suffix ||
234
+ normalizedFullPath.endsWith(`/${suffix}`)
235
+ );
236
+ }
237
+
238
+ // Count matching segments from the end: "x/a/b.js" and "y/a/b.js" → 2.
239
+ // needed for picking the best match when there are multiple candidates
240
+ function countSharedTrailingSegments(
241
+ leftPath: string,
242
+ rightPath: string
243
+ ): number {
244
+ const leftSegments = leftPath.toLowerCase().split('/');
245
+ const rightSegments = rightPath.toLowerCase().split('/');
246
+ const maxShared = Math.min(leftSegments.length, rightSegments.length);
247
+ let sharedCount = 0;
248
+
249
+ while (
250
+ sharedCount < maxShared &&
251
+ leftSegments[leftSegments.length - 1 - sharedCount] ===
252
+ rightSegments[rightSegments.length - 1 - sharedCount]
253
+ ) {
254
+ sharedCount++;
255
+ }
256
+
257
+ return sharedCount;
258
+ }
259
+
260
+ // Prefer more shared trailing segments, then the shorter path. Tie → null (ambiguous).
261
+ // needed for picking the best match when there are multiple candidates
262
+ function pickBestMatch(
263
+ coveragePath: string,
264
+ candidates: string[]
265
+ ): string | null {
266
+ let best: string | null = null;
267
+ let bestShared = -1;
268
+ let bestLength = Infinity;
269
+ let ambiguous = false;
270
+
271
+ for (const candidate of candidates) {
272
+ const shared = countSharedTrailingSegments(coveragePath, candidate);
273
+
274
+ if (
275
+ shared > bestShared ||
276
+ (shared === bestShared && candidate.length < bestLength)
277
+ ) {
278
+ best = candidate;
279
+ bestShared = shared;
280
+ bestLength = candidate.length;
281
+ ambiguous = false;
282
+ } else if (shared === bestShared && candidate.length === bestLength) {
283
+ ambiguous = true;
284
+ }
285
+ }
286
+
287
+ return ambiguous ? null : best;
288
+ }
289
+
290
+ // Map a coverage report path (LCOV SF:) to a real project file. No match → null (drop it).
291
+ export function createPathResolver(
292
+ projectFiles: string[]
293
+ ): (coveragePath: string) => string | null {
294
+ const byBasename = new Map<string, string[]>();
295
+
296
+ // group by basename
297
+ for (const projectPath of projectFiles) {
298
+ const key = projectPath
299
+ .slice(projectPath.lastIndexOf('/') + 1)
300
+ .toLowerCase();
301
+ const entries = byBasename.get(key);
302
+ if (entries) {
303
+ entries.push(projectPath);
304
+ } else {
305
+ byBasename.set(key, [projectPath]);
306
+ }
307
+ }
308
+
309
+ return (coveragePath: string) => {
310
+ const normalizedCoveragePath = normalizePath(coveragePath);
311
+ const key = normalizedCoveragePath
312
+ .slice(normalizedCoveragePath.lastIndexOf('/') + 1)
313
+ .toLowerCase();
314
+
315
+ // find all files with the same basename
316
+ const candidates = byBasename.get(key) ?? [];
317
+
318
+ // filter by path suffix
319
+ const matches = candidates.filter(
320
+ (projectPath) =>
321
+ isSamePathOrSuffix(projectPath, normalizedCoveragePath) ||
322
+ isSamePathOrSuffix(normalizedCoveragePath, projectPath)
323
+ );
324
+
325
+ if (matches.length === 0) {
326
+ return null;
327
+ }
328
+
329
+ const exactMatch = matches.find(
330
+ (projectPath) => projectPath === normalizedCoveragePath
331
+ );
332
+ if (exactMatch) {
333
+ return exactMatch;
334
+ }
335
+
336
+ if (matches.length === 1) {
337
+ return matches[0];
338
+ }
339
+
340
+ return pickBestMatch(normalizedCoveragePath, matches);
341
+ };
342
+ }
@@ -0,0 +1,63 @@
1
+ import { normalizePathSeparators, normalizeSourcePath } from './paths.js';
2
+
3
+ export type TCoverageFormat = 'lcov' | 'cobertura';
4
+
5
+ /**
6
+ * Detect coverage format from the report filename only.
7
+ */
8
+ export function detectFormatFromFilename(filename: string): TCoverageFormat {
9
+ const lower = normalizePathSeparators(filename).toLowerCase();
10
+ const base = lower.slice(lower.lastIndexOf('/') + 1);
11
+
12
+ if (base.endsWith('.lcov') || base.endsWith('.info') || base.includes('lcov')) {
13
+ return 'lcov';
14
+ }
15
+
16
+ if (base.endsWith('.xml') || base.includes('cobertura')) {
17
+ return 'cobertura';
18
+ }
19
+
20
+ throw new Error(
21
+ `Could not detect coverage format from filename "${filename}". ` +
22
+ 'Use a name like lcov.info, *.lcov, or *cobertura*.xml.'
23
+ );
24
+ }
25
+
26
+ /**
27
+ * Extract source file paths referenced in a coverage report (for EOF map).
28
+ */
29
+ export function extractCoveredSourcePaths(
30
+ content: string,
31
+ format: TCoverageFormat,
32
+ repositoryRoot: string
33
+ ): string[] {
34
+ if (format === 'cobertura') {
35
+ return extractCoberturaFilenames(content, repositoryRoot);
36
+ }
37
+
38
+ return extractLcovFilenames(content, repositoryRoot);
39
+ }
40
+
41
+ function extractLcovFilenames(
42
+ content: string,
43
+ repositoryRoot: string
44
+ ): string[] {
45
+ return [...content.matchAll(/^SF:([^\r\n]+)$/gm)]
46
+ .map((match) => normalizeSourcePath(match[1], repositoryRoot))
47
+ .filter(Boolean);
48
+ }
49
+
50
+ function extractCoberturaFilenames(
51
+ content: string,
52
+ repositoryRoot: string
53
+ ): string[] {
54
+ const paths: string[] = [];
55
+ for (const match of content.matchAll(/\bfilename\s*=\s*"([^"]+)"/gi)) {
56
+ const normalized = normalizeSourcePath(match[1], repositoryRoot);
57
+ if (normalized) {
58
+ paths.push(normalized);
59
+ }
60
+ }
61
+
62
+ return paths;
63
+ }
@@ -0,0 +1,73 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ // Drop coverage that points past the end of the source file.
5
+ // applied per-extension in their uploader and are not language-agnostic — we only
6
+ export async function loadSourceLineFixes(
7
+ repositoryRoot: string,
8
+ sourcePath: string
9
+ ): Promise<{ eof: number } | null> {
10
+ if (!repositoryRoot || !sourcePath) {
11
+ return null;
12
+ }
13
+
14
+ try {
15
+ const base = path.resolve(repositoryRoot);
16
+ const target = path.resolve(base, sourcePath);
17
+ const relative = path.relative(base, target);
18
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
19
+ return null;
20
+ }
21
+
22
+ // Do not follow symlinks: a tracked link can point at an unbounded
23
+ // special file (e.g. /dev/zero) and hang or exhaust memory on read.
24
+ const stats = await fs.lstat(target);
25
+ if (stats.isSymbolicLink() || !stats.isFile()) {
26
+ return null;
27
+ }
28
+
29
+ const content = await fs.readFile(target, 'utf8');
30
+ const lineCount = content.split(/\r?\n/).length;
31
+ return { eof: lineCount };
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ type TCoverageRecord = {
38
+ lines: Map<number, unknown>;
39
+ functions: Map<string, { line: number }>;
40
+ branches: Map<string, unknown>;
41
+ };
42
+
43
+ export function applySourceLineFixes(
44
+ record: TCoverageRecord,
45
+ lineFixes: { eof: number } | null
46
+ ): TCoverageRecord {
47
+ if (!lineFixes) {
48
+ return record;
49
+ }
50
+
51
+ const { eof } = lineFixes;
52
+
53
+ for (const lineNumber of record.lines.keys()) {
54
+ if (lineNumber < 1 || lineNumber > eof) {
55
+ record.lines.delete(lineNumber);
56
+ }
57
+ }
58
+
59
+ for (const [functionName, meta] of record.functions.entries()) {
60
+ if (meta.line < 1 || meta.line > eof) {
61
+ record.functions.delete(functionName);
62
+ }
63
+ }
64
+
65
+ for (const branchKey of record.branches.keys()) {
66
+ const lineNumber = Number(branchKey.split('\0')[0]);
67
+ if (lineNumber < 1 || lineNumber > eof) {
68
+ record.branches.delete(branchKey);
69
+ }
70
+ }
71
+
72
+ return record;
73
+ }
@@ -245,7 +245,7 @@ export const scan = async ({
245
245
  const parseCliOptions = (userCliOptions: TScanUserCliOptions) => {
246
246
  // Version provided to the API corresponds with the version in package.json
247
247
  // of the cli client
248
- const apiOptions: TScanApiOptions = { version: '1.0.16' };
248
+ const apiOptions: TScanApiOptions = { version: '1.0.18' };
249
249
  const cliOptions: TScanCliOptions = { pollInterval: 5 };
250
250
 
251
251
  if (userCliOptions.pullRequestTitle) {
@@ -14,6 +14,7 @@ import { getApiKey } from '../configuration.js';
14
14
  import {
15
15
  outputError,
16
16
  outputHttpError,
17
+ outputJson,
17
18
  outputLog,
18
19
  startSpinner,
19
20
  } from '../output.js';
@@ -49,6 +50,7 @@ type TScanArguments = {
49
50
 
50
51
  type TScanCliOptions = {
51
52
  pollInterval: number;
53
+ json: boolean;
52
54
  };
53
55
 
54
56
  type TScanUserCliOptions = {
@@ -63,6 +65,7 @@ type TScanUserCliOptions = {
63
65
  pollInterval?: number;
64
66
  baseBranch?: string;
65
67
  slaMode?: boolean;
68
+ json?: boolean;
66
69
  };
67
70
 
68
71
  async function cli(
@@ -83,6 +86,13 @@ async function cli(
83
86
  let loader: Ora | null;
84
87
  let pollCount: number = 1;
85
88
 
89
+ // when the json option is added, only output the json result.
90
+ // Disabling console output takes care of the spinners and other extra info
91
+ const jsonOutput = cliOptions.json;
92
+ if (jsonOutput) {
93
+ process.env.QUIET = true;
94
+ }
95
+
86
96
  // Setup different scan() event handlers
87
97
  const onStart = () => {
88
98
  loader = startSpinner('Starting Aikido Security release scan');
@@ -107,6 +117,20 @@ async function cli(
107
117
  };
108
118
 
109
119
  const onScanComplete = (pollResult: any) => {
120
+ if (jsonOutput) {
121
+ outputJson({
122
+ gate_passed: pollResult.gate_passed === true,
123
+ issue_breakdown: pollResult.issue_breakdown,
124
+ });
125
+
126
+ if (pollResult.gate_passed !== true) {
127
+ // Don't process.exit() here: with piped stdout (CI) the JSON write
128
+ // may still be queued and a forced exit can truncate it.
129
+ process.exitCode = 10;
130
+ }
131
+ return;
132
+ }
133
+
110
134
  if (pollResult.gate_passed === true) {
111
135
  loader?.succeed('Scan completed, no open issues found');
112
136
  if (pollResult.diff_url) {
@@ -245,8 +269,8 @@ export const scan = async ({
245
269
  const parseCliOptions = (userCliOptions: TScanUserCliOptions) => {
246
270
  // Version provided to the API corresponds with the version in package.json
247
271
  // of the cli client
248
- const apiOptions: TScanApiOptions = { version: '1.0.16' };
249
- const cliOptions: TScanCliOptions = { pollInterval: 10 };
272
+ const apiOptions: TScanApiOptions = { version: '1.0.18' };
273
+ const cliOptions: TScanCliOptions = { pollInterval: 10, json: false };
250
274
 
251
275
  if (userCliOptions.pullRequestTitle) {
252
276
  apiOptions.pull_request_metadata = {
@@ -292,6 +316,9 @@ const parseCliOptions = (userCliOptions: TScanUserCliOptions) => {
292
316
  } else if (userCliOptions.pollInterval) {
293
317
  cliOptions.pollInterval = userCliOptions.pollInterval;
294
318
  }
319
+ if (userCliOptions.json) {
320
+ cliOptions.json = true;
321
+ }
295
322
 
296
323
  return { apiOptions, cliOptions };
297
324
  };
@@ -363,6 +390,10 @@ export const cliSetup = (program: Command) =>
363
390
  '--sla-mode',
364
391
  'Let Aikido only fail only on open issues that have gone out of SLA.'
365
392
  )
393
+ .option(
394
+ '--json',
395
+ 'Output the scan result as JSON (gate_passed and issue_breakdown)'
396
+ )
366
397
  .description('Run a release scan of an Aikido repo.')
367
398
  .action(cli);
368
399
 
@@ -0,0 +1,115 @@
1
+ import { Command } from 'commander';
2
+ import { Ora } from 'ora';
3
+ import { uploadCodeCoverage } from '../aikidoApi.js';
4
+ import { collectUploadPayload } from '../codeCoverage/collectUploadPayload.js';
5
+ import { getApiKey } from '../configuration.js';
6
+ import {
7
+ outputError,
8
+ outputHttpError,
9
+ outputLog,
10
+ startSpinner,
11
+ } from '../output.js';
12
+
13
+ type TUploadCoverageUserCliOptions = {
14
+ repoName: string;
15
+ commitSha: string;
16
+ branchName: string;
17
+ filePaths: string[];
18
+ repositoryId: string | number;
19
+ };
20
+
21
+ async function cli(options: TUploadCoverageUserCliOptions) {
22
+ const apiKey = getApiKey();
23
+
24
+ if (!apiKey) {
25
+ outputError('Please set an api key using: aikido-api-client apikey <key>');
26
+ return;
27
+ return;
28
+ }
29
+
30
+ const filePaths = normalizeFilePaths(options.filePaths);
31
+ if (filePaths.length === 0) {
32
+ outputError(
33
+ 'No code coverage file(s) provided. Specify at least one path with --file-paths.'
34
+ );
35
+ }
36
+
37
+ let loader: Ora | null = startSpinner(
38
+ 'Collecting coverage files and repository metadata'
39
+ );
40
+
41
+ let payload;
42
+ try {
43
+ payload = await collectUploadPayload(filePaths);
44
+ } catch (error) {
45
+ loader?.fail();
46
+ outputError(error instanceof Error ? error.message : String(error));
47
+ return;
48
+ }
49
+
50
+ loader?.succeed(
51
+ `Found ${payload.files.length} coverage file(s) (source paths=${payload.repository_source_paths.length}, eof=${Object.keys(payload.eof).length})`
52
+ );
53
+
54
+ for (const file of payload.files) {
55
+ outputLog(` ${file.filename} (${file.format})`);
56
+ }
57
+
58
+ loader = startSpinner('Uploading code coverage to Aikido');
59
+
60
+ try {
61
+ await uploadCodeCoverage({
62
+ repository_id: options.repositoryId,
63
+ repo_name: options.repoName,
64
+ commit_sha: options.commitSha,
65
+ branch_name: options.branchName,
66
+ repository_source_paths: payload.repository_source_paths,
67
+ eof: payload.eof,
68
+ files: payload.files,
69
+ });
70
+
71
+
72
+ loader?.succeed('Code coverage upload completed');
73
+ process.exit(0);
74
+ } catch (error) {
75
+ loader?.fail();
76
+ outputHttpError(error as any);
77
+ process.exit(1);
78
+ }
79
+ }
80
+
81
+ const normalizeFilePaths = (filePaths: string[]): string[] =>
82
+ filePaths
83
+ .flatMap((entry) => entry.split(/\n|\s+|,/))
84
+ .map((filePath) => filePath.trim())
85
+ .filter(Boolean);
86
+
87
+ export const cliSetup = (program: Command) =>
88
+ program
89
+ .command('upload-coverage')
90
+ .requiredOption(
91
+ '-r, --repo-name <reponame>',
92
+ 'Repository name as owner/repo (e.g. org/my-repo)'
93
+ )
94
+ .requiredOption(
95
+ '-c, --commit-sha <commitsha>',
96
+ 'The commit SHA for this CI run'
97
+ )
98
+ .requiredOption(
99
+ '-b, --branch-name <branchname>',
100
+ 'The branch name for this CI run'
101
+ )
102
+ .requiredOption(
103
+ '-f, --file-paths <paths...>',
104
+ 'Path(s) to LCOV or Cobertura coverage report(s). Format is detected from each filename.'
105
+ )
106
+ .requiredOption(
107
+ '-ri, --repository-id <repositoryid>',
108
+ 'The scm repository id'
109
+ )
110
+ .description(
111
+ 'Upload LCOV or Cobertura coverage reports to Aikido (with repository_source_paths and EOF metadata).'
112
+ )
113
+ .action(cli);
114
+
115
+ export default { cli, cliSetup };