@llamaventures/cli 2.8.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,24 @@ this project adheres to [Semantic Versioning](https://semver.org).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.9.0] - 2026-11-13
10
+
11
+ ### Changed
12
+
13
+ - `llama auth login` no longer has to be running at the moment you approve in
14
+ the browser. The loopback listener is now a detached helper that stays open
15
+ for 15 minutes, finishes the sign-in when you approve, and stores the
16
+ credentials. If the command stops first (closed terminal, slow approval, an
17
+ agent tool that kills long commands), run `llama auth login` again: it picks
18
+ up the sign-in that is still waiting, or the approval you already gave,
19
+ instead of leaving the browser on a dead `127.0.0.1` page.
20
+ - `llama auth status` reports a sign-in that is waiting for approval.
21
+
22
+ ### Added
23
+
24
+ - `LLAMA_NO_BROWSER=1` makes `llama auth login` print the sign-in link instead
25
+ of opening a browser.
26
+
9
27
  ## [2.8.0] - 2026-11-12
10
28
 
11
29
  ### Added
package/README.md CHANGED
@@ -13,6 +13,11 @@ sign-in. Logout, administrator revocation, or account deactivation can end acces
13
13
  sooner. On systems without a credential store, credentials are stored in a local
14
14
  file readable only by your user.
15
15
 
16
+ The sign-in stays open for 15 minutes even if the command stops (a closed
17
+ terminal, or an agent tool that times out): run `llama auth login` again and it
18
+ picks up the same sign-in, or the approval you already gave. Set
19
+ `LLAMA_NO_BROWSER=1` to print the link instead of opening a browser.
20
+
16
21
  ## Install or upgrade
17
22
 
18
23
  ```bash
package/bin/llama.mjs CHANGED
@@ -28,8 +28,10 @@ import {
28
28
  startExternalSession,
29
29
  uploadExternalFile,
30
30
  } from "../lib/external.mjs";
31
- import { LLAMA_CLI_CLIENT_ID, pkceLoopbackFlow, revokeToken as revokeOAuthToken } from "../lib/oauth-flow.mjs";
32
- import { deleteBundle, detectBackend, readBundle, writeBundle } from "../lib/oauth-storage.mjs";
31
+ import { fileURLToPath } from "node:url";
32
+ import { LLAMA_CLI_CLIENT_ID, revokeToken as revokeOAuthToken } from "../lib/oauth-flow.mjs";
33
+ import { liveLogin, readPending, runLoginListener, startOrResumeLogin } from "../lib/oauth-background.mjs";
34
+ import { deleteBundle, detectBackend, readBundle } from "../lib/oauth-storage.mjs";
33
35
  import { getBuildInfo } from "../lib/build-info.mjs";
34
36
  import { getUpdateNudge, maybeNudgeUpdate } from "../lib/version-check.mjs";
35
37
  import {
@@ -516,6 +518,7 @@ async function handleAuth(area, action, rest) {
516
518
  }
517
519
  }
518
520
  const oauthBackend = oauth ? await detectBackend() : null;
521
+ const pendingLogin = readPending();
519
522
  print({
520
523
  baseUrl: getBaseUrl(),
521
524
  activeMethod: oauth?.access_token ? "oauth" : bearer ? "gcloud-bearer" : token ? "llama-token" : "none",
@@ -524,6 +527,9 @@ async function handleAuth(area, action, rest) {
524
527
  llamaToken: token ? `${token.slice(0, 8)}...${token.slice(-4)}` : "absent",
525
528
  llamaTokenSource: process.env.LLAMA_TOKEN ? "$LLAMA_TOKEN" : readCanonicalToken() ? "~/.llama/token" : null,
526
529
  serverCheck,
530
+ ...(liveLogin(pendingLogin, getBaseUrl())
531
+ ? { pendingLogin: { waitingForApproval: true, url: pendingLogin.url, expiresAt: new Date(pendingLogin.expires_at).toISOString() } }
532
+ : {}),
527
533
  });
528
534
  return true;
529
535
  }
@@ -531,18 +537,16 @@ async function handleAuth(area, action, rest) {
531
537
  const { flags } = parseFlags(rest, ["scope"]);
532
538
  const scope = typeof flags.scope === "string" ? flags.scope : "read write";
533
539
  const baseUrl = getBaseUrl();
534
- const bundle = await pkceLoopbackFlow({ baseUrl, scope, resource: baseUrl });
535
- const stored = await writeBundle({
536
- access_token: bundle.access_token,
537
- refresh_token: bundle.refresh_token,
538
- expires_at: Date.now() + (bundle.expires_in ?? 3600) * 1000,
539
- scope: bundle.scope,
540
- client_id: bundle.client_id,
541
- issuer: bundle.issuer,
542
- resource: bundle.resource,
543
- created_at: Date.now(),
544
- });
545
- print({ ok: true, client_id: LLAMA_CLI_CLIENT_ID, storage: stored.backend, scope: bundle.scope });
540
+ // The listener is a detached helper, so this login survives this command
541
+ // being closed or killed; see lib/oauth-background.mjs.
542
+ const result = await startOrResumeLogin({ baseUrl, scope, cliEntry: fileURLToPath(import.meta.url) });
543
+ print({ ok: true, client_id: LLAMA_CLI_CLIENT_ID, storage: result.storage, scope: result.scope, ...(result.resumed ? { resumed: true } : {}) });
544
+ return true;
545
+ }
546
+ if (area === "auth" && action === "login-listener") {
547
+ // Internal: the detached helper started by `llama auth login`.
548
+ const { flags } = parseFlags(rest, ["scope"]);
549
+ await runLoginListener({ baseUrl: getBaseUrl(), scope: typeof flags.scope === "string" ? flags.scope : "read write" });
546
550
  return true;
547
551
  }
548
552
  if (area === "auth" && action === "logout") {
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "format": "llama.cli-build.v1",
3
3
  "packageName": "@llamaventures/cli",
4
- "packageVersion": "2.8.0",
5
- "sourceSha": "ec9daefda51090ca3331a22db1db45b3c56c3452",
4
+ "packageVersion": "2.9.0",
5
+ "sourceSha": "55e2fd7670bb5667cf2c419e884620faa403d5c3",
6
6
  "sourceKind": "github",
7
7
  "sourceDirty": false,
8
8
  "coreApiContract": {
@@ -0,0 +1,175 @@
1
+ // Browser login that survives the command that started it.
2
+ //
3
+ // The loopback flow needs something listening on 127.0.0.1 at the moment the
4
+ // person clicks Approve. When `llama auth login` itself was that listener, a
5
+ // closed terminal, a slow approval, or an agent tool that kills commands after
6
+ // a couple of minutes left the browser pointing at a dead port.
7
+ //
8
+ // So the listener is a small detached helper process. `llama auth login`
9
+ // starts it, shows the link, and waits; if the command is killed the helper
10
+ // keeps listening, finishes the login when the person approves, and stores
11
+ // the credentials. Running `llama auth login` again picks the same login up
12
+ // instead of starting a second one.
13
+ //
14
+ // The helper's state lives in ~/.llama/oauth-pending.json (0600). It holds the
15
+ // sign-in link and a status, never a token or the PKCE verifier.
16
+
17
+ import fs from "node:fs";
18
+ import os from "node:os";
19
+ import path from "node:path";
20
+ import { spawn } from "node:child_process";
21
+ import { openBrowser, pkceLoopbackFlow } from "./oauth-flow.mjs";
22
+ import { writeBundle } from "./oauth-storage.mjs";
23
+
24
+ export const LOGIN_WINDOW_MS = 15 * 60 * 1000;
25
+ /** How long a finished result stays claimable by the next `llama auth login`. */
26
+ const RESULT_TTL_MS = 10 * 60 * 1000;
27
+ const POLL_MS = 400;
28
+
29
+ export function defaultPendingPath() {
30
+ return path.join(os.homedir(), ".llama", "oauth-pending.json");
31
+ }
32
+
33
+ export function readPending(pendingPath = defaultPendingPath()) {
34
+ try {
35
+ return JSON.parse(fs.readFileSync(pendingPath, "utf8"));
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ function writePending(pendingPath, state) {
42
+ fs.mkdirSync(path.dirname(pendingPath), { recursive: true, mode: 0o700 });
43
+ const tmp = `${pendingPath}.${process.pid}.tmp`;
44
+ fs.writeFileSync(tmp, `${JSON.stringify(state)}\n`, { mode: 0o600 });
45
+ fs.renameSync(tmp, pendingPath);
46
+ }
47
+
48
+ export function clearPending(pendingPath = defaultPendingPath()) {
49
+ try { fs.unlinkSync(pendingPath); } catch { /* not present */ }
50
+ }
51
+
52
+ function processAlive(pid) {
53
+ if (!Number.isInteger(pid) || pid <= 0) return false;
54
+ try {
55
+ process.kill(pid, 0);
56
+ return true;
57
+ } catch (error) {
58
+ return error?.code === "EPERM";
59
+ }
60
+ }
61
+
62
+ /** A login someone can still approve: same server, helper alive, inside its window. */
63
+ export function liveLogin(pending, baseUrl, now = Date.now()) {
64
+ return Boolean(
65
+ pending && pending.status === "waiting" && pending.base_url === baseUrl &&
66
+ now < pending.expires_at && processAlive(pending.pid),
67
+ );
68
+ }
69
+
70
+ /**
71
+ * The helper process body: listen, finish the login, store credentials.
72
+ * Everything it learns is written to the pending file for whoever asks next.
73
+ */
74
+ export async function runLoginListener({
75
+ baseUrl, scope, pendingPath = defaultPendingPath(), storeBundle = writeBundle, windowMs = LOGIN_WINDOW_MS, flow = pkceLoopbackFlow,
76
+ }) {
77
+ const started = Date.now();
78
+ const base = { pid: process.pid, base_url: baseUrl, scope, started_at: started, expires_at: started + windowMs };
79
+ try {
80
+ const bundle = await flow({
81
+ baseUrl, scope, resource: baseUrl, timeoutMs: windowMs, quiet: true,
82
+ launchBrowser: (url) => writePending(pendingPath, { ...base, status: "waiting", url }),
83
+ });
84
+ const stored = await storeBundle({
85
+ access_token: bundle.access_token,
86
+ refresh_token: bundle.refresh_token,
87
+ expires_at: Date.now() + (bundle.expires_in ?? 3600) * 1000,
88
+ scope: bundle.scope,
89
+ client_id: bundle.client_id,
90
+ issuer: bundle.issuer,
91
+ resource: bundle.resource,
92
+ created_at: Date.now(),
93
+ });
94
+ writePending(pendingPath, { ...base, status: "done", finished_at: Date.now(), storage: stored.backend, scope: bundle.scope, client_id: bundle.client_id });
95
+ } catch (error) {
96
+ writePending(pendingPath, { ...base, status: "error", finished_at: Date.now(), error: String(error?.message ?? error) });
97
+ }
98
+ }
99
+
100
+ function spawnDetachedListener({ cliEntry, scope }) {
101
+ const child = spawn(process.execPath, [cliEntry, "auth", "login-listener", "--scope", scope], {
102
+ detached: true, stdio: "ignore", windowsHide: true, env: process.env,
103
+ });
104
+ child.on("error", () => {});
105
+ child.unref();
106
+ }
107
+
108
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
109
+
110
+ /**
111
+ * Start a browser login, or pick up the one already in progress, and wait.
112
+ *
113
+ * Returns { ok, storage, scope, client_id, resumed } on success. Throws the
114
+ * login's own error (denied, timed out) when it failed. If the caller is
115
+ * killed while waiting, nothing is lost: run it again.
116
+ */
117
+ export async function startOrResumeLogin({
118
+ baseUrl, scope, cliEntry, pendingPath = defaultPendingPath(), waitMs = LOGIN_WINDOW_MS,
119
+ spawnListener = spawnDetachedListener, launchBrowser = openBrowser, log = (line) => console.error(line), now = () => Date.now(),
120
+ }) {
121
+ let pending = readPending(pendingPath);
122
+
123
+ // A login that finished after the previous command stopped waiting.
124
+ if (pending?.base_url === baseUrl && pending.status === "done" && now() - pending.finished_at < RESULT_TTL_MS) {
125
+ clearPending(pendingPath);
126
+ return { ok: true, storage: pending.storage, scope: pending.scope, client_id: pending.client_id, resumed: true };
127
+ }
128
+
129
+ const resumed = liveLogin(pending, baseUrl, now());
130
+ if (resumed) {
131
+ const minutes = Math.max(1, Math.round((pending.expires_at - now()) / 60000));
132
+ log(`A sign-in is already waiting for approval (about ${minutes} min left). Approve it in the browser:`);
133
+ log(` ${pending.url}`);
134
+ log("If you already clicked Approve and saw an error page, reload that tab.");
135
+ } else {
136
+ clearPending(pendingPath);
137
+ spawnListener({ cliEntry, scope, baseUrl, pendingPath });
138
+ const deadline = now() + 15_000;
139
+ while (now() < deadline) {
140
+ pending = readPending(pendingPath);
141
+ if (pending && pending.base_url === baseUrl && pending.status !== undefined) break;
142
+ await sleep(100);
143
+ }
144
+ if (!pending || pending.status === undefined) {
145
+ throw new Error("Error[OAUTH_LISTENER_FAILED]: could not start the sign-in listener. Re-run `llama auth login`.");
146
+ }
147
+ if (pending.status === "waiting") {
148
+ // LLAMA_NO_BROWSER=1: print the link only (SSH, agents, a browser on another machine's profile).
149
+ const openIt = !process.env.LLAMA_NO_BROWSER;
150
+ log(openIt ? `Opening browser to ${baseUrl} for sign-in...` : `Open this link to sign in to ${baseUrl}:`);
151
+ log(openIt ? `(If the browser does not open, visit this URL manually:\n ${pending.url}\n)` : ` ${pending.url}`);
152
+ log("This sign-in stays open for 15 minutes even if this command stops; run `llama auth login` again to pick it up.");
153
+ if (openIt) launchBrowser(pending.url);
154
+ }
155
+ }
156
+
157
+ const waitUntil = now() + waitMs;
158
+ while (now() < waitUntil) {
159
+ pending = readPending(pendingPath);
160
+ if (pending?.status === "done") {
161
+ clearPending(pendingPath);
162
+ return { ok: true, storage: pending.storage, scope: pending.scope, client_id: pending.client_id, resumed };
163
+ }
164
+ if (pending?.status === "error") {
165
+ clearPending(pendingPath);
166
+ throw new Error(pending.error);
167
+ }
168
+ if (!pending || (pending.status === "waiting" && !liveLogin(pending, baseUrl, now()))) {
169
+ clearPending(pendingPath);
170
+ throw new Error("Error[OAUTH_TIMEOUT]: the sign-in was not approved in time. Re-run `llama auth login`.");
171
+ }
172
+ await sleep(POLL_MS);
173
+ }
174
+ throw new Error("Error[OAUTH_STILL_WAITING]: still waiting for approval in the browser. Run `llama auth login` again to keep waiting.");
175
+ }
@@ -48,7 +48,7 @@ export function challengeFor(verifier) {
48
48
  // Browser launcher
49
49
  // ============================================================
50
50
 
51
- function openBrowser(url) {
51
+ export function openBrowser(url) {
52
52
  // Platform-native open. We never block on it (the user closes the
53
53
  // browser when they're done; the loopback server is what we wait for).
54
54
  let cmd, args;
@@ -112,7 +112,7 @@ function respondHtml(res, ok, message) {
112
112
  * @param {string} opts.resource RFC 8707 audience the access token will bind to
113
113
  * @returns {Promise<Object>} {access_token, refresh_token, expires_in, scope, token_type, redirect_uri}
114
114
  */
115
- export async function pkceLoopbackFlow({ baseUrl, scope, resource, launchBrowser = openBrowser }) {
115
+ export async function pkceLoopbackFlow({ baseUrl, scope, resource, launchBrowser = openBrowser, timeoutMs = FLOW_TIMEOUT_MS, quiet = false }) {
116
116
  const verifier = generateVerifier();
117
117
  const challenge = challengeFor(verifier);
118
118
  const state = base64url(randomBytes(16));
@@ -132,9 +132,9 @@ export async function pkceLoopbackFlow({ baseUrl, scope, resource, launchBrowser
132
132
  try { server.close(); } catch { /* */ }
133
133
  reject(new Error(
134
134
  "Error[OAUTH_TIMEOUT]: Browser flow did not complete within " +
135
- Math.round(FLOW_TIMEOUT_MS / 1000) + "s. Re-run `llama auth login`."
135
+ Math.round(timeoutMs / 1000) + "s. Re-run `llama auth login`."
136
136
  ));
137
- }, FLOW_TIMEOUT_MS);
137
+ }, timeoutMs);
138
138
 
139
139
  server.on("request", (req, res) => {
140
140
  const url = new URL(req.url, "http://127.0.0.1");
@@ -189,8 +189,10 @@ export async function pkceLoopbackFlow({ baseUrl, scope, resource, launchBrowser
189
189
  authorizeUrl.searchParams.set("code_challenge_method", "S256");
190
190
  authorizeUrl.searchParams.set("resource", resource);
191
191
 
192
- console.error(`Opening browser to ${baseUrl} for sign-in...`);
193
- console.error(`(If the browser does not open, visit this URL manually:\n ${authorizeUrl.toString()}\n)`);
192
+ if (!quiet) {
193
+ console.error(`Opening browser to ${baseUrl} for sign-in...`);
194
+ console.error(`(If the browser does not open, visit this URL manually:\n ${authorizeUrl.toString()}\n)`);
195
+ }
194
196
  launchBrowser(authorizeUrl.toString());
195
197
 
196
198
  const code = await codePromise;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llamaventures/cli",
3
- "version": "2.8.0",
3
+ "version": "2.9.0",
4
4
  "description": "CLI + MCP server for the Llama Ventures investment workbench (command.llamaventures.vc).",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -11,7 +11,7 @@
11
11
  "verify:release": "npm test && npm run verify:artifact && node scripts/verify-tarball-clean.mjs",
12
12
  "prepack": "node scripts/prepare-build-manifest.mjs",
13
13
  "postpack": "node scripts/cleanup-build-manifest.mjs",
14
- "test:auth": "node --test scripts/oauth-refresh.test.mjs scripts/oauth-flow.test.mjs"
14
+ "test:auth": "node --test scripts/oauth-refresh.test.mjs scripts/oauth-flow.test.mjs scripts/oauth-background.test.mjs"
15
15
  },
16
16
  "bin": {
17
17
  "llama": "bin/llama.mjs",