@botbuddy/cli 1.2.3 → 1.4.1
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/bin/botbuddy.mjs +5 -1
- package/package.json +1 -1
- package/src/agent-credential-store.mjs +208 -0
- package/src/api.mjs +39 -0
- package/src/auth.mjs +169 -70
- package/src/auth.test.mjs +404 -0
- package/src/codex-bridge.mjs +2 -1
- package/src/commands.mjs +206 -30
- package/src/config.mjs +5 -1
- package/src/discovery.mjs +141 -0
- package/src/discovery.test.mjs +195 -0
- package/src/locks.mjs +154 -0
- package/src/locks.test.mjs +60 -0
- package/src/oauth-loopback.mjs +228 -0
- package/src/profile-bootstrap.mjs +104 -0
- package/src/profile-bootstrap.test.mjs +205 -0
- package/src/publish-equal.mjs +207 -0
- package/src/publish-equal.test.mjs +176 -0
- package/src/publish-workflow.test.mjs +122 -0
- package/src/quiet-runner.mjs +134 -0
- package/src/quiet-runner.test.mjs +109 -0
- package/src/run.mjs +239 -0
- package/src/run.test.mjs +173 -0
- package/src/stack.mjs +572 -0
- package/src/stack.test.mjs +196 -0
- package/src/wait-core.mjs +1266 -0
- package/src/wait-profile.mjs +84 -0
- package/src/wait-profile.test.mjs +30 -0
- package/src/wait.mjs +727 -0
- package/src/wait.test.mjs +266 -0
package/bin/botbuddy.mjs
CHANGED
package/package.json
CHANGED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { chmod, link, mkdir, readFile, rename, stat, unlink, utimes, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir, userInfo } from "node:os";
|
|
3
|
+
import { execFile, spawn } from "node:child_process";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
|
|
9
|
+
const STORE_SCHEMA_VERSION = 1;
|
|
10
|
+
const LOCK_RETRY_MS = 10;
|
|
11
|
+
const LOCK_MAX_ATTEMPTS = 100;
|
|
12
|
+
const STALE_LOCK_MS = 30_000;
|
|
13
|
+
const execFileAsync = promisify(execFile);
|
|
14
|
+
|
|
15
|
+
export function ensureProfileCredentialBackend({ platform = process.platform, exists = existsSync } = {}) {
|
|
16
|
+
if (platform !== "darwin" || !exists("/usr/bin/security")) {
|
|
17
|
+
throw new Error("profile setup requires the macOS Keychain credential backend");
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function profileCredentialEnvironment(profile) {
|
|
22
|
+
return profile === "botbuddy-dev" ? "BOTBUDDY_BB_AGENT_KEY"
|
|
23
|
+
: profile === "supplyguard-dev" ? "BOTBUDDY_SG_AGENT_KEY" : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function keychainService(profile) {
|
|
27
|
+
return profileCredentialEnvironment(profile);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writePasswordPrompt(command, args, password) {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const child = spawn(command, args, { stdio: ["pipe", "ignore", "pipe"] });
|
|
33
|
+
let stderr = "";
|
|
34
|
+
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
|
35
|
+
child.once("error", reject);
|
|
36
|
+
child.once("close", (code) => {
|
|
37
|
+
if (code === 0) resolve();
|
|
38
|
+
else reject(new Error(`${command} exited ${code}${stderr ? `: ${stderr.trim()}` : ""}`));
|
|
39
|
+
});
|
|
40
|
+
// `security -w` prompts from standard input when it is the last option.
|
|
41
|
+
// Keeping the password off argv prevents process-list disclosure.
|
|
42
|
+
child.stdin.end(`${password}\n`);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function writeKeychain(profile, token) {
|
|
47
|
+
const service = keychainService(profile);
|
|
48
|
+
if (!service) throw new Error("unknown profile keychain service");
|
|
49
|
+
await writePasswordPrompt("security", ["add-generic-password", "-U", "-s", service, "-a", userInfo().username, "-w"], token);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function readKeychain(profile) {
|
|
53
|
+
const service = keychainService(profile);
|
|
54
|
+
if (!service) return null;
|
|
55
|
+
try {
|
|
56
|
+
const { stdout } = await execFileAsync("security", ["find-generic-password", "-s", service, "-a", userInfo().username, "-w"]);
|
|
57
|
+
return stdout.trim() || null;
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function profileCredentialStorePath(home = homedir()) {
|
|
64
|
+
return join(home, ".botbuddy", "agent-profiles.json");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function readStore({ home = homedir() } = {}) {
|
|
68
|
+
try {
|
|
69
|
+
const raw = await readFile(profileCredentialStorePath(home), "utf8");
|
|
70
|
+
const parsed = JSON.parse(raw);
|
|
71
|
+
if (parsed?.schema_version !== STORE_SCHEMA_VERSION || !parsed.profiles || typeof parsed.profiles !== "object") {
|
|
72
|
+
return { schema_version: STORE_SCHEMA_VERSION, profiles: {} };
|
|
73
|
+
}
|
|
74
|
+
return parsed;
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (error?.code === "ENOENT") return { schema_version: STORE_SCHEMA_VERSION, profiles: {} };
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function validIdentityEntry(entry) {
|
|
82
|
+
return entry && typeof entry === "object" && typeof entry.name === "string";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function withStoreLock(path, operation) {
|
|
86
|
+
const lockPath = `${path}.lock`;
|
|
87
|
+
for (let attempt = 0; attempt < LOCK_MAX_ATTEMPTS; attempt++) {
|
|
88
|
+
const ownerPath = `${lockPath}.${process.pid}.${randomUUID()}`;
|
|
89
|
+
try {
|
|
90
|
+
await writeFile(ownerPath, JSON.stringify({ pid: process.pid, owner_path: ownerPath }), { flag: "wx", mode: 0o600 });
|
|
91
|
+
await link(ownerPath, lockPath);
|
|
92
|
+
const ownerStat = await stat(ownerPath);
|
|
93
|
+
const ownsLock = async () => {
|
|
94
|
+
try {
|
|
95
|
+
const current = await stat(lockPath);
|
|
96
|
+
return current.dev === ownerStat.dev && current.ino === ownerStat.ino;
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (error?.code === "ENOENT") return false;
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
const refresh = setInterval(() => {
|
|
103
|
+
const now = new Date();
|
|
104
|
+
void ownsLock().then((owned) => owned && utimes(lockPath, now, now)).catch(() => {});
|
|
105
|
+
}, Math.floor(STALE_LOCK_MS / 3));
|
|
106
|
+
try {
|
|
107
|
+
return await operation();
|
|
108
|
+
} finally {
|
|
109
|
+
clearInterval(refresh);
|
|
110
|
+
if (await ownsLock()) await unlink(lockPath).catch(() => {});
|
|
111
|
+
await unlink(ownerPath).catch(() => {});
|
|
112
|
+
}
|
|
113
|
+
} catch (error) {
|
|
114
|
+
await unlink(ownerPath).catch(() => {});
|
|
115
|
+
if (error?.code !== "EEXIST") throw error;
|
|
116
|
+
try {
|
|
117
|
+
const lockStat = await stat(lockPath);
|
|
118
|
+
if (Date.now() - lockStat.mtimeMs > STALE_LOCK_MS) {
|
|
119
|
+
const lock = JSON.parse(await readFile(lockPath, "utf8"));
|
|
120
|
+
let ownerAlive = false;
|
|
121
|
+
try { process.kill(lock.pid, 0); ownerAlive = true; } catch (ownerError) { ownerAlive = ownerError?.code === "EPERM"; }
|
|
122
|
+
if (!ownerAlive) {
|
|
123
|
+
await unlink(lockPath);
|
|
124
|
+
if (typeof lock.owner_path === "string") await unlink(lock.owner_path).catch(() => {});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
} catch (lockError) {
|
|
128
|
+
if (lockError?.code !== "ENOENT") throw lockError;
|
|
129
|
+
}
|
|
130
|
+
await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_MS));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
throw new Error("profile credential store is busy; retry profile setup");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function readProfileIdentity(profile, options = {}) {
|
|
137
|
+
const store = await readStore(options);
|
|
138
|
+
const entry = store.profiles[profile];
|
|
139
|
+
if (!validIdentityEntry(entry) || typeof entry.tenant !== "string" || typeof entry.agent_id !== "string") return null;
|
|
140
|
+
return { agentId: entry.agent_id, tenant: entry.tenant, name: entry.name };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function readProfileRetryIdentity(profile, options = {}) {
|
|
144
|
+
const store = await readStore(options);
|
|
145
|
+
const entry = store.profiles[profile];
|
|
146
|
+
if (!validIdentityEntry(entry)) return null;
|
|
147
|
+
const agentId = typeof entry.agent_id === "string" ? entry.agent_id : entry.pending_agent_id;
|
|
148
|
+
return typeof agentId === "string" ? { agentId, name: entry.name } : null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function readProfileCredential(profile, options = {}) {
|
|
152
|
+
return (options.keychainRead ?? readKeychain)(profile);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function writeProfileMetadata(profile, entry, {
|
|
156
|
+
home = homedir(),
|
|
157
|
+
onlyIfNoAttestedIdentity = false,
|
|
158
|
+
afterWrite = null,
|
|
159
|
+
} = {}) {
|
|
160
|
+
const path = profileCredentialStorePath(home);
|
|
161
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
162
|
+
await withStoreLock(path, async () => {
|
|
163
|
+
const store = await readStore({ home });
|
|
164
|
+
const current = store.profiles[profile];
|
|
165
|
+
if (onlyIfNoAttestedIdentity && validIdentityEntry(current) && typeof current.tenant === "string" && typeof current.agent_id === "string") {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
const next = {
|
|
169
|
+
schema_version: STORE_SCHEMA_VERSION,
|
|
170
|
+
profiles: { ...store.profiles, [profile]: entry },
|
|
171
|
+
};
|
|
172
|
+
const temporaryPath = `${path}.${randomUUID()}.tmp`;
|
|
173
|
+
await writeFile(temporaryPath, JSON.stringify(next), { mode: 0o600 });
|
|
174
|
+
await chmod(temporaryPath, 0o600);
|
|
175
|
+
await rename(temporaryPath, path);
|
|
176
|
+
await chmod(path, 0o600);
|
|
177
|
+
await afterWrite?.();
|
|
178
|
+
return true;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function recordProfileRetryIdentity({ profile, agentId, name }, options = {}) {
|
|
183
|
+
if (![profile, agentId, name].every((value) => typeof value === "string" && value.length > 0)) {
|
|
184
|
+
throw new Error("profile retry identity requires profile, agentId, and name");
|
|
185
|
+
}
|
|
186
|
+
// This entry is explicitly not tenant-attested and contains no credential. It
|
|
187
|
+
// may only be used to reconnect with the profile's fixed tenant on retry.
|
|
188
|
+
await writeProfileMetadata(profile, { pending_agent_id: agentId, name }, {
|
|
189
|
+
...options,
|
|
190
|
+
onlyIfNoAttestedIdentity: true,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function installProfileCredential({ profile, tenant, agentId, name, token }, { home = homedir(), keychainWrite = writeKeychain } = {}) {
|
|
195
|
+
if (![profile, tenant, agentId, name, token].every((value) => typeof value === "string" && value.length > 0)) {
|
|
196
|
+
throw new Error("profile credential store requires profile, tenant, agentId, name, and token");
|
|
197
|
+
}
|
|
198
|
+
// Keep non-secret identity metadata even if Keychain is momentarily locked.
|
|
199
|
+
// The next setup call reconnects this same server agent instead of minting a
|
|
200
|
+
// random orphan; credentials are never written to this file.
|
|
201
|
+
await writeProfileMetadata(profile, { tenant, agent_id: agentId, name }, {
|
|
202
|
+
home,
|
|
203
|
+
// Keep this Keychain operation in the same lock as the metadata write. A
|
|
204
|
+
// later setup cannot leave metadata from one agent beside another agent's
|
|
205
|
+
// credential, while a failed write still leaves resumable metadata.
|
|
206
|
+
afterWrite: () => keychainWrite(profile, token),
|
|
207
|
+
});
|
|
208
|
+
}
|
package/src/api.mjs
CHANGED
|
@@ -39,6 +39,45 @@ export async function callTool(toolName, args = {}) {
|
|
|
39
39
|
return data;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// BOT-1220: programmatic tool call for `botbuddy stack` — returns the tool's parsed
|
|
43
|
+
// JSON result WITHOUT printing anything (the stack command owns stdout for its single
|
|
44
|
+
// JSON-line receipt). Never calls die(): auth/transport problems come back as a
|
|
45
|
+
// structured { ok:false, ... } so the caller can emit a typed receipt + exit code.
|
|
46
|
+
// * { ok:true, data } — tool result JSON (data.success may still be false)
|
|
47
|
+
// * { ok:false, auth:true } — not authenticated / token expired
|
|
48
|
+
// * { ok:false, status } — HTTP/JSON-RPC/transport error (status may be null)
|
|
49
|
+
export async function callToolJson(toolName, args = {}, { fetchImpl = fetch } = {}) {
|
|
50
|
+
const cfg = getConfig();
|
|
51
|
+
let auth;
|
|
52
|
+
if (cfg.access_token) {
|
|
53
|
+
if (cfg.token_expires_at && Date.now() >= cfg.token_expires_at) return { ok: false, auth: true, error: "token_expired" };
|
|
54
|
+
auth = { Authorization: `Bearer ${cfg.access_token}` };
|
|
55
|
+
} else if (cfg.api_key) {
|
|
56
|
+
auth = { "x-agent-api-key": cfg.api_key };
|
|
57
|
+
} else {
|
|
58
|
+
return { ok: false, auth: true, error: "not_authenticated" };
|
|
59
|
+
}
|
|
60
|
+
const body = { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: toolName, arguments: args } };
|
|
61
|
+
let res;
|
|
62
|
+
try {
|
|
63
|
+
res = await fetchImpl(SERVER_URL, {
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers: { "Content-Type": "application/json", ...auth },
|
|
66
|
+
body: JSON.stringify(body),
|
|
67
|
+
});
|
|
68
|
+
} catch (err) {
|
|
69
|
+
return { ok: false, status: null, error: `transport: ${err?.message ?? err}` };
|
|
70
|
+
}
|
|
71
|
+
if (res.status === 401 || res.status === 403) return { ok: false, auth: true, status: res.status, error: "unauthorized" };
|
|
72
|
+
let payload;
|
|
73
|
+
try { payload = await res.json(); } catch { return { ok: false, status: res.status, error: "invalid_json" }; }
|
|
74
|
+
if (payload.error?.message) return { ok: false, status: res.status, error: payload.error.message };
|
|
75
|
+
const text = payload.result?.content?.map((c) => c.text).filter(Boolean).join("\n") ?? "";
|
|
76
|
+
let data;
|
|
77
|
+
try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; }
|
|
78
|
+
return { ok: true, data, isError: payload.result?.isError === true };
|
|
79
|
+
}
|
|
80
|
+
|
|
42
81
|
export async function listResources() {
|
|
43
82
|
const headers = { "Content-Type": "application/json", ...authHeader() };
|
|
44
83
|
const body = { jsonrpc: "2.0", id: 1, method: "resources/list", params: {} };
|
package/src/auth.mjs
CHANGED
|
@@ -1,72 +1,171 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
1
|
+
import { SERVER_URL, saveConfig as realSaveConfig, getConfig as realGetConfig } from "./config.mjs";
|
|
2
|
+
import { green, dim, cyan, yellow, bold, red } from "./utils.mjs";
|
|
3
|
+
import {
|
|
4
|
+
createLoopbackReceiver,
|
|
5
|
+
generatePkce,
|
|
6
|
+
generateState,
|
|
7
|
+
buildAuthorizeUrl,
|
|
8
|
+
openBrowser as realOpenBrowser,
|
|
9
|
+
DEFAULT_LOGIN_TIMEOUT_MS,
|
|
10
|
+
OAuthCallbackError,
|
|
11
|
+
} from "./oauth-loopback.mjs";
|
|
12
|
+
|
|
13
|
+
// A login that could not complete. commands.mjs turns this into one terminal
|
|
14
|
+
// error line; the core never calls process.exit so it stays unit-testable.
|
|
15
|
+
export class LoginError extends Error {
|
|
16
|
+
constructor(message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "LoginError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Bounded, secret-free rendering of a failed /register or /token response body.
|
|
23
|
+
function describeResponse(data) {
|
|
24
|
+
let text;
|
|
25
|
+
try {
|
|
26
|
+
text = JSON.stringify(data);
|
|
27
|
+
} catch {
|
|
28
|
+
text = String(data);
|
|
29
|
+
}
|
|
30
|
+
return text.length > 300 ? `${text.slice(0, 300)}…` : text;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Map a callback failure to one actionable terminal message (AC-11).
|
|
34
|
+
function callbackErrorMessage(err) {
|
|
35
|
+
if (!(err instanceof OAuthCallbackError)) {
|
|
36
|
+
return err?.message || "Authorization failed";
|
|
37
|
+
}
|
|
38
|
+
switch (err.code) {
|
|
39
|
+
case "timeout":
|
|
40
|
+
return "Timed out waiting for authorization. Re-run `botbuddy login` and finish signing in in the browser before it times out.";
|
|
41
|
+
case "missing_code":
|
|
42
|
+
return "The authorization callback did not include a code. Re-run `botbuddy login` and try again.";
|
|
43
|
+
case "access_denied":
|
|
44
|
+
return "Authorization was denied. Re-run `botbuddy login` and approve access for an allowed user.";
|
|
45
|
+
default:
|
|
46
|
+
return `Authorization failed: ${err.message}`;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// AC-1..AC-13: orchestrate the loopback authorization-code flow.
|
|
51
|
+
//
|
|
52
|
+
// options: { noBrowser }
|
|
53
|
+
// deps (all injectable for tests): serverUrl, fetch, openBrowser, saveConfig,
|
|
54
|
+
// getConfig, log, errorLog, timeoutMs, now.
|
|
55
|
+
export async function doLogin(options = {}, deps = {}) {
|
|
56
|
+
const {
|
|
57
|
+
serverUrl = SERVER_URL,
|
|
58
|
+
fetch: fetchImpl = fetch,
|
|
59
|
+
openBrowser = realOpenBrowser,
|
|
60
|
+
saveConfig = realSaveConfig,
|
|
61
|
+
getConfig = realGetConfig,
|
|
62
|
+
log = (s) => console.log(s),
|
|
63
|
+
errorLog = (s) => console.error(s),
|
|
64
|
+
timeoutMs = DEFAULT_LOGIN_TIMEOUT_MS,
|
|
65
|
+
now = () => Date.now(),
|
|
66
|
+
} = deps;
|
|
67
|
+
const noBrowser = Boolean(options.noBrowser);
|
|
68
|
+
|
|
69
|
+
log(`${bold("BotBuddy OAuth Login")}\n`);
|
|
70
|
+
|
|
71
|
+
// AC-4/AC-13: fresh PKCE + state for THIS attempt — never reused across logins.
|
|
72
|
+
const { codeVerifier, codeChallenge } = generatePkce();
|
|
73
|
+
const state = generateState();
|
|
74
|
+
|
|
75
|
+
// AC-1/AC-2: start the loopback listener BEFORE registration or browser launch,
|
|
76
|
+
// on 127.0.0.1 with an OS-assigned ephemeral port.
|
|
77
|
+
const receiver = createLoopbackReceiver({ expectedState: state });
|
|
78
|
+
const { redirectUri } = await receiver.listen();
|
|
79
|
+
|
|
80
|
+
// AC-11: Ctrl-C must close the listener and exit non-zero without saving.
|
|
81
|
+
const onSigint = () => {
|
|
82
|
+
errorLog(`\n${red("✗")} Login canceled.`);
|
|
83
|
+
receiver.close().finally(() => process.exit(1));
|
|
84
|
+
};
|
|
85
|
+
process.once("SIGINT", onSigint);
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
// AC-3: dynamically register the EXACT loopback callback URI.
|
|
89
|
+
log(dim("→ Registering client..."));
|
|
90
|
+
const regRes = await fetchImpl(`${serverUrl}/register`, {
|
|
91
|
+
method: "POST",
|
|
92
|
+
headers: { "Content-Type": "application/json" },
|
|
93
|
+
body: JSON.stringify({
|
|
94
|
+
client_name: "botbuddy-cli-node",
|
|
95
|
+
redirect_uris: [redirectUri],
|
|
96
|
+
grant_types: ["authorization_code"],
|
|
97
|
+
token_endpoint_auth_method: "none",
|
|
98
|
+
}),
|
|
99
|
+
});
|
|
100
|
+
const regData = await regRes.json().catch(() => ({}));
|
|
101
|
+
const clientId = regData.client_id;
|
|
102
|
+
if (!clientId) throw new LoginError(`Client registration failed: ${describeResponse(regData)}`);
|
|
103
|
+
log(` ${green("✓")} Client registered: ${dim(clientId)}`);
|
|
104
|
+
|
|
105
|
+
// AC-6/AC-7: build the authorization URL and always print it (headless users
|
|
106
|
+
// open it themselves). The CLI never fetches /authorize.
|
|
107
|
+
const authUrl = buildAuthorizeUrl({ serverUrl, clientId, redirectUri, state, codeChallenge });
|
|
108
|
+
log("");
|
|
109
|
+
log(dim("→ Authorize BotBuddy in your browser:"));
|
|
110
|
+
log(` ${cyan(authUrl)}`);
|
|
111
|
+
log("");
|
|
112
|
+
|
|
113
|
+
// AC-5/AC-12: launch the browser unless suppressed; a launch failure prints
|
|
114
|
+
// guidance and keeps listening rather than aborting.
|
|
115
|
+
if (noBrowser) {
|
|
116
|
+
log(dim(" --no-browser set — open the URL above before the login times out."));
|
|
117
|
+
} else {
|
|
118
|
+
try {
|
|
119
|
+
await openBrowser(authUrl);
|
|
120
|
+
log(dim(" Opened your default browser. Complete sign-in there."));
|
|
121
|
+
} catch {
|
|
122
|
+
log(` ${yellow("!")} Couldn't open a browser automatically — open the URL above before the login times out.`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
log(dim(`→ Waiting for authorization (up to ${Math.round(timeoutMs / 60000)} min)...`));
|
|
126
|
+
|
|
127
|
+
// AC-8: await exactly one validated loopback callback.
|
|
128
|
+
let code;
|
|
129
|
+
try {
|
|
130
|
+
({ code } = await receiver.waitForCallback({ timeoutMs }));
|
|
131
|
+
} catch (err) {
|
|
132
|
+
throw new LoginError(callbackErrorMessage(err));
|
|
133
|
+
}
|
|
134
|
+
log(` ${green("✓")} Authorization received`);
|
|
135
|
+
|
|
136
|
+
// AC-10: exchange the code once, with the EXACT callback URI.
|
|
137
|
+
log(dim("→ Exchanging code for token..."));
|
|
138
|
+
const tokenRes = await fetchImpl(`${serverUrl}/token`, {
|
|
139
|
+
method: "POST",
|
|
140
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
141
|
+
body: new URLSearchParams({
|
|
142
|
+
grant_type: "authorization_code",
|
|
143
|
+
code,
|
|
144
|
+
client_id: clientId,
|
|
145
|
+
code_verifier: codeVerifier,
|
|
146
|
+
redirect_uri: redirectUri,
|
|
147
|
+
}),
|
|
148
|
+
});
|
|
149
|
+
const tokenData = await tokenRes.json().catch(() => ({}));
|
|
150
|
+
if (!tokenData.access_token) throw new LoginError(`Token exchange failed: ${describeResponse(tokenData)}`);
|
|
151
|
+
log(` ${green("✓")} Access token received`);
|
|
152
|
+
|
|
153
|
+
const expiresAt = tokenData.expires_in
|
|
154
|
+
? now() + tokenData.expires_in * 1000
|
|
155
|
+
: now() + 24 * 60 * 60 * 1000; // default 24h if server omits expires_in
|
|
156
|
+
|
|
157
|
+
saveConfig({
|
|
158
|
+
...getConfig(),
|
|
159
|
+
access_token: tokenData.access_token,
|
|
51
160
|
client_id: clientId,
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
: Date.now() + 24 * 60 * 60 * 1000; // default 24h if server omits expires_in
|
|
63
|
-
|
|
64
|
-
saveConfig({
|
|
65
|
-
...getConfig(),
|
|
66
|
-
access_token: tokenData.access_token,
|
|
67
|
-
client_id: clientId,
|
|
68
|
-
token_expires_at: expiresAt,
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
console.log(`\n${green("✓")} Logged in successfully! Token saved to ${dim("~/.botbuddy/config.json")}`);
|
|
161
|
+
token_expires_at: expiresAt,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
log(`\n${green("✓")} Logged in successfully! Token saved to ${dim("~/.botbuddy/config.json")}`);
|
|
165
|
+
return { clientId, redirectUri };
|
|
166
|
+
} finally {
|
|
167
|
+
// AC-11/AC-13: always release the socket so re-running login starts clean.
|
|
168
|
+
process.removeListener("SIGINT", onSigint);
|
|
169
|
+
await receiver.close();
|
|
170
|
+
}
|
|
72
171
|
}
|