@0cloud/cli 0.1.0 → 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 CHANGED
@@ -19,8 +19,8 @@ CI. `--endpoint` and `--org` override the saved service and workspace.
19
19
 
20
20
  | Task | Command |
21
21
  | --- | --- |
22
- | Analyze local source without executing it | `0cloud scan ./src` |
23
- | Deeper source-only analysis | `0cloud scan ./src --whitebox --focus "Account access"` |
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
24
  | Inspect connected codebases | `0cloud codebases list` or `codebases show <repo-or-id>` |
25
25
  | Check saved scope and readiness | `0cloud tests readiness <repo-or-id>` |
26
26
  | Start a saved-codebase security test | `0cloud tests start <repo-or-id> --idempotency-key <uuid>` |
@@ -38,19 +38,24 @@ repairs. Opening configuration or checking readiness does not start work.
38
38
  ## Source analysis
39
39
 
40
40
  ```sh
41
- 0cloud scan ./src
42
41
  0cloud scan https://github.com/owner/repository
43
42
  0cloud scan ./src --whitebox --focus "Authorization and tenant isolation"
44
43
  ```
45
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
+
46
49
  Uploads exclude symlinks, `.env` files, dependency/build directories, invalid
47
50
  UTF-8 and common binary formats. Limits: 300 files, 400 KB per file and 8 MB total.
48
51
  Excluded paths are reported on stderr; exceeding total limits fails rather than
49
52
  silently returning a partial submission. Review the files you select before upload.
50
53
 
51
54
  `--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.
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.
54
59
 
55
60
  For deployments with queued source operations enabled:
56
61
 
@@ -7,12 +7,12 @@ import { UserError } from "./errors.js";
7
7
  export function dashboardApiContext(ctx) {
8
8
  return { ...ctx, endpoint: ctx.endpoint.replace(/\/orchestrator\/?$/, "") };
9
9
  }
10
- export async function customerRequest(opts, route, method = "GET", body) {
10
+ export async function customerRequest(opts, route, method = "GET", body, idempotencyKey) {
11
11
  if (!/^\/[a-zA-Z0-9]/.test(route) || route.includes("\\") || route.includes("#") || /(^|\/)\.\.(\/|$)/.test(decodeURIComponent(route))) {
12
12
  throw new UserError("Use a relative API path, for example /scan or /project-setup.");
13
13
  }
14
14
  const ctx = dashboardApiContext(await resolveContext(opts));
15
- return request(ctx, route, { method, body });
15
+ return request(ctx, route, { method, body, idempotencyKey });
16
16
  }
17
17
  const SKIP = new Set([".git", "node_modules", "vendor", "dist", "build", ".next", "target", "__pycache__", ".env", ".env.local"]);
18
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;
@@ -73,31 +73,62 @@ export async function readSubmission(input) {
73
73
  throw new UserError("No source files to scan.");
74
74
  return { files, skipped };
75
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
+ }
76
99
  export async function scanSubmission(input, opts, options = {}) {
77
100
  if (options.focus && !options.whitebox)
78
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);
79
106
  const route = options.whitebox ? "/whitebox-scan" : "/scan";
80
107
  const focus = options.focus ? { focus: options.focus } : {};
108
+ let body;
81
109
  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 });
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);
87
119
  }
88
120
  export async function readCodebase(target, opts) {
89
121
  const field = /^[0-9a-f-]{36}$/i.test(target) ? "repository_id" : "target";
90
122
  return await customerRequest(opts, `/project-setup?${field}=${encodeURIComponent(target)}`);
91
123
  }
92
124
  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`);
125
+ idempotencyKey = attemptKey(idempotencyKey);
96
126
  const record = await readCodebase(target, opts);
97
127
  if (!record.readiness.canStart)
98
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");
99
130
  return customerRequest(opts, "/project-setup", "POST", {
100
- action: "start", repositoryId: record.repository.id,
131
+ ...quote, action: "start", repositoryId: record.repository.id,
101
132
  expectedRevision: record.revision, idempotencyKey,
102
133
  });
103
134
  }
@@ -32,6 +32,8 @@ export async function request(ctx, pathAndQuery, options = {}) {
32
32
  if (!options.omitOrgHeader && ctx.orgId) {
33
33
  headers["X-0-Org-Id"] = ctx.orgId;
34
34
  }
35
+ if (options.idempotencyKey)
36
+ headers["Idempotency-Key"] = options.idempotencyKey;
35
37
  let body;
36
38
  if (options.body !== undefined) {
37
39
  headers["Content-Type"] = "application/json";
@@ -42,7 +42,8 @@ export function buildPublicProgram() {
42
42
  .action(async (o, cmd) => runAuthLogin({ ...context(cmd), ...o }));
43
43
  auth.command("status").action(async (_o, cmd) => runAuthStatus({ json: true }));
44
44
  auth.command("logout").action(runAuthLogout);
45
- p.command("scan <file-folder-or-repo>").description("Analyze source without executing it")
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")
46
47
  .option("--whitebox", "Deeper source-only analysis, not an active security test")
47
48
  .option("--focus <text>", "Focus instructions for whitebox analysis")
48
49
  .action(async (input, o, cmd) => output(await scanSubmission(input, context(cmd), o)));
@@ -121,7 +122,7 @@ export function buildPublicProgram() {
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");
122
123
  p.command("capabilities").description("Print command and API coverage")
123
124
  .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
+ 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" },
125
126
  pending: ["Website target setup and authorized test-start API parity", "Manual PR review dispatch", "Knowledge documents public API", "Team, integration and payment administration"],
126
127
  note: "Server availability and workspace permissions determine which actions can run." }));
127
128
  return p;
@@ -1,7 +1,7 @@
1
1
  import { Option } from "commander";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { setTimeout as delay } from "node:timers/promises";
4
- import { customerRequest, readCodebase, startPentest } from "./customer.js";
4
+ import { customerRequest, quoteProduct, readCodebase, startPentest } from "./customer.js";
5
5
  import { resolveContext } from "./config.js";
6
6
  import { UserError } from "./errors.js";
7
7
  const output = (value) => { process.stdout.write(JSON.stringify(value, null, 2) + "\n"); };
@@ -38,6 +38,8 @@ export function registerWorkflowCommands(p) {
38
38
  const usage = p.command("usage").description("Read funding readiness and available inference models; never changes billing");
39
39
  usage.command("status").action(async (_o, cmd) => output(await customerRequest(context(cmd), "/inference/account")));
40
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")));
41
43
  const memory = p.command("memory").description("Read Memory and manage explicit rules with revision checks");
42
44
  memory.command("list").option("--repository <uuid>", "Scope to a codebase", uuid)
43
45
  .action(async (o, cmd) => output(await customerRequest(context(cmd), `/memory${o.repository ? `?repositoryId=${o.repository}` : ""}`)));
@@ -67,7 +69,8 @@ export function registerWorkflowCommands(p) {
67
69
  const ctx = await resolveContext(context(cmd));
68
70
  const idempotencyKey = o.idempotencyKey ?? randomUUID();
69
71
  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 } : {}) }));
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 } : {}) }));
71
74
  });
72
75
  async function read(id, cmd) {
73
76
  const ctx = await resolveContext(context(cmd));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@0cloud/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Standalone CLI for the 0cloud API: source scans, pentests, run status and workspace actions.",
6
6
  "bin": {