@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
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { lstat, readdir, realpath } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { extractCanonicalRepoMetadata } from "./repo-url.js";
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const IGNORED_DIRECTORIES = new Set([
|
|
8
|
+
".git",
|
|
9
|
+
".next",
|
|
10
|
+
"node_modules",
|
|
11
|
+
"dist",
|
|
12
|
+
"build",
|
|
13
|
+
"coverage",
|
|
14
|
+
".turbo",
|
|
15
|
+
".cache",
|
|
16
|
+
".pnpm-store",
|
|
17
|
+
"tmp",
|
|
18
|
+
"vendor",
|
|
19
|
+
]);
|
|
20
|
+
async function runGit(cwd, args) {
|
|
21
|
+
const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], {
|
|
22
|
+
encoding: "utf8",
|
|
23
|
+
});
|
|
24
|
+
return stdout.trim();
|
|
25
|
+
}
|
|
26
|
+
async function isGitRepository(directory) {
|
|
27
|
+
try {
|
|
28
|
+
await runGit(directory, ["rev-parse", "--is-inside-work-tree"]);
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async function getGitTopLevel(directory) {
|
|
36
|
+
try {
|
|
37
|
+
const topLevel = await runGit(directory, ["rev-parse", "--show-toplevel"]);
|
|
38
|
+
if (topLevel.length === 0)
|
|
39
|
+
return null;
|
|
40
|
+
return realpath(topLevel).catch(() => path.resolve(topLevel));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async function resolveComparablePath(directory) {
|
|
47
|
+
return realpath(directory).catch(() => path.resolve(directory));
|
|
48
|
+
}
|
|
49
|
+
async function chooseRemote(directory) {
|
|
50
|
+
const output = await runGit(directory, ["remote", "-v"]).catch(() => "");
|
|
51
|
+
const lines = output.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
52
|
+
if (lines.length === 0)
|
|
53
|
+
return null;
|
|
54
|
+
const remotes = lines
|
|
55
|
+
.map((line) => {
|
|
56
|
+
const match = line.match(/^([^\s]+)\s+([^\s]+)\s+\((fetch|push)\)$/);
|
|
57
|
+
if (!match)
|
|
58
|
+
return null;
|
|
59
|
+
return {
|
|
60
|
+
name: match[1],
|
|
61
|
+
url: match[2],
|
|
62
|
+
kind: match[3],
|
|
63
|
+
};
|
|
64
|
+
})
|
|
65
|
+
.filter((entry) => Boolean(entry));
|
|
66
|
+
const originFetch = remotes.find((remote) => remote.name === "origin" && remote.kind === "fetch");
|
|
67
|
+
if (originFetch)
|
|
68
|
+
return originFetch.url;
|
|
69
|
+
const firstFetch = remotes.find((remote) => remote.kind === "fetch");
|
|
70
|
+
if (firstFetch)
|
|
71
|
+
return firstFetch.url;
|
|
72
|
+
return remotes[0]?.url ?? null;
|
|
73
|
+
}
|
|
74
|
+
async function ensureDirectoryCandidate(cwd, directory) {
|
|
75
|
+
const stats = await lstat(directory).catch(() => null);
|
|
76
|
+
if (!stats) {
|
|
77
|
+
throw new Error(`Path does not exist: ${path.relative(cwd, directory) || "."}`);
|
|
78
|
+
}
|
|
79
|
+
if (stats.isDirectory()) {
|
|
80
|
+
return directory;
|
|
81
|
+
}
|
|
82
|
+
if (stats.isFile()) {
|
|
83
|
+
return path.dirname(directory);
|
|
84
|
+
}
|
|
85
|
+
throw new Error(`Path is not a directory: ${path.relative(cwd, directory) || "."}`);
|
|
86
|
+
}
|
|
87
|
+
function normalizeSelectedRoots(cwd, selectedPaths) {
|
|
88
|
+
const resolved = selectedPaths
|
|
89
|
+
.map((selectedPath) => path.resolve(cwd, selectedPath))
|
|
90
|
+
.filter((value, index, array) => array.indexOf(value) === index)
|
|
91
|
+
.sort((left, right) => left.length - right.length);
|
|
92
|
+
const disjoint = [];
|
|
93
|
+
for (const candidate of resolved) {
|
|
94
|
+
const covered = disjoint.some((existing) => {
|
|
95
|
+
if (existing === candidate)
|
|
96
|
+
return true;
|
|
97
|
+
const relative = path.relative(existing, candidate);
|
|
98
|
+
return relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
99
|
+
});
|
|
100
|
+
if (!covered) {
|
|
101
|
+
disjoint.push(candidate);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return disjoint;
|
|
105
|
+
}
|
|
106
|
+
export async function findGitRepositoryRoots(cwd) {
|
|
107
|
+
const discovered = new Set();
|
|
108
|
+
async function walk(directory) {
|
|
109
|
+
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
|
|
110
|
+
const hasGitMarker = entries.some((entry) => entry.name === ".git");
|
|
111
|
+
if (hasGitMarker && (await isGitRepository(directory))) {
|
|
112
|
+
discovered.add(directory);
|
|
113
|
+
}
|
|
114
|
+
for (const entry of entries) {
|
|
115
|
+
if (!entry.isDirectory())
|
|
116
|
+
continue;
|
|
117
|
+
if (IGNORED_DIRECTORIES.has(entry.name))
|
|
118
|
+
continue;
|
|
119
|
+
await walk(path.join(directory, entry.name));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
await walk(cwd);
|
|
123
|
+
return Array.from(discovered).sort((left, right) => left.length - right.length);
|
|
124
|
+
}
|
|
125
|
+
async function inspectGitRepository(rootCwd, directory) {
|
|
126
|
+
const remoteUrl = await chooseRemote(directory);
|
|
127
|
+
const metadata = remoteUrl ? extractCanonicalRepoMetadata(remoteUrl) : null;
|
|
128
|
+
const branchName = await runGit(directory, ["rev-parse", "--abbrev-ref", "HEAD"]).catch(() => "HEAD");
|
|
129
|
+
const commitSha = await runGit(directory, ["rev-parse", "HEAD"]).catch(() => null);
|
|
130
|
+
const dirtyOutput = await runGit(directory, ["status", "--porcelain"]).catch(() => "");
|
|
131
|
+
const relativePath = path.relative(rootCwd, directory) || ".";
|
|
132
|
+
const detached = !branchName || branchName === "HEAD";
|
|
133
|
+
return {
|
|
134
|
+
kind: "git_candidate",
|
|
135
|
+
path: relativePath,
|
|
136
|
+
absolutePath: directory,
|
|
137
|
+
displayName: path.basename(directory),
|
|
138
|
+
repoUrl: metadata?.repoUrl ?? remoteUrl,
|
|
139
|
+
provider: metadata?.provider ?? null,
|
|
140
|
+
branch: detached ? null : branchName,
|
|
141
|
+
commitSha: commitSha && commitSha.length > 0 ? commitSha : null,
|
|
142
|
+
dirty: dirtyOutput.length > 0,
|
|
143
|
+
isRoot: relativePath === ".",
|
|
144
|
+
sourceType: detached ? "commit" : "branch",
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
async function inspectSource(rootCwd, requestedDirectory) {
|
|
148
|
+
const directory = await ensureDirectoryCandidate(rootCwd, requestedDirectory);
|
|
149
|
+
const relativePath = path.relative(rootCwd, directory) || ".";
|
|
150
|
+
const gitTopLevel = await getGitTopLevel(directory);
|
|
151
|
+
const comparableDirectory = await resolveComparablePath(directory);
|
|
152
|
+
if (gitTopLevel && gitTopLevel === comparableDirectory) {
|
|
153
|
+
return inspectGitRepository(rootCwd, directory);
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
kind: "directory_candidate",
|
|
157
|
+
path: relativePath,
|
|
158
|
+
absolutePath: directory,
|
|
159
|
+
displayName: path.basename(directory),
|
|
160
|
+
isRoot: relativePath === ".",
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
export async function discoverSources(cwd, selectedPaths = []) {
|
|
164
|
+
const discoveredRoots = selectedPaths.length > 0
|
|
165
|
+
? normalizeSelectedRoots(cwd, selectedPaths)
|
|
166
|
+
: await findGitRepositoryRoots(cwd).then((gitRoots) => gitRoots.length > 0 ? gitRoots : [cwd]);
|
|
167
|
+
const sources = await Promise.all(discoveredRoots.map((root) => inspectSource(cwd, root)));
|
|
168
|
+
sources.sort((left, right) => left.path.localeCompare(right.path));
|
|
169
|
+
return { sources, scanned: discoveredRoots };
|
|
170
|
+
}
|
|
171
|
+
export function extractLegacyRepositories(sources) {
|
|
172
|
+
return sources.filter((source) => source.kind === "git_candidate" &&
|
|
173
|
+
typeof source.repoUrl === "string" &&
|
|
174
|
+
(source.provider === "github" || source.provider === "gitlab"));
|
|
175
|
+
}
|
package/dist/repo-url.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
function normalizeGitlabBaseUrl(value) {
|
|
2
|
+
try {
|
|
3
|
+
const url = new URL(value.trim());
|
|
4
|
+
if (!["http:", "https:"].includes(url.protocol)) {
|
|
5
|
+
return null;
|
|
6
|
+
}
|
|
7
|
+
url.hash = "";
|
|
8
|
+
url.search = "";
|
|
9
|
+
url.pathname = url.pathname.replace(/\/+$/, "");
|
|
10
|
+
return url.toString();
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function normalizeGithubRepoUrl(value) {
|
|
17
|
+
const trimmed = value.trim();
|
|
18
|
+
if (!trimmed)
|
|
19
|
+
return null;
|
|
20
|
+
const sshMatch = trimmed.match(/^git@github\.com:(.+)$/i);
|
|
21
|
+
if (sshMatch?.[1]) {
|
|
22
|
+
return normalizeGithubRepoUrl(`https://github.com/${sshMatch[1]}`);
|
|
23
|
+
}
|
|
24
|
+
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
|
|
25
|
+
try {
|
|
26
|
+
const url = new URL(trimmed);
|
|
27
|
+
if (url.hostname !== "github.com") {
|
|
28
|
+
return trimmed;
|
|
29
|
+
}
|
|
30
|
+
const parts = url.pathname.replace(/^\/+/, "").split("/").filter(Boolean);
|
|
31
|
+
if (parts.length < 2)
|
|
32
|
+
return null;
|
|
33
|
+
const owner = parts[0];
|
|
34
|
+
const repo = parts[1]?.replace(/\.git$/i, "");
|
|
35
|
+
if (!owner || !repo)
|
|
36
|
+
return null;
|
|
37
|
+
return `https://github.com/${owner}/${repo}.git`;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const [owner, repo] = trimmed.split("/");
|
|
44
|
+
if (!owner || !repo)
|
|
45
|
+
return null;
|
|
46
|
+
return `https://github.com/${owner}/${repo.replace(/\.git$/i, "")}.git`;
|
|
47
|
+
}
|
|
48
|
+
export function normalizeGitlabRepoUrl(value, baseUrl) {
|
|
49
|
+
const trimmed = value.trim();
|
|
50
|
+
if (!trimmed)
|
|
51
|
+
return null;
|
|
52
|
+
const sshMatch = trimmed.match(/^git@([^:]+):(.+)$/i);
|
|
53
|
+
if (sshMatch?.[1] && sshMatch[2]) {
|
|
54
|
+
return normalizeGitlabRepoUrl(`https://${sshMatch[1]}/${sshMatch[2]}`, baseUrl);
|
|
55
|
+
}
|
|
56
|
+
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
|
|
57
|
+
try {
|
|
58
|
+
const url = new URL(trimmed);
|
|
59
|
+
const path = url.pathname.replace(/^\/+/, "");
|
|
60
|
+
if (!path)
|
|
61
|
+
return null;
|
|
62
|
+
const cleanedPath = path.replace(/\.git$/i, "").split("/-/")[0];
|
|
63
|
+
if (!cleanedPath)
|
|
64
|
+
return null;
|
|
65
|
+
const normalizedBase = (normalizeGitlabBaseUrl(url.origin) ?? url.origin).replace(/\/+$/, "");
|
|
66
|
+
return `${normalizedBase}/${cleanedPath}.git`;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const rawBase = baseUrl ? normalizeGitlabBaseUrl(baseUrl) : null;
|
|
73
|
+
if (!rawBase)
|
|
74
|
+
return null;
|
|
75
|
+
const normalizedBase = rawBase.replace(/\/+$/, "");
|
|
76
|
+
const cleanedPath = trimmed.replace(/^\/+/, "").replace(/\.git$/i, "");
|
|
77
|
+
if (!cleanedPath)
|
|
78
|
+
return null;
|
|
79
|
+
return `${normalizedBase}/${cleanedPath}.git`;
|
|
80
|
+
}
|
|
81
|
+
export function resolveGitlabBaseUrlFromRepoUrl(repoUrl) {
|
|
82
|
+
try {
|
|
83
|
+
const url = new URL(repoUrl);
|
|
84
|
+
return normalizeGitlabBaseUrl(url.origin) ?? url.origin;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
export function extractGitlabProjectPathFromRepoUrl(repoUrl) {
|
|
91
|
+
try {
|
|
92
|
+
const url = new URL(repoUrl);
|
|
93
|
+
const repoPath = url.pathname.replace(/^\/+/, "").replace(/\.git$/i, "");
|
|
94
|
+
const cleanedPath = repoPath.split("/-/")[0]?.trim();
|
|
95
|
+
return cleanedPath ? cleanedPath : null;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
export function extractCanonicalRepoMetadata(value) {
|
|
102
|
+
const githubRepoUrl = normalizeGithubRepoUrl(value);
|
|
103
|
+
if (githubRepoUrl?.startsWith("https://github.com/")) {
|
|
104
|
+
try {
|
|
105
|
+
const url = new URL(githubRepoUrl);
|
|
106
|
+
const parts = url.pathname.replace(/^\/+/, "").replace(/\.git$/i, "").split("/");
|
|
107
|
+
const owner = parts[0];
|
|
108
|
+
const repo = parts[1];
|
|
109
|
+
if (!owner || !repo)
|
|
110
|
+
return null;
|
|
111
|
+
return {
|
|
112
|
+
provider: "github",
|
|
113
|
+
repoUrl: githubRepoUrl,
|
|
114
|
+
repoName: `${owner}/${repo}`,
|
|
115
|
+
baseUrl: null,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const gitlabRepoUrl = normalizeGitlabRepoUrl(value, null);
|
|
123
|
+
if (!gitlabRepoUrl)
|
|
124
|
+
return null;
|
|
125
|
+
const projectPath = extractGitlabProjectPathFromRepoUrl(gitlabRepoUrl);
|
|
126
|
+
if (!projectPath)
|
|
127
|
+
return null;
|
|
128
|
+
return {
|
|
129
|
+
provider: "gitlab",
|
|
130
|
+
repoUrl: gitlabRepoUrl,
|
|
131
|
+
repoName: projectPath,
|
|
132
|
+
baseUrl: resolveGitlabBaseUrlFromRepoUrl(gitlabRepoUrl)?.replace(/\/+$/, "") ?? null,
|
|
133
|
+
};
|
|
134
|
+
}
|
package/dist/scan.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { ApiError } from "./api-client.js";
|
|
2
|
+
const ACTIVE_SCAN_STATUSES = new Set([
|
|
3
|
+
"created",
|
|
4
|
+
"pending",
|
|
5
|
+
"queued",
|
|
6
|
+
"running",
|
|
7
|
+
"starting",
|
|
8
|
+
"in_progress",
|
|
9
|
+
"processing",
|
|
10
|
+
]);
|
|
11
|
+
function asRecord(value) {
|
|
12
|
+
return value && typeof value === "object" ? value : null;
|
|
13
|
+
}
|
|
14
|
+
function readString(...values) {
|
|
15
|
+
for (const value of values) {
|
|
16
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
17
|
+
return value.trim();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
function readTimestamp(record, ...keys) {
|
|
23
|
+
return readString(...keys.map((key) => record[key]));
|
|
24
|
+
}
|
|
25
|
+
function readNumber(...values) {
|
|
26
|
+
for (const value of values) {
|
|
27
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
function getScanListItems(payload) {
|
|
34
|
+
if (Array.isArray(payload)) {
|
|
35
|
+
return payload;
|
|
36
|
+
}
|
|
37
|
+
const record = asRecord(payload);
|
|
38
|
+
if (!record) {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
if (Array.isArray(record.scans)) {
|
|
42
|
+
return record.scans;
|
|
43
|
+
}
|
|
44
|
+
if (Array.isArray(record.data)) {
|
|
45
|
+
return record.data;
|
|
46
|
+
}
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
function normalizeScanRecord(payload) {
|
|
50
|
+
const record = asRecord(payload);
|
|
51
|
+
if (!record) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const scanId = readString(record.scanId, record.id, record.apexScanId, record.bedrockScanId, record.kernelScanId);
|
|
55
|
+
if (!scanId) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
scanId,
|
|
60
|
+
kernelScanId: readString(record.kernelScanId, record.kernel_scan_id),
|
|
61
|
+
apexScanId: readString(record.apexScanId, record.apex_scan_id),
|
|
62
|
+
bedrockScanId: readString(record.bedrockScanId, record.bedrock_scan_id),
|
|
63
|
+
displayName: readString(record.displayName, record.display_name, record.name),
|
|
64
|
+
sequenceNumber: readNumber(record.sequenceNumber, record.sequence_number),
|
|
65
|
+
status: readString(record.status) ?? "unknown",
|
|
66
|
+
mode: readString(record.mode, record.scanType, record.scan_type),
|
|
67
|
+
scanUrl: readString(record.scanUrl, record.url, record.scan_url),
|
|
68
|
+
createdAt: readTimestamp(record, "createdAt", "created_at"),
|
|
69
|
+
startedAt: readTimestamp(record, "startedAt", "started_at"),
|
|
70
|
+
updatedAt: readTimestamp(record, "updatedAt", "updated_at"),
|
|
71
|
+
finishedAt: readTimestamp(record, "finishedAt", "finished_at", "completedAt", "completed_at", "cancelledAt", "cancelled_at"),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
async function requestWithFallbacks(client, attempts) {
|
|
75
|
+
let lastApiError = null;
|
|
76
|
+
for (const attempt of attempts) {
|
|
77
|
+
try {
|
|
78
|
+
return await client.request(attempt.path, attempt.options);
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (error instanceof ApiError && [404, 405].includes(error.status)) {
|
|
82
|
+
lastApiError = error;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (lastApiError) {
|
|
89
|
+
throw lastApiError;
|
|
90
|
+
}
|
|
91
|
+
throw new Error("No scan routes were available.");
|
|
92
|
+
}
|
|
93
|
+
export function getTrackedScanId(scan) {
|
|
94
|
+
const trackedScanId = readString(scan.apexScanId, scan.bedrockScanId, scan.scanId, scan.kernelScanId);
|
|
95
|
+
if (!trackedScanId) {
|
|
96
|
+
throw new Error("Scan response did not include an Apex scan id.");
|
|
97
|
+
}
|
|
98
|
+
return trackedScanId;
|
|
99
|
+
}
|
|
100
|
+
export function getScanDisplayId(scan) {
|
|
101
|
+
return scan.apexScanId ?? scan.bedrockScanId ?? scan.scanId;
|
|
102
|
+
}
|
|
103
|
+
export function getScanDisplayLabel(scan) {
|
|
104
|
+
const displayId = getScanDisplayId(scan);
|
|
105
|
+
return scan.displayName ? `${scan.displayName} [${displayId}]` : displayId;
|
|
106
|
+
}
|
|
107
|
+
export function normalizeWorkspaceScans(payload) {
|
|
108
|
+
return getScanListItems(payload)
|
|
109
|
+
.map((item) => normalizeScanRecord(item))
|
|
110
|
+
.filter((scan) => scan !== null)
|
|
111
|
+
.sort((left, right) => {
|
|
112
|
+
const leftTime = Date.parse(left.startedAt ?? left.createdAt ?? left.updatedAt ?? left.finishedAt ?? "") || 0;
|
|
113
|
+
const rightTime = Date.parse(right.startedAt ?? right.createdAt ?? right.updatedAt ?? right.finishedAt ?? "") || 0;
|
|
114
|
+
return rightTime - leftTime;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
export function isActiveScanStatus(status) {
|
|
118
|
+
return status ? ACTIVE_SCAN_STATUSES.has(status.trim().toLowerCase()) : false;
|
|
119
|
+
}
|
|
120
|
+
export function findMostRelevantActiveScan(scans) {
|
|
121
|
+
return scans.find((scan) => isActiveScanStatus(scan.status)) ?? null;
|
|
122
|
+
}
|
|
123
|
+
export function matchesScanId(scan, scanId) {
|
|
124
|
+
return [scan.scanId, scan.apexScanId, scan.bedrockScanId, scan.kernelScanId].includes(scanId);
|
|
125
|
+
}
|
|
126
|
+
export function selectDefaultScanToCancel(scans, lastScanId) {
|
|
127
|
+
const activeScans = scans.filter((scan) => isActiveScanStatus(scan.status));
|
|
128
|
+
if (activeScans.length === 1) {
|
|
129
|
+
return activeScans[0];
|
|
130
|
+
}
|
|
131
|
+
if (activeScans.length > 1) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
if (lastScanId) {
|
|
135
|
+
return scans.find((scan) => matchesScanId(scan, lastScanId)) ?? null;
|
|
136
|
+
}
|
|
137
|
+
return scans[0] ?? null;
|
|
138
|
+
}
|
|
139
|
+
export async function fetchWorkspaceScans(client, workspaceId) {
|
|
140
|
+
const encodedWorkspaceId = encodeURIComponent(workspaceId);
|
|
141
|
+
const payload = await requestWithFallbacks(client, [
|
|
142
|
+
{ path: `/api/cli/v1/local-workspaces/${encodedWorkspaceId}/scans` },
|
|
143
|
+
{ path: `/api/cli/v1/scans?workspaceId=${encodedWorkspaceId}` },
|
|
144
|
+
{ path: `/api/workspaces/${encodedWorkspaceId}/scans` },
|
|
145
|
+
{ path: `/api/scans?workspaceId=${encodedWorkspaceId}` },
|
|
146
|
+
]);
|
|
147
|
+
return normalizeWorkspaceScans(payload);
|
|
148
|
+
}
|
|
149
|
+
export async function cancelScan(client, scanId) {
|
|
150
|
+
const encodedScanId = encodeURIComponent(scanId);
|
|
151
|
+
const payload = await requestWithFallbacks(client, [
|
|
152
|
+
{ path: `/api/cli/v1/scans/${encodedScanId}/cancel`, options: { method: "POST" } },
|
|
153
|
+
{
|
|
154
|
+
path: `/api/cli/v1/scans/${encodedScanId}`,
|
|
155
|
+
options: { method: "POST", json: { action: "cancel" } },
|
|
156
|
+
},
|
|
157
|
+
{ path: `/api/scans/${encodedScanId}/cancel`, options: { method: "POST" } },
|
|
158
|
+
{
|
|
159
|
+
path: `/api/scans/${encodedScanId}`,
|
|
160
|
+
options: { method: "POST", json: { action: "cancel" } },
|
|
161
|
+
},
|
|
162
|
+
]);
|
|
163
|
+
const direct = normalizeScanRecord(payload);
|
|
164
|
+
if (direct) {
|
|
165
|
+
return direct;
|
|
166
|
+
}
|
|
167
|
+
const nested = normalizeScanRecord(asRecord(payload)?.scan);
|
|
168
|
+
if (nested) {
|
|
169
|
+
return nested;
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
scanId,
|
|
173
|
+
kernelScanId: null,
|
|
174
|
+
apexScanId: null,
|
|
175
|
+
bedrockScanId: null,
|
|
176
|
+
displayName: null,
|
|
177
|
+
sequenceNumber: null,
|
|
178
|
+
status: "cancelled",
|
|
179
|
+
mode: null,
|
|
180
|
+
scanUrl: null,
|
|
181
|
+
createdAt: null,
|
|
182
|
+
startedAt: null,
|
|
183
|
+
updatedAt: null,
|
|
184
|
+
finishedAt: null,
|
|
185
|
+
};
|
|
186
|
+
}
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { getFlagList, getFlagString, isJsonMode, isNonInteractive } from "./args.js";
|
|
3
|
+
import { login } from "./auth.js";
|
|
4
|
+
import { ApiError } from "./api-client.js";
|
|
5
|
+
import { openInBrowser } from "./browser.js";
|
|
6
|
+
import { loadConfig } from "./config.js";
|
|
7
|
+
import { chooseOne, confirm, promptText, waitForEnter } from "./prompt.js";
|
|
8
|
+
import { discoverSources, extractLegacyRepositories } from "./repo-discovery.js";
|
|
9
|
+
import { loadWorkspaceBinding } from "./workspace-binding.js";
|
|
10
|
+
export function createWorkspaceBinding(result, currentBinding) {
|
|
11
|
+
if (!result.resolve.workspaceId) {
|
|
12
|
+
throw new Error("Workspace resolution did not return a workspaceId.");
|
|
13
|
+
}
|
|
14
|
+
const preservedBinding = currentBinding?.workspaceId === result.resolve.workspaceId ? currentBinding : null;
|
|
15
|
+
return {
|
|
16
|
+
version: 1,
|
|
17
|
+
companyId: result.company.id,
|
|
18
|
+
workspaceId: result.resolve.workspaceId,
|
|
19
|
+
workspaceName: result.workspaceName,
|
|
20
|
+
createdAt: preservedBinding?.createdAt ?? new Date().toISOString(),
|
|
21
|
+
lastSyncedAt: new Date().toISOString(),
|
|
22
|
+
lastScanId: preservedBinding?.lastScanId ?? null,
|
|
23
|
+
lastScanUrl: preservedBinding?.lastScanUrl ?? null,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export async function chooseCompany(me, flags, binding) {
|
|
27
|
+
const requested = getFlagString(flags, "company");
|
|
28
|
+
const config = await loadConfig();
|
|
29
|
+
const defaultRef = requested ?? binding?.companyId ?? config.defaultCompanyId ?? null;
|
|
30
|
+
if (defaultRef) {
|
|
31
|
+
const matched = me.companies.find((company) => company.id === defaultRef ||
|
|
32
|
+
(company.handle ?? "").toLowerCase() === defaultRef.toLowerCase());
|
|
33
|
+
if (matched) {
|
|
34
|
+
return matched;
|
|
35
|
+
}
|
|
36
|
+
if (requested) {
|
|
37
|
+
throw new Error(`Unknown company: ${requested}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (me.companies.length === 1) {
|
|
41
|
+
return me.companies[0];
|
|
42
|
+
}
|
|
43
|
+
if (isNonInteractive(flags)) {
|
|
44
|
+
throw new Error("Multiple companies available. Re-run with --company <id-or-handle>.");
|
|
45
|
+
}
|
|
46
|
+
const selected = await chooseOne("Select the Apex company to use for this directory:", me.companies.map((company) => ({
|
|
47
|
+
label: company.handle
|
|
48
|
+
? `${company.name ?? company.id} (${company.handle})`
|
|
49
|
+
: (company.name ?? company.id),
|
|
50
|
+
company,
|
|
51
|
+
})));
|
|
52
|
+
return selected.company;
|
|
53
|
+
}
|
|
54
|
+
async function chooseWorkspaceName(cwd, flags, binding) {
|
|
55
|
+
const explicit = getFlagString(flags, "workspace-name");
|
|
56
|
+
if (explicit) {
|
|
57
|
+
return explicit;
|
|
58
|
+
}
|
|
59
|
+
if (binding?.workspaceName) {
|
|
60
|
+
return binding.workspaceName;
|
|
61
|
+
}
|
|
62
|
+
const suggested = path.basename(cwd);
|
|
63
|
+
if (isNonInteractive(flags)) {
|
|
64
|
+
return suggested;
|
|
65
|
+
}
|
|
66
|
+
const chosen = await promptText("Workspace name to use in Apex for this directory (press Enter to use the current folder name)", suggested);
|
|
67
|
+
return chosen.trim().length > 0 ? chosen.trim() : suggested;
|
|
68
|
+
}
|
|
69
|
+
function getSourceMode(flags) {
|
|
70
|
+
const value = getFlagString(flags, "source-mode");
|
|
71
|
+
return value === "remote" || value === "local" ? value : "auto";
|
|
72
|
+
}
|
|
73
|
+
export async function ensureAuthenticated(client, flags) {
|
|
74
|
+
return login(client, {
|
|
75
|
+
noOpen: flags["no-open"] === true,
|
|
76
|
+
quiet: isJsonMode(flags),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
export async function resolveWorkspace(client, cwd, me, flags) {
|
|
80
|
+
const selection = await selectWorkspaceTarget(cwd, me, flags);
|
|
81
|
+
return resolveWorkspaceSelection(client, selection);
|
|
82
|
+
}
|
|
83
|
+
export async function selectWorkspaceTarget(cwd, me, flags) {
|
|
84
|
+
const binding = await loadWorkspaceBinding(cwd);
|
|
85
|
+
const company = await chooseCompany(me, flags, binding);
|
|
86
|
+
const workspaceName = await chooseWorkspaceName(cwd, flags, binding);
|
|
87
|
+
const sourceSelection = getFlagList(flags, "repo");
|
|
88
|
+
const discovered = await discoverSources(cwd, sourceSelection);
|
|
89
|
+
if (discovered.sources.length === 0) {
|
|
90
|
+
throw new Error("No local sources were found.");
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
company,
|
|
94
|
+
workspaceName,
|
|
95
|
+
binding,
|
|
96
|
+
sources: discovered.sources,
|
|
97
|
+
legacyRepositories: extractLegacyRepositories(discovered.sources),
|
|
98
|
+
sourceMode: getSourceMode(flags),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
export async function resolveWorkspaceSelection(client, selection) {
|
|
102
|
+
const resolve = await client.request("/api/cli/v2/local-workspaces/resolve", {
|
|
103
|
+
method: "POST",
|
|
104
|
+
json: {
|
|
105
|
+
companyId: selection.company.id,
|
|
106
|
+
workspaceId: selection.binding?.workspaceId ?? null,
|
|
107
|
+
workspaceName: selection.workspaceName,
|
|
108
|
+
sourceMode: selection.sourceMode,
|
|
109
|
+
sources: selection.sources.map((source) => source.kind === "git_candidate"
|
|
110
|
+
? {
|
|
111
|
+
path: source.path,
|
|
112
|
+
displayName: source.displayName,
|
|
113
|
+
kind: "git_candidate",
|
|
114
|
+
repoUrl: source.repoUrl,
|
|
115
|
+
provider: source.provider,
|
|
116
|
+
branch: source.branch,
|
|
117
|
+
commitSha: source.commitSha,
|
|
118
|
+
dirty: source.dirty,
|
|
119
|
+
}
|
|
120
|
+
: {
|
|
121
|
+
path: source.path,
|
|
122
|
+
displayName: source.displayName,
|
|
123
|
+
kind: "directory_candidate",
|
|
124
|
+
}),
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
return {
|
|
128
|
+
company: selection.company,
|
|
129
|
+
workspaceName: selection.workspaceName,
|
|
130
|
+
binding: selection.binding,
|
|
131
|
+
sources: selection.sources,
|
|
132
|
+
legacyRepositories: selection.legacyRepositories,
|
|
133
|
+
resolve,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
export async function resolveWorkspaceSelectionLegacy(client, selection, workspaceIdOverride) {
|
|
137
|
+
if (selection.legacyRepositories.length !== selection.sources.length) {
|
|
138
|
+
throw new Error("This scan requires explicit local-source handling and cannot use the legacy remote-only flow.");
|
|
139
|
+
}
|
|
140
|
+
return client.request("/api/cli/v1/local-workspaces/resolve", {
|
|
141
|
+
method: "POST",
|
|
142
|
+
json: {
|
|
143
|
+
companyId: selection.company.id,
|
|
144
|
+
workspaceId: workspaceIdOverride ?? selection.binding?.workspaceId ?? null,
|
|
145
|
+
workspaceName: selection.workspaceName,
|
|
146
|
+
dryRun: false,
|
|
147
|
+
repositories: selection.legacyRepositories.map((repository) => ({
|
|
148
|
+
path: repository.path,
|
|
149
|
+
repoUrl: repository.repoUrl,
|
|
150
|
+
provider: repository.provider,
|
|
151
|
+
branch: repository.branch,
|
|
152
|
+
commitSha: repository.commitSha,
|
|
153
|
+
sourceType: repository.sourceType,
|
|
154
|
+
})),
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
export async function resolveWorkspaceAllowingMissingConnections(client, cwd, me, flags) {
|
|
159
|
+
const selection = await selectWorkspaceTarget(cwd, me, flags);
|
|
160
|
+
try {
|
|
161
|
+
return await resolveWorkspaceSelection(client, selection);
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
if (error instanceof ApiError && error.status === 409) {
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
export async function remediateMissingConnections(missing, flags) {
|
|
171
|
+
if (missing.length === 0) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (isNonInteractive(flags)) {
|
|
175
|
+
throw new Error("Missing provider connections. Re-run interactively or pre-connect providers.");
|
|
176
|
+
}
|
|
177
|
+
for (const item of missing) {
|
|
178
|
+
if (!item.connectUrl) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const shouldOpen = await confirm(`Open the browser flow to connect ${item.provider} for ${item.repoUrl}?`);
|
|
182
|
+
if (!shouldOpen) {
|
|
183
|
+
throw new Error("Provider connection remediation was cancelled.");
|
|
184
|
+
}
|
|
185
|
+
await openInBrowser(item.connectUrl);
|
|
186
|
+
}
|
|
187
|
+
await waitForEnter("Finish the provider connection flow in your browser, then press Enter to retry.");
|
|
188
|
+
}
|