@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,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: apex-cli
|
|
3
|
+
description: Use when a user wants to run Apex scans, inspect findings, export results, bind workspaces, or troubleshoot Apex auth and provider setup. Prefer the Apex MCP tools when they are available, and fall back to the local Apex CLI only when MCP is unavailable.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Apex CLI
|
|
7
|
+
|
|
8
|
+
This skill is bundled with Apex CLI and can be installed into the current repository with `apex setup claude`.
|
|
9
|
+
|
|
10
|
+
## Instructions
|
|
11
|
+
|
|
12
|
+
Use Apex through the MCP server when the `apex-*` tools are available.
|
|
13
|
+
|
|
14
|
+
Recommended workflow:
|
|
15
|
+
|
|
16
|
+
1. Start with `apex-auth-status`.
|
|
17
|
+
2. If Apex is unauthenticated, call `apex-auth-start`, ask the user to approve the device-login URL, then call `apex-auth-wait` with the returned `deviceCode`.
|
|
18
|
+
3. For repository-scoped work, pass `cwd` explicitly.
|
|
19
|
+
4. Call `apex-doctor` before starting a scan if workspace binding or source planning might be unclear.
|
|
20
|
+
5. If the directory is not bound to the right workspace, call `apex-workspaces` and `apex-workspace-use`.
|
|
21
|
+
6. Use `apex-scan`, `apex-status`, `apex-scans`, `apex-findings`, and `apex-export-findings` for the scan lifecycle.
|
|
22
|
+
|
|
23
|
+
If the Apex MCP server is not configured, fall back to the local CLI:
|
|
24
|
+
|
|
25
|
+
- Prefer scripted commands over the interactive shell.
|
|
26
|
+
- Use `--non-interactive` and `--no-open` for automation-friendly CLI calls.
|
|
27
|
+
- Use `--json` whenever structured output is helpful.
|
|
28
|
+
- Work from the target repository directory so Apex can resolve `.apex/workspace.json`.
|
|
29
|
+
- `apex-doctor` reports whether Apex will use remote materialization or a local snapshot upload for each selected source.
|
|
30
|
+
- Plain local directories and dirty git worktrees can scan through local snapshot uploads without provider access.
|
|
31
|
+
- `apex-workspace-use` accepts a workspace name, prefix, or ID.
|
|
32
|
+
- Use `sourceMode: "remote"` only when the user explicitly wants to forbid local snapshot fallbacks.
|
|
33
|
+
|
|
34
|
+
## Examples
|
|
35
|
+
|
|
36
|
+
- Start a scan for the current repository or directory: run `apex-doctor`, bind a workspace if needed, then call `apex-scan`.
|
|
37
|
+
- Check an active scan: call `apex-status`, then `apex-findings` when the scan completes.
|
|
38
|
+
- Export findings for review: call `apex-export-findings` with `format` set to `markdown`, `json`, or `gitlab-sast`.
|
package/README.md
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# Apex CLI
|
|
2
|
+
|
|
3
|
+
Standalone CLI client for Apex.
|
|
4
|
+
|
|
5
|
+
## Installing And Updating
|
|
6
|
+
|
|
7
|
+
For a public install, use a global package manager install:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g @cantinasecurity/apex-cli
|
|
11
|
+
# or: pnpm add -g @cantinasecurity/apex-cli
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Then run:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
apex setup
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`apex setup` is the lowest-friction path for agent clients. It:
|
|
21
|
+
|
|
22
|
+
- registers Apex as an MCP server in any installed Codex and Claude Code CLIs
|
|
23
|
+
- installs the Codex skill into `$CODEX_HOME/skills/apex-cli`
|
|
24
|
+
- installs the Claude project skill into `.claude/skills/apex-cli` in the current repository
|
|
25
|
+
|
|
26
|
+
If you only want one client, run:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
apex setup codex
|
|
30
|
+
apex setup claude
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
If one client is not installed yet, `apex setup` skips it automatically. If you target a client explicitly, its CLI must already be installed.
|
|
34
|
+
|
|
35
|
+
Update a global install with:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
apex update
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
You can also update directly with your package manager:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npm install -g @cantinasecurity/apex-cli
|
|
45
|
+
# or: pnpm add -g @cantinasecurity/apex-cli
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
If you are running Apex CLI from a local checkout instead, update it with:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
git pull --ff-only
|
|
52
|
+
pnpm install
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
MCP registrations keep working across package updates because they point at the stable `apex-mcp` shim. Re-run `apex setup` after upgrading if you want to refresh copied skill files, and run `apex setup claude` in each repository where you want the Claude project skill.
|
|
56
|
+
|
|
57
|
+
When `apex` is run in an interactive terminal, it checks for updates periodically and offers to install them.
|
|
58
|
+
|
|
59
|
+
## Local Development
|
|
60
|
+
|
|
61
|
+
1. Install dependencies:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pnpm install
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
2. Run the CLI:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pnpm apex
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
By default, the CLI targets `https://ai.cantina.xyz/`.
|
|
74
|
+
|
|
75
|
+
Use `APEX_BASE_URL` only when testing against a non-production Apex host:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
APEX_BASE_URL=https://preview.cantina.xyz pnpm apex
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Interactive Shell
|
|
82
|
+
|
|
83
|
+
Bare `apex` opens the interactive shell:
|
|
84
|
+
|
|
85
|
+
```text
|
|
86
|
+
$ apex
|
|
87
|
+
|
|
88
|
+
Apex CLI
|
|
89
|
+
Connected to https://ai.cantina.xyz/
|
|
90
|
+
Type /scan to start a scan for this directory, /workspaces to browse workspace names, /workspace use "<name>" to switch, /help for commands.
|
|
91
|
+
apex>
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
In interactive terminals, Apex now shows a loading indicator while it resolves workspaces, loads scans, and starts commands.
|
|
95
|
+
|
|
96
|
+
If Apex asks for a workspace name, that is the Apex workspace name for the current directory. Press Enter to accept the current folder name, or pass `--workspace-name <name>` explicitly.
|
|
97
|
+
|
|
98
|
+
Supported shell commands:
|
|
99
|
+
|
|
100
|
+
- `/credits`
|
|
101
|
+
- `/scan [standard|ultra]`
|
|
102
|
+
- `/scans`
|
|
103
|
+
- `/findings [scan-id]`
|
|
104
|
+
- `/export [scan-id]`
|
|
105
|
+
- `/workspaces`
|
|
106
|
+
- `/cancel-scan [scan-id]`
|
|
107
|
+
- `/status`
|
|
108
|
+
- `/doctor`
|
|
109
|
+
- `/update`
|
|
110
|
+
- `/logout`
|
|
111
|
+
- `/repos`
|
|
112
|
+
- `/workspace`
|
|
113
|
+
- `/workspace use <workspace-name|workspace-prefix|workspace-id>`
|
|
114
|
+
- `/workspace name <name>`
|
|
115
|
+
- `/company [id|handle]`
|
|
116
|
+
- `/connect github`
|
|
117
|
+
- `/connect gitlab`
|
|
118
|
+
- `/open`
|
|
119
|
+
- `/clear`
|
|
120
|
+
- `/help`
|
|
121
|
+
- `/exit`
|
|
122
|
+
|
|
123
|
+
`/workspace use` accepts a workspace name, prefix, or ID. Quote workspace names that contain spaces, for example `/workspace use "Core Platform"`.
|
|
124
|
+
|
|
125
|
+
## Scripted Commands
|
|
126
|
+
|
|
127
|
+
- `apex credits`
|
|
128
|
+
- `apex scan`
|
|
129
|
+
- `apex scans`
|
|
130
|
+
- `apex findings [--scan <scan-id>]`
|
|
131
|
+
- `apex export findings [--scan <scan-id>] [--format markdown|json|gitlab-sast] [--output <path>]`
|
|
132
|
+
- `apex workspaces`
|
|
133
|
+
- `apex workspace`
|
|
134
|
+
- `apex workspace use <workspace-name|workspace-prefix|workspace-id>`
|
|
135
|
+
- `apex cancel-scan [scan-id]`
|
|
136
|
+
- `apex status`
|
|
137
|
+
- `apex doctor`
|
|
138
|
+
- `apex login`
|
|
139
|
+
- `apex logout`
|
|
140
|
+
- `apex setup [all|codex|claude]`
|
|
141
|
+
- `apex update`
|
|
142
|
+
- `apex connect github`
|
|
143
|
+
- `apex connect gitlab`
|
|
144
|
+
|
|
145
|
+
Helpful workspace flags:
|
|
146
|
+
|
|
147
|
+
- `--company <id-or-handle>` to choose the Apex company when more than one is available
|
|
148
|
+
- `--workspace-name <name>` to set the Apex workspace name for this directory
|
|
149
|
+
|
|
150
|
+
## Local Source Scans
|
|
151
|
+
|
|
152
|
+
`apex scan` now works against any local source root you point it at:
|
|
153
|
+
|
|
154
|
+
- clean GitHub or GitLab checkouts can stay on the remote-materialization path
|
|
155
|
+
- dirty git worktrees fall back to a local snapshot upload by default
|
|
156
|
+
- plain directories that are not git repositories are scanned through a local snapshot upload
|
|
157
|
+
|
|
158
|
+
Useful flags:
|
|
159
|
+
|
|
160
|
+
- `--repo <path>` to scan one or more explicit local roots
|
|
161
|
+
- `--source-mode auto|remote|local` to control remote-first fallback behavior
|
|
162
|
+
|
|
163
|
+
`auto` is the default. `remote` requires Apex to materialize from a remote repository. `local` forces a local snapshot upload even when a clean remote path is available.
|
|
164
|
+
|
|
165
|
+
Ultra scans still require provider-backed GitHub or GitLab repositories that Apex can materialize remotely without a local snapshot fallback.
|
|
166
|
+
|
|
167
|
+
## LLM / MCP Usage
|
|
168
|
+
|
|
169
|
+
The CLI now ships an MCP server so LLM clients can drive Apex directly over stdio.
|
|
170
|
+
|
|
171
|
+
If Apex is installed globally, prefer:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
apex setup
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
That registers Apex for installed Codex and Claude Code clients automatically.
|
|
178
|
+
|
|
179
|
+
If you want to wire clients manually instead, Apex ships a stable `apex-mcp` binary. For Codex:
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
codex mcp add apex -- apex-mcp
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
For Claude Code:
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
claude mcp add --scope user apex -- apex-mcp
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
For any other MCP client, configure it to launch:
|
|
192
|
+
|
|
193
|
+
```json
|
|
194
|
+
{
|
|
195
|
+
"mcpServers": {
|
|
196
|
+
"apex": {
|
|
197
|
+
"command": "apex-mcp"
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
From a local checkout during development, prefer the repo-local binary so the MCP stream stays clean:
|
|
204
|
+
|
|
205
|
+
```json
|
|
206
|
+
{
|
|
207
|
+
"mcpServers": {
|
|
208
|
+
"apex": {
|
|
209
|
+
"command": "/path/to/apex-cli/bin/apex-mcp"
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
If you need to launch through `pnpm`, use `--silent`:
|
|
216
|
+
|
|
217
|
+
```json
|
|
218
|
+
{
|
|
219
|
+
"mcpServers": {
|
|
220
|
+
"apex": {
|
|
221
|
+
"command": "pnpm",
|
|
222
|
+
"args": ["--silent", "mcp"],
|
|
223
|
+
"cwd": "/path/to/apex-cli"
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Do not point an MCP client at plain `pnpm mcp`. `pnpm` writes its script banner to `stdout` before the protocol stream, which can break the `initialize` handshake.
|
|
230
|
+
|
|
231
|
+
The MCP server exposes Apex-specific tools for:
|
|
232
|
+
|
|
233
|
+
- auth status and device login
|
|
234
|
+
- doctor, credits, and provider connection URLs
|
|
235
|
+
- workspace inspection and workspace binding
|
|
236
|
+
- scan start, status, cancellation, findings, and findings export
|
|
237
|
+
|
|
238
|
+
For repository-scoped operations, pass `cwd` explicitly so the server can resolve the right `.apex/workspace.json` binding and repository roots.
|
|
239
|
+
|
|
240
|
+
For Codex-style clients, the packaged skill can be installed with `apex setup codex`. The repo-local source lives at `skills/apex-cli/SKILL.md`.
|
|
241
|
+
|
|
242
|
+
For Claude Code, the packaged project skill can be installed into the current repository with `apex setup claude`. The repo-local source lives at `.claude/skills/apex-cli/SKILL.md`. Anthropic documents project skills as filesystem directories under `.claude/skills/<name>/SKILL.md`, and the Claude Agent SDK uses the same location when the `Skill` tool is enabled.
|
|
243
|
+
|
|
244
|
+
## Development Notes
|
|
245
|
+
|
|
246
|
+
The CLI uses the Apex `/api/cli/v2/**` local-source routes for scan planning and snapshot uploads, with legacy `/api/cli/v1/**` routes still used for provider-backed flows such as ultra scans. Local state is stored under:
|
|
247
|
+
|
|
248
|
+
- `~/.config/apex/config.json`
|
|
249
|
+
- `~/.config/apex/credentials.json`
|
|
250
|
+
- `.apex/workspace.json`
|
|
251
|
+
|
|
252
|
+
If a scan is already running in the current workspace, `apex scan` and `/scan` now require confirmation before starting another one. Scripted usage can opt in explicitly with `--force`.
|
|
253
|
+
|
|
254
|
+
To move between existing Apex workspaces from the CLI:
|
|
255
|
+
|
|
256
|
+
1. Run `apex workspaces` to list the workspaces available to your active company.
|
|
257
|
+
2. Run `apex workspace use <workspace-name|workspace-prefix|workspace-id>` to bind the current directory.
|
|
258
|
+
3. If the workspace name contains spaces, quote it, for example `apex workspace use "Core Platform"`.
|
|
259
|
+
4. Use `apex scans`, `apex findings`, and `apex export findings` against that binding.
|
package/dist/apex.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { ApexApiClient, formatApiError } from "./api-client.js";
|
|
2
|
+
import { parseArgs } from "./args.js";
|
|
3
|
+
import { commandCancelScan, commandConnect, commandCredits, commandDoctor, commandExportFindings, commandFindings, commandLogin, commandLogout, commandScan, commandScans, commandSetup, commandStatus, commandUpdate, commandWorkspace, commandWorkspaceUse, commandWorkspaces, } from "./commands.js";
|
|
4
|
+
import { CLI_HELP_TEXT } from "./help.js";
|
|
5
|
+
import { runMcpServer } from "./mcp.js";
|
|
6
|
+
import { runInteractiveShell } from "./shell.js";
|
|
7
|
+
import { maybePromptForUpdate } from "./update.js";
|
|
8
|
+
async function main() {
|
|
9
|
+
const parsed = parseArgs(process.argv.slice(2));
|
|
10
|
+
if (parsed.flags.help === true || parsed.command === "help") {
|
|
11
|
+
process.stdout.write(CLI_HELP_TEXT);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
if (parsed.command === "mcp") {
|
|
15
|
+
await runMcpServer();
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const client = new ApexApiClient();
|
|
19
|
+
const cwd = process.cwd();
|
|
20
|
+
if (await maybePromptForUpdate(parsed.flags, parsed.command)) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
switch (parsed.command) {
|
|
24
|
+
case null:
|
|
25
|
+
await runInteractiveShell(client, cwd, parsed.flags);
|
|
26
|
+
return;
|
|
27
|
+
case "scan":
|
|
28
|
+
await commandScan(client, cwd, parsed.flags);
|
|
29
|
+
return;
|
|
30
|
+
case "scans":
|
|
31
|
+
await commandScans(client, cwd, parsed.flags);
|
|
32
|
+
return;
|
|
33
|
+
case "cancel-scan":
|
|
34
|
+
await commandCancelScan(client, cwd, parsed.flags, parsed.subcommand);
|
|
35
|
+
return;
|
|
36
|
+
case "login":
|
|
37
|
+
await commandLogin(client, parsed.flags);
|
|
38
|
+
return;
|
|
39
|
+
case "logout":
|
|
40
|
+
await commandLogout(client, parsed.flags);
|
|
41
|
+
return;
|
|
42
|
+
case "update":
|
|
43
|
+
await commandUpdate(parsed.flags);
|
|
44
|
+
return;
|
|
45
|
+
case "setup":
|
|
46
|
+
await commandSetup(cwd, parsed.flags, parsed.subcommand);
|
|
47
|
+
return;
|
|
48
|
+
case "doctor":
|
|
49
|
+
await commandDoctor(client, cwd, parsed.flags);
|
|
50
|
+
return;
|
|
51
|
+
case "credits":
|
|
52
|
+
await commandCredits(client, cwd, parsed.flags);
|
|
53
|
+
return;
|
|
54
|
+
case "workspaces":
|
|
55
|
+
await commandWorkspaces(client, cwd, parsed.flags);
|
|
56
|
+
return;
|
|
57
|
+
case "workspace":
|
|
58
|
+
if (parsed.subcommand === "use") {
|
|
59
|
+
const workspaceRef = parsed.args.join(" ");
|
|
60
|
+
await commandWorkspaceUse(client, cwd, parsed.flags, workspaceRef);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
await commandWorkspace(cwd, parsed.flags);
|
|
64
|
+
return;
|
|
65
|
+
case "findings":
|
|
66
|
+
await commandFindings(client, cwd, parsed.flags);
|
|
67
|
+
return;
|
|
68
|
+
case "export":
|
|
69
|
+
if (parsed.subcommand && parsed.subcommand !== "findings") {
|
|
70
|
+
throw new Error("Usage: apex export [findings]");
|
|
71
|
+
}
|
|
72
|
+
await commandExportFindings(client, cwd, parsed.flags);
|
|
73
|
+
return;
|
|
74
|
+
case "status":
|
|
75
|
+
await commandStatus(client, cwd, parsed.flags);
|
|
76
|
+
return;
|
|
77
|
+
case "connect":
|
|
78
|
+
if (parsed.subcommand !== "github" && parsed.subcommand !== "gitlab") {
|
|
79
|
+
throw new Error("Usage: apex connect <github|gitlab>");
|
|
80
|
+
}
|
|
81
|
+
await commandConnect(client, cwd, parsed.flags, parsed.subcommand);
|
|
82
|
+
return;
|
|
83
|
+
default:
|
|
84
|
+
throw new Error(`Unknown command: ${parsed.command}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
main().catch((error) => {
|
|
88
|
+
if (error instanceof Error && error.reported) {
|
|
89
|
+
process.exitCode = 1;
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
process.stderr.write(`${formatApiError(error)}\n`);
|
|
93
|
+
process.exitCode = 1;
|
|
94
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { clearCredentials, loadConfig, loadCredentials, saveCredentials } from "./config.js";
|
|
2
|
+
export class ApiError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
body;
|
|
5
|
+
constructor(message, status, body) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "ApiError";
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.body = body;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function getApiErrorBodyMessage(body) {
|
|
13
|
+
if (!body || typeof body !== "object") {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
const error = body.error;
|
|
17
|
+
return typeof error === "string" && error.trim().length > 0 ? error : null;
|
|
18
|
+
}
|
|
19
|
+
export function formatApiError(error) {
|
|
20
|
+
if (error instanceof ApiError) {
|
|
21
|
+
return getApiErrorBodyMessage(error.body) ?? error.message;
|
|
22
|
+
}
|
|
23
|
+
return error instanceof Error ? error.message : String(error);
|
|
24
|
+
}
|
|
25
|
+
function expiresSoon(timestamp) {
|
|
26
|
+
if (!timestamp)
|
|
27
|
+
return true;
|
|
28
|
+
const time = new Date(timestamp).getTime();
|
|
29
|
+
if (!Number.isFinite(time))
|
|
30
|
+
return true;
|
|
31
|
+
return time <= Date.now() + 30_000;
|
|
32
|
+
}
|
|
33
|
+
async function parseResponse(response) {
|
|
34
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
35
|
+
if (contentType.includes("application/json")) {
|
|
36
|
+
return response.json().catch(() => null);
|
|
37
|
+
}
|
|
38
|
+
return response.text().catch(() => "");
|
|
39
|
+
}
|
|
40
|
+
export class ApexApiClient {
|
|
41
|
+
async getBaseUrl() {
|
|
42
|
+
const config = await loadConfig();
|
|
43
|
+
return config.baseUrl;
|
|
44
|
+
}
|
|
45
|
+
async getCredentials() {
|
|
46
|
+
return loadCredentials();
|
|
47
|
+
}
|
|
48
|
+
async clearCredentials() {
|
|
49
|
+
await clearCredentials();
|
|
50
|
+
}
|
|
51
|
+
async refreshIfNeeded() {
|
|
52
|
+
const credentials = await loadCredentials();
|
|
53
|
+
if (!credentials)
|
|
54
|
+
return null;
|
|
55
|
+
if (!expiresSoon(credentials.accessTokenExpiresAt)) {
|
|
56
|
+
return credentials;
|
|
57
|
+
}
|
|
58
|
+
return this.refreshSession(credentials.refreshToken);
|
|
59
|
+
}
|
|
60
|
+
async refreshSession(refreshToken) {
|
|
61
|
+
const existing = await loadCredentials();
|
|
62
|
+
const effectiveRefreshToken = refreshToken ?? existing?.refreshToken ?? null;
|
|
63
|
+
if (!effectiveRefreshToken)
|
|
64
|
+
return null;
|
|
65
|
+
const payload = (await this.request("/api/cli/v1/auth/refresh", {
|
|
66
|
+
method: "POST",
|
|
67
|
+
auth: false,
|
|
68
|
+
json: { refreshToken: effectiveRefreshToken },
|
|
69
|
+
retryOnUnauthorized: false,
|
|
70
|
+
}));
|
|
71
|
+
const nextCredentials = {
|
|
72
|
+
accessToken: payload.accessToken,
|
|
73
|
+
refreshToken: payload.refreshToken,
|
|
74
|
+
accessTokenExpiresAt: payload.accessTokenExpiresAt,
|
|
75
|
+
refreshTokenExpiresAt: payload.refreshTokenExpiresAt,
|
|
76
|
+
};
|
|
77
|
+
await saveCredentials(nextCredentials);
|
|
78
|
+
return nextCredentials;
|
|
79
|
+
}
|
|
80
|
+
async request(path, options = {}) {
|
|
81
|
+
const baseUrl = await this.getBaseUrl();
|
|
82
|
+
const headers = new Headers(options.headers ?? {});
|
|
83
|
+
let credentials = options.auth === false ? null : await this.refreshIfNeeded().catch(() => null);
|
|
84
|
+
if (options.json !== undefined) {
|
|
85
|
+
headers.set("Content-Type", "application/json");
|
|
86
|
+
}
|
|
87
|
+
if (credentials?.accessToken) {
|
|
88
|
+
headers.set("Authorization", `Bearer ${credentials.accessToken}`);
|
|
89
|
+
}
|
|
90
|
+
const response = await fetch(new URL(path, baseUrl), {
|
|
91
|
+
...options,
|
|
92
|
+
headers,
|
|
93
|
+
body: options.json !== undefined ? JSON.stringify(options.json) : options.body,
|
|
94
|
+
});
|
|
95
|
+
if (response.status === 401 &&
|
|
96
|
+
options.auth !== false &&
|
|
97
|
+
options.retryOnUnauthorized !== false) {
|
|
98
|
+
credentials = await this.refreshSession();
|
|
99
|
+
if (credentials?.accessToken) {
|
|
100
|
+
headers.set("Authorization", `Bearer ${credentials.accessToken}`);
|
|
101
|
+
const retryResponse = await fetch(new URL(path, baseUrl), {
|
|
102
|
+
...options,
|
|
103
|
+
headers,
|
|
104
|
+
body: options.json !== undefined ? JSON.stringify(options.json) : options.body,
|
|
105
|
+
});
|
|
106
|
+
const retryBody = await parseResponse(retryResponse);
|
|
107
|
+
if (!retryResponse.ok) {
|
|
108
|
+
if (retryResponse.status === 401) {
|
|
109
|
+
await clearCredentials();
|
|
110
|
+
}
|
|
111
|
+
throw new ApiError(`Request failed with ${retryResponse.status}`, retryResponse.status, retryBody);
|
|
112
|
+
}
|
|
113
|
+
return retryBody;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const body = await parseResponse(response);
|
|
117
|
+
if (!response.ok) {
|
|
118
|
+
if (response.status === 401) {
|
|
119
|
+
await clearCredentials();
|
|
120
|
+
}
|
|
121
|
+
throw new ApiError(`Request failed with ${response.status}`, response.status, body);
|
|
122
|
+
}
|
|
123
|
+
return body;
|
|
124
|
+
}
|
|
125
|
+
}
|
package/dist/args.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const BOOLEAN_FLAGS = new Set(["force", "non-interactive", "json", "no-open", "help"]);
|
|
2
|
+
export function parseArgs(argv) {
|
|
3
|
+
const flags = {};
|
|
4
|
+
const positional = [];
|
|
5
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
6
|
+
const value = argv[index];
|
|
7
|
+
if (!value.startsWith("--")) {
|
|
8
|
+
positional.push(value);
|
|
9
|
+
continue;
|
|
10
|
+
}
|
|
11
|
+
const key = value.slice(2);
|
|
12
|
+
if (BOOLEAN_FLAGS.has(key)) {
|
|
13
|
+
flags[key] = true;
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
const next = argv[index + 1];
|
|
17
|
+
if (!next || next.startsWith("--")) {
|
|
18
|
+
throw new Error(`Missing value for --${key}`);
|
|
19
|
+
}
|
|
20
|
+
if (key === "repo") {
|
|
21
|
+
const existing = flags[key] ?? [];
|
|
22
|
+
existing.push(next);
|
|
23
|
+
flags[key] = existing;
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
flags[key] = next;
|
|
27
|
+
}
|
|
28
|
+
index += 1;
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
command: positional[0] ?? null,
|
|
32
|
+
subcommand: positional[1] ?? null,
|
|
33
|
+
args: positional.slice(2),
|
|
34
|
+
flags,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function isJsonMode(flags) {
|
|
38
|
+
return flags.json === true;
|
|
39
|
+
}
|
|
40
|
+
export function isNonInteractive(flags) {
|
|
41
|
+
return flags["non-interactive"] === true;
|
|
42
|
+
}
|
|
43
|
+
export function getFlagString(flags, key) {
|
|
44
|
+
const value = flags[key];
|
|
45
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
46
|
+
}
|
|
47
|
+
export function getFlagList(flags, key) {
|
|
48
|
+
const value = flags[key];
|
|
49
|
+
return Array.isArray(value) ? value : [];
|
|
50
|
+
}
|
|
51
|
+
export function withFlag(flags, key, value) {
|
|
52
|
+
return {
|
|
53
|
+
...flags,
|
|
54
|
+
[key]: value,
|
|
55
|
+
};
|
|
56
|
+
}
|