@zksecurity/zkao-cli 0.5.0 → 0.11.0-next.2

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 +377 -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,44 +143,84 @@ 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 [];
183
+ }
184
+ if (isPendingLogin(parsed)) {
185
+ return [parsed];
98
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
@@ -157,15 +273,39 @@ Approve only if you started this login.
157
273
  `
158
274
  );
159
275
  }
276
+ function describeProject(projectId, names) {
277
+ if (!names.name) {
278
+ return projectId;
279
+ }
280
+ const org = names.organization ? ` of organization "${names.organization}"` : "";
281
+ return `"${names.name}"${org} (${projectId})`;
282
+ }
160
283
  function persistCredentials(poll, baseUrl) {
161
- const saved = writeConfig({
162
- token: poll.token,
163
- projectId: poll.projectId,
164
- baseUrl
284
+ const tokens = poll.tokens?.length ? poll.tokens : [poll];
285
+ for (const issued of [...tokens].reverse()) {
286
+ saveProjectCredentials(issued.projectId, {
287
+ token: issued.token,
288
+ baseUrl,
289
+ name: issued.projectName,
290
+ organization: issued.organizationName
291
+ });
292
+ }
293
+ const describe = (issued) => describeProject(issued.projectId, {
294
+ name: issued.projectName,
295
+ organization: issued.organizationName
165
296
  });
166
- clearPendingLogin();
167
- console.log("Authorized. Credentials saved.\n");
168
- console.log(` project: ${saved.projectId}`);
297
+ if (tokens.length === 1) {
298
+ console.log(`Authorized for project ${describe(poll)}.`);
299
+ } else {
300
+ console.log(`Authorized for ${tokens.length} projects:`);
301
+ for (const issued of tokens) {
302
+ console.log(` ${describe(issued)}`);
303
+ }
304
+ }
305
+ console.log(`Active project: ${describe(poll)}.`);
306
+ console.log(
307
+ "Credentials for other projects are kept. `zkao config show` lists them; `zkao config use <projectId>` switches."
308
+ );
169
309
  }
170
310
  async function login(flags) {
171
311
  const cfg = resolveConfig({
@@ -175,98 +315,129 @@ async function login(flags) {
175
315
  });
176
316
  const apiBase = cfg.baseUrl ?? DEFAULT_BASE_URL;
177
317
  const origin = appOrigin(apiBase);
318
+ const project = flags.project ?? process.env.ZKAO_PROJECT_ID;
178
319
  const start = await postJson(`${origin}/api/auth/device`, {
179
- label: hostname()
320
+ label: hostname(),
321
+ ...project ? { project } : {}
180
322
  });
181
323
  printInstructions(start);
182
324
  if (!flags.noBrowser) {
183
325
  openBrowser(start.verificationUriComplete);
184
326
  }
185
- writePendingLogin({
327
+ const pending = {
186
328
  deviceCode: start.deviceCode,
329
+ userCode: start.userCode,
187
330
  origin,
188
331
  interval: Math.max(1, start.interval),
189
332
  expiresAtMs: Date.now() + start.expiresIn * 1e3,
190
333
  baseUrl: cfg.baseUrl
191
- });
334
+ };
335
+ addPendingLogin(pending);
192
336
  if (flags.wait === false) {
193
337
  console.log(
194
338
  "Once you approve in the browser, run `zkao login --resume` to finish.\n"
195
339
  );
196
340
  return;
197
341
  }
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
- });
342
+ await pollUntilDone([pending], pending.expiresAtMs);
205
343
  }
206
344
  async function resumeLogin(flags) {
207
- const pending = readPendingLogin();
208
- if (!pending) {
345
+ const all = readPendingLogins();
346
+ const now = Date.now();
347
+ const live = all.filter((l) => l.expiresAtMs > now);
348
+ if (live.length < all.length) {
349
+ removePendingLogins([]);
350
+ }
351
+ if (live.length === 0) {
209
352
  throw new Error(
210
- "No pending login. Run `zkao login --no-wait` to start one first."
353
+ all.length > 0 ? "The login request expired. Run `zkao login` again." : "No pending login. Run `zkao login --no-wait` to start one first."
211
354
  );
212
355
  }
213
- if (Date.now() >= pending.expiresAtMs) {
214
- clearPendingLogin();
215
- throw new Error("The login request expired. Run `zkao login` again.");
216
- }
217
356
  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
- });
357
+ const lastExpiry = Math.max(...live.map((l) => l.expiresAtMs));
358
+ await pollUntilDone(live, Math.min(lastExpiry, Date.now() + waitMs), true);
226
359
  }
227
- async function pollUntilDone(opts) {
228
- let intervalMs = opts.intervalMs;
360
+ function label(pending) {
361
+ return pending.userCode ? `code ${pending.userCode}` : "an earlier login";
362
+ }
363
+ async function pollUntilDone(logins, deadlineMs, reportStillPending = false) {
364
+ let open = [...logins];
365
+ let intervalMs = Math.min(...open.map((l) => Math.max(1, l.interval))) * 1e3;
366
+ let lastFailure = "The login request expired. Run `zkao login` again.";
229
367
  let polled = false;
230
- for (; ; ) {
368
+ while (open.length > 0) {
231
369
  if (polled) {
232
- const remaining = opts.deadlineMs - Date.now();
370
+ const remaining = deadlineMs - Date.now();
233
371
  if (remaining <= 0) {
234
372
  break;
235
373
  }
236
374
  await sleep(Math.min(intervalMs, remaining));
237
375
  }
238
376
  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") {
377
+ const resolved = [];
378
+ const dropped = [];
379
+ for (const pending of open) {
380
+ if (Date.now() >= pending.expiresAtMs) {
381
+ resolved.push(pending.deviceCode);
382
+ dropped.push(`${label(pending)} expired`);
383
+ lastFailure = "The login request expired. Run `zkao login` again.";
384
+ continue;
385
+ }
386
+ const poll = await postJson(
387
+ `${pending.origin}/api/auth/device/token`,
388
+ { deviceCode: pending.deviceCode }
389
+ );
390
+ switch (poll.status) {
391
+ case "pending":
392
+ break;
393
+ case "slow_down":
247
394
  intervalMs += 2e3;
395
+ break;
396
+ case "denied":
397
+ resolved.push(pending.deviceCode);
398
+ dropped.push(`${label(pending)} was denied`);
399
+ lastFailure = "Authorization was denied in the browser.";
400
+ break;
401
+ case "expired":
402
+ resolved.push(pending.deviceCode);
403
+ dropped.push(`${label(pending)} expired`);
404
+ lastFailure = "The login request expired. Run `zkao login` again.";
405
+ break;
406
+ case "approved": {
407
+ removePendingLogins([...resolved, pending.deviceCode]);
408
+ persistCredentials(poll, pending.baseUrl);
409
+ const rest = open.filter(
410
+ (l) => l !== pending && !resolved.includes(l.deviceCode)
411
+ );
412
+ if (rest.length > 0) {
413
+ console.log(
414
+ `Still pending: ${rest.map(label).join(", ")}. Run \`zkao login --resume\` again to finish ${rest.length > 1 ? "them" : "it"}.`
415
+ );
416
+ }
417
+ return;
248
418
  }
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
- );
419
+ default: {
420
+ const _exhaustive = poll;
421
+ throw new Error(
422
+ `Unexpected device status: ${JSON.stringify(_exhaustive)}`
423
+ );
424
+ }
425
+ }
426
+ }
427
+ if (resolved.length > 0) {
428
+ removePendingLogins(resolved);
429
+ open = open.filter((l) => !resolved.includes(l.deviceCode));
430
+ if (open.length > 0) {
431
+ console.error(`zkao: dropped pending login: ${dropped.join(", ")}.`);
264
432
  }
265
433
  }
266
434
  }
267
- if (opts.reportStillPending) {
435
+ if (open.length === 0) {
436
+ throw new Error(lastFailure);
437
+ }
438
+ if (reportStillPending) {
268
439
  console.log(
269
- "Still waiting for approval. Approve in the browser, then run `zkao login --resume` again."
440
+ 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
441
  );
271
442
  return;
272
443
  }
@@ -280,6 +451,9 @@ var CACHE_PATH = join2(dirname2(configPath()), "update-check.json");
280
451
  var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
281
452
  var REGISTRY_TIMEOUT_MS = 1500;
282
453
  var PACKAGE = "@zksecurity/zkao-cli";
454
+ function channelOf(version2) {
455
+ return version2.includes("-") ? "next" : "latest";
456
+ }
283
457
  function debug(message, err) {
284
458
  if (process.env.ZKAO_DEBUG) {
285
459
  console.error(`zkao: ${message}: ${err instanceof Error ? err.message : String(err)}`);
@@ -288,7 +462,7 @@ function debug(message, err) {
288
462
  function disabled() {
289
463
  return Boolean(process.env.ZKAO_NO_UPDATE_CHECK);
290
464
  }
291
- function readCache() {
465
+ function readCache(channel) {
292
466
  let raw;
293
467
  try {
294
468
  raw = readFileSync2(CACHE_PATH, "utf8");
@@ -300,7 +474,8 @@ function readCache() {
300
474
  }
301
475
  try {
302
476
  const parsed = JSON.parse(raw);
303
- return typeof parsed.latest === "string" && typeof parsed.checkedAtMs === "number" ? parsed : null;
477
+ const valid = typeof parsed.latest === "string" && typeof parsed.checkedAtMs === "number";
478
+ return valid && (parsed.channel ?? "latest") === channel ? parsed : null;
304
479
  } catch (err) {
305
480
  debug("ignoring an unparseable update cache", err);
306
481
  return null;
@@ -317,47 +492,53 @@ function writeCache(cache) {
317
492
  }
318
493
  function isNewerVersion(latest, current) {
319
494
  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;
495
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:-next\.(\d+))?$/.exec(v.trim());
496
+ return match ? {
497
+ core: [Number(match[1]), Number(match[2]), Number(match[3])],
498
+ pre: match[4] === void 0 ? null : Number(match[4])
499
+ } : null;
322
500
  };
323
501
  const a = parse(latest);
324
502
  const b = parse(current);
325
- if (!(a && b)) {
503
+ if (!(a && b) || a.pre !== null && b.pre === null) {
326
504
  return false;
327
505
  }
328
- const [aMajor, aMinor, aPatch] = a;
329
- const [bMajor, bMinor, bPatch] = b;
330
- if (aMajor !== bMajor) {
331
- return aMajor > bMajor;
506
+ for (const [x, y] of a.core.map((n, i) => [n, b.core[i] ?? 0])) {
507
+ if (x !== y) {
508
+ return x > y;
509
+ }
332
510
  }
333
- if (aMinor !== bMinor) {
334
- return aMinor > bMinor;
511
+ if (a.pre === null || b.pre === null) {
512
+ return a.pre === null && b.pre !== null;
335
513
  }
336
- return aPatch > bPatch;
514
+ return a.pre > b.pre;
337
515
  }
338
516
  function printUpdateNotice(currentVersion) {
339
517
  if (disabled()) {
340
518
  return;
341
519
  }
342
- const cache = readCache();
520
+ const channel = channelOf(currentVersion);
521
+ const cache = readCache(channel);
343
522
  if (!(cache && isNewerVersion(cache.latest, currentVersion))) {
344
523
  return;
345
524
  }
525
+ const spec = channel === "next" ? `${PACKAGE}@next` : PACKAGE;
346
526
  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.`
527
+ `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
528
  );
349
529
  }
350
- async function refreshUpdateCache() {
530
+ async function refreshUpdateCache(currentVersion) {
351
531
  if (disabled()) {
352
532
  return;
353
533
  }
354
- const cache = readCache();
534
+ const channel = channelOf(currentVersion);
535
+ const cache = readCache(channel);
355
536
  if (cache && Date.now() - cache.checkedAtMs < CHECK_INTERVAL_MS) {
356
537
  return;
357
538
  }
358
- const keepPrevious = () => writeCache({ checkedAtMs: Date.now(), latest: cache?.latest ?? "" });
539
+ const keepPrevious = () => writeCache({ checkedAtMs: Date.now(), latest: cache?.latest ?? "", channel });
359
540
  try {
360
- const res = await fetch(`https://registry.npmjs.org/${PACKAGE}/latest`, {
541
+ const res = await fetch(`https://registry.npmjs.org/${PACKAGE}/${channel}`, {
361
542
  headers: { accept: "application/json" },
362
543
  signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS)
363
544
  });
@@ -372,7 +553,7 @@ async function refreshUpdateCache() {
372
553
  keepPrevious();
373
554
  return;
374
555
  }
375
- writeCache({ checkedAtMs: Date.now(), latest: body.version });
556
+ writeCache({ checkedAtMs: Date.now(), latest: body.version, channel });
376
557
  } catch (err) {
377
558
  debug("could not reach the npm registry", err);
378
559
  keepPrevious();
@@ -390,11 +571,16 @@ function fail(message) {
390
571
  console.error(message);
391
572
  process.exit(1);
392
573
  }
393
- function client() {
574
+ function globalConfig() {
394
575
  const g = program.opts();
395
- const cfg = resolveConfig({ token: g.token, projectId: g.project, baseUrl: g.baseUrl });
576
+ return resolveConfig({ token: g.token, projectId: g.project, baseUrl: g.baseUrl });
577
+ }
578
+ function client() {
579
+ const cfg = globalConfig();
396
580
  if (!cfg.token) {
397
- fail("No API token. Pass --token, set ZKAO_API_TOKEN, or run `zkao config set --token <token>`.");
581
+ fail(
582
+ 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."
583
+ );
398
584
  }
399
585
  if (!cfg.projectId) {
400
586
  fail("No project id. Pass --project, set ZKAO_PROJECT_ID, or run `zkao config set --project <id>`.");
@@ -453,8 +639,28 @@ program.command("login").description(
453
639
  });
454
640
  }
455
641
  );
642
+ program.command("whoami").description("Show which project and organization the current token belongs to").action(async () => {
643
+ const cfg = globalConfig();
644
+ if (!cfg.token) {
645
+ fail("No API token. Run `zkao login`, pass --token, or set ZKAO_API_TOKEN.");
646
+ }
647
+ try {
648
+ const info = await getTokenInfo({ token: cfg.token, baseUrl: cfg.baseUrl });
649
+ if (cfg.projectId && cfg.projectId !== info.project.id) {
650
+ console.error(
651
+ `zkao: this token belongs to project ${info.project.id}, not the configured ${cfg.projectId}.`
652
+ );
653
+ }
654
+ out(info);
655
+ } catch (err) {
656
+ if (err instanceof ZkaoApiError) {
657
+ fail(`Error ${err.status} (${err.code}): ${err.message}`);
658
+ }
659
+ throw err;
660
+ }
661
+ });
456
662
  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) => {
663
+ 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
664
  const g = program.opts();
459
665
  const update = {
460
666
  token: opts.token ?? g.token,
@@ -464,17 +670,52 @@ config.command("set").description(`Persist settings to ${configPath()}`).option(
464
670
  if (update.token === void 0 && update.projectId === void 0 && update.baseUrl === void 0) {
465
671
  fail("Nothing to save. Pass --token, --project, or --base-url.");
466
672
  }
673
+ if (update.token !== void 0 && update.projectId === void 0) {
674
+ const baseUrl = resolveConfig({ baseUrl: update.baseUrl }).baseUrl;
675
+ try {
676
+ const info = await getTokenInfo({ token: update.token, baseUrl });
677
+ const names = {
678
+ name: info.project.name,
679
+ organization: info.project.organization.name
680
+ };
681
+ saveProjectCredentials(info.project.id, { token: update.token, baseUrl, ...names });
682
+ console.log(
683
+ `Saved to ${configPath()}. Active project: ${describeProject(info.project.id, names)}`
684
+ );
685
+ return;
686
+ } catch (err) {
687
+ console.error(
688
+ `zkao: could not look up the token's project (${err instanceof Error ? err.message : String(err)}); saving it without one.`
689
+ );
690
+ }
691
+ }
467
692
  writeConfig(update);
468
693
  console.log(`Saved to ${configPath()}`);
469
694
  });
470
695
  config.command("show").description("Show the resolved settings (token masked)").action(() => {
471
696
  const cfg = resolveConfig({});
697
+ const { activeProjectId, projects } = savedProjects();
472
698
  out({
473
699
  token: cfg.token ? `${cfg.token.slice(0, 14)}\u2026` : null,
474
700
  projectId: cfg.projectId ?? null,
475
- baseUrl: cfg.baseUrl ?? "https://zkao.io/api/v1 (default)"
701
+ baseUrl: cfg.baseUrl ?? "https://zkao.io/api/v1 (default)",
702
+ savedProjects: Object.entries(projects).map(([id, saved]) => ({
703
+ projectId: id,
704
+ name: saved.name ?? null,
705
+ organization: saved.organization ?? null,
706
+ baseUrl: saved.baseUrl ?? "https://zkao.io/api/v1 (default)",
707
+ active: id === activeProjectId
708
+ }))
476
709
  });
477
710
  });
711
+ config.command("use <projectId>").description("Switch the active project to one with saved credentials").action((projectId) => {
712
+ try {
713
+ const saved = useProject(projectId);
714
+ console.log(`Active project: ${describeProject(projectId, saved)}`);
715
+ } catch (err) {
716
+ fail(err instanceof Error ? err.message : String(err));
717
+ }
718
+ });
478
719
  program.command("repos").description("List repositories in the project").action(() => run((c) => c.listRepositories()));
479
720
  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
721
  (repositoryId, opts) => run(
@@ -542,7 +783,14 @@ scans.command("wait <scanId>").description("Poll a scan until it finishes (honor
542
783
  }).finally(() => process.removeListener("SIGINT", onSigint))
543
784
  );
544
785
  });
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(
786
+ scans.command("launch").description("Launch a scan").requiredOption("--repo <id>", "repository id").option(
787
+ "--budget <credits>",
788
+ "max budget, in credits (default: the budget zkao recommends for this scan type on this repository)",
789
+ toInt
790
+ ).option("--preset <ref>", "scan preset ref (see `zkao presets`)").option("--branch <name>", "branch to scan").option("--commit <sha>", "specific commit to scan").option(
791
+ "--base <ref>",
792
+ "base commit or ref of a diff scan; the scan audits only the change from it (requires a diff preset)"
793
+ ).option("--message <text>", "commit message (metadata)").option(
546
794
  "--guidance <file|->",
547
795
  "per-scan guidance from a file (or - for stdin); replaces the repo's guidance for this scan"
548
796
  ).option(
@@ -558,8 +806,8 @@ scans.command("launch").description("Launch a scan").requiredOption("--repo <id>
558
806
  presetRef: opts.preset,
559
807
  branch: opts.branch ?? null,
560
808
  commitHash: opts.commit ?? null,
809
+ baseCommit: opts.base,
561
810
  commitMessage: opts.message ?? null,
562
- optInFlowIds: opts.flow.length > 0 ? opts.flow : void 0,
563
811
  auditAreaKeys: opts.area.length > 0 ? opts.area : void 0,
564
812
  ...opts.guidance !== void 0 ? { guidance: readContentArg(opts.guidance) } : {}
565
813
  };
@@ -594,16 +842,30 @@ findings.command("resolution <findingId> <status>").description("Set resolution
594
842
  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
843
  (findingId, opts) => run((c) => c.publishFinding(findingId, { noteId: opts.note, withPassword: opts.password }))
596
844
  );
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()));
845
+ var billing = program.command("billing").description("Read the credit balance available to the project and its usage");
846
+ billing.command("balance").description("The organization's shared credit balance, what active scans hold, and what is available").action(() => run((c) => c.getBillingBalance()));
599
847
  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
848
  (opts) => run((c) => c.getBillingUsage(opts))
601
849
  );
602
850
  billing.command("summary").description("Per-month credits spent and purchased, newest first").option("--months <n>", "how many months to return", toInt).action(
603
851
  (opts) => run((c) => c.getBillingSummary({ months: opts.months }))
604
852
  );
853
+ var admin = program.command("admin", { hidden: true }).description("Site-admin operations on the project");
854
+ 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(
855
+ "--findings <file|->",
856
+ "findings YAML (report-tool: `just export-findings <slug>`)"
857
+ ).option("--report <file|->", "report body markdown (report-tool: `just export-overview <slug>`)").option("--commit-message <text>", "commit message to show with the commit").action(
858
+ (opts) => run(
859
+ (c) => c.createManualAudit({
860
+ repositoryId: opts.repo,
861
+ commitHash: opts.commit,
862
+ findingsYaml: readContentArg(opts.findings),
863
+ reportMarkdown: opts.report === void 0 ? null : readContentArg(opts.report),
864
+ commitMessage: opts.commitMessage ?? null
865
+ })
866
+ )
867
+ );
605
868
  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
869
  function errorMessage(err) {
608
870
  if (err instanceof AggregateError && err.errors.length > 0) {
609
871
  return err.errors.map(errorMessage).join("; ");
@@ -615,7 +877,7 @@ function errorMessage(err) {
615
877
  return err.cause ? `${message}: ${errorMessage(err.cause)}` : message;
616
878
  }
617
879
  printUpdateNotice(version);
618
- program.parseAsync().then(() => refreshUpdateCache()).catch((err) => {
880
+ program.parseAsync().then(() => refreshUpdateCache(version)).catch((err) => {
619
881
  console.error(errorMessage(err));
620
882
  process.exit(1);
621
883
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zksecurity/zkao-cli",
3
- "version": "0.5.0",
3
+ "version": "0.11.0-next.2",
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.0",
42
+ "@zksecurity/zkao-sdk": "0.11.0-next.2",
43
43
  "commander": "^12.1.0"
44
44
  },
45
45
  "devDependencies": {