@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/dist/mcp.js ADDED
@@ -0,0 +1,487 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import path from "node:path";
4
+ import { z } from "zod";
5
+ import { getMe, logout, startDeviceLogin, waitForDeviceLoginApproval } from "./auth.js";
6
+ import { ApexApiClient, formatApiError } from "./api-client.js";
7
+ import { commandCancelScan, commandConnect, commandCredits, commandDoctor, commandExportFindings, commandFindings, commandScan, commandScans, commandStatus, commandWorkspace, commandWorkspaceUse, commandWorkspaces, } from "./commands.js";
8
+ import { CLI_HELP_TEXT } from "./help.js";
9
+ import { APEX_CLI_VERSION } from "./version.js";
10
+ const MCP_WORKFLOW_GUIDE = `Use Apex through these tools instead of shelling out to the CLI.
11
+
12
+ Suggested workflow:
13
+ 1. Call apex-auth-status.
14
+ 2. If unauthenticated, call apex-auth-start and ask the user to approve the device login URL, then call apex-auth-wait.
15
+ 3. Call apex-doctor for the target repository cwd.
16
+ 4. If the directory is not bound to the right workspace, call apex-workspaces and apex-workspace-use.
17
+ 5. Start and monitor scans with apex-scan, apex-status, apex-scans, apex-findings, and apex-export-findings.
18
+
19
+ Notes:
20
+ - Pass cwd explicitly for repository-specific operations.
21
+ - Tool calls are always non-interactive and never auto-open a browser.
22
+ - Doctor reports whether Apex will use remote materialization or a local snapshot upload for each source.`;
23
+ const WORKSPACE_BINDING_ERROR = "This directory is not bound to an Apex workspace yet. Run `apex workspaces` to list workspace names, prefixes, and IDs, then bind one with `apex workspace use \"<workspace name>\"`, or run `apex scan` to create or resolve one automatically.";
24
+ const MISSING_PROVIDER_CONNECTIONS_ERROR = "Missing provider connections. Re-run interactively or pre-connect providers.";
25
+ const MULTIPLE_ACTIVE_SCANS_ERROR = "Multiple active scans are running. Run `apex scans` to list scan IDs, then re-run `apex cancel-scan <scan-id>`.";
26
+ const DUPLICATE_SCAN_FORCE_GUIDANCE = "Re-run with --force to start another scan.";
27
+ const MULTIPLE_COMPANIES_ERROR = "Multiple companies available. Re-run with --company <id-or-handle>.";
28
+ function resolveCwd(cwd) {
29
+ return path.resolve(cwd && cwd.trim().length > 0 ? cwd : process.cwd());
30
+ }
31
+ function buildFlags(options) {
32
+ const flags = {
33
+ "non-interactive": true,
34
+ "no-open": true,
35
+ };
36
+ if (options.company) {
37
+ flags.company = options.company;
38
+ }
39
+ if (options.workspaceName) {
40
+ flags["workspace-name"] = options.workspaceName;
41
+ }
42
+ if (options.scanId) {
43
+ flags.scan = options.scanId;
44
+ }
45
+ if (options.format) {
46
+ flags.format = options.format;
47
+ }
48
+ if (options.output) {
49
+ flags.output = options.output;
50
+ }
51
+ if (options.mode) {
52
+ flags.mode = options.mode;
53
+ }
54
+ if (options.sourceMode) {
55
+ flags["source-mode"] = options.sourceMode;
56
+ }
57
+ if (options.force === true) {
58
+ flags.force = true;
59
+ }
60
+ if (options.repoPaths && options.repoPaths.length > 0) {
61
+ flags.repo = options.repoPaths;
62
+ }
63
+ if (typeof options.limit === "number") {
64
+ flags.limit = String(options.limit);
65
+ }
66
+ return flags;
67
+ }
68
+ function formatToolText(summary, value) {
69
+ return `${summary}\n\n${JSON.stringify(value, null, 2)}`;
70
+ }
71
+ function successResult(summary, value) {
72
+ return {
73
+ content: [
74
+ {
75
+ type: "text",
76
+ text: formatToolText(summary, value),
77
+ },
78
+ ],
79
+ structuredContent: value,
80
+ };
81
+ }
82
+ function formatMcpErrorMessage(toolName, error) {
83
+ const message = formatApiError(error);
84
+ if (message === WORKSPACE_BINDING_ERROR) {
85
+ return "No workspace is bound to this directory. Call `apex-workspace-use` to bind an existing workspace, or call `apex-scan` to create or resolve one first.";
86
+ }
87
+ if (message === MISSING_PROVIDER_CONNECTIONS_ERROR) {
88
+ return "Missing provider connections. Call `apex-connect-provider` for each missing provider, complete the browser flow, then retry `apex-scan`.";
89
+ }
90
+ if (message === MULTIPLE_COMPANIES_ERROR) {
91
+ return "Multiple companies are available. Retry this tool with the `company` input set to the desired company handle or ID.";
92
+ }
93
+ if (toolName === "apex-cancel-scan" &&
94
+ message === MULTIPLE_ACTIVE_SCANS_ERROR) {
95
+ return "Multiple active scans are running. Call `apex-scans` to list active scan IDs, then retry `apex-cancel-scan` with the `scanId` input.";
96
+ }
97
+ if (toolName === "apex-scan" &&
98
+ message.includes(DUPLICATE_SCAN_FORCE_GUIDANCE)) {
99
+ return message.replace(DUPLICATE_SCAN_FORCE_GUIDANCE, "Retry `apex-scan` with `force: true` to start another scan.");
100
+ }
101
+ return message;
102
+ }
103
+ function errorResult(toolName, error) {
104
+ const message = formatMcpErrorMessage(toolName, error);
105
+ return {
106
+ content: [
107
+ {
108
+ type: "text",
109
+ text: message,
110
+ },
111
+ ],
112
+ structuredContent: {
113
+ ok: false,
114
+ error: message,
115
+ },
116
+ isError: true,
117
+ };
118
+ }
119
+ async function runTool(toolName, action, summary) {
120
+ try {
121
+ const value = await action();
122
+ return successResult(summary(value), value);
123
+ }
124
+ catch (error) {
125
+ return errorResult(toolName, error);
126
+ }
127
+ }
128
+ async function requireAuthenticated(client) {
129
+ const me = await getMe(client);
130
+ if (!me) {
131
+ throw new Error("Not authenticated. Call `apex-auth-start`, ask the user to approve the device login, then call `apex-auth-wait`.");
132
+ }
133
+ return me;
134
+ }
135
+ function registerResources(server) {
136
+ server.registerResource("apex-cli-help", "apex://help/cli", {
137
+ title: "Apex CLI Help",
138
+ description: "Command reference for the Apex CLI.",
139
+ mimeType: "text/plain",
140
+ }, async (uri) => ({
141
+ contents: [
142
+ {
143
+ uri: uri.href,
144
+ text: CLI_HELP_TEXT,
145
+ },
146
+ ],
147
+ }));
148
+ server.registerResource("apex-mcp-workflow", "apex://help/mcp-workflow", {
149
+ title: "Apex MCP Workflow",
150
+ description: "Recommended workflow for driving Apex from an LLM via MCP tools.",
151
+ mimeType: "text/plain",
152
+ }, async (uri) => ({
153
+ contents: [
154
+ {
155
+ uri: uri.href,
156
+ text: MCP_WORKFLOW_GUIDE,
157
+ },
158
+ ],
159
+ }));
160
+ }
161
+ function registerTools(server) {
162
+ server.registerTool("apex-auth-status", {
163
+ title: "Apex Auth Status",
164
+ description: "Check whether Apex is already authenticated on this machine.",
165
+ }, async () => runTool("apex-auth-status", async () => {
166
+ const client = new ApexApiClient();
167
+ const me = await getMe(client);
168
+ const baseUrl = await client.getBaseUrl();
169
+ return {
170
+ authenticated: Boolean(me),
171
+ baseUrl,
172
+ me,
173
+ };
174
+ }, (value) => (value.authenticated ? "Apex is authenticated." : "Apex is not authenticated.")));
175
+ server.registerTool("apex-auth-start", {
176
+ title: "Start Apex Device Login",
177
+ description: "Start the Apex device login flow and return the verification URL and user code. This tool never opens a browser.",
178
+ }, async () => runTool("apex-auth-start", async () => {
179
+ const client = new ApexApiClient();
180
+ const login = await startDeviceLogin(client);
181
+ return {
182
+ authenticated: false,
183
+ ...login,
184
+ };
185
+ }, () => "Device login started. Ask the user to open the verification URL and enter the code."));
186
+ server.registerTool("apex-auth-wait", {
187
+ title: "Wait For Apex Login Approval",
188
+ description: "Poll a previously started Apex device login until it is approved, still pending, or expired.",
189
+ inputSchema: {
190
+ deviceCode: z.string().min(1),
191
+ intervalSeconds: z.number().int().nonnegative().optional(),
192
+ expiresAt: z.string().optional(),
193
+ timeoutSeconds: z.number().int().positive().max(1800).optional(),
194
+ },
195
+ }, async ({ deviceCode, intervalSeconds, expiresAt, timeoutSeconds }) => runTool("apex-auth-wait", async () => {
196
+ const client = new ApexApiClient();
197
+ const result = await waitForDeviceLoginApproval(client, {
198
+ deviceCode,
199
+ intervalSeconds,
200
+ expiresAt: expiresAt ?? null,
201
+ timeoutMs: timeoutSeconds ? timeoutSeconds * 1000 : undefined,
202
+ });
203
+ return {
204
+ authenticated: result.status === "approved",
205
+ ...result,
206
+ };
207
+ }, (value) => {
208
+ if (value.status === "approved") {
209
+ return "Device login approved.";
210
+ }
211
+ if (value.status === "expired") {
212
+ return "Device login expired before approval.";
213
+ }
214
+ return "Device login is still pending.";
215
+ }));
216
+ server.registerTool("apex-logout", {
217
+ title: "Log Out Of Apex",
218
+ description: "Clear the locally stored Apex session.",
219
+ }, async () => runTool("apex-logout", async () => {
220
+ const client = new ApexApiClient();
221
+ await logout(client);
222
+ return { ok: true };
223
+ }, () => "Logged out of Apex."));
224
+ server.registerTool("apex-doctor", {
225
+ title: "Run Apex Doctor",
226
+ description: "Validate auth, local source detection, workspace binding, and the planned remote-vs-local materialization strategy.",
227
+ inputSchema: {
228
+ cwd: z.string().optional(),
229
+ company: z.string().optional(),
230
+ workspaceName: z.string().optional(),
231
+ repoPaths: z.array(z.string()).optional(),
232
+ sourceMode: z.enum(["auto", "remote", "local"]).optional(),
233
+ },
234
+ }, async ({ cwd, company, workspaceName, repoPaths, sourceMode }) => runTool("apex-doctor", async () => {
235
+ const client = new ApexApiClient();
236
+ await requireAuthenticated(client);
237
+ const targetCwd = resolveCwd(cwd);
238
+ const payload = await commandDoctor(client, targetCwd, buildFlags({
239
+ company,
240
+ workspaceName,
241
+ repoPaths,
242
+ sourceMode,
243
+ }));
244
+ return {
245
+ cwd: targetCwd,
246
+ ...payload,
247
+ };
248
+ }, (value) => `Apex doctor completed for ${String(value.cwd)}.`));
249
+ server.registerTool("apex-credits", {
250
+ title: "Get Apex Credits",
251
+ description: "Show scan credits for the active or selected company.",
252
+ inputSchema: {
253
+ cwd: z.string().optional(),
254
+ company: z.string().optional(),
255
+ },
256
+ }, async ({ cwd, company }) => runTool("apex-credits", async () => {
257
+ const client = new ApexApiClient();
258
+ await requireAuthenticated(client);
259
+ const targetCwd = resolveCwd(cwd);
260
+ const payload = await commandCredits(client, targetCwd, buildFlags({
261
+ company,
262
+ }));
263
+ return {
264
+ cwd: targetCwd,
265
+ ...payload,
266
+ };
267
+ }, (value) => `Fetched Apex credits for ${String(value.cwd)}.`));
268
+ server.registerTool("apex-workspace", {
269
+ title: "Get Current Apex Workspace Binding",
270
+ description: "Show the current Apex workspace bound to a directory, if any.",
271
+ inputSchema: {
272
+ cwd: z.string().optional(),
273
+ },
274
+ }, async ({ cwd }) => runTool("apex-workspace", async () => {
275
+ const targetCwd = resolveCwd(cwd);
276
+ const payload = await commandWorkspace(targetCwd, buildFlags({}));
277
+ return {
278
+ cwd: targetCwd,
279
+ ...payload,
280
+ };
281
+ }, (value) => `Loaded workspace binding for ${String(value.cwd)}.`));
282
+ server.registerTool("apex-workspaces", {
283
+ title: "List Apex Workspaces",
284
+ description: "List workspaces available to the active or selected company.",
285
+ inputSchema: {
286
+ cwd: z.string().optional(),
287
+ company: z.string().optional(),
288
+ },
289
+ }, async ({ cwd, company }) => runTool("apex-workspaces", async () => {
290
+ const client = new ApexApiClient();
291
+ await requireAuthenticated(client);
292
+ const targetCwd = resolveCwd(cwd);
293
+ const payload = await commandWorkspaces(client, targetCwd, buildFlags({
294
+ company,
295
+ }));
296
+ return {
297
+ cwd: targetCwd,
298
+ ...payload,
299
+ };
300
+ }, (value) => `Listed Apex workspaces for ${String(value.cwd)}.`));
301
+ server.registerTool("apex-workspace-use", {
302
+ title: "Bind Directory To Apex Workspace",
303
+ description: "Bind a directory to an existing Apex workspace by id, prefix, or name.",
304
+ inputSchema: {
305
+ cwd: z.string().optional(),
306
+ company: z.string().optional(),
307
+ workspaceRef: z.string().min(1),
308
+ },
309
+ }, async ({ cwd, company, workspaceRef }) => runTool("apex-workspace-use", async () => {
310
+ const client = new ApexApiClient();
311
+ await requireAuthenticated(client);
312
+ const targetCwd = resolveCwd(cwd);
313
+ const payload = await commandWorkspaceUse(client, targetCwd, buildFlags({
314
+ company,
315
+ }), workspaceRef);
316
+ return {
317
+ cwd: targetCwd,
318
+ ...payload,
319
+ };
320
+ }, (value) => `Bound ${String(value.cwd)} to an Apex workspace.`));
321
+ server.registerTool("apex-scan", {
322
+ title: "Start Apex Scan",
323
+ description: "Start a new Apex scan for any local repository or directory. Pass cwd explicitly for reliable workspace resolution.",
324
+ inputSchema: {
325
+ cwd: z.string().optional(),
326
+ company: z.string().optional(),
327
+ workspaceName: z.string().optional(),
328
+ repoPaths: z.array(z.string()).optional(),
329
+ mode: z.enum(["standard", "ultra"]).optional(),
330
+ sourceMode: z.enum(["auto", "remote", "local"]).optional(),
331
+ force: z.boolean().optional(),
332
+ },
333
+ }, async ({ cwd, company, workspaceName, repoPaths, mode, sourceMode, force }) => runTool("apex-scan", async () => {
334
+ const client = new ApexApiClient();
335
+ await requireAuthenticated(client);
336
+ const targetCwd = resolveCwd(cwd);
337
+ const payload = await commandScan(client, targetCwd, buildFlags({
338
+ company,
339
+ workspaceName,
340
+ repoPaths,
341
+ mode,
342
+ sourceMode,
343
+ force,
344
+ }));
345
+ return {
346
+ cwd: targetCwd,
347
+ ...payload,
348
+ };
349
+ }, (value) => `Started an Apex scan for ${String(value.cwd)}.`));
350
+ server.registerTool("apex-status", {
351
+ title: "Get Apex Scan Status",
352
+ description: "Show progress for the most recent Apex scan in a directory.",
353
+ inputSchema: {
354
+ cwd: z.string().optional(),
355
+ },
356
+ }, async ({ cwd }) => runTool("apex-status", async () => {
357
+ const client = new ApexApiClient();
358
+ await requireAuthenticated(client);
359
+ const targetCwd = resolveCwd(cwd);
360
+ const payload = await commandStatus(client, targetCwd, buildFlags({}));
361
+ return {
362
+ cwd: targetCwd,
363
+ ...payload,
364
+ };
365
+ }, (value) => `Fetched scan status for ${String(value.cwd)}.`));
366
+ server.registerTool("apex-scans", {
367
+ title: "List Apex Scans",
368
+ description: "List scans for the Apex workspace bound to a directory.",
369
+ inputSchema: {
370
+ cwd: z.string().optional(),
371
+ },
372
+ }, async ({ cwd }) => runTool("apex-scans", async () => {
373
+ const client = new ApexApiClient();
374
+ await requireAuthenticated(client);
375
+ const targetCwd = resolveCwd(cwd);
376
+ const payload = await commandScans(client, targetCwd, buildFlags({}));
377
+ return {
378
+ cwd: targetCwd,
379
+ ...payload,
380
+ };
381
+ }, (value) => `Listed scans for ${String(value.cwd)}.`));
382
+ server.registerTool("apex-cancel-scan", {
383
+ title: "Cancel Apex Scan",
384
+ description: "Cancel a running Apex scan in the bound workspace.",
385
+ inputSchema: {
386
+ cwd: z.string().optional(),
387
+ scanId: z.string().optional(),
388
+ },
389
+ }, async ({ cwd, scanId }) => runTool("apex-cancel-scan", async () => {
390
+ const client = new ApexApiClient();
391
+ await requireAuthenticated(client);
392
+ const targetCwd = resolveCwd(cwd);
393
+ const payload = await commandCancelScan(client, targetCwd, buildFlags({}), scanId);
394
+ return {
395
+ cwd: targetCwd,
396
+ ...payload,
397
+ };
398
+ }, (value) => `Cancelled the selected Apex scan for ${String(value.cwd)}.`));
399
+ server.registerTool("apex-findings", {
400
+ title: "List Apex Findings",
401
+ description: "List findings for the latest or selected Apex scan in a directory.",
402
+ inputSchema: {
403
+ cwd: z.string().optional(),
404
+ scanId: z.string().optional(),
405
+ limit: z.number().int().positive().optional(),
406
+ },
407
+ }, async ({ cwd, scanId, limit }) => runTool("apex-findings", async () => {
408
+ const client = new ApexApiClient();
409
+ await requireAuthenticated(client);
410
+ const targetCwd = resolveCwd(cwd);
411
+ const payload = await commandFindings(client, targetCwd, buildFlags({
412
+ scanId,
413
+ limit,
414
+ }));
415
+ return {
416
+ cwd: targetCwd,
417
+ ...payload,
418
+ };
419
+ }, (value) => `Fetched Apex findings for ${String(value.cwd)}.`));
420
+ server.registerTool("apex-export-findings", {
421
+ title: "Export Apex Findings",
422
+ description: "Export findings for the latest or selected Apex scan to a file on disk.",
423
+ inputSchema: {
424
+ cwd: z.string().optional(),
425
+ scanId: z.string().optional(),
426
+ format: z.enum(["markdown", "json", "gitlab-sast"]).optional(),
427
+ output: z.string().optional(),
428
+ },
429
+ }, async ({ cwd, scanId, format, output }) => runTool("apex-export-findings", async () => {
430
+ const client = new ApexApiClient();
431
+ await requireAuthenticated(client);
432
+ const targetCwd = resolveCwd(cwd);
433
+ const payload = await commandExportFindings(client, targetCwd, buildFlags({
434
+ scanId,
435
+ format,
436
+ output,
437
+ }));
438
+ return {
439
+ cwd: targetCwd,
440
+ format: payload.format,
441
+ fileName: payload.fileName,
442
+ contentType: payload.contentType,
443
+ findingCount: payload.findingCount,
444
+ workspace: payload.workspace,
445
+ scan: payload.scan,
446
+ outputPath: payload.outputPath,
447
+ };
448
+ }, (value) => `Exported Apex findings for ${String(value.cwd)}.`));
449
+ server.registerTool("apex-connect-provider", {
450
+ title: "Get Apex Provider Connection URL",
451
+ description: "Return the browser URL a user needs to connect GitHub or GitLab access for Apex. This tool never opens a browser.",
452
+ inputSchema: {
453
+ cwd: z.string().optional(),
454
+ company: z.string().optional(),
455
+ provider: z.enum(["github", "gitlab"]),
456
+ },
457
+ }, async ({ cwd, company, provider }) => runTool("apex-connect-provider", async () => {
458
+ const client = new ApexApiClient();
459
+ await requireAuthenticated(client);
460
+ const targetCwd = resolveCwd(cwd);
461
+ const payload = await commandConnect(client, targetCwd, buildFlags({
462
+ company,
463
+ }), provider);
464
+ return {
465
+ cwd: targetCwd,
466
+ ...payload,
467
+ };
468
+ }, (value) => `Fetched the ${String(value.provider)} connection URL.`));
469
+ }
470
+ export async function runMcpServer() {
471
+ const server = new McpServer({
472
+ name: "apex-cli",
473
+ version: APEX_CLI_VERSION,
474
+ });
475
+ registerResources(server);
476
+ registerTools(server);
477
+ const transport = new StdioServerTransport();
478
+ await server.connect(transport);
479
+ }
480
+ export const testing = {
481
+ buildFlags,
482
+ formatMcpErrorMessage,
483
+ registerTools,
484
+ requireAuthenticated,
485
+ resolveCwd,
486
+ runTool,
487
+ };
package/dist/output.js ADDED
@@ -0,0 +1,93 @@
1
+ import { isJsonMode, isNonInteractive } from "./args.js";
2
+ export function printJson(value) {
3
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
4
+ }
5
+ export function logLine(message, flags) {
6
+ if (!isJsonMode(flags)) {
7
+ process.stderr.write(`${message}\n`);
8
+ }
9
+ }
10
+ function shouldShowLoadingIndicator(flags) {
11
+ return !isJsonMode(flags) && !isNonInteractive(flags) && process.stderr.isTTY === true;
12
+ }
13
+ export async function withLoadingIndicator(label, flags, action) {
14
+ if (!shouldShowLoadingIndicator(flags)) {
15
+ return action();
16
+ }
17
+ const frames = ["-", "\\", "|", "/"];
18
+ let frameIndex = 0;
19
+ const render = () => {
20
+ process.stderr.write(`\r\x1b[2K${frames[frameIndex % frames.length]} ${label}`);
21
+ frameIndex += 1;
22
+ };
23
+ render();
24
+ const timer = setInterval(render, 80);
25
+ timer.unref?.();
26
+ try {
27
+ return await action();
28
+ }
29
+ finally {
30
+ clearInterval(timer);
31
+ process.stderr.write("\r\x1b[2K");
32
+ }
33
+ }
34
+ function getShortSourceName(source) {
35
+ if (source.kind === "git_candidate" && source.repoUrl) {
36
+ return source.repoUrl
37
+ .replace(/^https?:\/\/[^/]+\//, "")
38
+ .replace(/\.git$/, "");
39
+ }
40
+ return source.displayName;
41
+ }
42
+ function getSourceRefLabel(source) {
43
+ if (source.kind !== "git_candidate") {
44
+ return "directory";
45
+ }
46
+ if (source.branch) {
47
+ return source.branch;
48
+ }
49
+ if (source.commitSha) {
50
+ return source.commitSha.slice(0, 7);
51
+ }
52
+ return source.repoUrl ? "detached" : "local snapshot";
53
+ }
54
+ export function printSourceList(sources) {
55
+ process.stdout.write("Sources:\n");
56
+ sources.forEach((source) => {
57
+ if (source.kind === "directory_candidate") {
58
+ process.stdout.write(` - ${source.displayName} (directory)\n`);
59
+ return;
60
+ }
61
+ const providerLabel = source.provider ?? "git";
62
+ const dirtySuffix = source.dirty ? ", dirty" : "";
63
+ process.stdout.write(` - ${getShortSourceName(source)} (${providerLabel}, ${getSourceRefLabel(source)}${dirtySuffix})\n`);
64
+ });
65
+ process.stdout.write("\n");
66
+ }
67
+ export function printCompanyDetails(session) {
68
+ process.stdout.write(`Company: ${session.company.name ?? session.company.id}\n`);
69
+ if (session.me.companies.length > 1) {
70
+ process.stdout.write("Available:\n");
71
+ session.me.companies.forEach((company) => {
72
+ const label = company.handle
73
+ ? `${company.name ?? company.id} (${company.handle})`
74
+ : (company.name ?? company.id);
75
+ process.stdout.write(` - ${label}\n`);
76
+ });
77
+ process.stdout.write("Use /company <id|handle> to switch.\n");
78
+ }
79
+ process.stdout.write("\n");
80
+ }
81
+ export function printWorkspaceDetails(session) {
82
+ process.stdout.write(`This directory is bound to workspace: ${session.workspaceName}\n`);
83
+ process.stdout.write(`Workspace ID: ${session.binding.workspaceId}\n\n`);
84
+ }
85
+ export function printInteractiveSessionSummary(session) {
86
+ process.stdout.write("Apex CLI\n");
87
+ process.stdout.write(`Connected to ${session.baseUrl}\n`);
88
+ process.stdout.write(`Signed in as ${session.me.user.username ?? session.me.user.id}\n`);
89
+ process.stdout.write(`Company: ${session.company.name ?? session.company.handle ?? session.company.id}\n`);
90
+ process.stdout.write(`Workspace: ${session.workspaceName}\n`);
91
+ printSourceList(session.sources);
92
+ process.stdout.write("Type /scan to start a scan for this directory, /workspaces to browse workspace names, /workspace use \"<name>\" to switch, press Tab to autocomplete, /help for commands.\n\n");
93
+ }
package/dist/prompt.js ADDED
@@ -0,0 +1,80 @@
1
+ import readline from "node:readline/promises";
2
+ import { stdin as input, stdout as output } from "node:process";
3
+ function assertInteractive() {
4
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
5
+ throw new Error("Interactive input is required. Re-run without --non-interactive.");
6
+ }
7
+ }
8
+ export async function readLine(message, options = {}) {
9
+ assertInteractive();
10
+ const rl = readline.createInterface({
11
+ input,
12
+ output,
13
+ completer: options.completer
14
+ ? (line) => [options.completer?.(line) ?? [], line]
15
+ : undefined,
16
+ });
17
+ try {
18
+ return await rl.question(message);
19
+ }
20
+ finally {
21
+ rl.close();
22
+ }
23
+ }
24
+ export async function promptText(message, defaultValue = null) {
25
+ const suffix = defaultValue && defaultValue.length > 0 ? ` [${defaultValue}]` : "";
26
+ const answer = (await readLine(`${message}${suffix} `)).trim();
27
+ if (answer.length > 0) {
28
+ return answer;
29
+ }
30
+ return defaultValue ?? "";
31
+ }
32
+ export async function confirm(message, defaultValue = true) {
33
+ assertInteractive();
34
+ const rl = readline.createInterface({ input, output });
35
+ const suffix = defaultValue ? "[Y/n]" : "[y/N]";
36
+ try {
37
+ const answer = (await rl.question(`${message} ${suffix} `)).trim().toLowerCase();
38
+ if (!answer)
39
+ return defaultValue;
40
+ return answer === "y" || answer === "yes";
41
+ }
42
+ finally {
43
+ rl.close();
44
+ }
45
+ }
46
+ export async function chooseOne(message, options) {
47
+ assertInteractive();
48
+ if (options.length === 0) {
49
+ throw new Error("No options available");
50
+ }
51
+ const rl = readline.createInterface({ input, output });
52
+ try {
53
+ output.write(`${message}\n`);
54
+ options.forEach((option, index) => {
55
+ output.write(` ${index + 1}. ${option.label}\n`);
56
+ });
57
+ output.write("Enter the number to continue.\n");
58
+ while (true) {
59
+ const answer = await rl.question("> ");
60
+ const selected = Number.parseInt(answer.trim(), 10);
61
+ if (Number.isInteger(selected) && selected >= 1 && selected <= options.length) {
62
+ return options[selected - 1];
63
+ }
64
+ output.write(`Please enter a number between 1 and ${options.length}.\n`);
65
+ }
66
+ }
67
+ finally {
68
+ rl.close();
69
+ }
70
+ }
71
+ export async function waitForEnter(message) {
72
+ assertInteractive();
73
+ const rl = readline.createInterface({ input, output });
74
+ try {
75
+ await rl.question(`${message}\n`);
76
+ }
77
+ finally {
78
+ rl.close();
79
+ }
80
+ }