@voltade/volty 0.1.18 → 0.1.20

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/dist/volty.js CHANGED
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // bin/volty.ts
4
- import { existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync5 } from "node:fs";
4
+ import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync5 } from "node:fs";
5
5
  import { homedir as homedir3 } from "node:os";
6
6
  import { join as join4 } from "node:path";
7
7
 
8
8
  // src/api.ts
9
+ import { createHash } from "node:crypto";
9
10
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
11
  import { tmpdir } from "node:os";
11
12
  import { join } from "node:path";
@@ -41,51 +42,36 @@ async function apiFetch(ctx, path, init) {
41
42
  } catch {}
42
43
  return { ok: res.ok, status: res.status, body };
43
44
  }
44
- var cachePath = (env) => join(tmpdir(), `volty-catalog-${env}-${targetOrg ?? "default"}.json`);
45
- function staleKeyHint(env) {
46
- return `: this key is not valid any more, run \`volty auth login --env ${env}\``;
45
+ var keyTag = (ctx) => createHash("sha256").update(ctx.apiKey ?? "").digest("hex").slice(0, 12);
46
+ var cachePath = (env, ctx) => join(tmpdir(), `volty-catalog-${env}-${targetOrg ?? "default"}-${keyTag(ctx)}.json`);
47
+ function refusalSuffix(res, env) {
48
+ if (res.body.error?.message)
49
+ return `: ${res.body.error.message}`;
50
+ return res.status === 401 ? `: this key is not valid any more, run \`volty auth login --env ${env}\`` : "";
47
51
  }
48
52
  async function fetchCatalog(ctx, env, useCache = true) {
49
- const path = cachePath(env);
53
+ const path = cachePath(env, ctx);
50
54
  if (useCache && existsSync(path)) {
51
55
  try {
52
56
  const cached = JSON.parse(readFileSync(path, "utf8"));
53
- if (Date.now() - cached.at < CATALOG_TTL_MS && cached.renamed && cached.inApp)
57
+ if (Date.now() - cached.at < CATALOG_TTL_MS && cached.renamed && cached.inApp && cached.outOfScope)
54
58
  return cached;
55
59
  } catch {}
56
60
  }
57
61
  const res = await apiFetch(ctx, "/api/v/_catalog");
58
62
  if (!res.ok || !res.body.catalog) {
59
- throw new Error(`could not fetch the operation catalog (HTTP ${res.status})${res.status === 401 ? staleKeyHint(env) : ""}`);
63
+ throw new Error(`could not fetch the operation catalog (HTTP ${res.status})${refusalSuffix(res, env)}`);
60
64
  }
61
65
  const ops = res.body.catalog;
62
66
  const retired = res.body.retired ?? {};
63
67
  const renamed = res.body.renamed ?? {};
64
68
  const inApp = res.body.inApp ?? {};
69
+ const outOfScope = res.body.outOfScope ?? {};
65
70
  try {
66
71
  mkdirSync(tmpdir(), { recursive: true });
67
- writeFileSync(path, JSON.stringify({ at: Date.now(), ops, retired, renamed, inApp }));
72
+ writeFileSync(path, JSON.stringify({ at: Date.now(), ops, retired, renamed, inApp, outOfScope }));
68
73
  } catch {}
69
- return { ops, retired, renamed, inApp };
70
- }
71
- async function fetchOrgs(ctx, env, useCache = true) {
72
- const path = join(tmpdir(), `volty-orgs-${env}.json`);
73
- if (useCache && existsSync(path)) {
74
- try {
75
- const cached = JSON.parse(readFileSync(path, "utf8"));
76
- if (Date.now() - cached.at < CATALOG_TTL_MS)
77
- return cached.orgs;
78
- } catch {}
79
- }
80
- const res = await apiFetch(ctx, "/api/cli/orgs");
81
- if (!res.ok || !res.body.orgs) {
82
- throw new Error(`could not fetch your org memberships (HTTP ${res.status})${res.status === 401 ? staleKeyHint(env) : ""}`);
83
- }
84
- const orgs = res.body.orgs;
85
- try {
86
- writeFileSync(path, JSON.stringify({ at: Date.now(), orgs }));
87
- } catch {}
88
- return orgs;
74
+ return { ops, retired, renamed, inApp, outOfScope };
89
75
  }
90
76
  function pathForOp(name) {
91
77
  return `/api/v/${name.trim().split(/\s+/).join("/")}`;
@@ -166,16 +152,59 @@ function ensureContext(config, env) {
166
152
  return ctx;
167
153
  }
168
154
 
155
+ // src/org-keys.ts
156
+ function storedKeyFor(ctx, org, now = Date.now()) {
157
+ if (!ctx.apiKey)
158
+ return null;
159
+ if (!org || org === ctx.organizationId || org === ctx.organizationSlug) {
160
+ return { apiKey: ctx.apiKey, organizationId: ctx.organizationId };
161
+ }
162
+ for (const [organizationId, k] of Object.entries(ctx.orgKeys ?? {})) {
163
+ if (organizationId !== org && k.slug !== org)
164
+ continue;
165
+ if (k.expiresAt && Date.parse(k.expiresAt) <= now)
166
+ return null;
167
+ return { apiKey: k.apiKey, organizationId };
168
+ }
169
+ return null;
170
+ }
171
+ async function ensureOrgKey(ctx, org, onMint) {
172
+ const stored = storedKeyFor(ctx, org);
173
+ if (stored)
174
+ return { ...stored, minted: false };
175
+ setOrgOverride(undefined);
176
+ const res = await apiFetch(ctx, "/api/cli/keys", { method: "POST", body: { org } });
177
+ if (!res.ok || !res.body.apiKey || !res.body.organizationId) {
178
+ throw new Error(res.body.error?.message ?? `could not get a key for '${org}' (HTTP ${res.status})`);
179
+ }
180
+ const entry = { apiKey: res.body.apiKey, slug: res.body.slug ?? org, expiresAt: res.body.expiresAt };
181
+ ctx.orgKeys = { ...ctx.orgKeys, [res.body.organizationId]: entry };
182
+ onMint(`Minted a key for ${entry.slug} (expires ${entry.expiresAt?.slice(0, 10) ?? "never"})`);
183
+ return { apiKey: entry.apiKey, organizationId: res.body.organizationId, minted: true };
184
+ }
185
+ async function dropOrgKeys(ctx) {
186
+ await Promise.all(Object.values(ctx.orgKeys ?? {}).map(async (k) => {
187
+ const res = await apiFetch({ ...ctx, apiKey: k.apiKey }, "/api/cli/logout", { method: "POST", body: {} }).catch(() => null);
188
+ if (!res || !res.ok && res.status !== 401) {
189
+ process.stderr.write(`Could not revoke the key for ${k.slug}; \`volty keys revoke cli --org ${k.slug}\` removes it.
190
+ `);
191
+ }
192
+ }));
193
+ ctx.orgKeys = undefined;
194
+ }
195
+
169
196
  // src/auth.ts
170
197
  async function whoami(ctx) {
171
198
  if (!ctx.apiKey)
172
- return null;
199
+ return { error: "not logged in" };
173
200
  const res = await apiFetch(ctx, "/api/cli/whoami");
174
- if (!res.ok || !res.body.organizationId)
175
- return null;
201
+ if (!res.ok || !res.body.organizationId) {
202
+ return { error: res.body?.error?.message ?? "the stored key did not validate, run `volty auth login --rotate`" };
203
+ }
176
204
  ctx.organizationId = res.body.organizationId;
205
+ ctx.organizationSlug = res.body.organizationSlug ?? undefined;
177
206
  ctx.principal = res.body.email ?? res.body.name ?? res.body.userId;
178
- return res.body;
207
+ return { who: res.body };
179
208
  }
180
209
  function openBrowser(url) {
181
210
  const cmd = process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? ["cmd", "/c", "start", url] : ["xdg-open", url];
@@ -233,13 +262,13 @@ async function login(config, env, opts) {
233
262
  ctx.baseUrl = opts.baseUrl;
234
263
  if (!ctx.baseUrl)
235
264
  throw new Error(`no base URL for '${env}' — pass --base-url <url>`);
236
- if (!opts.rotate && await whoami(ctx)) {
265
+ if (!opts.rotate && "who" in await whoami(ctx)) {
237
266
  return `Already authenticated to '${env}' as ${ctx.principal ?? "(unknown)"} — reusing the stored key.`;
238
267
  }
268
+ await dropOrgKeys(ctx);
239
269
  if (opts.token) {
240
270
  ctx.apiKey = opts.token;
241
- const who = await whoami(ctx);
242
- if (!who)
271
+ if ("error" in await whoami(ctx))
243
272
  throw new Error("the provided --token did not validate");
244
273
  return `Authenticated to '${env}' as ${ctx.principal ?? "(unknown)"} (org ${ctx.organizationId}).`;
245
274
  }
@@ -247,28 +276,29 @@ async function login(config, env, opts) {
247
276
  const key = await devGrant(ctx, opts.devPersona, opts.org);
248
277
  if (key) {
249
278
  ctx.apiKey = key;
250
- const who = await whoami(ctx);
251
- return `Authenticated to '${env}' as ${who?.email ?? ctx.organizationId ?? "(unknown)"} (dev persona ${opts.devPersona}).`;
279
+ await whoami(ctx);
280
+ return `Authenticated to '${env}' as ${ctx.principal ?? ctx.organizationId ?? "(unknown)"} (dev persona ${opts.devPersona}).`;
252
281
  }
253
282
  process.stdout.write(`The '${env}' server is not in development mode, so --as has no dev grant to use. Falling back to the browser grant.
254
283
  `);
255
284
  }
256
285
  ctx.apiKey = await deviceGrant(ctx);
257
- const who = await whoami(ctx);
258
- return `Authenticated to '${env}' as ${who?.email ?? ctx.organizationId ?? "(unknown)"}.`;
286
+ await whoami(ctx);
287
+ return `Authenticated to '${env}' as ${ctx.principal ?? ctx.organizationId ?? "(unknown)"}.`;
259
288
  }
260
289
  async function logout(config, env) {
261
290
  const ctx = config.contexts[env];
262
291
  if (!ctx?.apiKey)
263
292
  return `No stored key for '${env}'.`;
264
293
  await apiFetch(ctx, "/api/cli/logout", { method: "POST", body: {} });
294
+ await dropOrgKeys(ctx);
265
295
  ctx.apiKey = undefined;
266
296
  ctx.principal = undefined;
267
297
  return `Logged out of '${env}' (key revoked).`;
268
298
  }
269
299
 
270
300
  // src/dispatch.ts
271
- import { readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
301
+ import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
272
302
 
273
303
  // ../shared/ops/flag-parse.ts
274
304
  function acceptsNull(prop) {
@@ -386,6 +416,9 @@ function missMessage(catalog, argv) {
386
416
  const inApp = retiredPrefix(catalog.inApp, argv);
387
417
  if (inApp)
388
418
  return `\`${inApp.name}\` is not run from here. ${inApp.replacement}`;
419
+ const outOfScope = retiredPrefix(catalog.outOfScope, argv);
420
+ if (outOfScope)
421
+ return outOfScope.replacement;
389
422
  const retired = retiredPrefix(catalog.retired, argv);
390
423
  if (retired)
391
424
  return `\`${retired.name}\` is retired: ${retired.replacement}`;
@@ -406,12 +439,29 @@ function parseFlags(op, rest) {
406
439
  const parsed = parseFlagTokens(props, rest, CliUsageError);
407
440
  for (const [key, value] of Object.entries(parsed.input)) {
408
441
  const prop = props[key];
409
- if (typeof value === "string" && value.startsWith("@") && prop && isBase64(prop)) {
442
+ if (typeof value !== "string" || !value.startsWith("@") || !prop)
443
+ continue;
444
+ if (isBase64(prop))
410
445
  parsed.input[key] = readBase64File(key, value.slice(1));
411
- }
446
+ else if (isText(prop))
447
+ parsed.input[key] = readTextFlag(key, value.slice(1)) ?? value;
412
448
  }
413
449
  return parsed;
414
450
  }
451
+ function isText(prop) {
452
+ return [prop, ...prop.anyOf ?? []].some((branch) => branch.type === "string" && !branch.enum);
453
+ }
454
+ function readTextFlag(flag, path) {
455
+ if (path === "-")
456
+ return readFileSync3(0, "utf8");
457
+ if (!existsSync3(path))
458
+ return null;
459
+ try {
460
+ return readFileSync3(path, "utf8");
461
+ } catch (err) {
462
+ throw new CliUsageError(`--${flag}: cannot read ${path} (${err.code ?? "error"})`);
463
+ }
464
+ }
415
465
  function isBase64(prop) {
416
466
  return [prop, ...prop.anyOf ?? []].some((branch) => branch.contentEncoding === "base64");
417
467
  }
@@ -478,14 +528,19 @@ function opUsage(op) {
478
528
  }
479
529
  return parts.join(" ");
480
530
  }
481
- function renderCatalog(catalog) {
482
- const lines = catalog.toSorted((a, b) => a.name.localeCompare(b.name)).map((op) => ` ${op.name} — ${op.description}`);
531
+ function renderCatalog(catalog, group) {
532
+ const shown = group ? catalog.filter((op) => op.name === group || op.name.startsWith(`${group} `)) : catalog;
533
+ if (group && shown.length === 0) {
534
+ const groups = [...new Set(catalog.map((op) => op.name.split(" ")[0]))].toSorted();
535
+ return `No operations in '${group}'. Groups: ${groups.join(", ")}`;
536
+ }
537
+ const lines = shown.toSorted((a, b) => a.name.localeCompare(b.name)).map((op) => ` ${op.name} — ${op.description}`);
483
538
  return [
484
539
  "Run a Volty platform operation: volty <operation> [--flags] [--json]",
485
540
  "",
486
541
  ...lines,
487
542
  "",
488
- "`volty <operation> --help` shows usage; `--json` returns the machine-readable result."
543
+ "`volty help <group>` lists one group (`volty help chat-flow`); `volty <operation> --help` shows usage; `--json` returns the machine-readable result. A text flag takes a file as `--source=@flow.ts`, or stdin as `--source=@-`."
489
544
  ].join(`
490
545
  `);
491
546
  }
@@ -517,6 +572,35 @@ function renderResult(body, json) {
517
572
  return text || "(no output)";
518
573
  }
519
574
 
575
+ // src/keys.ts
576
+ var VALUE_FLAGS = new Set(["name", "for", "for-user-id", "expires", "expires-in-days", "families"]);
577
+ function keysArgv(argv) {
578
+ const out = argv.slice(0, 2);
579
+ const rest = argv.slice(2);
580
+ for (let i = 0;i < rest.length; i++) {
581
+ const token = rest[i];
582
+ if (!token.startsWith("--")) {
583
+ out.push("--name", token);
584
+ continue;
585
+ }
586
+ const eq = token.indexOf("=");
587
+ const flag = eq > 0 ? token.slice(2, eq) : token.slice(2);
588
+ const value = eq > 0 ? token.slice(eq + 1) : VALUE_FLAGS.has(flag) ? rest[++i] : undefined;
589
+ if (value === undefined)
590
+ out.push(token);
591
+ else if (flag === "for")
592
+ out.push("--for-user-id", value);
593
+ else if (flag === "expires")
594
+ out.push("--expires-in-days", value.replace(/d$/, ""));
595
+ else if (flag === "families")
596
+ for (const f of value.split(","))
597
+ out.push("--families", f.trim());
598
+ else
599
+ out.push(`--${flag}`, value);
600
+ }
601
+ return out;
602
+ }
603
+
520
604
  // src/org-guard.ts
521
605
  var ORG_NATIVE = new Set(["use", "current", "list"]);
522
606
  function isOrgFreeCommand(command) {
@@ -553,12 +637,6 @@ function resolveTargetOrg({ env, baseUrl, override, sticky, command }) {
553
637
  return { org: undefined };
554
638
  return { refusal: PROD_ORG_REFUSAL };
555
639
  }
556
- function describeOrgTarget(orgs, org, env) {
557
- const match = orgs.find((o) => o.organizationId === org);
558
- if (!match)
559
- return { refusal: `org ${org} is not one of your memberships on ${env}` };
560
- return { line: `→ ${env} · ${match.name} (${match.organizationId}) · ${match.role}` };
561
- }
562
640
 
563
641
  // src/proposals.ts
564
642
  function decideProposal(ctx, proposalId, decision, reason) {
@@ -690,11 +768,11 @@ async function handleAuth(rest, config, env, orgOverride) {
690
768
  const ctx = config.contexts[env];
691
769
  if (!ctx?.apiKey)
692
770
  fail(`not logged in to '${env}' — run \`volty auth login --env ${env}\``);
693
- const who = await whoami(ctx);
694
- if (!who)
695
- fail("the stored key did not validate — run `volty auth login --rotate`");
771
+ const res = await whoami(ctx);
772
+ if ("error" in res)
773
+ fail(res.error);
696
774
  saveConfig(config);
697
- process.stdout.write(`${who.email ?? who.userId} · org ${who.organizationId} · role ${who.role}
775
+ process.stdout.write(`${res.who.email ?? res.who.userId} · org ${res.who.organizationId} · role ${res.who.role}
698
776
  `);
699
777
  return;
700
778
  }
@@ -778,23 +856,27 @@ async function handleOrg(rest, config, env, target) {
778
856
  const json = takeBool(rest.slice(1), "--json");
779
857
  if (!ctx.apiKey)
780
858
  fail(`not logged in to '${env}' — run \`volty auth login --env ${env}\``);
781
- const res = await apiFetch(ctx, "/api/cli/orgs");
782
- if (!res.ok || !res.body.orgs)
783
- failResult("org list", res.body, res.status, json.present);
859
+ const orgs = [
860
+ { organizationId: ctx.organizationId ?? "", slug: ctx.organizationSlug ?? "", expiresAt: null },
861
+ ...Object.entries(ctx.orgKeys ?? {}).map(([organizationId, k]) => ({
862
+ organizationId,
863
+ slug: k.slug,
864
+ expiresAt: k.expiresAt ?? null
865
+ }))
866
+ ];
784
867
  if (json.present) {
785
- process.stdout.write(`${JSON.stringify(res.body, null, 2)}
868
+ process.stdout.write(`${JSON.stringify({ orgs }, null, 2)}
786
869
  `);
787
870
  return;
788
871
  }
789
- const active = target.org ?? res.body.current;
790
- if (res.body.orgs.length === 0)
791
- process.stdout.write(`(no org memberships)
792
- `);
793
- for (const o of res.body.orgs) {
794
- const marker = o.organizationId === active ? "*" : " ";
795
- process.stdout.write(`${marker} ${o.organizationId} ${o.role} ${o.name}
872
+ for (const [i, o] of orgs.entries()) {
873
+ const active = target.org ? target.org === o.organizationId || target.org === o.slug : i === 0;
874
+ const note = i === 0 ? "signed in" : `expires ${o.expiresAt?.slice(0, 10) ?? "never"}`;
875
+ process.stdout.write(`${active ? "*" : " "} ${o.organizationId} ${o.slug} ${note}
796
876
  `);
797
877
  }
878
+ process.stdout.write(`Another workspace you belong to: pass --org <slug> and the CLI gets a key for it.
879
+ `);
798
880
  return;
799
881
  }
800
882
  fail("usage: volty org <use|current|list>");
@@ -837,7 +919,7 @@ function handleSkill(rest) {
837
919
  join4(import.meta.dirname, "..", "skill", "volty-cli-ops", "SKILL.md"),
838
920
  join4(import.meta.dirname, "..", "..", ".claude", "skills", "volty-cli-ops", "SKILL.md")
839
921
  ];
840
- const source = candidates.find((p) => existsSync3(p));
922
+ const source = candidates.find((p) => existsSync4(p));
841
923
  if (!source)
842
924
  fail("bundled SKILL.md not found — reinstall the @voltade/volty package");
843
925
  const destDir = join4(homedir3(), ".claude", "skills", "volty-cli-ops");
@@ -867,7 +949,6 @@ async function main() {
867
949
  });
868
950
  if ("refusal" in target)
869
951
  fail(target.refusal);
870
- setOrgOverride(target.org);
871
952
  const [cmd, ...rest] = argv;
872
953
  if (cmd === "auth")
873
954
  return handleAuth(rest, config, env, orgExtract.value ?? (process.env.VOLTY_ORG || undefined));
@@ -879,21 +960,37 @@ async function main() {
879
960
  return handleDocs(config, env);
880
961
  if (cmd === "skill")
881
962
  return handleSkill(rest);
882
- const ctx = config.contexts[env];
883
- if (!ctx?.apiKey)
963
+ const login = config.contexts[env];
964
+ if (!login?.apiKey)
884
965
  fail(`not logged in to '${env}' — run \`volty auth login --env ${env}\``);
966
+ let ctx = login;
967
+ if (target.org) {
968
+ let selected;
969
+ try {
970
+ selected = await ensureOrgKey(login, target.org, (line) => process.stderr.write(`${line}
971
+ `));
972
+ } catch (err) {
973
+ fail(err instanceof Error ? err.message : String(err));
974
+ }
975
+ if (selected.minted)
976
+ saveConfig(config);
977
+ ctx = { ...login, apiKey: selected.apiKey, organizationId: selected.organizationId };
978
+ setOrgOverride(selected.organizationId);
979
+ }
885
980
  if (prod && !isOrgFreeCommand(argv) && target.org) {
886
- const described = describeOrgTarget(await fetchOrgs(ctx, env, !cacheFlag.present), target.org, env);
887
- if ("refusal" in described)
888
- fail(described.refusal);
889
- process.stderr.write(`${described.line}
981
+ const res = await whoami(ctx);
982
+ if ("error" in res)
983
+ fail(res.error);
984
+ process.stderr.write(`→ ${env} · ${res.who.organizationName} (${res.who.organizationId}) · ${res.who.role}
890
985
  `);
891
986
  }
892
987
  if (cmd === "changes" && rest[0] === "decide")
893
988
  return handleChanges(rest, ctx);
989
+ if (cmd === "keys")
990
+ argv = keysArgv(argv);
894
991
  const catalog = await fetchCatalog(ctx, env, !cacheFlag.present);
895
992
  if (!cmd || cmd === "help" || cmd === "--help") {
896
- process.stdout.write(`${renderCatalog(catalog.ops)}
993
+ process.stdout.write(`${renderCatalog(catalog.ops, rest.length ? rest.join(" ") : undefined)}
897
994
  `);
898
995
  return;
899
996
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltade/volty",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "description": "Remote CLI for the Volty platform — run operations, trigger real agent wakes, and inspect LLM I/O over an API key.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -25,6 +25,7 @@ Use the lane the behaviour was reported on. On the contact lane Volty declines t
25
25
 
26
26
  ## On prod
27
27
 
28
+ - A key reaches one org and calls operations only; `--org` gets each org you belong to its own key the first time. A call a key cannot make is refused with a sentence naming why; report that sentence instead of working around it. `volty keys create <name>` mints a narrower named key for a script (`volty docs` has the flags).
28
29
  - Name the org on every command (`--env prod --org <id>`, or `VOLTY_ENV` and `VOLTY_ORG` in the process), and read the stderr line the CLI prints before each command to check it names the org you meant. Act only in the org the user named.
29
30
  - Test through the Sandbox, where a reply to the synthetic contact is recorded as sent and never leaves. A real conversation is never a test target.
30
31
  - Run `volty debug sandbox-cleanup --all --env prod --org <id>` when you finish.