@cantinasecurity/apex-cli 0.1.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.
@@ -0,0 +1,50 @@
1
+ import { fetchWorkspaceScans, getScanDisplayId, matchesScanId, } from "./scan.js";
2
+ function parsePositiveInt(value) {
3
+ if (!value)
4
+ return null;
5
+ const parsed = Number(value);
6
+ if (!Number.isFinite(parsed) || parsed <= 0) {
7
+ return null;
8
+ }
9
+ return Math.floor(parsed);
10
+ }
11
+ export async function fetchScanFindings(client, scanId, limit) {
12
+ const params = new URLSearchParams();
13
+ const parsedLimit = parsePositiveInt(limit);
14
+ if (parsedLimit !== null) {
15
+ params.set("limit", String(parsedLimit));
16
+ }
17
+ const suffix = params.toString();
18
+ return client.request(`/api/cli/v1/scans/${encodeURIComponent(scanId)}/findings${suffix ? `?${suffix}` : ""}`);
19
+ }
20
+ export async function fetchScanExport(client, scanId, format) {
21
+ const params = new URLSearchParams();
22
+ if (format.trim().length > 0) {
23
+ params.set("format", format.trim());
24
+ }
25
+ return client.request(`/api/cli/v1/scans/${encodeURIComponent(scanId)}/export?${params.toString()}`);
26
+ }
27
+ export async function resolveScanSelection(params) {
28
+ const scans = await fetchWorkspaceScans(params.client, params.binding.workspaceId);
29
+ if (scans.length === 0) {
30
+ throw new Error("No scans are available for this workspace.");
31
+ }
32
+ const requested = params.requestedScanId?.trim() ?? "";
33
+ if (requested.length > 0) {
34
+ const matched = scans.find((scan) => matchesScanId(scan, requested));
35
+ if (!matched) {
36
+ throw new Error(`Unknown scan: ${requested}`);
37
+ }
38
+ return matched;
39
+ }
40
+ if (params.binding.lastScanId) {
41
+ const tracked = scans.find((scan) => matchesScanId(scan, params.binding.lastScanId ?? ""));
42
+ if (tracked) {
43
+ return tracked;
44
+ }
45
+ }
46
+ return scans[0];
47
+ }
48
+ export function describeScanSelection(scan) {
49
+ return scan.scanUrl ?? getScanDisplayId(scan);
50
+ }
package/dist/help.js ADDED
@@ -0,0 +1,75 @@
1
+ export const CLI_HELP_TEXT = `Usage:
2
+ apex Open the interactive Apex shell
3
+ apex credits Show scan credits for the active company
4
+ apex scan Create or resolve a workspace for this directory and start a scan
5
+ apex scans List scans for the current workspace binding
6
+ apex findings List findings for the latest or selected scan
7
+ apex export findings Export findings for the latest or selected scan
8
+ apex workspaces List accessible workspaces for the active company
9
+ apex workspace Show the workspace currently bound to this directory
10
+ apex workspace use <workspace-name|workspace-prefix|workspace-id>
11
+ Bind this directory to an existing Apex workspace
12
+ apex cancel-scan [scan-id] Cancel a running scan
13
+ apex status Show the latest scan progress for this directory
14
+ apex doctor Validate auth, repos, connections, and workspace binding
15
+ apex login Sign in to Apex
16
+ apex logout Sign out locally
17
+ apex mcp Start the Apex MCP server over stdio
18
+ apex setup [all|codex|claude] Configure Apex for Codex and Claude Code
19
+ apex update Update the local Apex CLI install
20
+ apex connect github Open the GitHub connection flow
21
+ apex connect gitlab Open the GitLab connection flow
22
+
23
+ Flags:
24
+ --company <id-or-handle> Choose the Apex company to use
25
+ --workspace-name <name> Set the Apex workspace name for this directory
26
+ --scan <scan-id> Select a specific scan for findings or export
27
+ --format markdown|json|gitlab-sast
28
+ Choose the export format for findings
29
+ --output <path> Write exported findings to this file path
30
+ --limit <count> Limit the number of findings returned
31
+ --mode standard|ultra Choose the scan mode
32
+ --source-mode auto|remote|local Control remote-vs-local source materialization
33
+ --force Start a new scan even if another scan is active
34
+ --repo <path> Include one or more explicit local source roots
35
+ --json Print machine-readable JSON output
36
+ --no-open Do not open browser flows automatically
37
+ --non-interactive Disable prompts and browser-dependent UX
38
+ --help Show this help
39
+
40
+ Tips:
41
+ apex scan uses the current directory name as the default workspace name unless you pass --workspace-name.
42
+ apex workspace use accepts a workspace name, prefix, or ID.
43
+ Quote workspace names that contain spaces:
44
+ apex workspace use "Core Platform"
45
+ `;
46
+ export const SHELL_HELP_TEXT = `Press Tab to autocomplete commands and common arguments.
47
+
48
+ Commands:
49
+ /credits Show scan credits for the active company
50
+ /scan [standard|ultra] Start a new Apex scan for this workspace
51
+ /scans List scans for this workspace
52
+ /findings [scan-id] List findings for the latest or selected scan
53
+ /export [scan-id] Export findings for the latest or selected scan
54
+ /workspaces List accessible workspaces for the active company
55
+ /cancel-scan [scan-id] Cancel a running or most recent scan
56
+ /status Show progress for the most recent scan
57
+ /doctor Validate auth, repos, connections, and workspace binding
58
+ /update Update the local Apex CLI install and exit the shell
59
+ /logout Sign out locally and exit the shell
60
+ /repos List detected repositories
61
+ /workspace Show the current local workspace binding
62
+ /workspace use <ref> Bind this directory to an existing workspace by name, prefix, or ID
63
+ /workspace name <name> Update the current workspace name
64
+ /company [id|handle] Show or switch the active company
65
+ /connect github Connect GitHub access in the browser
66
+ /connect gitlab Connect GitLab access in the browser
67
+ /open Open the latest scan or Apex home in the browser
68
+ /clear Clear the terminal
69
+ /help Show this help
70
+ /exit Exit Apex
71
+
72
+ Tips:
73
+ /workspace use accepts a workspace name, prefix, or ID.
74
+ Quote workspace names that contain spaces: /workspace use "Core Platform"
75
+ `;
@@ -0,0 +1,304 @@
1
+ import { createHash } from "node:crypto";
2
+ import { execFile } from "node:child_process";
3
+ import { createReadStream } from "node:fs";
4
+ import { lstat, mkdtemp, readFile, readdir, readlink, rm, stat, } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import path from "node:path";
7
+ import { promisify } from "node:util";
8
+ import ignore from "ignore";
9
+ import * as tar from "tar";
10
+ const execFileAsync = promisify(execFile);
11
+ const BUILTIN_IGNORE_PATTERNS = [
12
+ ".git",
13
+ ".git/**",
14
+ "node_modules",
15
+ "node_modules/**",
16
+ ".next",
17
+ ".next/**",
18
+ "dist",
19
+ "dist/**",
20
+ "build",
21
+ "build/**",
22
+ "coverage",
23
+ "coverage/**",
24
+ ".turbo",
25
+ ".turbo/**",
26
+ ".cache",
27
+ ".cache/**",
28
+ ".pnpm-store",
29
+ ".pnpm-store/**",
30
+ "tmp",
31
+ "tmp/**",
32
+ ];
33
+ const FIXED_MTIME = new Date(0);
34
+ async function runGit(cwd, args) {
35
+ const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], {
36
+ encoding: "utf8",
37
+ });
38
+ return stdout;
39
+ }
40
+ function buildIgnoreMatcher(rootPath) {
41
+ const matcher = ignore().add(BUILTIN_IGNORE_PATTERNS);
42
+ return readFile(path.join(rootPath, ".apexignore"), "utf8")
43
+ .then((contents) => {
44
+ matcher.add(contents);
45
+ return matcher;
46
+ })
47
+ .catch(() => matcher);
48
+ }
49
+ function normalizeRelativeEntry(entryPath) {
50
+ return entryPath.replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, "");
51
+ }
52
+ function ensureEntryWithinRoot(rootPath, relativePath, targetPath) {
53
+ if (path.isAbsolute(targetPath)) {
54
+ throw new Error(`Refusing to archive absolute symlink target in ${relativePath}`);
55
+ }
56
+ const resolved = path.resolve(rootPath, path.dirname(relativePath), targetPath);
57
+ const relative = path.relative(rootPath, resolved);
58
+ if (!relative || relative === ".")
59
+ return;
60
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
61
+ throw new Error(`Refusing to archive path-escaping symlink in ${relativePath}`);
62
+ }
63
+ }
64
+ async function collectGitEntries(rootPath) {
65
+ const output = await runGit(rootPath, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"]).catch(() => "");
66
+ return output
67
+ .split("\0")
68
+ .map((entry) => normalizeRelativeEntry(entry))
69
+ .filter((entry) => entry.length > 0);
70
+ }
71
+ async function collectDirectoryEntries(rootPath) {
72
+ const entries = [];
73
+ async function walk(currentPath) {
74
+ const children = await readdir(currentPath, { withFileTypes: true });
75
+ for (const child of children) {
76
+ const childPath = path.join(currentPath, child.name);
77
+ const relativePath = normalizeRelativeEntry(path.relative(rootPath, childPath));
78
+ if (!relativePath)
79
+ continue;
80
+ if (child.isDirectory()) {
81
+ entries.push(relativePath);
82
+ await walk(childPath);
83
+ continue;
84
+ }
85
+ entries.push(relativePath);
86
+ }
87
+ }
88
+ await walk(rootPath);
89
+ return entries;
90
+ }
91
+ async function collectArchiveEntries(source) {
92
+ const matcher = await buildIgnoreMatcher(source.absolutePath);
93
+ const rawEntries = source.kind === "git_candidate"
94
+ ? await collectGitEntries(source.absolutePath)
95
+ : await collectDirectoryEntries(source.absolutePath);
96
+ const fileEntries = new Set();
97
+ let uncompressedBytes = 0;
98
+ for (const entry of rawEntries.sort((left, right) => left.localeCompare(right))) {
99
+ if (!entry)
100
+ continue;
101
+ if (matcher.ignores(entry) || matcher.ignores(`${entry}/`))
102
+ continue;
103
+ const absoluteEntryPath = path.join(source.absolutePath, entry);
104
+ const entryStats = await lstat(absoluteEntryPath).catch(() => null);
105
+ if (!entryStats)
106
+ continue;
107
+ if (entryStats.isDirectory()) {
108
+ continue;
109
+ }
110
+ if (entryStats.isSymbolicLink()) {
111
+ const target = await readlink(absoluteEntryPath);
112
+ ensureEntryWithinRoot(source.absolutePath, entry, target);
113
+ fileEntries.add(entry);
114
+ uncompressedBytes += Buffer.byteLength(target);
115
+ continue;
116
+ }
117
+ if (!entryStats.isFile()) {
118
+ throw new Error(`Unsupported filesystem entry in source: ${entry}`);
119
+ }
120
+ fileEntries.add(entry);
121
+ uncompressedBytes += entryStats.size;
122
+ }
123
+ return {
124
+ files: Array.from(fileEntries).sort((left, right) => left.localeCompare(right)),
125
+ uncompressedBytes,
126
+ };
127
+ }
128
+ async function createArchiveManifest(archivePath) {
129
+ const hash = createHash("sha256");
130
+ const stream = createReadStream(archivePath);
131
+ await new Promise((resolve, reject) => {
132
+ stream.on("data", (chunk) => hash.update(chunk));
133
+ stream.on("error", reject);
134
+ stream.on("end", () => resolve());
135
+ });
136
+ const archiveStats = await stat(archivePath);
137
+ return {
138
+ sha256: hash.digest("hex"),
139
+ compressedBytes: archiveStats.size,
140
+ uncompressedBytes: 0,
141
+ };
142
+ }
143
+ async function createDeterministicArchive(params) {
144
+ const { files, uncompressedBytes } = await collectArchiveEntries(params.source);
145
+ await tar.create({
146
+ cwd: params.source.absolutePath,
147
+ file: params.archivePath,
148
+ gzip: true,
149
+ portable: true,
150
+ noPax: true,
151
+ mtime: FIXED_MTIME,
152
+ follow: false,
153
+ preservePaths: false,
154
+ jobs: 1,
155
+ sync: false,
156
+ noDirRecurse: false,
157
+ onWriteEntry(entry) {
158
+ entry.mtime = FIXED_MTIME;
159
+ entry.uid = 0;
160
+ entry.gid = 0;
161
+ entry.uname = "root";
162
+ entry.gname = "root";
163
+ },
164
+ }, files);
165
+ const manifest = await createArchiveManifest(params.archivePath);
166
+ return {
167
+ ...manifest,
168
+ uncompressedBytes,
169
+ };
170
+ }
171
+ export async function fetchLocalSourceScanCapabilities(client) {
172
+ return client.request("/api/cli/v2/capabilities");
173
+ }
174
+ export function requiresExplicitSourceFlow(plannedSources) {
175
+ return plannedSources.some((source) => source.sourceKind === "local_archive" ||
176
+ (source.sourceKind === "remote_repo" && source.materializationAuth === "public"));
177
+ }
178
+ export function supportsLegacyRemoteFlow(plannedSources) {
179
+ return plannedSources.every((source) => source.sourceKind === "remote_repo" &&
180
+ source.materializationAuth !== "public");
181
+ }
182
+ async function uploadArchive(archivePath, uploadUrl, headers) {
183
+ const response = await fetch(uploadUrl, {
184
+ method: "PUT",
185
+ headers,
186
+ body: createReadStream(archivePath),
187
+ duplex: "half",
188
+ });
189
+ if (!response.ok) {
190
+ const details = await response.text().catch(() => "");
191
+ throw new Error(details
192
+ ? `Archive upload failed: ${details}`
193
+ : `Archive upload failed with ${response.status}`);
194
+ }
195
+ }
196
+ export async function prepareExplicitScanSources(args) {
197
+ const capabilities = await fetchLocalSourceScanCapabilities(args.client);
198
+ const localSources = args.plannedSources.filter((source) => source.sourceKind === "local_archive");
199
+ if (args.plannedSources.length > capabilities.archive.maxSourcesPerScan) {
200
+ throw new Error(`This scan includes ${args.plannedSources.length} sources, but the server limit is ${capabilities.archive.maxSourcesPerScan}.`);
201
+ }
202
+ if (localSources.length === 0) {
203
+ return args.plannedSources.map((source) => source.sourceKind === "remote_repo"
204
+ ? source
205
+ : {
206
+ sourceKind: "local_archive",
207
+ displayName: source.displayName,
208
+ relativePath: source.relativePath,
209
+ archiveId: "",
210
+ sha256: "",
211
+ git: source.git,
212
+ });
213
+ }
214
+ const detectedByPath = new Map(args.detectedSources.map((source) => [source.path, source]));
215
+ const tempDir = await mkdtemp(path.join(tmpdir(), "apex-local-source-"));
216
+ try {
217
+ const preparedUploads = [];
218
+ for (const [index, source] of localSources.entries()) {
219
+ const detectedSource = detectedByPath.get(source.path);
220
+ if (!detectedSource) {
221
+ throw new Error(`Could not resolve local source path: ${source.path}`);
222
+ }
223
+ const archivePath = path.join(tempDir, `source-${index + 1}.tar.gz`);
224
+ const manifest = await createDeterministicArchive({
225
+ source: detectedSource,
226
+ archivePath,
227
+ });
228
+ if (manifest.compressedBytes > capabilities.archive.maxCompressedBytes) {
229
+ throw new Error(`Archive for ${source.displayName} exceeds the compressed upload limit.`);
230
+ }
231
+ if (manifest.uncompressedBytes > capabilities.archive.maxUncompressedBytes) {
232
+ throw new Error(`Archive for ${source.displayName} exceeds the uncompressed upload limit.`);
233
+ }
234
+ preparedUploads.push({
235
+ plannedSource: source,
236
+ archivePath,
237
+ upload: {
238
+ displayName: source.displayName,
239
+ relativePath: source.relativePath,
240
+ archiveFormat: "tar.gz",
241
+ sha256: manifest.sha256,
242
+ compressedBytes: manifest.compressedBytes,
243
+ uncompressedBytes: manifest.uncompressedBytes,
244
+ git: source.git,
245
+ },
246
+ });
247
+ }
248
+ const uploadSessions = await args.client.request("/api/cli/v2/local-source-uploads", {
249
+ method: "POST",
250
+ json: {
251
+ workspaceId: args.workspaceId,
252
+ uploads: preparedUploads.map((entry) => entry.upload),
253
+ },
254
+ });
255
+ const archiveIdByRelativePath = new Map();
256
+ for (const [index, session] of uploadSessions.uploads.entries()) {
257
+ const prepared = preparedUploads[index];
258
+ if (!prepared) {
259
+ throw new Error("Upload session count did not match the prepared archive list.");
260
+ }
261
+ let archiveId = session.archiveId ?? "";
262
+ if (!session.alreadyUploaded) {
263
+ if (!session.upload?.putUrl) {
264
+ throw new Error("Upload session did not include a signed upload URL.");
265
+ }
266
+ await uploadArchive(prepared.archivePath, session.upload.putUrl, session.upload.headers ?? {});
267
+ const completed = await args.client.request(`/api/cli/v2/local-source-uploads/${encodeURIComponent(session.uploadId)}/complete`, {
268
+ method: "POST",
269
+ json: {
270
+ workspaceId: args.workspaceId,
271
+ },
272
+ });
273
+ archiveId = completed.archiveId;
274
+ }
275
+ if (!archiveId) {
276
+ throw new Error("Upload completed without an archiveId.");
277
+ }
278
+ archiveIdByRelativePath.set(prepared.plannedSource.relativePath, {
279
+ archiveId,
280
+ sha256: prepared.upload.sha256,
281
+ });
282
+ }
283
+ return args.plannedSources.map((source) => {
284
+ if (source.sourceKind === "remote_repo") {
285
+ return source;
286
+ }
287
+ const uploaded = archiveIdByRelativePath.get(source.relativePath);
288
+ if (!uploaded) {
289
+ throw new Error(`Archive upload did not complete for ${source.displayName}.`);
290
+ }
291
+ return {
292
+ sourceKind: "local_archive",
293
+ displayName: source.displayName,
294
+ relativePath: source.relativePath,
295
+ archiveId: uploaded.archiveId,
296
+ sha256: uploaded.sha256,
297
+ git: source.git,
298
+ };
299
+ });
300
+ }
301
+ finally {
302
+ await rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
303
+ }
304
+ }
@@ -0,0 +1,10 @@
1
+ import { formatApiError } from "./api-client.js";
2
+ import { runMcpServer } from "./mcp.js";
3
+ export async function runMcpMain() {
4
+ await runMcpServer();
5
+ }
6
+ export function handleMcpFatalError(error) {
7
+ process.stderr.write(`${formatApiError(error)}\n`);
8
+ process.exitCode = 1;
9
+ }
10
+ runMcpMain().catch(handleMcpFatalError);