@0cloud/cli 0.0.0-stage → 0.2.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/README.md +170 -2
- package/dist/public/commands/auth.js +114 -0
- package/dist/public/config.js +218 -0
- package/dist/public/customer.js +151 -0
- package/dist/public/errors.js +30 -0
- package/dist/public/format.js +71 -0
- package/dist/public/http.js +101 -0
- package/dist/public/public-cli.js +146 -0
- package/dist/public/workflow-commands.js +102 -0
- package/package.json +40 -4
package/README.md
CHANGED
|
@@ -1,3 +1,171 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 0cloud CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The service client for 0cloud. Use the same workspace from your terminal, scripts or coding agent. Requires Node.js 24 or later. The separate `0` engine is not required or installed.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install -g @0cloud/cli
|
|
7
|
+
0cloud auth login
|
|
8
|
+
0cloud codebases list
|
|
9
|
+
0cloud capabilities
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
For a local deployment, use `0cloud auth login --dashboard http://localhost:3001`.
|
|
13
|
+
Login opens the dashboard and saves the selected workspace's scoped token in
|
|
14
|
+
`~/.0cloud/credentials.json` with owner-only permissions. It never changes `0`
|
|
15
|
+
credentials. `ZERO_CLOUD_TOKEN`, `ZERO_CLOUD_ORG` and `ZERO_CLOUD_ENDPOINT` support
|
|
16
|
+
CI. `--endpoint` and `--org` override the saved service and workspace.
|
|
17
|
+
|
|
18
|
+
## Choose the work
|
|
19
|
+
|
|
20
|
+
| Task | Command |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| Analyze repository source where enabled | `0cloud scan https://github.com/owner/repository` |
|
|
23
|
+
| Source upload where enabled | `0cloud scan ./src --whitebox --focus "Account access"` |
|
|
24
|
+
| Inspect connected codebases | `0cloud codebases list` or `codebases show <repo-or-id>` |
|
|
25
|
+
| Check saved scope and readiness | `0cloud tests readiness <repo-or-id>` |
|
|
26
|
+
| Start a saved-codebase security test | `0cloud tests start <repo-or-id> --idempotency-key <uuid>` |
|
|
27
|
+
| Read PR Security history | `0cloud reviews list` |
|
|
28
|
+
| Inspect progress or results | `0cloud runs show <id>` or `runs report <id>` |
|
|
29
|
+
| Inspect recorded problems | `0cloud findings list` or `findings show <id>` |
|
|
30
|
+
| Read Memory rules and feedback | `0cloud memory list` |
|
|
31
|
+
| Check funding readiness | `0cloud usage status` |
|
|
32
|
+
|
|
33
|
+
**Security tests** is the dashboard workflow name. `tests` is the corresponding
|
|
34
|
+
CLI command. Existing `pentests start` commands remain compatible. `scan` is
|
|
35
|
+
source-only analysis, with results that do not establish reproduction or verified
|
|
36
|
+
repairs. Opening configuration or checking readiness does not start work.
|
|
37
|
+
|
|
38
|
+
## Source analysis
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
0cloud scan https://github.com/owner/repository
|
|
42
|
+
0cloud scan ./src --whitebox --focus "Authorization and tenant isolation"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Ordinary `scan` accepts an HTTPS repository URL. Local files require an explicit
|
|
46
|
+
`--whitebox` source-analysis request; the CLI rejects them before reading source
|
|
47
|
+
or requesting a price. It never switches workflows automatically.
|
|
48
|
+
|
|
49
|
+
Uploads exclude symlinks, `.env` files, dependency/build directories, invalid
|
|
50
|
+
UTF-8 and common binary formats. Limits: 300 files, 400 KB per file and 8 MB total.
|
|
51
|
+
Excluded paths are reported on stderr; exceeding total limits fails rather than
|
|
52
|
+
silently returning a partial submission. Review the files you select before upload.
|
|
53
|
+
|
|
54
|
+
`--whitebox` uses `/api/whitebox-scan`; `--focus` requires it. Repository URL
|
|
55
|
+
support depends on deployment configuration. The `/api/scan` route is repository-only, while fixed-credit or funded source
|
|
56
|
+
configuration can reject URL acquisition. Those deployments may require explicit
|
|
57
|
+
`--whitebox` file uploads or the connected `source-scans` workflow below. A rejected
|
|
58
|
+
URL is reported as an error without a fallback.
|
|
59
|
+
|
|
60
|
+
For deployments with queued source operations enabled:
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
0cloud source-scans start <repository-uuid> --commit <full-40-character-sha> \
|
|
64
|
+
--operation whitebox-scan --idempotency-key <uuid>
|
|
65
|
+
0cloud source-scans list --repository <repository-uuid>
|
|
66
|
+
0cloud source-scans show <job-id>
|
|
67
|
+
0cloud source-scans wait <job-id>
|
|
68
|
+
0cloud source-scans cancel <job-id>
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Queued source jobs use their own IDs and `source-scans` commands. Managed
|
|
72
|
+
executions use `runs`. A `recovery_required` result is a failure requiring usage
|
|
73
|
+
reconciliation, never a reason to resubmit automatically. Deployment feature
|
|
74
|
+
flags, authority and operation limits remain server-controlled.
|
|
75
|
+
|
|
76
|
+
## Codebases and security tests
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
0cloud codebases show owner/repository
|
|
80
|
+
0cloud codebases configure owner/repository --file plan.json
|
|
81
|
+
0cloud tests readiness owner/repository
|
|
82
|
+
0cloud tests start owner/repository --idempotency-key <uuid>
|
|
83
|
+
0cloud runs wait <run-id>
|
|
84
|
+
0cloud runs report <run-id>
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`configure` reads JSON containing `plan` and optional `enableSchedule`. It saves
|
|
88
|
+
against the server's current revision and source revision. Inspect `codebases
|
|
89
|
+
show` and the proposed plan before saving. It never starts a test. Changing a
|
|
90
|
+
schedule or publication policy requires your explicit decision.
|
|
91
|
+
|
|
92
|
+
`codebases enroll <repository-uuid>` enrolls a repository already available
|
|
93
|
+
through the workspace's GitHub connection; inspect its saved trigger policy first.
|
|
94
|
+
GitHub connection and new-install consent remain browser flows.
|
|
95
|
+
|
|
96
|
+
Start uses the saved scope, budget and publication policy. The CLI prints an
|
|
97
|
+
attempt key to stderr before submission. Save it. If a response is lost, inspect
|
|
98
|
+
the recorded runs and reuse the same key for the same attempt. Do not generate a
|
|
99
|
+
new key or replay automatically when execution or usage is uncertain. A start
|
|
100
|
+
response is admission evidence, not proof of a completed test.
|
|
101
|
+
|
|
102
|
+
## History and review
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
0cloud runs list --status failed --limit 20 --offset 20
|
|
106
|
+
0cloud runs events <run-id> --limit 100 --offset 100
|
|
107
|
+
0cloud findings list --scan-id <run-id> --status pending --limit 20 --offset 20
|
|
108
|
+
0cloud findings triage <finding-id> --status unsure --reason "Needs reproduction evidence"
|
|
109
|
+
0cloud reviews list --days 0 --repository <repository-uuid>
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
PR history defaults to all time; `--days` accepts 0, 7, 30 or 90. Use `--cursor`
|
|
113
|
+
with the JSON cursor returned by the API, or `--page` for its existing page API.
|
|
114
|
+
Runs, findings and events support `--limit` and `--offset`. Review decisions do
|
|
115
|
+
not establish reproduction or a verified repair. PR execution follows saved
|
|
116
|
+
repository triggers; manual review dispatch and GitHub delivery are not added
|
|
117
|
+
by this CLI release.
|
|
118
|
+
|
|
119
|
+
## Memory and usage
|
|
120
|
+
|
|
121
|
+
```sh
|
|
122
|
+
0cloud memory list
|
|
123
|
+
0cloud memory list --repository <repository-uuid>
|
|
124
|
+
0cloud memory add "Check tenant ownership before reading records" --revision <revision>
|
|
125
|
+
0cloud memory remove <workspace-memory-id> --revision <revision>
|
|
126
|
+
0cloud usage status
|
|
127
|
+
0cloud usage models
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Memory writes manage explicit workspace rules. Use the exact revision and rule
|
|
131
|
+
ID returned by the API. Conflicts are reported without automatic overwrite.
|
|
132
|
+
The repository filter scopes reads; it does not change the scope of new rules.
|
|
133
|
+
Observed facts and feedback remain distinct from approved rules. Generated
|
|
134
|
+
Knowledge documents do not yet have a dedicated public CLI API.
|
|
135
|
+
|
|
136
|
+
Usage commands only read account admission and the model catalog. They cannot
|
|
137
|
+
purchase usage, change caps, consent to paid usage or waive unresolved charges.
|
|
138
|
+
Required token scopes and workspace roles are enforced by the server.
|
|
139
|
+
|
|
140
|
+
## Other commands
|
|
141
|
+
|
|
142
|
+
- `dependencies list`, `dependencies scan`: read or refresh dependency checks.
|
|
143
|
+
- `dependencies fix <dependency-id> <finding-id>`: explicitly open an upgrade PR; never merge it.
|
|
144
|
+
- `auth status`, `auth logout`: inspect or remove this CLI's saved credentials.
|
|
145
|
+
- `api get/post/patch/delete <relative-path> [--file body.json]`: call an existing
|
|
146
|
+
workspace API with the same credentials and permissions. It does not expose
|
|
147
|
+
internal dashboard RPCs or bypass authorization.
|
|
148
|
+
- `capabilities`: inspect the installed commands and known API gaps.
|
|
149
|
+
|
|
150
|
+
Website/app target creation, authorization and test start need a public API
|
|
151
|
+
matching the dashboard flow. They are not disguised as codebase tests. Team,
|
|
152
|
+
integration and payment administration remain dashboard workflows.
|
|
153
|
+
|
|
154
|
+
## Output and errors
|
|
155
|
+
|
|
156
|
+
Results are JSON on stdout. Diagnostics and attempt keys go to stderr. Exit codes:
|
|
157
|
+
0 success, 1 invalid input/configuration or wait timeout, 2 API/network failure or
|
|
158
|
+
an unsuccessful terminal run. Source allow/review/block verdicts are data, not
|
|
159
|
+
transport failures. A wait timeout does not cancel the job. The client never
|
|
160
|
+
retries writes automatically. Unavailable routes fail visibly.
|
|
161
|
+
|
|
162
|
+
## Development
|
|
163
|
+
|
|
164
|
+
```sh
|
|
165
|
+
pnpm --filter @0cloud/cli build
|
|
166
|
+
pnpm --filter @0cloud/cli test
|
|
167
|
+
node packages/0cloud-cli/dist/public/public-cli.js --help
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
The npm allowlist includes only `dist/public` and this README. Operator commands
|
|
171
|
+
in `src/cli.ts` and `OPERATOR.md` are excluded from the public entry point.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// `0cloud auth <subcommand>` — dashboard browser login for CLI tokens.
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { apiEndpointFromDashboard, credentialsPath, DEFAULT_DASHBOARD_URL, DEFAULT_ENDPOINT, deleteCredentialsFile, readCredentialsFile, writeCredentialsFile, } from "../config.js";
|
|
5
|
+
import { printJson } from "../format.js";
|
|
6
|
+
import { UserError } from "../errors.js";
|
|
7
|
+
export async function runAuthLogin(opts) {
|
|
8
|
+
const dashboardUrl = stripTrailingSlash(opts.dashboard ??
|
|
9
|
+
process.env.ZERO_CLOUD_DASHBOARD_URL ??
|
|
10
|
+
DEFAULT_DASHBOARD_URL);
|
|
11
|
+
const endpoint = stripTrailingSlash(opts.endpoint ?? process.env.ZERO_CLOUD_ENDPOINT ?? apiEndpointFromDashboard(dashboardUrl));
|
|
12
|
+
const sessionId = randomBytes(24).toString("base64url");
|
|
13
|
+
const loginUrl = `${dashboardUrl}/cli-auth?session=${encodeURIComponent(sessionId)}`;
|
|
14
|
+
process.stdout.write(`Open this URL to authorize 0cloud:\n${loginUrl}\n`);
|
|
15
|
+
if (opts.open !== false)
|
|
16
|
+
openBrowser(loginUrl);
|
|
17
|
+
const intervalMs = positiveInt(opts.pollInterval ?? 2, "--poll-interval") * 1000;
|
|
18
|
+
const timeoutMs = positiveInt(opts.timeout ?? 600, "--timeout") * 1000;
|
|
19
|
+
const deadline = Date.now() + timeoutMs;
|
|
20
|
+
while (Date.now() < deadline) {
|
|
21
|
+
const result = await pollSession(dashboardUrl, sessionId);
|
|
22
|
+
if (result.status === "ready") {
|
|
23
|
+
if (!result.org_id) {
|
|
24
|
+
throw new UserError("Dashboard returned a token without org_id; cannot configure tenant scope.");
|
|
25
|
+
}
|
|
26
|
+
await writeCredentialsFile({
|
|
27
|
+
token: result.token,
|
|
28
|
+
orgId: result.org_id,
|
|
29
|
+
endpoint,
|
|
30
|
+
dashboardUrl,
|
|
31
|
+
scopes: result.scopes,
|
|
32
|
+
createdAt: new Date().toISOString(),
|
|
33
|
+
});
|
|
34
|
+
process.stdout.write(`Authenticated. Wrote ${credentialsPath()}\n`);
|
|
35
|
+
process.stdout.write(` org_id: ${result.org_id}\n`);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (result.status === "expired") {
|
|
39
|
+
throw new UserError("CLI auth session expired. Run `0cloud auth login` again.");
|
|
40
|
+
}
|
|
41
|
+
await sleep(Math.min(intervalMs, Math.max(0, deadline - Date.now())));
|
|
42
|
+
}
|
|
43
|
+
throw new UserError(`Timed out waiting for browser authorization after ${Math.floor(timeoutMs / 1000)}s.`);
|
|
44
|
+
}
|
|
45
|
+
export async function runAuthStatus(opts = {}) {
|
|
46
|
+
const creds = await readCredentialsFile();
|
|
47
|
+
if (opts.json) {
|
|
48
|
+
printJson({
|
|
49
|
+
authenticated: creds !== null,
|
|
50
|
+
credentials_file: credentialsPath(),
|
|
51
|
+
credentials: creds
|
|
52
|
+
? {
|
|
53
|
+
orgId: creds.orgId,
|
|
54
|
+
orgSlug: creds.orgSlug ?? null,
|
|
55
|
+
endpoint: creds.endpoint ?? null,
|
|
56
|
+
dashboardUrl: creds.dashboardUrl ?? null,
|
|
57
|
+
scopes: creds.scopes ?? [],
|
|
58
|
+
createdAt: creds.createdAt ?? null,
|
|
59
|
+
}
|
|
60
|
+
: null,
|
|
61
|
+
});
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (!creds) {
|
|
65
|
+
process.stdout.write(`Not authenticated. Run \`0cloud auth login\`.\n`);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
process.stdout.write(`Authenticated\n credentials: ${credentialsPath()}\n org_id: ${creds.orgId}\n endpoint: ${creds.endpoint ?? DEFAULT_ENDPOINT}\n dashboard: ${creds.dashboardUrl ?? DEFAULT_DASHBOARD_URL}\n`);
|
|
69
|
+
}
|
|
70
|
+
export async function runAuthLogout() {
|
|
71
|
+
const deleted = await deleteCredentialsFile();
|
|
72
|
+
process.stdout.write(deleted ? `Deleted ${credentialsPath()}\n` : "No credentials file found.\n");
|
|
73
|
+
}
|
|
74
|
+
async function pollSession(dashboardUrl, sessionId) {
|
|
75
|
+
const url = `${dashboardUrl}/cli-auth/sessions/${encodeURIComponent(sessionId)}`;
|
|
76
|
+
const response = await fetch(url, {
|
|
77
|
+
headers: { Accept: "application/json" },
|
|
78
|
+
});
|
|
79
|
+
if (response.status === 404)
|
|
80
|
+
return { status: "pending" };
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
throw new UserError(`CLI auth poll failed: HTTP ${response.status}`);
|
|
83
|
+
}
|
|
84
|
+
const parsed = (await response.json());
|
|
85
|
+
if (parsed.status !== "pending" &&
|
|
86
|
+
parsed.status !== "expired" &&
|
|
87
|
+
parsed.status !== "ready") {
|
|
88
|
+
throw new UserError("CLI auth poll returned an unknown status.");
|
|
89
|
+
}
|
|
90
|
+
return parsed;
|
|
91
|
+
}
|
|
92
|
+
function openBrowser(url) {
|
|
93
|
+
const command = process.platform === "darwin"
|
|
94
|
+
? "open"
|
|
95
|
+
: process.platform === "win32"
|
|
96
|
+
? "cmd"
|
|
97
|
+
: "xdg-open";
|
|
98
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
99
|
+
const child = spawn(command, args, { stdio: "ignore", detached: true });
|
|
100
|
+
child.on("error", () => undefined);
|
|
101
|
+
child.unref();
|
|
102
|
+
}
|
|
103
|
+
function positiveInt(value, name) {
|
|
104
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
105
|
+
throw new UserError(`${name} must be a positive integer.`);
|
|
106
|
+
}
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
function sleep(ms) {
|
|
110
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
111
|
+
}
|
|
112
|
+
function stripTrailingSlash(value) {
|
|
113
|
+
return value.endsWith("/") ? value.slice(0, -1) : value;
|
|
114
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// Config-file + env resolution for 0cloud.
|
|
2
|
+
//
|
|
3
|
+
// Config layout:
|
|
4
|
+
// ~/.0cloud/config.json — { endpoint, orgId, orgSlug? }
|
|
5
|
+
// ~/.0cloud/credentials.json — dashboard-minted CLI token
|
|
6
|
+
// env ZERO_CLOUD_OPERATOR_TOKEN — operator bearer override
|
|
7
|
+
// env ZERO_CLOUD_TOKEN — CLI token override
|
|
8
|
+
// env ZERO_CLOUD_ENDPOINT — override endpoint
|
|
9
|
+
// env ZERO_CLOUD_ORG — override org slug
|
|
10
|
+
//
|
|
11
|
+
// Precedence (high to low) for endpoint + org:
|
|
12
|
+
// 1. command-line flag (--endpoint, --org)
|
|
13
|
+
// 2. env var
|
|
14
|
+
// 3. config file
|
|
15
|
+
// 4. built-in default (endpoint only — org has no default)
|
|
16
|
+
//
|
|
17
|
+
// Long-lived CLI tokens live in credentials.json with mode-0600. The
|
|
18
|
+
// operator token remains env-only.
|
|
19
|
+
import { promises as fs } from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import os from "node:os";
|
|
22
|
+
import { UserError } from "./errors.js";
|
|
23
|
+
let configFileReader = (p) => fs.readFile(p, "utf8");
|
|
24
|
+
export function _setConfigFileReader(reader) {
|
|
25
|
+
configFileReader = reader ?? ((p) => fs.readFile(p, "utf8"));
|
|
26
|
+
}
|
|
27
|
+
// The orchestrator API is served at cloud.0.security/api (Traefik routes
|
|
28
|
+
// /api/* → strip-api → orchestrator). `api.0.security` has no DNS record,
|
|
29
|
+
// so it must NOT be a default (#508). The dashboard (auth, cli-auth,
|
|
30
|
+
// health) is the bare cloud.0.security host.
|
|
31
|
+
//
|
|
32
|
+
// Canonical-host change 2026-08-08: 0.security replaced the former
|
|
33
|
+
// 0sec.ai brand domain. A redirect default is broken for this CLI: native fetch drops the
|
|
34
|
+
// Authorization header on the cross-origin redirect hop, so every
|
|
35
|
+
// authenticated request 401s with "missing bearer token". The defaults
|
|
36
|
+
// must point at the canonical host directly.
|
|
37
|
+
export const DEFAULT_ENDPOINT = "https://cloud.0.security/api";
|
|
38
|
+
export const DEFAULT_DASHBOARD_URL = "https://cloud.0.security";
|
|
39
|
+
// Dev environment: the dashboard + orchestrator HMR served from the devbox
|
|
40
|
+
// over Tailscale at dev.cloud.0.security (the website dev host is
|
|
41
|
+
// dev.0.security). Reachable only on the tailnet. `--dev` selects these so a
|
|
42
|
+
// founder does not have to remember the hostnames or export env overrides.
|
|
43
|
+
export const DEV_ENDPOINT = "https://dev.cloud.0.security/api";
|
|
44
|
+
export const DEV_DASHBOARD_URL = "https://dev.cloud.0.security";
|
|
45
|
+
/**
|
|
46
|
+
* Derive the orchestrator API base URL from a dashboard host. 0
|
|
47
|
+
* only knows the dashboard host (cloud.0.security); the 0cloud API lives
|
|
48
|
+
* under `/api` on that same host. Idempotent if `/api` is already present.
|
|
49
|
+
*/
|
|
50
|
+
export function apiEndpointFromDashboard(dashboardHost) {
|
|
51
|
+
const trimmed = dashboardHost.replace(/\/+$/, "");
|
|
52
|
+
return trimmed.endsWith("/api") ? trimmed : `${trimmed}/api`;
|
|
53
|
+
}
|
|
54
|
+
export function configDir() {
|
|
55
|
+
return process.env.ZERO_CLOUD_CONFIG_DIR ?? path.join(os.homedir(), ".0cloud");
|
|
56
|
+
}
|
|
57
|
+
export function configPath() {
|
|
58
|
+
return path.join(configDir(), "config.json");
|
|
59
|
+
}
|
|
60
|
+
export function credentialsPath() {
|
|
61
|
+
return path.join(configDir(), "credentials.json");
|
|
62
|
+
}
|
|
63
|
+
export async function readConfigFile() {
|
|
64
|
+
try {
|
|
65
|
+
const raw = await configFileReader(configPath());
|
|
66
|
+
const parsed = JSON.parse(raw);
|
|
67
|
+
if (typeof parsed === "object" &&
|
|
68
|
+
parsed !== null &&
|
|
69
|
+
"endpoint" in parsed &&
|
|
70
|
+
"orgId" in parsed &&
|
|
71
|
+
typeof parsed.endpoint === "string" &&
|
|
72
|
+
typeof parsed.orgId === "string") {
|
|
73
|
+
const cf = parsed;
|
|
74
|
+
return {
|
|
75
|
+
endpoint: cf.endpoint,
|
|
76
|
+
orgId: cf.orgId,
|
|
77
|
+
orgSlug: typeof cf.orgSlug === "string" ? cf.orgSlug : undefined,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
if (err.code === "ENOENT")
|
|
84
|
+
return null;
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export async function writeConfigFile(cfg) {
|
|
89
|
+
await fs.mkdir(configDir(), { recursive: true });
|
|
90
|
+
await fs.writeFile(configPath(), JSON.stringify(cfg, null, 2) + "\n", {
|
|
91
|
+
mode: 0o600,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
export async function readCredentialsFile() {
|
|
95
|
+
try {
|
|
96
|
+
const raw = await configFileReader(credentialsPath());
|
|
97
|
+
const parsed = JSON.parse(raw);
|
|
98
|
+
if (typeof parsed === "object" &&
|
|
99
|
+
parsed !== null &&
|
|
100
|
+
typeof parsed.token === "string" &&
|
|
101
|
+
typeof parsed.orgId === "string") {
|
|
102
|
+
const c = parsed;
|
|
103
|
+
return {
|
|
104
|
+
token: c.token,
|
|
105
|
+
orgId: c.orgId,
|
|
106
|
+
orgSlug: typeof c.orgSlug === "string" ? c.orgSlug : undefined,
|
|
107
|
+
endpoint: typeof c.endpoint === "string" ? c.endpoint : undefined,
|
|
108
|
+
dashboardUrl: typeof c.dashboardUrl === "string" ? c.dashboardUrl : undefined,
|
|
109
|
+
scopes: Array.isArray(c.scopes)
|
|
110
|
+
? c.scopes.filter((s) => typeof s === "string")
|
|
111
|
+
: undefined,
|
|
112
|
+
createdAt: typeof c.createdAt === "string" ? c.createdAt : undefined,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
if (err.code === "ENOENT") {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
throw err;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
export async function writeCredentialsFile(creds) {
|
|
125
|
+
await fs.mkdir(configDir(), { recursive: true });
|
|
126
|
+
await fs.writeFile(credentialsPath(), JSON.stringify(creds, null, 2) + "\n", {
|
|
127
|
+
mode: 0o600,
|
|
128
|
+
});
|
|
129
|
+
await fs.chmod(credentialsPath(), 0o600);
|
|
130
|
+
}
|
|
131
|
+
export async function deleteCredentialsFile() {
|
|
132
|
+
try {
|
|
133
|
+
await fs.unlink(credentialsPath());
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
if (err.code === "ENOENT")
|
|
138
|
+
return false;
|
|
139
|
+
throw err;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const ORG_ID_RE = /^[A-Za-z0-9_-]{16,64}$/;
|
|
143
|
+
/**
|
|
144
|
+
* Resolve the runtime context (endpoint, org id, token) for a command
|
|
145
|
+
* invocation. Throws UserError with a helpful message when any required
|
|
146
|
+
* piece is missing — the command handler propagates that up to cli.ts
|
|
147
|
+
* which maps it to exit 1.
|
|
148
|
+
*/
|
|
149
|
+
export async function resolveContext(overrides, env = process.env, options = {}) {
|
|
150
|
+
const requireOrg = options.requireOrg ?? true;
|
|
151
|
+
const cfgFile = await readConfigFile();
|
|
152
|
+
const credsFile = await readCredentialsFile();
|
|
153
|
+
const token = env.ZERO_CLOUD_OPERATOR_TOKEN ??
|
|
154
|
+
env.ZERO_CLOUD_TOKEN ??
|
|
155
|
+
credsFile?.token;
|
|
156
|
+
if (!token || token.trim() === "") {
|
|
157
|
+
throw new UserError("No cloud token found. Run `0cloud auth login`, set ZERO_CLOUD_TOKEN, or export ZERO_CLOUD_OPERATOR_TOKEN.");
|
|
158
|
+
}
|
|
159
|
+
const endpoint = overrides.endpoint ??
|
|
160
|
+
env.ZERO_CLOUD_ENDPOINT ??
|
|
161
|
+
cfgFile?.endpoint ??
|
|
162
|
+
credsFile?.endpoint ??
|
|
163
|
+
DEFAULT_ENDPOINT;
|
|
164
|
+
if (!requireOrg) {
|
|
165
|
+
return {
|
|
166
|
+
endpoint: stripTrailingSlash(endpoint),
|
|
167
|
+
token,
|
|
168
|
+
verbose: overrides.verbose === true,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
// --org / env / config-file. We accept either an id (header-ready)
|
|
172
|
+
// or a slug (display-only) at this layer. `0cloud orgs list` can
|
|
173
|
+
// bootstrap ids, but tenant-scoped commands still need a header-ready
|
|
174
|
+
// id or a config-file slug -> id mapping.
|
|
175
|
+
const rawOrg = overrides.org ??
|
|
176
|
+
env.ZERO_CLOUD_ORG ??
|
|
177
|
+
cfgFile?.orgSlug ??
|
|
178
|
+
cfgFile?.orgId ??
|
|
179
|
+
credsFile?.orgSlug ??
|
|
180
|
+
credsFile?.orgId;
|
|
181
|
+
if (!rawOrg) {
|
|
182
|
+
throw new UserError("No org specified. Set --org <id-or-slug>, ZERO_CLOUD_ORG, or run `0cloud config init`.");
|
|
183
|
+
}
|
|
184
|
+
let orgId;
|
|
185
|
+
let orgSlug;
|
|
186
|
+
if (ORG_ID_RE.test(rawOrg)) {
|
|
187
|
+
// Header-ready id.
|
|
188
|
+
orgId = rawOrg;
|
|
189
|
+
orgSlug = cfgFile?.orgSlug ?? credsFile?.orgSlug;
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
// Looks like a slug. If the config file maps it to an id, use the
|
|
193
|
+
// id; otherwise refuse rather than send a slug the orchestrator
|
|
194
|
+
// will silently re-attribute to the operator org (see the memory
|
|
195
|
+
// note at reference_operator_token_org_scoping.md).
|
|
196
|
+
if (cfgFile && cfgFile.orgSlug === rawOrg) {
|
|
197
|
+
orgId = cfgFile.orgId;
|
|
198
|
+
orgSlug = cfgFile.orgSlug;
|
|
199
|
+
}
|
|
200
|
+
else if (credsFile && credsFile.orgSlug === rawOrg) {
|
|
201
|
+
orgId = credsFile.orgId;
|
|
202
|
+
orgSlug = credsFile.orgSlug;
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
throw new UserError(`Org slug "${rawOrg}" is not mapped to an id. Pass the opaque org id via --org / ZERO_CLOUD_ORG, run \`0cloud auth login\`, or set { "orgSlug": "${rawOrg}", "orgId": "..." } in ~/.0cloud/config.json.`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
endpoint: stripTrailingSlash(endpoint),
|
|
210
|
+
orgId,
|
|
211
|
+
orgSlug,
|
|
212
|
+
token,
|
|
213
|
+
verbose: overrides.verbose === true,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
function stripTrailingSlash(s) {
|
|
217
|
+
return s.endsWith("/") ? s.slice(0, -1) : s;
|
|
218
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readdir, lstat, readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { resolveContext } from "./config.js";
|
|
5
|
+
import { request } from "./http.js";
|
|
6
|
+
import { UserError } from "./errors.js";
|
|
7
|
+
export function dashboardApiContext(ctx) {
|
|
8
|
+
return { ...ctx, endpoint: ctx.endpoint.replace(/\/orchestrator\/?$/, "") };
|
|
9
|
+
}
|
|
10
|
+
export async function customerRequest(opts, route, method = "GET", body, idempotencyKey) {
|
|
11
|
+
if (!/^\/[a-zA-Z0-9]/.test(route) || route.includes("\\") || route.includes("#") || /(^|\/)\.\.(\/|$)/.test(decodeURIComponent(route))) {
|
|
12
|
+
throw new UserError("Use a relative API path, for example /scan or /project-setup.");
|
|
13
|
+
}
|
|
14
|
+
const ctx = dashboardApiContext(await resolveContext(opts));
|
|
15
|
+
return request(ctx, route, { method, body, idempotencyKey });
|
|
16
|
+
}
|
|
17
|
+
const SKIP = new Set([".git", "node_modules", "vendor", "dist", "build", ".next", "target", "__pycache__", ".env", ".env.local"]);
|
|
18
|
+
const BINARY = /\.(png|jpe?g|gif|webp|ico|pdf|zip|gz|tar|wasm|woff2?|ttf|eot|mp[34]|mov|exe|dll|so|dylib|bin)$/i;
|
|
19
|
+
const MAX_FILES = 300;
|
|
20
|
+
const MAX_FILE_BYTES = 400_000;
|
|
21
|
+
const MAX_TOTAL_BYTES = 8_000_000;
|
|
22
|
+
/** Upload source as data. Never execute it or follow filesystem symlinks. */
|
|
23
|
+
export async function readSubmission(input) {
|
|
24
|
+
const root = path.resolve(input);
|
|
25
|
+
const info = await lstat(root).catch(() => { throw new UserError(`File or folder not found: ${input}`); });
|
|
26
|
+
if (info.isSymbolicLink())
|
|
27
|
+
throw new UserError("Choose a file or folder, not a symlink.");
|
|
28
|
+
const files = [];
|
|
29
|
+
let skipped = 0;
|
|
30
|
+
let bytes = 0;
|
|
31
|
+
async function walk(file) {
|
|
32
|
+
const name = path.basename(file);
|
|
33
|
+
if (SKIP.has(name) || name.startsWith(".env.")) {
|
|
34
|
+
skipped++;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const stat = await lstat(file);
|
|
38
|
+
if (stat.isSymbolicLink()) {
|
|
39
|
+
skipped++;
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (stat.isDirectory()) {
|
|
43
|
+
for (const entry of (await readdir(file)).sort())
|
|
44
|
+
await walk(path.join(file, entry));
|
|
45
|
+
}
|
|
46
|
+
else if (stat.isFile()) {
|
|
47
|
+
if (BINARY.test(name) || stat.size > MAX_FILE_BYTES) {
|
|
48
|
+
skipped++;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (files.length >= MAX_FILES || bytes + stat.size > MAX_TOTAL_BYTES) {
|
|
52
|
+
throw new UserError("Submission exceeds 300 files or 8 MB. Select a smaller folder.");
|
|
53
|
+
}
|
|
54
|
+
const buffer = await readFile(file);
|
|
55
|
+
if (buffer.includes(0)) {
|
|
56
|
+
skipped++;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
let content;
|
|
60
|
+
try {
|
|
61
|
+
content = new TextDecoder("utf-8", { fatal: true }).decode(buffer);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
skipped++;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
files.push({ path: info.isDirectory() ? path.relative(root, file).split(path.sep).join("/") : name, content });
|
|
68
|
+
bytes += buffer.length;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
await walk(root);
|
|
72
|
+
if (!files.length)
|
|
73
|
+
throw new UserError("No source files to scan.");
|
|
74
|
+
return { files, skipped };
|
|
75
|
+
}
|
|
76
|
+
/** Fail closed on missing, unauthorized, malformed or unavailable pricing APIs. */
|
|
77
|
+
export async function quoteProduct(opts, offering) {
|
|
78
|
+
const catalog = await customerRequest(opts, "/product-prices");
|
|
79
|
+
if (!catalog || typeof catalog.enabled !== "boolean")
|
|
80
|
+
throw new UserError("Invalid product price response; no work was submitted.");
|
|
81
|
+
if (!catalog.enabled)
|
|
82
|
+
return {};
|
|
83
|
+
if (!Array.isArray(catalog.prices))
|
|
84
|
+
throw new UserError("Invalid product price response; no work was submitted.");
|
|
85
|
+
const matches = catalog.prices.filter((p) => !!p && typeof p === "object" && p.offering === offering);
|
|
86
|
+
const price = matches[0];
|
|
87
|
+
if (matches.length !== 1 || !price || !Number.isSafeInteger(price.credits) || price.credits <= 0 || typeof price.priceVersion !== "string" || !price.priceVersion.trim()) {
|
|
88
|
+
throw new UserError(`No valid fixed-credit price for ${offering}; no work was submitted.`);
|
|
89
|
+
}
|
|
90
|
+
process.stderr.write(`Price: ${price.credits} credits (${offering}; version ${price.priceVersion}).\n`);
|
|
91
|
+
return { expectedPriceVersion: price.priceVersion };
|
|
92
|
+
}
|
|
93
|
+
function attemptKey(key = randomUUID()) {
|
|
94
|
+
if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(key))
|
|
95
|
+
throw new UserError("Idempotency key must be a UUID.");
|
|
96
|
+
process.stderr.write(`Attempt key: ${key}. Reuse it for the same request after a connection failure; do not automatically resubmit.\n`);
|
|
97
|
+
return key;
|
|
98
|
+
}
|
|
99
|
+
export async function scanSubmission(input, opts, options = {}) {
|
|
100
|
+
if (options.focus && !options.whitebox)
|
|
101
|
+
throw new UserError("--focus requires --whitebox.");
|
|
102
|
+
if (!options.whitebox && !/^https:\/\//i.test(input)) {
|
|
103
|
+
throw new UserError("Repository analysis requires an HTTPS repository URL. Source uploads require explicit --whitebox and an enabled deployment.");
|
|
104
|
+
}
|
|
105
|
+
const key = attemptKey(options.idempotencyKey);
|
|
106
|
+
const route = options.whitebox ? "/whitebox-scan" : "/scan";
|
|
107
|
+
const focus = options.focus ? { focus: options.focus } : {};
|
|
108
|
+
let body;
|
|
109
|
+
if (/^https:\/\//i.test(input))
|
|
110
|
+
body = options.whitebox ? { input, ...focus } : { repo_url: input };
|
|
111
|
+
else {
|
|
112
|
+
const { files, skipped } = await readSubmission(input);
|
|
113
|
+
if (skipped)
|
|
114
|
+
process.stderr.write(`Skipped ${skipped} binary, generated or excluded paths.\n`);
|
|
115
|
+
body = { files, ...focus };
|
|
116
|
+
}
|
|
117
|
+
const quote = await quoteProduct(opts, "upload");
|
|
118
|
+
return customerRequest(opts, route, "POST", { ...body, ...(quote.expectedPriceVersion !== undefined ? { expected_price_version: quote.expectedPriceVersion } : {}) }, key);
|
|
119
|
+
}
|
|
120
|
+
export async function readCodebase(target, opts) {
|
|
121
|
+
const field = /^[0-9a-f-]{36}$/i.test(target) ? "repository_id" : "target";
|
|
122
|
+
return await customerRequest(opts, `/project-setup?${field}=${encodeURIComponent(target)}`);
|
|
123
|
+
}
|
|
124
|
+
export async function startPentest(target, opts, idempotencyKey = randomUUID()) {
|
|
125
|
+
idempotencyKey = attemptKey(idempotencyKey);
|
|
126
|
+
const record = await readCodebase(target, opts);
|
|
127
|
+
if (!record.readiness.canStart)
|
|
128
|
+
throw new UserError(record.readiness.reason || "Save and approve the codebase configuration before starting a pentest.");
|
|
129
|
+
const quote = await quoteProduct(opts, record.plan?.productTier ?? "quick");
|
|
130
|
+
return customerRequest(opts, "/project-setup", "POST", {
|
|
131
|
+
...quote, action: "start", repositoryId: record.repository.id,
|
|
132
|
+
expectedRevision: record.revision, idempotencyKey,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
export async function saveCodebase(target, file, opts) {
|
|
136
|
+
const record = await readCodebase(target, opts);
|
|
137
|
+
let input;
|
|
138
|
+
try {
|
|
139
|
+
input = JSON.parse(await readFile(file, "utf8"));
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
throw new UserError("Configuration must be a JSON file containing plan and optional enableSchedule.");
|
|
143
|
+
}
|
|
144
|
+
if (!input || typeof input !== "object" || !input.plan)
|
|
145
|
+
throw new UserError("Configuration must contain plan.");
|
|
146
|
+
return customerRequest(opts, "/project-setup", "POST", {
|
|
147
|
+
action: "save", repositoryId: record.repository.id, expectedRevision: record.revision,
|
|
148
|
+
sourceRevision: record.repository.sourceRevision, plan: input.plan,
|
|
149
|
+
...(input.enableSchedule !== undefined ? { enableSchedule: input.enableSchedule } : {}),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Exit-code discipline for 0cloud.
|
|
2
|
+
//
|
|
3
|
+
// 0 = success
|
|
4
|
+
// 1 = user error (bad flags, missing token, missing org, config error)
|
|
5
|
+
// 2 = server error (4xx/5xx from the API, network error)
|
|
6
|
+
//
|
|
7
|
+
// The CLI is meant to be safe to wrap in shell scripts and agent
|
|
8
|
+
// tool-calls, so the exit code is the single contract for "did this
|
|
9
|
+
// work?". Errors that violate the contract (e.g. an uncaught throw
|
|
10
|
+
// in a command handler) are caught at the top of cli.ts and mapped
|
|
11
|
+
// to exit 2 with a generic message so we never leak a stack trace
|
|
12
|
+
// to stdout / stderr.
|
|
13
|
+
export class UserError extends Error {
|
|
14
|
+
exitCode = 1;
|
|
15
|
+
constructor(message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "UserError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export class ServerError extends Error {
|
|
21
|
+
exitCode = 2;
|
|
22
|
+
status;
|
|
23
|
+
body;
|
|
24
|
+
constructor(message, status, body) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = "ServerError";
|
|
27
|
+
this.status = status;
|
|
28
|
+
this.body = body;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Tiny built-in table formatter — no cli-table / cli-truncate deps.
|
|
2
|
+
//
|
|
3
|
+
// The CLI's whole job is to be fast to install + boring to maintain;
|
|
4
|
+
// pulling in a chalk + cli-table stack adds dependencies that the
|
|
5
|
+
// `pnpm audit` surface has to track forever. String-padding is fine
|
|
6
|
+
// for the row counts we render (scans/findings lists cap at ~500).
|
|
7
|
+
//
|
|
8
|
+
// Color is intentionally off. The CLI is consumed by agents and by
|
|
9
|
+
// scripts piping to other tools; ANSI escapes break shell pipelines
|
|
10
|
+
// silently. If we add color later, gate it behind `--color` and
|
|
11
|
+
// auto-disable when stdout is not a TTY.
|
|
12
|
+
export function formatTable(rows, columns) {
|
|
13
|
+
if (rows.length === 0)
|
|
14
|
+
return "(no rows)\n";
|
|
15
|
+
const stringRows = rows.map((row) => columns.map((col) => {
|
|
16
|
+
const v = col.value(row);
|
|
17
|
+
const s = v == null ? "" : String(v);
|
|
18
|
+
return truncate(s, col.maxWidth);
|
|
19
|
+
}));
|
|
20
|
+
const widths = columns.map((col, i) => {
|
|
21
|
+
const headerWidth = col.header.length;
|
|
22
|
+
const maxCell = stringRows.reduce((acc, r) => Math.max(acc, (r[i] ?? "").length), 0);
|
|
23
|
+
return Math.max(headerWidth, maxCell);
|
|
24
|
+
});
|
|
25
|
+
const headerLine = columns
|
|
26
|
+
.map((col, i) => col.header.padEnd(widths[i] ?? col.header.length))
|
|
27
|
+
.join(" ");
|
|
28
|
+
const separator = widths.map((w) => "-".repeat(w)).join(" ");
|
|
29
|
+
const dataLines = stringRows.map((r) => r.map((cell, i) => cell.padEnd(widths[i] ?? cell.length)).join(" "));
|
|
30
|
+
return [headerLine, separator, ...dataLines].join("\n") + "\n";
|
|
31
|
+
}
|
|
32
|
+
function truncate(s, max) {
|
|
33
|
+
if (max === undefined || s.length <= max)
|
|
34
|
+
return s;
|
|
35
|
+
if (max <= 1)
|
|
36
|
+
return s.slice(0, max);
|
|
37
|
+
return s.slice(0, max - 1) + "…";
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Format a Date / ISO string into a compact relative form
|
|
41
|
+
* ("2m ago", "5h ago", "3d ago"). Falls back to the raw ISO string
|
|
42
|
+
* for values older than ~30 days so the caller can read the date.
|
|
43
|
+
*/
|
|
44
|
+
export function relativeTime(value, now = Date.now()) {
|
|
45
|
+
if (!value)
|
|
46
|
+
return "";
|
|
47
|
+
const t = Date.parse(value);
|
|
48
|
+
if (Number.isNaN(t))
|
|
49
|
+
return value;
|
|
50
|
+
const deltaSec = Math.max(0, Math.floor((now - t) / 1000));
|
|
51
|
+
if (deltaSec < 60)
|
|
52
|
+
return `${deltaSec}s ago`;
|
|
53
|
+
const deltaMin = Math.floor(deltaSec / 60);
|
|
54
|
+
if (deltaMin < 60)
|
|
55
|
+
return `${deltaMin}m ago`;
|
|
56
|
+
const deltaHr = Math.floor(deltaMin / 60);
|
|
57
|
+
if (deltaHr < 48)
|
|
58
|
+
return `${deltaHr}h ago`;
|
|
59
|
+
const deltaDay = Math.floor(deltaHr / 24);
|
|
60
|
+
if (deltaDay < 30)
|
|
61
|
+
return `${deltaDay}d ago`;
|
|
62
|
+
return value.slice(0, 10);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Pretty-print an arbitrary JSON value to stdout with 2-space indent.
|
|
66
|
+
* Used by every command's --json branch. Centralised here so we can
|
|
67
|
+
* later add a `--compact` / `--ndjson` mode in one place.
|
|
68
|
+
*/
|
|
69
|
+
export function printJson(value) {
|
|
70
|
+
process.stdout.write(JSON.stringify(value, null, 2) + "\n");
|
|
71
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Thin HTTP wrapper around native fetch.
|
|
2
|
+
//
|
|
3
|
+
// Adds:
|
|
4
|
+
// - Authorization: Bearer <operator token>
|
|
5
|
+
// - X-0-Org-Id: <orgId> for tenant-scoped requests (see the
|
|
6
|
+
// org-scoping memory note; without this the orchestrator defaults
|
|
7
|
+
// to the operator org and tenant scan / finding rows become
|
|
8
|
+
// invisible in /<slug>/scans)
|
|
9
|
+
// - User-Agent: 0cloud/<version>
|
|
10
|
+
// - JSON request body + Content-Type handling
|
|
11
|
+
// - Optional verbose log line (method + url + status — NEVER the
|
|
12
|
+
// auth header)
|
|
13
|
+
// - Maps non-2xx responses to a typed ServerError; uses 2xx body as
|
|
14
|
+
// `unknown` so callers narrow with a type guard.
|
|
15
|
+
//
|
|
16
|
+
// Native fetch is used directly — Node 24 ships a stable
|
|
17
|
+
// implementation (the orchestrator pins ">=24.0.0" in its
|
|
18
|
+
// package.json `engines`, this package matches).
|
|
19
|
+
import { ServerError } from "./errors.js";
|
|
20
|
+
// Hardcoded — kept in sync with package.json manually. The dist
|
|
21
|
+
// build doesn't have a `process.env.npm_package_version` baked in
|
|
22
|
+
// when run as a global bin, so we ship a constant.
|
|
23
|
+
export const USER_AGENT_VERSION = "0.1.0";
|
|
24
|
+
export async function request(ctx, pathAndQuery, options = {}) {
|
|
25
|
+
const method = options.method ?? "GET";
|
|
26
|
+
const url = buildUrl(ctx.endpoint, pathAndQuery, options.query);
|
|
27
|
+
const headers = {
|
|
28
|
+
Authorization: `Bearer ${ctx.token}`,
|
|
29
|
+
"User-Agent": `0cloud/${USER_AGENT_VERSION}`,
|
|
30
|
+
Accept: "application/json",
|
|
31
|
+
};
|
|
32
|
+
if (!options.omitOrgHeader && ctx.orgId) {
|
|
33
|
+
headers["X-0-Org-Id"] = ctx.orgId;
|
|
34
|
+
}
|
|
35
|
+
if (options.idempotencyKey)
|
|
36
|
+
headers["Idempotency-Key"] = options.idempotencyKey;
|
|
37
|
+
let body;
|
|
38
|
+
if (options.body !== undefined) {
|
|
39
|
+
headers["Content-Type"] = "application/json";
|
|
40
|
+
body = JSON.stringify(options.body);
|
|
41
|
+
}
|
|
42
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
43
|
+
const stderr = options.stderr ?? process.stderr;
|
|
44
|
+
const startedAt = Date.now();
|
|
45
|
+
const response = await fetchImpl(url, { method, headers, body, redirect: "error", signal: AbortSignal.timeout(300_000) });
|
|
46
|
+
const durationMs = Date.now() - startedAt;
|
|
47
|
+
if (ctx.verbose) {
|
|
48
|
+
// NEVER log the Authorization header. Method + url + status +
|
|
49
|
+
// duration is enough to debug routing / org scoping without
|
|
50
|
+
// leaking the operator bearer into a shared terminal.
|
|
51
|
+
stderr.write(`[0cloud] ${method} ${url} -> ${response.status} (${durationMs}ms)\n`);
|
|
52
|
+
}
|
|
53
|
+
// Try to parse the body as JSON. Some routes (e.g. the artifact-
|
|
54
|
+
// bundle zip stream — not currently called by the CLI) return
|
|
55
|
+
// non-JSON; we don't handle that here. All JSON-returning routes
|
|
56
|
+
// emit either a 2xx object or an `{ error: string, ... }` 4xx/5xx.
|
|
57
|
+
const text = await response.text();
|
|
58
|
+
let parsed = null;
|
|
59
|
+
if (text.length > 0) {
|
|
60
|
+
try {
|
|
61
|
+
parsed = JSON.parse(text);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// Non-JSON body. Fall through with `parsed = null`; the error
|
|
65
|
+
// path below carries `text` as a fallback.
|
|
66
|
+
parsed = text;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (!response.ok) {
|
|
70
|
+
const message = extractErrorMessage(parsed, text, response.status);
|
|
71
|
+
throw new ServerError(message, response.status, parsed);
|
|
72
|
+
}
|
|
73
|
+
return parsed;
|
|
74
|
+
}
|
|
75
|
+
function buildUrl(endpoint, pathAndQuery, query) {
|
|
76
|
+
const base = endpoint.endsWith("/") ? endpoint.slice(0, -1) : endpoint;
|
|
77
|
+
const suffix = pathAndQuery.startsWith("/") ? pathAndQuery : `/${pathAndQuery}`;
|
|
78
|
+
const url = new URL(`${base}${suffix}`);
|
|
79
|
+
if (query) {
|
|
80
|
+
for (const [k, v] of Object.entries(query)) {
|
|
81
|
+
if (v === undefined)
|
|
82
|
+
continue;
|
|
83
|
+
url.searchParams.set(k, String(v));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return url.toString();
|
|
87
|
+
}
|
|
88
|
+
function extractErrorMessage(parsed, rawText, status) {
|
|
89
|
+
if (parsed && typeof parsed === "object") {
|
|
90
|
+
const obj = parsed;
|
|
91
|
+
if (typeof obj.message === "string" && obj.message.length > 0) {
|
|
92
|
+
return `${status} ${obj.message}`;
|
|
93
|
+
}
|
|
94
|
+
if (typeof obj.error === "string" && obj.error.length > 0) {
|
|
95
|
+
return `${status} ${obj.error}`;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (rawText.length > 0)
|
|
99
|
+
return `${status} ${rawText.slice(0, 200)}`;
|
|
100
|
+
return `HTTP ${status}`;
|
|
101
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command, CommanderError, Option } from "commander";
|
|
3
|
+
import { realpathSync } from "node:fs";
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
7
|
+
import { runAuthLogin, runAuthLogout, runAuthStatus } from "./commands/auth.js";
|
|
8
|
+
import { resolveContext } from "./config.js";
|
|
9
|
+
import { request } from "./http.js";
|
|
10
|
+
import { UserError } from "./errors.js";
|
|
11
|
+
import { customerRequest, readCodebase, saveCodebase, scanSubmission, startPentest } from "./customer.js";
|
|
12
|
+
import { registerWorkflowCommands } from "./workflow-commands.js";
|
|
13
|
+
import { USER_AGENT_VERSION } from "./http.js";
|
|
14
|
+
function output(value) { process.stdout.write(JSON.stringify(value, null, 2) + "\n"); }
|
|
15
|
+
function positive(value) {
|
|
16
|
+
const n = Number(value);
|
|
17
|
+
if (!Number.isSafeInteger(n) || n < 1)
|
|
18
|
+
throw new UserError("Expected a positive integer.");
|
|
19
|
+
return n;
|
|
20
|
+
}
|
|
21
|
+
function nonnegative(value) {
|
|
22
|
+
const n = Number(value);
|
|
23
|
+
if (!Number.isSafeInteger(n) || n < 0)
|
|
24
|
+
throw new UserError("Expected a nonnegative integer.");
|
|
25
|
+
return n;
|
|
26
|
+
}
|
|
27
|
+
function context(cmd) { return cmd.optsWithGlobals(); }
|
|
28
|
+
const terminal = new Set(["complete", "failed", "cancelled", "cost_exceeded", "capacity_exceeded"]);
|
|
29
|
+
/** Public client only. Internal research/disclosure/operator commands stay private. */
|
|
30
|
+
export function buildPublicProgram() {
|
|
31
|
+
const p = new Command().name("0cloud").version(USER_AGENT_VERSION)
|
|
32
|
+
.description("Security tests, source analysis and workspace actions through the 0cloud API. Results are JSON.")
|
|
33
|
+
.option("--endpoint <url>", "API base URL; defaults to your saved login")
|
|
34
|
+
.option("--org <id>", "Workspace ID; defaults to your saved login")
|
|
35
|
+
.option("--json", "Output JSON (default)")
|
|
36
|
+
.option("-v, --verbose", "Log request method, URL and status to stderr");
|
|
37
|
+
const auth = p.command("auth").description("Sign in and manage this CLI's credentials");
|
|
38
|
+
auth.command("login").option("--dashboard <url>", "Dashboard URL")
|
|
39
|
+
.option("--no-open", "Print the login URL without opening it")
|
|
40
|
+
.option("--timeout <seconds>", "Login timeout", positive, 600)
|
|
41
|
+
.option("--poll-interval <seconds>", "Login polling interval", positive, 2)
|
|
42
|
+
.action(async (o, cmd) => runAuthLogin({ ...context(cmd), ...o }));
|
|
43
|
+
auth.command("status").action(async (_o, cmd) => runAuthStatus({ json: true }));
|
|
44
|
+
auth.command("logout").action(runAuthLogout);
|
|
45
|
+
p.command("scan <repository-url-or-source>").description("Analyze a repository URL; --whitebox also accepts source uploads where enabled")
|
|
46
|
+
.option("--idempotency-key <uuid>", "Reuse this key when retrying the same source submission")
|
|
47
|
+
.option("--whitebox", "Deeper source-only analysis, not an active security test")
|
|
48
|
+
.option("--focus <text>", "Focus instructions for whitebox analysis")
|
|
49
|
+
.action(async (input, o, cmd) => output(await scanSubmission(input, context(cmd), o)));
|
|
50
|
+
const codebases = p.command("codebases").description("Read and configure enrolled codebases");
|
|
51
|
+
codebases.command("list").action(async (_o, cmd) => output(await customerRequest(context(cmd), "/project-setup")));
|
|
52
|
+
codebases.command("show <repo-or-id>").action(async (target, _o, cmd) => output(await readCodebase(target, context(cmd))));
|
|
53
|
+
codebases.command("configure <repo-or-id>").requiredOption("--file <json>", "Plan JSON; schedules change only when enableSchedule is supplied")
|
|
54
|
+
.action(async (target, o, cmd) => output(await saveCodebase(target, o.file, context(cmd))));
|
|
55
|
+
codebases.command("enroll <repository-id>").description("Enroll a repository already available through the workspace GitHub connection")
|
|
56
|
+
.action(async (repositoryId, _o, cmd) => output(await customerRequest(context(cmd), "/project-setup", "POST", { action: "enroll", repositoryId })));
|
|
57
|
+
registerWorkflowCommands(p);
|
|
58
|
+
const pentests = p.command("pentests", { hidden: true }).description("Compatibility command; use tests start");
|
|
59
|
+
pentests.command("start <repo-or-id>").option("--idempotency-key <uuid>", "Reuse this key when retrying the same request")
|
|
60
|
+
.action(async (target, o, cmd) => output(await startPentest(target, context(cmd), o.idempotencyKey)));
|
|
61
|
+
const runs = p.command("runs").description("Read progress, results and cancellation for managed runs");
|
|
62
|
+
runs.command("list").option("--status <status>").option("--limit <n>", "Page size", positive, 20).option("--offset <n>", "Page offset", nonnegative, 0)
|
|
63
|
+
.action(async (o, cmd) => output(await request(await resolveContext(context(cmd)), "/scans", { query: { status: o.status, limit: o.limit, offset: o.offset } })));
|
|
64
|
+
runs.command("show <id>").action(async (id, _o, cmd) => output(await request(await resolveContext(context(cmd)), `/scans/${encodeURIComponent(id)}`)));
|
|
65
|
+
runs.command("events <id>").option("--limit <n>", "Event page size", positive, 100).option("--offset <n>", "Event offset", nonnegative, 0)
|
|
66
|
+
.action(async (id, o, cmd) => output(await request(await resolveContext(context(cmd)), `/scans/${encodeURIComponent(id)}/events`, { query: { limit: o.limit, offset: o.offset } })));
|
|
67
|
+
runs.command("cancel <id>").action(async (id, _o, cmd) => output(await request(await resolveContext(context(cmd)), `/scans/${encodeURIComponent(id)}/cancel`, { method: "POST" })));
|
|
68
|
+
runs.command("report <id>").action(async (id, _o, cmd) => output(await request(await resolveContext(context(cmd)), `/scans/${encodeURIComponent(id)}/artifact-bundle`)));
|
|
69
|
+
runs.command("wait <id>").option("--interval <seconds>", "Polling interval", positive, 5).option("--timeout <seconds>", "Timeout", positive, 1800)
|
|
70
|
+
.action(async (id, o, cmd) => {
|
|
71
|
+
const ctx = await resolveContext(context(cmd));
|
|
72
|
+
const end = Date.now() + o.timeout * 1000;
|
|
73
|
+
for (;;) {
|
|
74
|
+
const run = await request(ctx, `/scans/${encodeURIComponent(id)}`);
|
|
75
|
+
if (terminal.has(run.status)) {
|
|
76
|
+
output(run);
|
|
77
|
+
if (run.status !== "complete")
|
|
78
|
+
process.exitCode = 2;
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (Date.now() >= end)
|
|
82
|
+
throw new UserError("Timed out waiting. The run continues; use runs show or runs cancel.");
|
|
83
|
+
await delay(Math.min(o.interval * 1000, end - Date.now()));
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
const findings = p.command("findings").description("Read and review workspace findings");
|
|
87
|
+
findings.command("list").option("--status <status>", "Recorded review status").option("--category <category>").option("--scan-id <id>").option("--severity <severity>").option("--limit <n>", "Page size", positive, 20).option("--offset <n>", "Page offset", nonnegative, 0)
|
|
88
|
+
.action(async (o, cmd) => output(await request(await resolveContext(context(cmd)), "/findings", { query: { status: o.status, category: o.category, scan_id: o.scanId, severity: o.severity, limit: o.limit, offset: o.offset } })));
|
|
89
|
+
findings.command("show <id>").action(async (id, _o, cmd) => output(await request(await resolveContext(context(cmd)), `/findings/${encodeURIComponent(id)}`)));
|
|
90
|
+
findings.command("triage <id>").addOption(new Option("--status <status>").choices(["pending", "true_positive", "false_positive", "unsure", "reported", "wont_fix"]).makeOptionMandatory())
|
|
91
|
+
.requiredOption("--reason <text>", "Reason for the review decision")
|
|
92
|
+
.action(async (id, o, cmd) => output(await request(await resolveContext(context(cmd)), `/findings/${encodeURIComponent(id)}/triage`, { method: "POST", body: { triage_status: o.status, triage_notes: o.reason } })));
|
|
93
|
+
const deps = p.command("dependencies").description("Read and check dependencies of enrolled codebases");
|
|
94
|
+
deps.command("list").option("--limit <n>", "Page size (max 500)", positive, 20).option("--offset <n>", "Offset", value => { const n = Number(value); if (!Number.isSafeInteger(n) || n < 0)
|
|
95
|
+
throw new UserError("Offset must be a nonnegative integer."); return n; }, 0)
|
|
96
|
+
.action(async (o, cmd) => output(await customerRequest(context(cmd), `/dependencies?limit=${o.limit}&offset=${o.offset}`)));
|
|
97
|
+
deps.command("scan").action(async (_o, cmd) => output(await customerRequest(context(cmd), "/dependencies/scan", "POST")));
|
|
98
|
+
deps.command("fix <dependency-id> <finding-id>").description("Open an upgrade PR; does not merge it")
|
|
99
|
+
.action(async (dependencyId, findingId, _o, cmd) => output(await customerRequest(context(cmd), "/dependencies/fix", "POST", { dependencyId, findingId })));
|
|
100
|
+
const reviews = p.command("reviews").description("Read PR Security history; automatic reviews follow saved repository settings");
|
|
101
|
+
reviews.command("list").addOption(new Option("--days <n>", "History window; 0 means all time").choices(["0", "7", "30", "90"]).default("0")).option("--page <n>", "Page", positive, 1)
|
|
102
|
+
.option("--repository <id>", "Codebase UUID").option("--cursor <json>", "Next cursor returned by the API")
|
|
103
|
+
.action(async (o, cmd) => output(await customerRequest(context(cmd), `/reviews?${new URLSearchParams({ days: String(o.days), page: String(o.page), ...(o.repository ? { repository: o.repository } : {}), ...(o.cursor ? { cursor: o.cursor } : {}) })}`)));
|
|
104
|
+
const api = p.command("api").description("Call other workspace API actions using the same login");
|
|
105
|
+
for (const method of ["GET", "POST", "PATCH", "DELETE"]) {
|
|
106
|
+
api.command(`${method.toLowerCase()} <path>`).option("--file <json>", "JSON request body file")
|
|
107
|
+
.action(async (route, o, cmd) => {
|
|
108
|
+
let body;
|
|
109
|
+
if (o.file) {
|
|
110
|
+
try {
|
|
111
|
+
body = JSON.parse(await readFile(o.file, "utf8"));
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
throw new UserError("Request body must be a JSON file.");
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (method === "GET" && body !== undefined)
|
|
118
|
+
throw new UserError("GET requests cannot have a body.");
|
|
119
|
+
output(await customerRequest(context(cmd), route, method, body));
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
p.addHelpText("after", "\nStart here:\n 0cloud auth login\n 0cloud codebases list\n 0cloud tests readiness <codebase>\n 0cloud tests start <codebase> --idempotency-key <uuid>\n\nscan analyzes source; tests starts saved-codebase execution and honors its saved publication policy.\n");
|
|
123
|
+
p.command("capabilities").description("Print command and API coverage")
|
|
124
|
+
.action(() => output({ version: USER_AGENT_VERSION, commands: p.commands.map(c => c.name()),
|
|
125
|
+
workflows: { tests: "Saved-codebase security tests; pentests is a compatibility command", scan: "Immediate source-only analysis", sourceScans: "Queued exact-commit source analysis", memory: "Rules through the current Memory API", usage: "Read-only funding readiness, fixed product prices and model catalog" },
|
|
126
|
+
pending: ["Website target setup and authorized test-start API parity", "Manual PR review dispatch", "Knowledge documents public API", "Team, integration and payment administration"],
|
|
127
|
+
note: "Server availability and workspace permissions determine which actions can run." }));
|
|
128
|
+
return p;
|
|
129
|
+
}
|
|
130
|
+
export async function runPublic(argv) {
|
|
131
|
+
process.exitCode = undefined;
|
|
132
|
+
const p = buildPublicProgram().exitOverride();
|
|
133
|
+
try {
|
|
134
|
+
await p.parseAsync(argv, { from: "user" });
|
|
135
|
+
return process.exitCode === 2 ? 2 : 0;
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
if (err instanceof CommanderError)
|
|
139
|
+
return err.exitCode;
|
|
140
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
141
|
+
return err instanceof UserError ? 1 : 2;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const entry = process.argv[1] ? realpathSync(process.argv[1]) : null;
|
|
145
|
+
if (entry === fileURLToPath(import.meta.url))
|
|
146
|
+
void runPublic(process.argv.slice(2)).then(code => { process.exitCode = code; });
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { Option } from "commander";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
4
|
+
import { customerRequest, quoteProduct, readCodebase, startPentest } from "./customer.js";
|
|
5
|
+
import { resolveContext } from "./config.js";
|
|
6
|
+
import { UserError } from "./errors.js";
|
|
7
|
+
const output = (value) => { process.stdout.write(JSON.stringify(value, null, 2) + "\n"); };
|
|
8
|
+
const context = (cmd) => cmd.optsWithGlobals();
|
|
9
|
+
function integer(value) {
|
|
10
|
+
const n = Number(value);
|
|
11
|
+
if (!Number.isSafeInteger(n) || n < 0)
|
|
12
|
+
throw new UserError("Expected a nonnegative integer.");
|
|
13
|
+
return n;
|
|
14
|
+
}
|
|
15
|
+
function positive(value) {
|
|
16
|
+
const n = integer(value);
|
|
17
|
+
if (!n)
|
|
18
|
+
throw new UserError("Expected a positive integer.");
|
|
19
|
+
return n;
|
|
20
|
+
}
|
|
21
|
+
function uuid(value) {
|
|
22
|
+
if (!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(value))
|
|
23
|
+
throw new UserError("Expected a UUID.");
|
|
24
|
+
return value.toLowerCase();
|
|
25
|
+
}
|
|
26
|
+
function sha(value) {
|
|
27
|
+
if (!/^[a-f0-9]{40}$/i.test(value))
|
|
28
|
+
throw new UserError("Use the full 40-character commit SHA.");
|
|
29
|
+
return value.toLowerCase();
|
|
30
|
+
}
|
|
31
|
+
/** Only existing public HTTP contracts. No dashboard RPCs or operator privileges. */
|
|
32
|
+
export function registerWorkflowCommands(p) {
|
|
33
|
+
const tests = p.command("tests").description("Start security tests using an enrolled codebase's saved scope and budget");
|
|
34
|
+
tests.command("readiness <codebase>").description("Read configuration and admission readiness; does not start work")
|
|
35
|
+
.action(async (target, _o, cmd) => output(await readCodebase(target, context(cmd))));
|
|
36
|
+
tests.command("start <codebase>").option("--idempotency-key <uuid>", "Reuse for the same attempt; never automatically replay an unknown result", uuid)
|
|
37
|
+
.action(async (target, o, cmd) => output(await startPentest(target, context(cmd), o.idempotencyKey)));
|
|
38
|
+
const usage = p.command("usage").description("Read funding readiness and available inference models; never changes billing");
|
|
39
|
+
usage.command("status").action(async (_o, cmd) => output(await customerRequest(context(cmd), "/inference/account")));
|
|
40
|
+
usage.command("models").action(async (_o, cmd) => output(await customerRequest(context(cmd), "/inference/v1/models")));
|
|
41
|
+
usage.command("prices").description("Read fixed product credit prices for this workspace")
|
|
42
|
+
.action(async (_o, cmd) => output(await customerRequest(context(cmd), "/product-prices")));
|
|
43
|
+
const memory = p.command("memory").description("Read Memory and manage explicit rules with revision checks");
|
|
44
|
+
memory.command("list").option("--repository <uuid>", "Scope to a codebase", uuid)
|
|
45
|
+
.action(async (o, cmd) => output(await customerRequest(context(cmd), `/memory${o.repository ? `?repositoryId=${o.repository}` : ""}`)));
|
|
46
|
+
memory.command("add <text>").description("Add a workspace rule; does not alter repository execution policy").requiredOption("--revision <n>", "Revision returned by memory list", integer)
|
|
47
|
+
.action(async (text, o, cmd) => {
|
|
48
|
+
if (!text.trim() || text.trim().length > 1000)
|
|
49
|
+
throw new UserError("Use 1–1000 characters.");
|
|
50
|
+
output(await customerRequest(context(cmd), "/memory", "POST", { action: "add", text: text.trim(), expectedRevision: o.revision }));
|
|
51
|
+
});
|
|
52
|
+
memory.command("remove <id>").description("Remove an explicit workspace rule by its exact ID").requiredOption("--revision <n>", "Revision returned by memory list", integer)
|
|
53
|
+
.action(async (id, o, cmd) => output(await customerRequest(context(cmd), "/memory", "POST", { action: "remove", id, expectedRevision: o.revision })));
|
|
54
|
+
const source = p.command("source-scans").description("Queued source-only analysis pinned to an exact codebase commit; deployment must enable it");
|
|
55
|
+
source.command("list").option("--repository <uuid>", "Codebase UUID", uuid).option("--cursor <cursor>", "Next cursor returned by the API")
|
|
56
|
+
.action(async (o, cmd) => {
|
|
57
|
+
const ctx = await resolveContext(context(cmd));
|
|
58
|
+
const q = new URLSearchParams({ org_id: ctx.orgId, ...(o.repository ? { repository_id: o.repository } : {}), ...(o.cursor ? { cursor: o.cursor } : {}) });
|
|
59
|
+
output(await customerRequest(context(cmd), `/source-scans?${q}`));
|
|
60
|
+
});
|
|
61
|
+
source.command("start <repository-id>").requiredOption("--commit <sha>", "Full immutable commit SHA", sha)
|
|
62
|
+
.addOption(new Option("--operation <type>", "Source analysis method").choices(["scan", "whitebox-scan"]).default("scan"))
|
|
63
|
+
.option("--focus <text>", "Focus instructions, up to 4000 characters")
|
|
64
|
+
.option("--idempotency-key <uuid>", "Reuse for the same immutable submission", uuid)
|
|
65
|
+
.action(async (repository, o, cmd) => {
|
|
66
|
+
const repositoryId = uuid(repository);
|
|
67
|
+
if (o.focus?.length > 4000)
|
|
68
|
+
throw new UserError("Focus must be at most 4000 characters.");
|
|
69
|
+
const ctx = await resolveContext(context(cmd));
|
|
70
|
+
const idempotencyKey = o.idempotencyKey ?? randomUUID();
|
|
71
|
+
process.stderr.write(`Attempt key: ${idempotencyKey}. Reuse with the same repository, commit, operation and focus after a connection failure.\n`);
|
|
72
|
+
const quote = await quoteProduct(context(cmd), "upload");
|
|
73
|
+
output(await customerRequest(context(cmd), "/source-scans", "POST", { ...quote, orgId: ctx.orgId, repositoryId, commitSha: o.commit, operation: o.operation, idempotencyKey, ...(o.focus !== undefined ? { focus: o.focus } : {}) }));
|
|
74
|
+
});
|
|
75
|
+
async function read(id, cmd) {
|
|
76
|
+
const ctx = await resolveContext(context(cmd));
|
|
77
|
+
return await customerRequest(context(cmd), `/source-scans/${uuid(id)}?org_id=${encodeURIComponent(ctx.orgId)}`);
|
|
78
|
+
}
|
|
79
|
+
source.command("show <id>").action(async (id, _o, cmd) => output(await read(id, cmd)));
|
|
80
|
+
source.command("cancel <id>").action(async (id, _o, cmd) => {
|
|
81
|
+
const ctx = await resolveContext(context(cmd));
|
|
82
|
+
output(await customerRequest(context(cmd), `/source-scans/${uuid(id)}/cancel`, "POST", { orgId: ctx.orgId }));
|
|
83
|
+
});
|
|
84
|
+
source.command("wait <id>").option("--interval <seconds>", "Polling interval", positive, 5).option("--timeout <seconds>", "Maximum wait; does not cancel the job", positive, 1800)
|
|
85
|
+
.action(async (id, o, cmd) => {
|
|
86
|
+
const end = Date.now() + o.timeout * 1000;
|
|
87
|
+
for (;;) {
|
|
88
|
+
const job = await read(id, cmd);
|
|
89
|
+
if (["completed", "failed", "cancelled", "recovery_required"].includes(job.state)) {
|
|
90
|
+
output(job);
|
|
91
|
+
if (job.state !== "completed")
|
|
92
|
+
process.exitCode = 2;
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (!["queued", "running"].includes(job.state))
|
|
96
|
+
throw new UserError(`Unknown source job state: ${job.state}. Inspect source-scans show; do not resubmit.`);
|
|
97
|
+
if (Date.now() >= end)
|
|
98
|
+
throw new UserError("Wait timed out. The job was not cancelled; inspect source-scans show.");
|
|
99
|
+
await delay(Math.min(o.interval * 1000, end - Date.now()));
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@0cloud/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Standalone CLI for the 0cloud API: source scans, pentests, run status and workspace actions.",
|
|
6
|
+
"bin": {
|
|
7
|
+
"0cloud": "./dist/public/public-cli.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./dist/public/public-cli.js",
|
|
10
|
+
"files": [
|
|
11
|
+
"dist/public",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "node -e \"require('node:fs').rmSync('dist/public', {recursive:true,force:true})\" && tsc -p tsconfig.publish.json && chmod +x dist/public/public-cli.js",
|
|
16
|
+
"dev": "tsx watch src/cli.ts",
|
|
17
|
+
"start": "node dist/public/public-cli.js",
|
|
18
|
+
"test": "vitest run",
|
|
19
|
+
"test:watch": "vitest",
|
|
20
|
+
"typecheck": "tsc --noEmit",
|
|
21
|
+
"prepublishOnly": "pnpm run build",
|
|
22
|
+
"build:operator": "tsc",
|
|
23
|
+
"prepack": "pnpm run build"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"commander": "^12.1.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "^26.5.1",
|
|
30
|
+
"tsx": "^4.23.13",
|
|
31
|
+
"typescript": "^5.7.2",
|
|
32
|
+
"vitest": "^5.0.1",
|
|
33
|
+
"@0cloud/cloud-contracts": "workspace:*",
|
|
34
|
+
"@0cloud/report": "workspace:*"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=24.0.0"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
}
|
|
42
|
+
}
|