@zksecurity/zkao-cli 0.5.1 → 0.11.0-next.3

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.
Files changed (3) hide show
  1. package/README.md +1 -0
  2. package/dist/index.js +383 -115
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -14,6 +14,7 @@ npx @zksecurity/zkao-cli --help
14
14
  zkao login # browser approval; saves credentials
15
15
  zkao repos
16
16
  zkao scans launch --repo <repoId> --budget 500 --preset <ref>
17
+ zkao scans launch --repo <repoId> --preset "builtin:Diff Scan" --base main --commit <sha>
17
18
  zkao scans wait <scanId> # block until the scan finishes
18
19
  zkao scans cancel <scanId> # cancel a running or queued scan
19
20
  zkao findings list --scan <scanId>
package/dist/index.js CHANGED
@@ -6,7 +6,8 @@ import { createRequire } from "module";
6
6
  import { Command } from "commander";
7
7
  import {
8
8
  ZkaoApiError,
9
- ZkaoClient
9
+ ZkaoClient,
10
+ getTokenInfo
10
11
  } from "@zksecurity/zkao-sdk";
11
12
 
12
13
  // src/config.ts
@@ -39,18 +40,93 @@ function readFile() {
39
40
  );
40
41
  }
41
42
  }
42
- function writeConfig(update) {
43
- let existing;
43
+ function readFileForWrite() {
44
+ let file;
44
45
  try {
45
- existing = readFile();
46
+ file = readFile();
46
47
  } catch {
47
48
  console.error(`zkao: replacing unparseable config at ${CONFIG_PATH}`);
48
- existing = {};
49
+ return {};
50
+ }
51
+ if (file.projectId && file.token && !file.projects?.[file.projectId]) {
52
+ file.projects = {
53
+ ...file.projects,
54
+ [file.projectId]: stripUndefinedProject({
55
+ token: file.token,
56
+ baseUrl: file.baseUrl
57
+ })
58
+ };
49
59
  }
50
- const merged = { ...existing, ...stripUndefined(update) };
51
- writeSecretFile(CONFIG_PATH, `${JSON.stringify(merged, null, 2)}
60
+ return file;
61
+ }
62
+ function writeFile(file) {
63
+ writeSecretFile(CONFIG_PATH, `${JSON.stringify(file, null, 2)}
52
64
  `);
53
- return merged;
65
+ }
66
+ function activate(file, projectId, saved) {
67
+ file.projectId = projectId;
68
+ file.token = saved.token;
69
+ if (saved.baseUrl === void 0) {
70
+ delete file.baseUrl;
71
+ } else {
72
+ file.baseUrl = saved.baseUrl;
73
+ }
74
+ }
75
+ function writeConfig(update) {
76
+ const file = readFileForWrite();
77
+ const projectId = update.projectId ?? file.projectId;
78
+ const saved = update.projectId ? file.projects?.[update.projectId] : void 0;
79
+ if (update.token === void 0 && update.projectId !== void 0 && saved) {
80
+ activate(file, update.projectId, {
81
+ ...saved,
82
+ baseUrl: update.baseUrl ?? saved.baseUrl
83
+ });
84
+ } else {
85
+ const switching = update.projectId !== void 0 && update.projectId !== file.projectId;
86
+ Object.assign(file, stripUndefined(update));
87
+ if (switching && update.token === void 0) {
88
+ delete file.token;
89
+ }
90
+ }
91
+ if (projectId && file.token && file.projectId === projectId) {
92
+ file.projects = {
93
+ ...file.projects,
94
+ [projectId]: {
95
+ ...file.projects?.[projectId],
96
+ token: file.token,
97
+ baseUrl: file.baseUrl
98
+ }
99
+ };
100
+ }
101
+ writeFile(file);
102
+ return { token: file.token, projectId: file.projectId, baseUrl: file.baseUrl };
103
+ }
104
+ function saveProjectCredentials(projectId, saved) {
105
+ const file = readFileForWrite();
106
+ file.projects = { ...file.projects, [projectId]: stripUndefinedProject(saved) };
107
+ activate(file, projectId, saved);
108
+ writeFile(file);
109
+ }
110
+ function useProject(projectId) {
111
+ const file = readFileForWrite();
112
+ const saved = file.projects?.[projectId];
113
+ if (!saved) {
114
+ throw new Error(
115
+ `No saved credentials for project ${projectId}. Run \`zkao login --project ${projectId}\`.`
116
+ );
117
+ }
118
+ activate(file, projectId, saved);
119
+ writeFile(file);
120
+ return saved;
121
+ }
122
+ function savedProjects() {
123
+ const file = readFile();
124
+ return { activeProjectId: file.projectId, projects: file.projects ?? {} };
125
+ }
126
+ function stripUndefinedProject(saved) {
127
+ return Object.fromEntries(
128
+ Object.entries(saved).filter(([, v]) => v !== void 0)
129
+ );
54
130
  }
55
131
  function writeSecretFile(path, contents) {
56
132
  mkdirSync(dirname(path), { recursive: true, mode: 448 });
@@ -67,50 +143,91 @@ function stripUndefined(obj) {
67
143
  }
68
144
  function resolveConfig(flags) {
69
145
  const file = readFile();
146
+ const projectId = flags.projectId ?? process.env.ZKAO_PROJECT_ID ?? file.projectId;
147
+ const saved = projectId ? file.projects?.[projectId] : void 0;
148
+ const activeMatches = file.projectId === void 0 || file.projectId === projectId;
70
149
  return {
71
- token: flags.token ?? process.env.ZKAO_API_TOKEN ?? file.token,
72
- projectId: flags.projectId ?? process.env.ZKAO_PROJECT_ID ?? file.projectId,
73
- baseUrl: flags.baseUrl ?? resolveBaseUrlFromEnv() ?? file.baseUrl
150
+ token: flags.token ?? process.env.ZKAO_API_TOKEN ?? saved?.token ?? (activeMatches ? file.token : void 0),
151
+ projectId,
152
+ baseUrl: flags.baseUrl ?? resolveBaseUrlFromEnv() ?? // The top-level base URL is the active project's; a saved project
153
+ // carries its own (none means production).
154
+ (saved ? saved.baseUrl : file.baseUrl)
74
155
  };
75
156
  }
76
157
  var PENDING_PATH = join(homedir(), ".zkao", "pending-login.json");
77
- function writePendingLogin(pending) {
78
- writeSecretFile(PENDING_PATH, `${JSON.stringify(pending, null, 2)}
79
- `);
158
+ function isPendingLogin(value) {
159
+ if (typeof value !== "object" || value === null) {
160
+ return false;
161
+ }
162
+ const v = value;
163
+ return typeof v.deviceCode === "string" && typeof v.origin === "string" && typeof v.interval === "number" && typeof v.expiresAtMs === "number";
80
164
  }
81
- function readPendingLogin() {
165
+ function readPendingLogins() {
82
166
  let raw;
83
167
  try {
84
168
  raw = readFileSync(PENDING_PATH, "utf8");
85
169
  } catch (err) {
86
170
  if (err.code === "ENOENT") {
87
- return null;
171
+ return [];
88
172
  }
89
173
  throw err;
90
174
  }
175
+ let parsed;
91
176
  try {
92
- return JSON.parse(raw);
177
+ parsed = JSON.parse(raw);
93
178
  } catch {
94
179
  console.error(
95
180
  `zkao: ignoring unparseable pending login at ${PENDING_PATH}`
96
181
  );
97
- return null;
182
+ return [];
98
183
  }
184
+ if (isPendingLogin(parsed)) {
185
+ return [parsed];
186
+ }
187
+ const logins = parsed?.logins;
188
+ return logins && typeof logins === "object" ? Object.values(logins).filter(isPendingLogin) : [];
99
189
  }
100
- function clearPendingLogin() {
101
- try {
102
- unlinkSync(PENDING_PATH);
103
- } catch (err) {
104
- if (err.code !== "ENOENT") {
105
- throw err;
190
+ function writePendingLogins(logins) {
191
+ if (logins.length === 0) {
192
+ try {
193
+ unlinkSync(PENDING_PATH);
194
+ } catch (err) {
195
+ if (err.code !== "ENOENT") {
196
+ throw err;
197
+ }
106
198
  }
199
+ return;
107
200
  }
201
+ const file = {
202
+ logins: Object.fromEntries(logins.map((l) => [l.deviceCode, l]))
203
+ };
204
+ writeSecretFile(PENDING_PATH, `${JSON.stringify(file, null, 2)}
205
+ `);
206
+ }
207
+ function addPendingLogin(pending) {
208
+ const now = Date.now();
209
+ writePendingLogins([
210
+ ...readPendingLogins().filter(
211
+ (l) => l.expiresAtMs > now && l.deviceCode !== pending.deviceCode
212
+ ),
213
+ pending
214
+ ]);
215
+ }
216
+ function removePendingLogins(deviceCodes) {
217
+ const now = Date.now();
218
+ const drop = new Set(deviceCodes);
219
+ writePendingLogins(
220
+ readPendingLogins().filter(
221
+ (l) => l.expiresAtMs > now && !drop.has(l.deviceCode)
222
+ )
223
+ );
108
224
  }
109
225
 
110
226
  // src/login.ts
111
227
  import { spawn } from "child_process";
112
228
  import { hostname } from "os";
113
229
  import { DEFAULT_BASE_URL } from "@zksecurity/zkao-sdk";
230
+ var DEFAULT_LOGIN_SCOPES = ["read", "scans:launch", "findings:write"];
114
231
  function appOrigin(baseUrl) {
115
232
  const url = new URL(baseUrl);
116
233
  const prefix = url.pathname.replace(/\/+$/, "").replace(/\/api\/v\d+$/, "");
@@ -157,15 +274,39 @@ Approve only if you started this login.
157
274
  `
158
275
  );
159
276
  }
277
+ function describeProject(projectId, names) {
278
+ if (!names.name) {
279
+ return projectId;
280
+ }
281
+ const org = names.organization ? ` of organization "${names.organization}"` : "";
282
+ return `"${names.name}"${org} (${projectId})`;
283
+ }
160
284
  function persistCredentials(poll, baseUrl) {
161
- const saved = writeConfig({
162
- token: poll.token,
163
- projectId: poll.projectId,
164
- baseUrl
285
+ const tokens = poll.tokens?.length ? poll.tokens : [poll];
286
+ for (const issued of [...tokens].reverse()) {
287
+ saveProjectCredentials(issued.projectId, {
288
+ token: issued.token,
289
+ baseUrl,
290
+ name: issued.projectName,
291
+ organization: issued.organizationName
292
+ });
293
+ }
294
+ const describe = (issued) => describeProject(issued.projectId, {
295
+ name: issued.projectName,
296
+ organization: issued.organizationName
165
297
  });
166
- clearPendingLogin();
167
- console.log("Authorized. Credentials saved.\n");
168
- console.log(` project: ${saved.projectId}`);
298
+ if (tokens.length === 1) {
299
+ console.log(`Authorized for project ${describe(poll)}.`);
300
+ } else {
301
+ console.log(`Authorized for ${tokens.length} projects:`);
302
+ for (const issued of tokens) {
303
+ console.log(` ${describe(issued)}`);
304
+ }
305
+ }
306
+ console.log(`Active project: ${describe(poll)}.`);
307
+ console.log(
308
+ "Credentials for other projects are kept. `zkao config show` lists them; `zkao config use <projectId>` switches."
309
+ );
169
310
  }
170
311
  async function login(flags) {
171
312
  const cfg = resolveConfig({
@@ -175,98 +316,130 @@ async function login(flags) {
175
316
  });
176
317
  const apiBase = cfg.baseUrl ?? DEFAULT_BASE_URL;
177
318
  const origin = appOrigin(apiBase);
319
+ const project = flags.project ?? process.env.ZKAO_PROJECT_ID;
178
320
  const start = await postJson(`${origin}/api/auth/device`, {
179
- label: hostname()
321
+ label: hostname(),
322
+ scopes: flags.scopes ?? DEFAULT_LOGIN_SCOPES,
323
+ ...project ? { project } : {}
180
324
  });
181
325
  printInstructions(start);
182
326
  if (!flags.noBrowser) {
183
327
  openBrowser(start.verificationUriComplete);
184
328
  }
185
- writePendingLogin({
329
+ const pending = {
186
330
  deviceCode: start.deviceCode,
331
+ userCode: start.userCode,
187
332
  origin,
188
333
  interval: Math.max(1, start.interval),
189
334
  expiresAtMs: Date.now() + start.expiresIn * 1e3,
190
335
  baseUrl: cfg.baseUrl
191
- });
336
+ };
337
+ addPendingLogin(pending);
192
338
  if (flags.wait === false) {
193
339
  console.log(
194
340
  "Once you approve in the browser, run `zkao login --resume` to finish.\n"
195
341
  );
196
342
  return;
197
343
  }
198
- await pollUntilDone({
199
- origin,
200
- deviceCode: start.deviceCode,
201
- intervalMs: Math.max(1, start.interval) * 1e3,
202
- deadlineMs: Date.now() + start.expiresIn * 1e3,
203
- baseUrl: cfg.baseUrl
204
- });
344
+ await pollUntilDone([pending], pending.expiresAtMs);
205
345
  }
206
346
  async function resumeLogin(flags) {
207
- const pending = readPendingLogin();
208
- if (!pending) {
347
+ const all = readPendingLogins();
348
+ const now = Date.now();
349
+ const live = all.filter((l) => l.expiresAtMs > now);
350
+ if (live.length < all.length) {
351
+ removePendingLogins([]);
352
+ }
353
+ if (live.length === 0) {
209
354
  throw new Error(
210
- "No pending login. Run `zkao login --no-wait` to start one first."
355
+ all.length > 0 ? "The login request expired. Run `zkao login` again." : "No pending login. Run `zkao login --no-wait` to start one first."
211
356
  );
212
357
  }
213
- if (Date.now() >= pending.expiresAtMs) {
214
- clearPendingLogin();
215
- throw new Error("The login request expired. Run `zkao login` again.");
216
- }
217
358
  const waitMs = (flags.timeout ?? 0) * 1e3;
218
- await pollUntilDone({
219
- origin: pending.origin,
220
- deviceCode: pending.deviceCode,
221
- intervalMs: Math.max(1, pending.interval) * 1e3,
222
- deadlineMs: Math.min(pending.expiresAtMs, Date.now() + waitMs),
223
- baseUrl: pending.baseUrl,
224
- reportStillPending: true
225
- });
359
+ const lastExpiry = Math.max(...live.map((l) => l.expiresAtMs));
360
+ await pollUntilDone(live, Math.min(lastExpiry, Date.now() + waitMs), true);
361
+ }
362
+ function label(pending) {
363
+ return pending.userCode ? `code ${pending.userCode}` : "an earlier login";
226
364
  }
227
- async function pollUntilDone(opts) {
228
- let intervalMs = opts.intervalMs;
365
+ async function pollUntilDone(logins, deadlineMs, reportStillPending = false) {
366
+ let open = [...logins];
367
+ let intervalMs = Math.min(...open.map((l) => Math.max(1, l.interval))) * 1e3;
368
+ let lastFailure = "The login request expired. Run `zkao login` again.";
229
369
  let polled = false;
230
- for (; ; ) {
370
+ while (open.length > 0) {
231
371
  if (polled) {
232
- const remaining = opts.deadlineMs - Date.now();
372
+ const remaining = deadlineMs - Date.now();
233
373
  if (remaining <= 0) {
234
374
  break;
235
375
  }
236
376
  await sleep(Math.min(intervalMs, remaining));
237
377
  }
238
378
  polled = true;
239
- const poll = await postJson(
240
- `${opts.origin}/api/auth/device/token`,
241
- { deviceCode: opts.deviceCode }
242
- );
243
- switch (poll.status) {
244
- case "pending":
245
- case "slow_down":
246
- if (poll.status === "slow_down") {
379
+ const resolved = [];
380
+ const dropped = [];
381
+ for (const pending of open) {
382
+ if (Date.now() >= pending.expiresAtMs) {
383
+ resolved.push(pending.deviceCode);
384
+ dropped.push(`${label(pending)} expired`);
385
+ lastFailure = "The login request expired. Run `zkao login` again.";
386
+ continue;
387
+ }
388
+ const poll = await postJson(
389
+ `${pending.origin}/api/auth/device/token`,
390
+ { deviceCode: pending.deviceCode }
391
+ );
392
+ switch (poll.status) {
393
+ case "pending":
394
+ break;
395
+ case "slow_down":
247
396
  intervalMs += 2e3;
397
+ break;
398
+ case "denied":
399
+ resolved.push(pending.deviceCode);
400
+ dropped.push(`${label(pending)} was denied`);
401
+ lastFailure = "Authorization was denied in the browser.";
402
+ break;
403
+ case "expired":
404
+ resolved.push(pending.deviceCode);
405
+ dropped.push(`${label(pending)} expired`);
406
+ lastFailure = "The login request expired. Run `zkao login` again.";
407
+ break;
408
+ case "approved": {
409
+ removePendingLogins([...resolved, pending.deviceCode]);
410
+ persistCredentials(poll, pending.baseUrl);
411
+ const rest = open.filter(
412
+ (l) => l !== pending && !resolved.includes(l.deviceCode)
413
+ );
414
+ if (rest.length > 0) {
415
+ console.log(
416
+ `Still pending: ${rest.map(label).join(", ")}. Run \`zkao login --resume\` again to finish ${rest.length > 1 ? "them" : "it"}.`
417
+ );
418
+ }
419
+ return;
248
420
  }
249
- break;
250
- case "denied":
251
- clearPendingLogin();
252
- throw new Error("Authorization was denied in the browser.");
253
- case "expired":
254
- clearPendingLogin();
255
- throw new Error("The login request expired. Run `zkao login` again.");
256
- case "approved":
257
- persistCredentials(poll, opts.baseUrl);
258
- return;
259
- default: {
260
- const _exhaustive = poll;
261
- throw new Error(
262
- `Unexpected device status: ${JSON.stringify(_exhaustive)}`
263
- );
421
+ default: {
422
+ const _exhaustive = poll;
423
+ throw new Error(
424
+ `Unexpected device status: ${JSON.stringify(_exhaustive)}`
425
+ );
426
+ }
427
+ }
428
+ }
429
+ if (resolved.length > 0) {
430
+ removePendingLogins(resolved);
431
+ open = open.filter((l) => !resolved.includes(l.deviceCode));
432
+ if (open.length > 0) {
433
+ console.error(`zkao: dropped pending login: ${dropped.join(", ")}.`);
264
434
  }
265
435
  }
266
436
  }
267
- if (opts.reportStillPending) {
437
+ if (open.length === 0) {
438
+ throw new Error(lastFailure);
439
+ }
440
+ if (reportStillPending) {
268
441
  console.log(
269
- "Still waiting for approval. Approve in the browser, then run `zkao login --resume` again."
442
+ open.length > 1 ? `Still waiting for approval of ${open.length} logins (${open.map(label).join(", ")}). Approve in the browser, then run \`zkao login --resume\` again.` : "Still waiting for approval. Approve in the browser, then run `zkao login --resume` again."
270
443
  );
271
444
  return;
272
445
  }
@@ -280,6 +453,9 @@ var CACHE_PATH = join2(dirname2(configPath()), "update-check.json");
280
453
  var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
281
454
  var REGISTRY_TIMEOUT_MS = 1500;
282
455
  var PACKAGE = "@zksecurity/zkao-cli";
456
+ function channelOf(version2) {
457
+ return version2.includes("-") ? "next" : "latest";
458
+ }
283
459
  function debug(message, err) {
284
460
  if (process.env.ZKAO_DEBUG) {
285
461
  console.error(`zkao: ${message}: ${err instanceof Error ? err.message : String(err)}`);
@@ -288,7 +464,7 @@ function debug(message, err) {
288
464
  function disabled() {
289
465
  return Boolean(process.env.ZKAO_NO_UPDATE_CHECK);
290
466
  }
291
- function readCache() {
467
+ function readCache(channel) {
292
468
  let raw;
293
469
  try {
294
470
  raw = readFileSync2(CACHE_PATH, "utf8");
@@ -300,7 +476,8 @@ function readCache() {
300
476
  }
301
477
  try {
302
478
  const parsed = JSON.parse(raw);
303
- return typeof parsed.latest === "string" && typeof parsed.checkedAtMs === "number" ? parsed : null;
479
+ const valid = typeof parsed.latest === "string" && typeof parsed.checkedAtMs === "number";
480
+ return valid && (parsed.channel ?? "latest") === channel ? parsed : null;
304
481
  } catch (err) {
305
482
  debug("ignoring an unparseable update cache", err);
306
483
  return null;
@@ -317,47 +494,53 @@ function writeCache(cache) {
317
494
  }
318
495
  function isNewerVersion(latest, current) {
319
496
  const parse = (v) => {
320
- const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(v.trim());
321
- return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
497
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:-next\.(\d+))?$/.exec(v.trim());
498
+ return match ? {
499
+ core: [Number(match[1]), Number(match[2]), Number(match[3])],
500
+ pre: match[4] === void 0 ? null : Number(match[4])
501
+ } : null;
322
502
  };
323
503
  const a = parse(latest);
324
504
  const b = parse(current);
325
- if (!(a && b)) {
505
+ if (!(a && b) || a.pre !== null && b.pre === null) {
326
506
  return false;
327
507
  }
328
- const [aMajor, aMinor, aPatch] = a;
329
- const [bMajor, bMinor, bPatch] = b;
330
- if (aMajor !== bMajor) {
331
- return aMajor > bMajor;
508
+ for (const [x, y] of a.core.map((n, i) => [n, b.core[i] ?? 0])) {
509
+ if (x !== y) {
510
+ return x > y;
511
+ }
332
512
  }
333
- if (aMinor !== bMinor) {
334
- return aMinor > bMinor;
513
+ if (a.pre === null || b.pre === null) {
514
+ return a.pre === null && b.pre !== null;
335
515
  }
336
- return aPatch > bPatch;
516
+ return a.pre > b.pre;
337
517
  }
338
518
  function printUpdateNotice(currentVersion) {
339
519
  if (disabled()) {
340
520
  return;
341
521
  }
342
- const cache = readCache();
522
+ const channel = channelOf(currentVersion);
523
+ const cache = readCache(channel);
343
524
  if (!(cache && isNewerVersion(cache.latest, currentVersion))) {
344
525
  return;
345
526
  }
527
+ const spec = channel === "next" ? `${PACKAGE}@next` : PACKAGE;
346
528
  console.error(
347
- `zkao: version ${cache.latest} is available (running ${currentVersion}). Update with \`npm install -g ${PACKAGE}\`. If you are an agent working from a zkao skill file, update that too: it may describe fewer commands than the API now offers.`
529
+ `zkao: version ${cache.latest} is available (running ${currentVersion}). Update with \`npm install -g ${spec}\`. If you are an agent working from a zkao skill file, update that too: it may describe fewer commands than the API now offers.`
348
530
  );
349
531
  }
350
- async function refreshUpdateCache() {
532
+ async function refreshUpdateCache(currentVersion) {
351
533
  if (disabled()) {
352
534
  return;
353
535
  }
354
- const cache = readCache();
536
+ const channel = channelOf(currentVersion);
537
+ const cache = readCache(channel);
355
538
  if (cache && Date.now() - cache.checkedAtMs < CHECK_INTERVAL_MS) {
356
539
  return;
357
540
  }
358
- const keepPrevious = () => writeCache({ checkedAtMs: Date.now(), latest: cache?.latest ?? "" });
541
+ const keepPrevious = () => writeCache({ checkedAtMs: Date.now(), latest: cache?.latest ?? "", channel });
359
542
  try {
360
- const res = await fetch(`https://registry.npmjs.org/${PACKAGE}/latest`, {
543
+ const res = await fetch(`https://registry.npmjs.org/${PACKAGE}/${channel}`, {
361
544
  headers: { accept: "application/json" },
362
545
  signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS)
363
546
  });
@@ -372,7 +555,7 @@ async function refreshUpdateCache() {
372
555
  keepPrevious();
373
556
  return;
374
557
  }
375
- writeCache({ checkedAtMs: Date.now(), latest: body.version });
558
+ writeCache({ checkedAtMs: Date.now(), latest: body.version, channel });
376
559
  } catch (err) {
377
560
  debug("could not reach the npm registry", err);
378
561
  keepPrevious();
@@ -390,11 +573,16 @@ function fail(message) {
390
573
  console.error(message);
391
574
  process.exit(1);
392
575
  }
393
- function client() {
576
+ function globalConfig() {
394
577
  const g = program.opts();
395
- const cfg = resolveConfig({ token: g.token, projectId: g.project, baseUrl: g.baseUrl });
578
+ return resolveConfig({ token: g.token, projectId: g.project, baseUrl: g.baseUrl });
579
+ }
580
+ function client() {
581
+ const cfg = globalConfig();
396
582
  if (!cfg.token) {
397
- fail("No API token. Pass --token, set ZKAO_API_TOKEN, or run `zkao config set --token <token>`.");
583
+ fail(
584
+ cfg.projectId ? `No saved credentials for project ${cfg.projectId}. Run \`zkao login --project ${cfg.projectId}\`, or pass --token / set ZKAO_API_TOKEN.` : "No API token. Run `zkao login`, pass --token, or set ZKAO_API_TOKEN."
585
+ );
398
586
  }
399
587
  if (!cfg.projectId) {
400
588
  fail("No project id. Pass --project, set ZKAO_PROJECT_ID, or run `zkao config set --project <id>`.");
@@ -434,6 +622,9 @@ program.command("login").description(
434
622
  "--no-wait",
435
623
  "start the flow, print the URL/code, and exit; finish with --resume"
436
624
  ).option("--resume", "poll a login previously started with --no-wait").option(
625
+ "--scope <scopes...>",
626
+ `scopes to request; the approver can change them (default: ${DEFAULT_LOGIN_SCOPES.join(" ")})`
627
+ ).option(
437
628
  "--timeout <seconds>",
438
629
  "with --resume, wait up to this long for approval (default: poll once)",
439
630
  toInt
@@ -449,12 +640,33 @@ program.command("login").description(
449
640
  project: g.project,
450
641
  baseUrl: g.baseUrl,
451
642
  noBrowser: opts.browser === false,
643
+ scopes: opts.scope,
452
644
  wait: opts.wait !== false
453
645
  });
454
646
  }
455
647
  );
648
+ program.command("whoami").description("Show which project and organization the current token belongs to").action(async () => {
649
+ const cfg = globalConfig();
650
+ if (!cfg.token) {
651
+ fail("No API token. Run `zkao login`, pass --token, or set ZKAO_API_TOKEN.");
652
+ }
653
+ try {
654
+ const info = await getTokenInfo({ token: cfg.token, baseUrl: cfg.baseUrl });
655
+ if (cfg.projectId && cfg.projectId !== info.project.id) {
656
+ console.error(
657
+ `zkao: this token belongs to project ${info.project.id}, not the configured ${cfg.projectId}.`
658
+ );
659
+ }
660
+ out(info);
661
+ } catch (err) {
662
+ if (err instanceof ZkaoApiError) {
663
+ fail(`Error ${err.status} (${err.code}): ${err.message}`);
664
+ }
665
+ throw err;
666
+ }
667
+ });
456
668
  var config = program.command("config").description("Manage stored credentials");
457
- config.command("set").description(`Persist settings to ${configPath()}`).option("--token <token>", "project API token").option("--project <id>", "project id").option("--base-url <url>", "API base URL").action((opts) => {
669
+ config.command("set").description(`Persist settings to ${configPath()}`).option("--token <token>", "project API token").option("--project <id>", "project id").option("--base-url <url>", "API base URL").action(async (opts) => {
458
670
  const g = program.opts();
459
671
  const update = {
460
672
  token: opts.token ?? g.token,
@@ -464,17 +676,52 @@ config.command("set").description(`Persist settings to ${configPath()}`).option(
464
676
  if (update.token === void 0 && update.projectId === void 0 && update.baseUrl === void 0) {
465
677
  fail("Nothing to save. Pass --token, --project, or --base-url.");
466
678
  }
679
+ if (update.token !== void 0 && update.projectId === void 0) {
680
+ const baseUrl = resolveConfig({ baseUrl: update.baseUrl }).baseUrl;
681
+ try {
682
+ const info = await getTokenInfo({ token: update.token, baseUrl });
683
+ const names = {
684
+ name: info.project.name,
685
+ organization: info.project.organization.name
686
+ };
687
+ saveProjectCredentials(info.project.id, { token: update.token, baseUrl, ...names });
688
+ console.log(
689
+ `Saved to ${configPath()}. Active project: ${describeProject(info.project.id, names)}`
690
+ );
691
+ return;
692
+ } catch (err) {
693
+ console.error(
694
+ `zkao: could not look up the token's project (${err instanceof Error ? err.message : String(err)}); saving it without one.`
695
+ );
696
+ }
697
+ }
467
698
  writeConfig(update);
468
699
  console.log(`Saved to ${configPath()}`);
469
700
  });
470
701
  config.command("show").description("Show the resolved settings (token masked)").action(() => {
471
702
  const cfg = resolveConfig({});
703
+ const { activeProjectId, projects } = savedProjects();
472
704
  out({
473
705
  token: cfg.token ? `${cfg.token.slice(0, 14)}\u2026` : null,
474
706
  projectId: cfg.projectId ?? null,
475
- baseUrl: cfg.baseUrl ?? "https://zkao.io/api/v1 (default)"
707
+ baseUrl: cfg.baseUrl ?? "https://zkao.io/api/v1 (default)",
708
+ savedProjects: Object.entries(projects).map(([id, saved]) => ({
709
+ projectId: id,
710
+ name: saved.name ?? null,
711
+ organization: saved.organization ?? null,
712
+ baseUrl: saved.baseUrl ?? "https://zkao.io/api/v1 (default)",
713
+ active: id === activeProjectId
714
+ }))
476
715
  });
477
716
  });
717
+ config.command("use <projectId>").description("Switch the active project to one with saved credentials").action((projectId) => {
718
+ try {
719
+ const saved = useProject(projectId);
720
+ console.log(`Active project: ${describeProject(projectId, saved)}`);
721
+ } catch (err) {
722
+ fail(err instanceof Error ? err.message : String(err));
723
+ }
724
+ });
478
725
  program.command("repos").description("List repositories in the project").action(() => run((c) => c.listRepositories()));
479
726
  program.command("repos:wait <repositoryId>").description("Wait until a repository is ready to scan").option("--timeout <ms>", "give up after this long", toInt).action(
480
727
  (repositoryId, opts) => run(
@@ -542,7 +789,14 @@ scans.command("wait <scanId>").description("Poll a scan until it finishes (honor
542
789
  }).finally(() => process.removeListener("SIGINT", onSigint))
543
790
  );
544
791
  });
545
- scans.command("launch").description("Launch a scan").requiredOption("--repo <id>", "repository id").requiredOption("--budget <credits>", "max budget, in credits", toInt).option("--preset <ref>", "scan preset ref (see `zkao presets`)").option("--branch <name>", "branch to scan").option("--commit <sha>", "specific commit to scan").option("--message <text>", "commit message (metadata)").option("--flow <id>", "optional-flow id to opt into (repeatable)", collect, []).option(
792
+ scans.command("launch").description("Launch a scan").requiredOption("--repo <id>", "repository id").option(
793
+ "--budget <credits>",
794
+ "max budget, in credits (default: the budget zkao recommends for this scan type on this repository)",
795
+ toInt
796
+ ).option("--preset <ref>", "scan preset ref (see `zkao presets`)").option("--branch <name>", "branch to scan").option("--commit <sha>", "specific commit to scan").option(
797
+ "--base <ref>",
798
+ "base commit or ref of a diff scan; the scan audits only the change from it (requires a diff preset)"
799
+ ).option("--message <text>", "commit message (metadata)").option(
546
800
  "--guidance <file|->",
547
801
  "per-scan guidance from a file (or - for stdin); replaces the repo's guidance for this scan"
548
802
  ).option(
@@ -558,8 +812,8 @@ scans.command("launch").description("Launch a scan").requiredOption("--repo <id>
558
812
  presetRef: opts.preset,
559
813
  branch: opts.branch ?? null,
560
814
  commitHash: opts.commit ?? null,
815
+ baseCommit: opts.base,
561
816
  commitMessage: opts.message ?? null,
562
- optInFlowIds: opts.flow.length > 0 ? opts.flow : void 0,
563
817
  auditAreaKeys: opts.area.length > 0 ? opts.area : void 0,
564
818
  ...opts.guidance !== void 0 ? { guidance: readContentArg(opts.guidance) } : {}
565
819
  };
@@ -594,16 +848,30 @@ findings.command("resolution <findingId> <status>").description("Set resolution
594
848
  findings.command("publish <findingId>").description("Publish a finding as a public artifact").option("--note <noteId>", "note to pin as the published note").option("--password", "generate an access password").action(
595
849
  (findingId, opts) => run((c) => c.publishFinding(findingId, { noteId: opts.note, withPassword: opts.password }))
596
850
  );
597
- var billing = program.command("billing").description("Read the project's credit balance and usage");
598
- billing.command("balance").description("Credit balance, what active scans hold, and what is available").action(() => run((c) => c.getBillingBalance()));
851
+ var billing = program.command("billing").description("Read the credit balance available to the project and its usage");
852
+ billing.command("balance").description("The organization's shared credit balance, what active scans hold, and what is available").action(() => run((c) => c.getBillingBalance()));
599
853
  billing.command("usage").description("Credit ledger movements (defaults to the last 30 days)").option("--from <date>", "ISO date or timestamp to start from").option("--to <date>", "ISO date or timestamp to stop at").option("--limit <n>", "max events to return", toInt).action(
600
854
  (opts) => run((c) => c.getBillingUsage(opts))
601
855
  );
602
856
  billing.command("summary").description("Per-month credits spent and purchased, newest first").option("--months <n>", "how many months to return", toInt).action(
603
857
  (opts) => run((c) => c.getBillingSummary({ months: opts.months }))
604
858
  );
859
+ var admin = program.command("admin", { hidden: true }).description("Site-admin operations on the project");
860
+ admin.command("manual-audit").description("Record a zkSecurity audit report as a manual audit").requiredOption("--repo <id>", "repository id").requiredOption("--commit <sha>", "commit the audit reviewed").requiredOption(
861
+ "--findings <file|->",
862
+ "findings YAML (report-tool: `just export-findings <slug>`)"
863
+ ).option("--report <file|->", "report body markdown (report-tool: `just export-overview <slug>`)").option("--commit-message <text>", "commit message to show with the commit").action(
864
+ (opts) => run(
865
+ (c) => c.createManualAudit({
866
+ repositoryId: opts.repo,
867
+ commitHash: opts.commit,
868
+ findingsYaml: readContentArg(opts.findings),
869
+ reportMarkdown: opts.report === void 0 ? null : readContentArg(opts.report),
870
+ commitMessage: opts.commitMessage ?? null
871
+ })
872
+ )
873
+ );
605
874
  program.command("presets").description("List scan presets the project can launch").action(() => run((c) => c.listScanPresets()));
606
- program.command("flows").description("List opt-in flows available at launch").action(() => run((c) => c.listOptionalFlows()));
607
875
  function errorMessage(err) {
608
876
  if (err instanceof AggregateError && err.errors.length > 0) {
609
877
  return err.errors.map(errorMessage).join("; ");
@@ -615,7 +883,7 @@ function errorMessage(err) {
615
883
  return err.cause ? `${message}: ${errorMessage(err.cause)}` : message;
616
884
  }
617
885
  printUpdateNotice(version);
618
- program.parseAsync().then(() => refreshUpdateCache()).catch((err) => {
886
+ program.parseAsync().then(() => refreshUpdateCache(version)).catch((err) => {
619
887
  console.error(errorMessage(err));
620
888
  process.exit(1);
621
889
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zksecurity/zkao-cli",
3
- "version": "0.5.1",
3
+ "version": "0.11.0-next.3",
4
4
  "description": "Command-line interface for the zkao Project API",
5
5
  "license": "MIT",
6
6
  "author": "zkSecurity",
@@ -39,7 +39,7 @@
39
39
  "prepublishOnly": "node -e \"if (!(process.env.npm_config_user_agent || '').includes('bun/')) { console.error('Publish with bun publish: npm ships the literal workspace:* dependency and breaks installs.'); process.exit(1); }\""
40
40
  },
41
41
  "dependencies": {
42
- "@zksecurity/zkao-sdk": "0.5.1",
42
+ "@zksecurity/zkao-sdk": "0.11.0-next.3",
43
43
  "commander": "^12.1.0"
44
44
  },
45
45
  "devDependencies": {