@voltade/volty 0.1.19 → 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
@@ -6,6 +6,7 @@ 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,21 +276,22 @@ 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).`;
@@ -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}`;
@@ -539,6 +572,35 @@ function renderResult(body, json) {
539
572
  return text || "(no output)";
540
573
  }
541
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
+
542
604
  // src/org-guard.ts
543
605
  var ORG_NATIVE = new Set(["use", "current", "list"]);
544
606
  function isOrgFreeCommand(command) {
@@ -575,12 +637,6 @@ function resolveTargetOrg({ env, baseUrl, override, sticky, command }) {
575
637
  return { org: undefined };
576
638
  return { refusal: PROD_ORG_REFUSAL };
577
639
  }
578
- function describeOrgTarget(orgs, org, env) {
579
- const match = orgs.find((o) => o.organizationId === org);
580
- if (!match)
581
- return { refusal: `org ${org} is not one of your memberships on ${env}` };
582
- return { line: `→ ${env} · ${match.name} (${match.organizationId}) · ${match.role}` };
583
- }
584
640
 
585
641
  // src/proposals.ts
586
642
  function decideProposal(ctx, proposalId, decision, reason) {
@@ -712,11 +768,11 @@ async function handleAuth(rest, config, env, orgOverride) {
712
768
  const ctx = config.contexts[env];
713
769
  if (!ctx?.apiKey)
714
770
  fail(`not logged in to '${env}' — run \`volty auth login --env ${env}\``);
715
- const who = await whoami(ctx);
716
- if (!who)
717
- 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);
718
774
  saveConfig(config);
719
- 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}
720
776
  `);
721
777
  return;
722
778
  }
@@ -800,23 +856,27 @@ async function handleOrg(rest, config, env, target) {
800
856
  const json = takeBool(rest.slice(1), "--json");
801
857
  if (!ctx.apiKey)
802
858
  fail(`not logged in to '${env}' — run \`volty auth login --env ${env}\``);
803
- const res = await apiFetch(ctx, "/api/cli/orgs");
804
- if (!res.ok || !res.body.orgs)
805
- 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
+ ];
806
867
  if (json.present) {
807
- process.stdout.write(`${JSON.stringify(res.body, null, 2)}
868
+ process.stdout.write(`${JSON.stringify({ orgs }, null, 2)}
808
869
  `);
809
870
  return;
810
871
  }
811
- const active = target.org ?? res.body.current;
812
- if (res.body.orgs.length === 0)
813
- process.stdout.write(`(no org memberships)
814
- `);
815
- for (const o of res.body.orgs) {
816
- const marker = o.organizationId === active ? "*" : " ";
817
- 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}
818
876
  `);
819
877
  }
878
+ process.stdout.write(`Another workspace you belong to: pass --org <slug> and the CLI gets a key for it.
879
+ `);
820
880
  return;
821
881
  }
822
882
  fail("usage: volty org <use|current|list>");
@@ -889,7 +949,6 @@ async function main() {
889
949
  });
890
950
  if ("refusal" in target)
891
951
  fail(target.refusal);
892
- setOrgOverride(target.org);
893
952
  const [cmd, ...rest] = argv;
894
953
  if (cmd === "auth")
895
954
  return handleAuth(rest, config, env, orgExtract.value ?? (process.env.VOLTY_ORG || undefined));
@@ -901,18 +960,34 @@ async function main() {
901
960
  return handleDocs(config, env);
902
961
  if (cmd === "skill")
903
962
  return handleSkill(rest);
904
- const ctx = config.contexts[env];
905
- if (!ctx?.apiKey)
963
+ const login = config.contexts[env];
964
+ if (!login?.apiKey)
906
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
+ }
907
980
  if (prod && !isOrgFreeCommand(argv) && target.org) {
908
- const described = describeOrgTarget(await fetchOrgs(ctx, env, !cacheFlag.present), target.org, env);
909
- if ("refusal" in described)
910
- fail(described.refusal);
911
- 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}
912
985
  `);
913
986
  }
914
987
  if (cmd === "changes" && rest[0] === "decide")
915
988
  return handleChanges(rest, ctx);
989
+ if (cmd === "keys")
990
+ argv = keysArgv(argv);
916
991
  const catalog = await fetchCatalog(ctx, env, !cacheFlag.present);
917
992
  if (!cmd || cmd === "help" || cmd === "--help") {
918
993
  process.stdout.write(`${renderCatalog(catalog.ops, rest.length ? rest.join(" ") : undefined)}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltade/volty",
3
- "version": "0.1.19",
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.