@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.
- package/.claude/skills/apex-cli/SKILL.md +38 -0
- package/README.md +259 -0
- package/dist/apex.js +94 -0
- package/dist/api-client.js +125 -0
- package/dist/args.js +56 -0
- package/dist/auth.js +143 -0
- package/dist/browser.js +18 -0
- package/dist/commands.js +629 -0
- package/dist/config.js +54 -0
- package/dist/findings.js +50 -0
- package/dist/help.js +75 -0
- package/dist/local-source-scan.js +304 -0
- package/dist/mcp-main.js +10 -0
- package/dist/mcp.js +487 -0
- package/dist/output.js +93 -0
- package/dist/prompt.js +80 -0
- package/dist/repo-discovery.js +175 -0
- package/dist/repo-url.js +134 -0
- package/dist/scan.js +186 -0
- package/dist/session.js +188 -0
- package/dist/setup.js +275 -0
- package/dist/shell.js +320 -0
- package/dist/types.js +1 -0
- package/dist/update.js +462 -0
- package/dist/version.js +7 -0
- package/dist/workspace-binding.js +30 -0
- package/dist/workspaces.js +50 -0
- package/package.json +43 -0
- package/pkg-bin/apex-mcp.js +2 -0
- package/pkg-bin/apex.js +2 -0
- package/skills/apex-cli/SKILL.md +29 -0
package/dist/findings.js
ADDED
|
@@ -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
|
+
}
|
package/dist/mcp-main.js
ADDED
|
@@ -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);
|