@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 +18 -0
- package/README.md +5 -0
- package/bin/llama.mjs +18 -14
- package/lib/build-manifest.json +2 -2
- package/lib/oauth-background.mjs +175 -0
- package/lib/oauth-flow.mjs +8 -6
- package/package.json +2 -2
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 {
|
|
32
|
-
import {
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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") {
|
package/lib/build-manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"format": "llama.cli-build.v1",
|
|
3
3
|
"packageName": "@llamaventures/cli",
|
|
4
|
-
"packageVersion": "2.
|
|
5
|
-
"sourceSha": "
|
|
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
|
+
}
|
package/lib/oauth-flow.mjs
CHANGED
|
@@ -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(
|
|
135
|
+
Math.round(timeoutMs / 1000) + "s. Re-run `llama auth login`."
|
|
136
136
|
));
|
|
137
|
-
},
|
|
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
|
-
|
|
193
|
-
|
|
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.
|
|
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",
|