@0cloud/cli 0.0.0-stage → 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/README.md CHANGED
@@ -1,3 +1,166 @@
1
- # Temporary Holding Version
1
+ # 0cloud CLI
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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 local source without executing it | `0cloud scan ./src` |
23
+ | Deeper source-only analysis | `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 ./src
42
+ 0cloud scan https://github.com/owner/repository
43
+ 0cloud scan ./src --whitebox --focus "Authorization and tenant isolation"
44
+ ```
45
+
46
+ Uploads exclude symlinks, `.env` files, dependency/build directories, invalid
47
+ UTF-8 and common binary formats. Limits: 300 files, 400 KB per file and 8 MB total.
48
+ Excluded paths are reported on stderr; exceeding total limits fails rather than
49
+ silently returning a partial submission. Review the files you select before upload.
50
+
51
+ `--whitebox` uses `/api/whitebox-scan`; `--focus` requires it. Repository URL
52
+ support depends on deployment configuration. Funded source-analysis deployments
53
+ can require file uploads; a rejected URL is reported as an error without a fallback.
54
+
55
+ For deployments with queued source operations enabled:
56
+
57
+ ```sh
58
+ 0cloud source-scans start <repository-uuid> --commit <full-40-character-sha> \
59
+ --operation whitebox-scan --idempotency-key <uuid>
60
+ 0cloud source-scans list --repository <repository-uuid>
61
+ 0cloud source-scans show <job-id>
62
+ 0cloud source-scans wait <job-id>
63
+ 0cloud source-scans cancel <job-id>
64
+ ```
65
+
66
+ Queued source jobs use their own IDs and `source-scans` commands. Managed
67
+ executions use `runs`. A `recovery_required` result is a failure requiring usage
68
+ reconciliation, never a reason to resubmit automatically. Deployment feature
69
+ flags, authority and operation limits remain server-controlled.
70
+
71
+ ## Codebases and security tests
72
+
73
+ ```sh
74
+ 0cloud codebases show owner/repository
75
+ 0cloud codebases configure owner/repository --file plan.json
76
+ 0cloud tests readiness owner/repository
77
+ 0cloud tests start owner/repository --idempotency-key <uuid>
78
+ 0cloud runs wait <run-id>
79
+ 0cloud runs report <run-id>
80
+ ```
81
+
82
+ `configure` reads JSON containing `plan` and optional `enableSchedule`. It saves
83
+ against the server's current revision and source revision. Inspect `codebases
84
+ show` and the proposed plan before saving. It never starts a test. Changing a
85
+ schedule or publication policy requires your explicit decision.
86
+
87
+ `codebases enroll <repository-uuid>` enrolls a repository already available
88
+ through the workspace's GitHub connection; inspect its saved trigger policy first.
89
+ GitHub connection and new-install consent remain browser flows.
90
+
91
+ Start uses the saved scope, budget and publication policy. The CLI prints an
92
+ attempt key to stderr before submission. Save it. If a response is lost, inspect
93
+ the recorded runs and reuse the same key for the same attempt. Do not generate a
94
+ new key or replay automatically when execution or usage is uncertain. A start
95
+ response is admission evidence, not proof of a completed test.
96
+
97
+ ## History and review
98
+
99
+ ```sh
100
+ 0cloud runs list --status failed --limit 20 --offset 20
101
+ 0cloud runs events <run-id> --limit 100 --offset 100
102
+ 0cloud findings list --scan-id <run-id> --status pending --limit 20 --offset 20
103
+ 0cloud findings triage <finding-id> --status unsure --reason "Needs reproduction evidence"
104
+ 0cloud reviews list --days 0 --repository <repository-uuid>
105
+ ```
106
+
107
+ PR history defaults to all time; `--days` accepts 0, 7, 30 or 90. Use `--cursor`
108
+ with the JSON cursor returned by the API, or `--page` for its existing page API.
109
+ Runs, findings and events support `--limit` and `--offset`. Review decisions do
110
+ not establish reproduction or a verified repair. PR execution follows saved
111
+ repository triggers; manual review dispatch and GitHub delivery are not added
112
+ by this CLI release.
113
+
114
+ ## Memory and usage
115
+
116
+ ```sh
117
+ 0cloud memory list
118
+ 0cloud memory list --repository <repository-uuid>
119
+ 0cloud memory add "Check tenant ownership before reading records" --revision <revision>
120
+ 0cloud memory remove <workspace-memory-id> --revision <revision>
121
+ 0cloud usage status
122
+ 0cloud usage models
123
+ ```
124
+
125
+ Memory writes manage explicit workspace rules. Use the exact revision and rule
126
+ ID returned by the API. Conflicts are reported without automatic overwrite.
127
+ The repository filter scopes reads; it does not change the scope of new rules.
128
+ Observed facts and feedback remain distinct from approved rules. Generated
129
+ Knowledge documents do not yet have a dedicated public CLI API.
130
+
131
+ Usage commands only read account admission and the model catalog. They cannot
132
+ purchase usage, change caps, consent to paid usage or waive unresolved charges.
133
+ Required token scopes and workspace roles are enforced by the server.
134
+
135
+ ## Other commands
136
+
137
+ - `dependencies list`, `dependencies scan`: read or refresh dependency checks.
138
+ - `dependencies fix <dependency-id> <finding-id>`: explicitly open an upgrade PR; never merge it.
139
+ - `auth status`, `auth logout`: inspect or remove this CLI's saved credentials.
140
+ - `api get/post/patch/delete <relative-path> [--file body.json]`: call an existing
141
+ workspace API with the same credentials and permissions. It does not expose
142
+ internal dashboard RPCs or bypass authorization.
143
+ - `capabilities`: inspect the installed commands and known API gaps.
144
+
145
+ Website/app target creation, authorization and test start need a public API
146
+ matching the dashboard flow. They are not disguised as codebase tests. Team,
147
+ integration and payment administration remain dashboard workflows.
148
+
149
+ ## Output and errors
150
+
151
+ Results are JSON on stdout. Diagnostics and attempt keys go to stderr. Exit codes:
152
+ 0 success, 1 invalid input/configuration or wait timeout, 2 API/network failure or
153
+ an unsuccessful terminal run. Source allow/review/block verdicts are data, not
154
+ transport failures. A wait timeout does not cancel the job. The client never
155
+ retries writes automatically. Unavailable routes fail visibly.
156
+
157
+ ## Development
158
+
159
+ ```sh
160
+ pnpm --filter @0cloud/cli build
161
+ pnpm --filter @0cloud/cli test
162
+ node packages/0cloud-cli/dist/public/public-cli.js --help
163
+ ```
164
+
165
+ The npm allowlist includes only `dist/public` and this README. Operator commands
166
+ 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,120 @@
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) {
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 });
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
+ export async function scanSubmission(input, opts, options = {}) {
77
+ if (options.focus && !options.whitebox)
78
+ throw new UserError("--focus requires --whitebox.");
79
+ const route = options.whitebox ? "/whitebox-scan" : "/scan";
80
+ const focus = options.focus ? { focus: options.focus } : {};
81
+ if (/^https:\/\//i.test(input))
82
+ return customerRequest(opts, route, "POST", options.whitebox ? { input, ...focus } : { repo_url: input });
83
+ const { files, skipped } = await readSubmission(input);
84
+ if (skipped)
85
+ process.stderr.write(`Skipped ${skipped} binary, generated or excluded paths.\n`);
86
+ return customerRequest(opts, route, "POST", { files, ...focus });
87
+ }
88
+ export async function readCodebase(target, opts) {
89
+ const field = /^[0-9a-f-]{36}$/i.test(target) ? "repository_id" : "target";
90
+ return await customerRequest(opts, `/project-setup?${field}=${encodeURIComponent(target)}`);
91
+ }
92
+ export async function startPentest(target, opts, idempotencyKey = randomUUID()) {
93
+ if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(idempotencyKey))
94
+ throw new UserError("Idempotency key must be a UUID.");
95
+ process.stderr.write(`Attempt key: ${idempotencyKey}. Reuse it for the same request after a connection failure; do not automatically resubmit.\n`);
96
+ const record = await readCodebase(target, opts);
97
+ if (!record.readiness.canStart)
98
+ throw new UserError(record.readiness.reason || "Save and approve the codebase configuration before starting a pentest.");
99
+ return customerRequest(opts, "/project-setup", "POST", {
100
+ action: "start", repositoryId: record.repository.id,
101
+ expectedRevision: record.revision, idempotencyKey,
102
+ });
103
+ }
104
+ export async function saveCodebase(target, file, opts) {
105
+ const record = await readCodebase(target, opts);
106
+ let input;
107
+ try {
108
+ input = JSON.parse(await readFile(file, "utf8"));
109
+ }
110
+ catch {
111
+ throw new UserError("Configuration must be a JSON file containing plan and optional enableSchedule.");
112
+ }
113
+ if (!input || typeof input !== "object" || !input.plan)
114
+ throw new UserError("Configuration must contain plan.");
115
+ return customerRequest(opts, "/project-setup", "POST", {
116
+ action: "save", repositoryId: record.repository.id, expectedRevision: record.revision,
117
+ sourceRevision: record.repository.sourceRevision, plan: input.plan,
118
+ ...(input.enableSchedule !== undefined ? { enableSchedule: input.enableSchedule } : {}),
119
+ });
120
+ }
@@ -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,99 @@
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
+ let body;
36
+ if (options.body !== undefined) {
37
+ headers["Content-Type"] = "application/json";
38
+ body = JSON.stringify(options.body);
39
+ }
40
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
41
+ const stderr = options.stderr ?? process.stderr;
42
+ const startedAt = Date.now();
43
+ const response = await fetchImpl(url, { method, headers, body, redirect: "error", signal: AbortSignal.timeout(300_000) });
44
+ const durationMs = Date.now() - startedAt;
45
+ if (ctx.verbose) {
46
+ // NEVER log the Authorization header. Method + url + status +
47
+ // duration is enough to debug routing / org scoping without
48
+ // leaking the operator bearer into a shared terminal.
49
+ stderr.write(`[0cloud] ${method} ${url} -> ${response.status} (${durationMs}ms)\n`);
50
+ }
51
+ // Try to parse the body as JSON. Some routes (e.g. the artifact-
52
+ // bundle zip stream — not currently called by the CLI) return
53
+ // non-JSON; we don't handle that here. All JSON-returning routes
54
+ // emit either a 2xx object or an `{ error: string, ... }` 4xx/5xx.
55
+ const text = await response.text();
56
+ let parsed = null;
57
+ if (text.length > 0) {
58
+ try {
59
+ parsed = JSON.parse(text);
60
+ }
61
+ catch {
62
+ // Non-JSON body. Fall through with `parsed = null`; the error
63
+ // path below carries `text` as a fallback.
64
+ parsed = text;
65
+ }
66
+ }
67
+ if (!response.ok) {
68
+ const message = extractErrorMessage(parsed, text, response.status);
69
+ throw new ServerError(message, response.status, parsed);
70
+ }
71
+ return parsed;
72
+ }
73
+ function buildUrl(endpoint, pathAndQuery, query) {
74
+ const base = endpoint.endsWith("/") ? endpoint.slice(0, -1) : endpoint;
75
+ const suffix = pathAndQuery.startsWith("/") ? pathAndQuery : `/${pathAndQuery}`;
76
+ const url = new URL(`${base}${suffix}`);
77
+ if (query) {
78
+ for (const [k, v] of Object.entries(query)) {
79
+ if (v === undefined)
80
+ continue;
81
+ url.searchParams.set(k, String(v));
82
+ }
83
+ }
84
+ return url.toString();
85
+ }
86
+ function extractErrorMessage(parsed, rawText, status) {
87
+ if (parsed && typeof parsed === "object") {
88
+ const obj = parsed;
89
+ if (typeof obj.message === "string" && obj.message.length > 0) {
90
+ return `${status} ${obj.message}`;
91
+ }
92
+ if (typeof obj.error === "string" && obj.error.length > 0) {
93
+ return `${status} ${obj.error}`;
94
+ }
95
+ }
96
+ if (rawText.length > 0)
97
+ return `${status} ${rawText.slice(0, 200)}`;
98
+ return `HTTP ${status}`;
99
+ }
@@ -0,0 +1,145 @@
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 <file-folder-or-repo>").description("Analyze source without executing it")
46
+ .option("--whitebox", "Deeper source-only analysis, not an active security test")
47
+ .option("--focus <text>", "Focus instructions for whitebox analysis")
48
+ .action(async (input, o, cmd) => output(await scanSubmission(input, context(cmd), o)));
49
+ const codebases = p.command("codebases").description("Read and configure enrolled codebases");
50
+ codebases.command("list").action(async (_o, cmd) => output(await customerRequest(context(cmd), "/project-setup")));
51
+ codebases.command("show <repo-or-id>").action(async (target, _o, cmd) => output(await readCodebase(target, context(cmd))));
52
+ codebases.command("configure <repo-or-id>").requiredOption("--file <json>", "Plan JSON; schedules change only when enableSchedule is supplied")
53
+ .action(async (target, o, cmd) => output(await saveCodebase(target, o.file, context(cmd))));
54
+ codebases.command("enroll <repository-id>").description("Enroll a repository already available through the workspace GitHub connection")
55
+ .action(async (repositoryId, _o, cmd) => output(await customerRequest(context(cmd), "/project-setup", "POST", { action: "enroll", repositoryId })));
56
+ registerWorkflowCommands(p);
57
+ const pentests = p.command("pentests", { hidden: true }).description("Compatibility command; use tests start");
58
+ pentests.command("start <repo-or-id>").option("--idempotency-key <uuid>", "Reuse this key when retrying the same request")
59
+ .action(async (target, o, cmd) => output(await startPentest(target, context(cmd), o.idempotencyKey)));
60
+ const runs = p.command("runs").description("Read progress, results and cancellation for managed runs");
61
+ runs.command("list").option("--status <status>").option("--limit <n>", "Page size", positive, 20).option("--offset <n>", "Page offset", nonnegative, 0)
62
+ .action(async (o, cmd) => output(await request(await resolveContext(context(cmd)), "/scans", { query: { status: o.status, limit: o.limit, offset: o.offset } })));
63
+ runs.command("show <id>").action(async (id, _o, cmd) => output(await request(await resolveContext(context(cmd)), `/scans/${encodeURIComponent(id)}`)));
64
+ runs.command("events <id>").option("--limit <n>", "Event page size", positive, 100).option("--offset <n>", "Event offset", nonnegative, 0)
65
+ .action(async (id, o, cmd) => output(await request(await resolveContext(context(cmd)), `/scans/${encodeURIComponent(id)}/events`, { query: { limit: o.limit, offset: o.offset } })));
66
+ runs.command("cancel <id>").action(async (id, _o, cmd) => output(await request(await resolveContext(context(cmd)), `/scans/${encodeURIComponent(id)}/cancel`, { method: "POST" })));
67
+ runs.command("report <id>").action(async (id, _o, cmd) => output(await request(await resolveContext(context(cmd)), `/scans/${encodeURIComponent(id)}/artifact-bundle`)));
68
+ runs.command("wait <id>").option("--interval <seconds>", "Polling interval", positive, 5).option("--timeout <seconds>", "Timeout", positive, 1800)
69
+ .action(async (id, o, cmd) => {
70
+ const ctx = await resolveContext(context(cmd));
71
+ const end = Date.now() + o.timeout * 1000;
72
+ for (;;) {
73
+ const run = await request(ctx, `/scans/${encodeURIComponent(id)}`);
74
+ if (terminal.has(run.status)) {
75
+ output(run);
76
+ if (run.status !== "complete")
77
+ process.exitCode = 2;
78
+ return;
79
+ }
80
+ if (Date.now() >= end)
81
+ throw new UserError("Timed out waiting. The run continues; use runs show or runs cancel.");
82
+ await delay(Math.min(o.interval * 1000, end - Date.now()));
83
+ }
84
+ });
85
+ const findings = p.command("findings").description("Read and review workspace findings");
86
+ 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)
87
+ .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 } })));
88
+ findings.command("show <id>").action(async (id, _o, cmd) => output(await request(await resolveContext(context(cmd)), `/findings/${encodeURIComponent(id)}`)));
89
+ findings.command("triage <id>").addOption(new Option("--status <status>").choices(["pending", "true_positive", "false_positive", "unsure", "reported", "wont_fix"]).makeOptionMandatory())
90
+ .requiredOption("--reason <text>", "Reason for the review decision")
91
+ .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 } })));
92
+ const deps = p.command("dependencies").description("Read and check dependencies of enrolled codebases");
93
+ 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)
94
+ throw new UserError("Offset must be a nonnegative integer."); return n; }, 0)
95
+ .action(async (o, cmd) => output(await customerRequest(context(cmd), `/dependencies?limit=${o.limit}&offset=${o.offset}`)));
96
+ deps.command("scan").action(async (_o, cmd) => output(await customerRequest(context(cmd), "/dependencies/scan", "POST")));
97
+ deps.command("fix <dependency-id> <finding-id>").description("Open an upgrade PR; does not merge it")
98
+ .action(async (dependencyId, findingId, _o, cmd) => output(await customerRequest(context(cmd), "/dependencies/fix", "POST", { dependencyId, findingId })));
99
+ const reviews = p.command("reviews").description("Read PR Security history; automatic reviews follow saved repository settings");
100
+ 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)
101
+ .option("--repository <id>", "Codebase UUID").option("--cursor <json>", "Next cursor returned by the API")
102
+ .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 } : {}) })}`)));
103
+ const api = p.command("api").description("Call other workspace API actions using the same login");
104
+ for (const method of ["GET", "POST", "PATCH", "DELETE"]) {
105
+ api.command(`${method.toLowerCase()} <path>`).option("--file <json>", "JSON request body file")
106
+ .action(async (route, o, cmd) => {
107
+ let body;
108
+ if (o.file) {
109
+ try {
110
+ body = JSON.parse(await readFile(o.file, "utf8"));
111
+ }
112
+ catch {
113
+ throw new UserError("Request body must be a JSON file.");
114
+ }
115
+ }
116
+ if (method === "GET" && body !== undefined)
117
+ throw new UserError("GET requests cannot have a body.");
118
+ output(await customerRequest(context(cmd), route, method, body));
119
+ });
120
+ }
121
+ 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");
122
+ p.command("capabilities").description("Print command and API coverage")
123
+ .action(() => output({ version: USER_AGENT_VERSION, commands: p.commands.map(c => c.name()),
124
+ 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 and model catalog" },
125
+ pending: ["Website target setup and authorized test-start API parity", "Manual PR review dispatch", "Knowledge documents public API", "Team, integration and payment administration"],
126
+ note: "Server availability and workspace permissions determine which actions can run." }));
127
+ return p;
128
+ }
129
+ export async function runPublic(argv) {
130
+ process.exitCode = undefined;
131
+ const p = buildPublicProgram().exitOverride();
132
+ try {
133
+ await p.parseAsync(argv, { from: "user" });
134
+ return process.exitCode === 2 ? 2 : 0;
135
+ }
136
+ catch (err) {
137
+ if (err instanceof CommanderError)
138
+ return err.exitCode;
139
+ process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
140
+ return err instanceof UserError ? 1 : 2;
141
+ }
142
+ }
143
+ const entry = process.argv[1] ? realpathSync(process.argv[1]) : null;
144
+ if (entry === fileURLToPath(import.meta.url))
145
+ void runPublic(process.argv.slice(2)).then(code => { process.exitCode = code; });
@@ -0,0 +1,99 @@
1
+ import { Option } from "commander";
2
+ import { randomUUID } from "node:crypto";
3
+ import { setTimeout as delay } from "node:timers/promises";
4
+ import { customerRequest, 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
+ const memory = p.command("memory").description("Read Memory and manage explicit rules with revision checks");
42
+ memory.command("list").option("--repository <uuid>", "Scope to a codebase", uuid)
43
+ .action(async (o, cmd) => output(await customerRequest(context(cmd), `/memory${o.repository ? `?repositoryId=${o.repository}` : ""}`)));
44
+ memory.command("add <text>").description("Add a workspace rule; does not alter repository execution policy").requiredOption("--revision <n>", "Revision returned by memory list", integer)
45
+ .action(async (text, o, cmd) => {
46
+ if (!text.trim() || text.trim().length > 1000)
47
+ throw new UserError("Use 1–1000 characters.");
48
+ output(await customerRequest(context(cmd), "/memory", "POST", { action: "add", text: text.trim(), expectedRevision: o.revision }));
49
+ });
50
+ memory.command("remove <id>").description("Remove an explicit workspace rule by its exact ID").requiredOption("--revision <n>", "Revision returned by memory list", integer)
51
+ .action(async (id, o, cmd) => output(await customerRequest(context(cmd), "/memory", "POST", { action: "remove", id, expectedRevision: o.revision })));
52
+ const source = p.command("source-scans").description("Queued source-only analysis pinned to an exact codebase commit; deployment must enable it");
53
+ source.command("list").option("--repository <uuid>", "Codebase UUID", uuid).option("--cursor <cursor>", "Next cursor returned by the API")
54
+ .action(async (o, cmd) => {
55
+ const ctx = await resolveContext(context(cmd));
56
+ const q = new URLSearchParams({ org_id: ctx.orgId, ...(o.repository ? { repository_id: o.repository } : {}), ...(o.cursor ? { cursor: o.cursor } : {}) });
57
+ output(await customerRequest(context(cmd), `/source-scans?${q}`));
58
+ });
59
+ source.command("start <repository-id>").requiredOption("--commit <sha>", "Full immutable commit SHA", sha)
60
+ .addOption(new Option("--operation <type>", "Source analysis method").choices(["scan", "whitebox-scan"]).default("scan"))
61
+ .option("--focus <text>", "Focus instructions, up to 4000 characters")
62
+ .option("--idempotency-key <uuid>", "Reuse for the same immutable submission", uuid)
63
+ .action(async (repository, o, cmd) => {
64
+ const repositoryId = uuid(repository);
65
+ if (o.focus?.length > 4000)
66
+ throw new UserError("Focus must be at most 4000 characters.");
67
+ const ctx = await resolveContext(context(cmd));
68
+ const idempotencyKey = o.idempotencyKey ?? randomUUID();
69
+ process.stderr.write(`Attempt key: ${idempotencyKey}. Reuse with the same repository, commit, operation and focus after a connection failure.\n`);
70
+ output(await customerRequest(context(cmd), "/source-scans", "POST", { orgId: ctx.orgId, repositoryId, commitSha: o.commit, operation: o.operation, idempotencyKey, ...(o.focus !== undefined ? { focus: o.focus } : {}) }));
71
+ });
72
+ async function read(id, cmd) {
73
+ const ctx = await resolveContext(context(cmd));
74
+ return await customerRequest(context(cmd), `/source-scans/${uuid(id)}?org_id=${encodeURIComponent(ctx.orgId)}`);
75
+ }
76
+ source.command("show <id>").action(async (id, _o, cmd) => output(await read(id, cmd)));
77
+ source.command("cancel <id>").action(async (id, _o, cmd) => {
78
+ const ctx = await resolveContext(context(cmd));
79
+ output(await customerRequest(context(cmd), `/source-scans/${uuid(id)}/cancel`, "POST", { orgId: ctx.orgId }));
80
+ });
81
+ source.command("wait <id>").option("--interval <seconds>", "Polling interval", positive, 5).option("--timeout <seconds>", "Maximum wait; does not cancel the job", positive, 1800)
82
+ .action(async (id, o, cmd) => {
83
+ const end = Date.now() + o.timeout * 1000;
84
+ for (;;) {
85
+ const job = await read(id, cmd);
86
+ if (["completed", "failed", "cancelled", "recovery_required"].includes(job.state)) {
87
+ output(job);
88
+ if (job.state !== "completed")
89
+ process.exitCode = 2;
90
+ return;
91
+ }
92
+ if (!["queued", "running"].includes(job.state))
93
+ throw new UserError(`Unknown source job state: ${job.state}. Inspect source-scans show; do not resubmit.`);
94
+ if (Date.now() >= end)
95
+ throw new UserError("Wait timed out. The job was not cancelled; inspect source-scans show.");
96
+ await delay(Math.min(o.interval * 1000, end - Date.now()));
97
+ }
98
+ });
99
+ }
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "@0cloud/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.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
+ }