@bulkgrid/cli 0.1.0 → 0.2.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.
@@ -0,0 +1,723 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { checkbox, confirm, input, password, select } from "@inquirer/prompts";
6
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
7
+ import { z } from "zod";
8
+ import { chmod, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
9
+ import { createServer } from "node:http";
10
+ //#region src/init.ts
11
+ const DEFAULT_SERVER_NAME = "bulkgrid";
12
+ const API_KEY_ENV_VAR = "BULKGRID_API_KEY";
13
+ const MCP_URL_ENV_VAR = "BULKGRID_MCP_URL";
14
+ const DASHBOARD_API_KEYS_URL = "https://bulkgrid.com/dashboard/settings/api-keys";
15
+ const AGENT_CHOICES = [
16
+ {
17
+ label: "Cursor",
18
+ value: "cursor"
19
+ },
20
+ {
21
+ label: "VS Code",
22
+ value: "vscode"
23
+ },
24
+ {
25
+ label: "Claude Code",
26
+ value: "claude"
27
+ },
28
+ {
29
+ label: "Codex",
30
+ value: "codex"
31
+ }
32
+ ];
33
+ function isJsonObject(value) {
34
+ return typeof value === "object" && value !== null && !Array.isArray(value);
35
+ }
36
+ function parseJsonObject(filePath) {
37
+ if (!existsSync(filePath)) return {};
38
+ const parsed = JSON.parse(readFileSync(filePath, "utf8"));
39
+ if (!isJsonObject(parsed)) throw new Error(`${filePath} must contain a JSON object`);
40
+ return parsed;
41
+ }
42
+ function writeJsonFile(filePath, data) {
43
+ mkdirSync(dirname(filePath), { recursive: true });
44
+ writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`);
45
+ }
46
+ function getObjectProperty(parent, key) {
47
+ const current = parent[key];
48
+ if (isJsonObject(current)) return current;
49
+ const next = {};
50
+ parent[key] = next;
51
+ return next;
52
+ }
53
+ function buildAuthorizationHeader(context, inputExpression) {
54
+ if (context.writeApiKey && context.apiKey) return `Bearer ${context.apiKey}`;
55
+ return `Bearer ${inputExpression}`;
56
+ }
57
+ function buildCursorServer(context) {
58
+ return {
59
+ url: context.mcpUrl,
60
+ headers: { Authorization: buildAuthorizationHeader(context, `\${env:${API_KEY_ENV_VAR}}`) }
61
+ };
62
+ }
63
+ function mergeCursorConfig(existing, context) {
64
+ const next = { ...existing };
65
+ const servers = getObjectProperty(next, "mcpServers");
66
+ servers[DEFAULT_SERVER_NAME] = buildCursorServer(context);
67
+ return next;
68
+ }
69
+ function buildVsCodeConfig(context) {
70
+ return {
71
+ inputs: [{
72
+ type: "promptString",
73
+ id: "bulkgrid-api-key",
74
+ description: "Bulkgrid API Key",
75
+ password: true
76
+ }],
77
+ servers: { [DEFAULT_SERVER_NAME]: {
78
+ type: "http",
79
+ url: context.mcpUrl,
80
+ headers: { Authorization: buildAuthorizationHeader(context, "${input:bulkgrid-api-key}") }
81
+ } }
82
+ };
83
+ }
84
+ function mergeVsCodeConfig(existing, context) {
85
+ const next = { ...existing };
86
+ const config = buildVsCodeConfig(context);
87
+ const existingServers = getObjectProperty(next, "servers");
88
+ const configServers = getObjectProperty(config, "servers");
89
+ existingServers[DEFAULT_SERVER_NAME] = configServers[DEFAULT_SERVER_NAME] ?? {};
90
+ if (!Array.isArray(next.inputs)) {
91
+ next.inputs = config.inputs ?? [];
92
+ return next;
93
+ }
94
+ if (!next.inputs.some((item) => isJsonObject(item) && item.id === "bulkgrid-api-key") && Array.isArray(config.inputs)) next.inputs = [...next.inputs, ...config.inputs];
95
+ return next;
96
+ }
97
+ function resolveCursorPath(scope) {
98
+ if (scope === "global") return join(homedir(), ".cursor", "mcp.json");
99
+ return join(process.cwd(), ".cursor", "mcp.json");
100
+ }
101
+ function resolveVsCodePath(scope) {
102
+ if (scope === "global") {
103
+ if (process.platform === "darwin") return join(homedir(), "Library", "Application Support", "Code", "User", "mcp.json");
104
+ if (process.platform === "win32") return join(homedir(), "AppData", "Roaming", "Code", "User", "mcp.json");
105
+ return join(homedir(), ".config", "Code", "User", "mcp.json");
106
+ }
107
+ return join(process.cwd(), ".vscode", "mcp.json");
108
+ }
109
+ function setupCursor(context) {
110
+ const filePath = resolveCursorPath(context.scope);
111
+ writeJsonFile(filePath, mergeCursorConfig(parseJsonObject(filePath), context));
112
+ return {
113
+ agent: "cursor",
114
+ status: "configured",
115
+ message: `Wrote ${filePath}`
116
+ };
117
+ }
118
+ function setupVsCode(context) {
119
+ const filePath = resolveVsCodePath(context.scope);
120
+ writeJsonFile(filePath, mergeVsCodeConfig(parseJsonObject(filePath), context));
121
+ return {
122
+ agent: "vscode",
123
+ status: "configured",
124
+ message: `Wrote ${filePath}`
125
+ };
126
+ }
127
+ function commandExists(command) {
128
+ return spawnSync(command, ["--version"], {
129
+ encoding: "utf8",
130
+ stdio: "ignore"
131
+ }).status === 0;
132
+ }
133
+ function runCommand(command, args) {
134
+ const result = spawnSync(command, args, {
135
+ encoding: "utf8",
136
+ stdio: "inherit"
137
+ });
138
+ if (result.error) throw result.error;
139
+ if (result.status !== 0) throw new Error(`${command} ${args.join(" ")} failed with exit code ${result.status ?? "unknown"}`);
140
+ }
141
+ function printInitBanner() {
142
+ console.log("");
143
+ console.log(" ▦ Bulkgrid init");
144
+ console.log("");
145
+ }
146
+ async function promptForConfirmation(message, defaultValue) {
147
+ return confirm({
148
+ message,
149
+ default: defaultValue
150
+ });
151
+ }
152
+ async function promptForSelect(message, choices) {
153
+ return select({
154
+ message,
155
+ choices: choices.map((choice) => ({
156
+ name: choice.label,
157
+ value: choice.value
158
+ }))
159
+ });
160
+ }
161
+ async function promptForMultiSelect(message, choices) {
162
+ return checkbox({
163
+ message,
164
+ choices: choices.map((choice) => ({
165
+ name: choice.label,
166
+ value: choice.value,
167
+ checked: true
168
+ })),
169
+ required: true,
170
+ pageSize: choices.length
171
+ });
172
+ }
173
+ async function maybeInstallGlobally(options) {
174
+ if (options.installGlobal) {
175
+ runCommand("npm", [
176
+ "install",
177
+ "-g",
178
+ "@bulkgrid/cli"
179
+ ]);
180
+ return;
181
+ }
182
+ if (options.yes) return;
183
+ if (await promptForConfirmation("Install @bulkgrid/cli globally?", false)) runCommand("npm", [
184
+ "install",
185
+ "-g",
186
+ "@bulkgrid/cli"
187
+ ]);
188
+ }
189
+ function openBrowser(url) {
190
+ let command;
191
+ let args;
192
+ if (process.platform === "darwin") {
193
+ command = "open";
194
+ args = [url];
195
+ } else if (process.platform === "win32") {
196
+ command = "cmd";
197
+ args = [
198
+ "/c",
199
+ "start",
200
+ "",
201
+ url
202
+ ];
203
+ } else {
204
+ command = "xdg-open";
205
+ args = [url];
206
+ }
207
+ if (spawnSync(command, args, {
208
+ encoding: "utf8",
209
+ stdio: "ignore"
210
+ }).status !== 0) console.log(`Open ${url} to create a Bulkgrid API key.`);
211
+ }
212
+ async function resolveApiKey(options) {
213
+ const configuredApiKey = options.apiKey ?? process.env[API_KEY_ENV_VAR];
214
+ if (configuredApiKey || options.yes) return configuredApiKey;
215
+ const authMode = options.auth ?? await promptForSelect("Authenticate to Bulkgrid", [
216
+ {
217
+ label: "Open the dashboard and paste a new API key",
218
+ value: "browser"
219
+ },
220
+ {
221
+ label: "Enter an existing API key",
222
+ value: "manual"
223
+ },
224
+ {
225
+ label: "Skip this step",
226
+ value: "skip"
227
+ }
228
+ ]);
229
+ if (authMode === "skip") {
230
+ console.log(`Skipped. Set ${API_KEY_ENV_VAR} later or use client-side prompts.`);
231
+ return;
232
+ }
233
+ if (authMode === "browser") {
234
+ openBrowser(DASHBOARD_API_KEYS_URL);
235
+ console.log("Create an API key with mcp:use and search:query scopes.");
236
+ }
237
+ return await password({
238
+ message: "Bulkgrid API key",
239
+ mask: "*",
240
+ validate: (value) => value.trim().length > 0 || "Enter a Bulkgrid API key."
241
+ }) || void 0;
242
+ }
243
+ function setupCodex(context) {
244
+ if (!commandExists("codex")) return {
245
+ agent: "codex",
246
+ status: "skipped",
247
+ message: "codex command was not found"
248
+ };
249
+ runCommand("codex", [
250
+ "mcp",
251
+ "add",
252
+ DEFAULT_SERVER_NAME,
253
+ "--url",
254
+ context.mcpUrl,
255
+ "--bearer-token-env-var",
256
+ API_KEY_ENV_VAR
257
+ ]);
258
+ return {
259
+ agent: "codex",
260
+ status: "configured",
261
+ message: `Registered ${DEFAULT_SERVER_NAME} with codex mcp add`
262
+ };
263
+ }
264
+ function setupClaude(context) {
265
+ if (!commandExists("claude")) return {
266
+ agent: "claude",
267
+ status: "skipped",
268
+ message: "claude command was not found"
269
+ };
270
+ if (!context.apiKey) return {
271
+ agent: "claude",
272
+ status: "skipped",
273
+ message: `Set ${API_KEY_ENV_VAR} or pass --api-key to configure Claude Code`
274
+ };
275
+ const args = [
276
+ "mcp",
277
+ "add",
278
+ "--transport",
279
+ "http"
280
+ ];
281
+ if (context.scope === "global") args.push("--scope", "user");
282
+ args.push(DEFAULT_SERVER_NAME, context.mcpUrl, "--header", `Authorization: Bearer ${context.apiKey}`);
283
+ runCommand("claude", args);
284
+ return {
285
+ agent: "claude",
286
+ status: "configured",
287
+ message: `Registered ${DEFAULT_SERVER_NAME} with claude mcp add`
288
+ };
289
+ }
290
+ async function resolveAgents(options) {
291
+ if (options.all || !options.cursor && !options.vscode && !options.claude && !options.codex) {
292
+ if (!options.all && !options.yes) {
293
+ if (!await promptForConfirmation("Configure the Bulkgrid MCP server for clients?", true)) return [];
294
+ return promptForMultiSelect("Choose MCP clients to configure:", AGENT_CHOICES);
295
+ }
296
+ return [
297
+ "cursor",
298
+ "vscode",
299
+ "claude",
300
+ "codex"
301
+ ];
302
+ }
303
+ const agents = [];
304
+ if (options.cursor) agents.push("cursor");
305
+ if (options.vscode) agents.push("vscode");
306
+ if (options.claude) agents.push("claude");
307
+ if (options.codex) agents.push("codex");
308
+ return agents;
309
+ }
310
+ async function promptForValue(message) {
311
+ return input({
312
+ message,
313
+ validate: (value) => value.trim().length > 0 || "Enter a value."
314
+ });
315
+ }
316
+ async function buildContext(options) {
317
+ const mcpUrl = options.mcpUrl ?? process.env[MCP_URL_ENV_VAR] ?? (options.yes ? void 0 : await promptForValue("Bulkgrid MCP URL: "));
318
+ if (!mcpUrl) throw new Error(`Missing MCP URL. Pass --mcp-url or set ${MCP_URL_ENV_VAR}.`);
319
+ const apiKey = await resolveApiKey(options);
320
+ return {
321
+ agents: await resolveAgents(options),
322
+ mcpUrl,
323
+ apiKey,
324
+ scope: options.global ? "global" : "project",
325
+ writeApiKey: options.writeApiKey ?? false
326
+ };
327
+ }
328
+ async function runInitCommand(options) {
329
+ printInitBanner();
330
+ await maybeInstallGlobally(options);
331
+ const context = await buildContext(options);
332
+ const results = [];
333
+ for (const agent of context.agents) if (agent === "cursor") results.push(setupCursor(context));
334
+ else if (agent === "vscode") results.push(setupVsCode(context));
335
+ else if (agent === "claude") results.push(setupClaude(context));
336
+ else if (agent === "codex") results.push(setupCodex(context));
337
+ return results;
338
+ }
339
+ //#endregion
340
+ //#region src/authStorage.ts
341
+ const credentialsSchema = z.object({
342
+ origin: z.string().url(),
343
+ issuer: z.string().url(),
344
+ tokenEndpoint: z.string().url(),
345
+ clientId: z.string().min(1),
346
+ accessToken: z.string().min(1),
347
+ refreshToken: z.string().min(1),
348
+ expiresAt: z.number().finite()
349
+ });
350
+ function hasCode(error, code) {
351
+ return error instanceof Error && "code" in error && error.code === code;
352
+ }
353
+ var AuthStorage = class {
354
+ directory;
355
+ constructor(directory = join(homedir(), ".config", "bulkgrid")) {
356
+ this.directory = directory;
357
+ }
358
+ path(origin) {
359
+ return join(this.directory, `${createHash("sha256").update(origin).digest("hex")}.json`);
360
+ }
361
+ async read(origin) {
362
+ try {
363
+ const result = credentialsSchema.safeParse(JSON.parse(await readFile(this.path(origin), "utf8")));
364
+ if (!result.success || result.data.origin !== origin) throw new Error("Invalid saved CLI credentials. Run bulkgrid logout --local to clear them.");
365
+ return result.data;
366
+ } catch (error) {
367
+ if (hasCode(error, "ENOENT")) return;
368
+ if (error instanceof SyntaxError) throw new Error("Invalid saved CLI credentials. Run bulkgrid logout --local to clear them.");
369
+ throw error;
370
+ }
371
+ }
372
+ async save(credentials) {
373
+ await mkdir(this.directory, {
374
+ recursive: true,
375
+ mode: 448
376
+ });
377
+ await chmod(this.directory, 448);
378
+ const target = this.path(credentials.origin);
379
+ const temporary = `${target}.${randomUUID()}.tmp`;
380
+ try {
381
+ await writeFile(temporary, JSON.stringify(credentials), {
382
+ mode: 384,
383
+ flag: "wx"
384
+ });
385
+ await rename(temporary, target);
386
+ } finally {
387
+ await unlink(temporary).catch((error) => {
388
+ if (!hasCode(error, "ENOENT")) throw error;
389
+ });
390
+ }
391
+ }
392
+ async clear(origin) {
393
+ await unlink(this.path(origin)).catch((error) => {
394
+ if (!hasCode(error, "ENOENT")) throw error;
395
+ });
396
+ }
397
+ async exclusive(origin, operation) {
398
+ await mkdir(this.directory, {
399
+ recursive: true,
400
+ mode: 448
401
+ });
402
+ const lock = `${this.path(origin)}.lock`;
403
+ try {
404
+ await writeFile(lock, String(process.pid), {
405
+ flag: "wx",
406
+ mode: 384
407
+ });
408
+ } catch (error) {
409
+ if (hasCode(error, "EEXIST")) throw new Error(`Another CLI authentication operation is running. If it has stopped, remove ${lock} and retry.`);
410
+ throw error;
411
+ }
412
+ try {
413
+ return await operation();
414
+ } finally {
415
+ await unlink(lock);
416
+ }
417
+ }
418
+ };
419
+ //#endregion
420
+ //#region src/oauthCallback.ts
421
+ const CLI_CALLBACK_PATH = "/bulkgrid/cli/callback";
422
+ async function listenForOAuthCallback(state, issuer, timeoutMs = 3e5) {
423
+ let resolveCode;
424
+ let rejectCode;
425
+ const code = new Promise((resolve, reject) => {
426
+ resolveCode = resolve;
427
+ rejectCode = reject;
428
+ });
429
+ code.catch(() => void 0);
430
+ let redirectUri = "";
431
+ const server = createServer((request, response) => {
432
+ response.setHeader("Content-Type", "text/plain; charset=utf-8");
433
+ response.setHeader("Cache-Control", "no-store");
434
+ response.setHeader("Referrer-Policy", "no-referrer");
435
+ let url;
436
+ try {
437
+ url = new URL(request.url ?? "/", redirectUri);
438
+ } catch {
439
+ response.writeHead(400).end("Invalid callback URL.");
440
+ return;
441
+ }
442
+ if (request.method !== "GET" || url.pathname !== "/bulkgrid/cli/callback" || request.headers.host !== new URL(redirectUri).host) {
443
+ response.writeHead(404).end("Not found");
444
+ return;
445
+ }
446
+ const receivedState = url.searchParams.get("state") ?? "";
447
+ if (Buffer.byteLength(receivedState) !== Buffer.byteLength(state) || !timingSafeEqual(Buffer.from(receivedState), Buffer.from(state))) {
448
+ response.writeHead(400).end("Invalid login state. Return to the original login tab.");
449
+ return;
450
+ }
451
+ if (url.searchParams.has("iss") && url.searchParams.get("iss") !== issuer) {
452
+ response.writeHead(400).end("Invalid authorization server.");
453
+ rejectCode(/* @__PURE__ */ new Error("OAuth callback issuer did not match."));
454
+ return;
455
+ }
456
+ if (url.searchParams.has("error")) {
457
+ response.writeHead(400).end("Login was declined. Return to your terminal.");
458
+ rejectCode(/* @__PURE__ */ new Error("Authorization was declined. Run bulkgrid login to try again."));
459
+ return;
460
+ }
461
+ const authorizationCode = url.searchParams.get("code");
462
+ if (!authorizationCode) {
463
+ response.writeHead(400).end("Missing authorization code.");
464
+ return;
465
+ }
466
+ response.end("Authorization received. Return to your terminal to check login completed.");
467
+ resolveCode(authorizationCode);
468
+ });
469
+ await new Promise((resolve, reject) => {
470
+ server.once("error", reject);
471
+ server.listen(0, "127.0.0.1", () => {
472
+ server.removeListener("error", reject);
473
+ resolve();
474
+ });
475
+ });
476
+ const address = server.address();
477
+ if (!address || typeof address === "string") {
478
+ server.close();
479
+ throw new Error("Unable to start the CLI login callback.");
480
+ }
481
+ redirectUri = `http://127.0.0.1:${address.port}${CLI_CALLBACK_PATH}`;
482
+ const timer = setTimeout(() => rejectCode(/* @__PURE__ */ new Error("Login timed out. Run bulkgrid login to try again.")), timeoutMs);
483
+ const cancel = () => rejectCode(/* @__PURE__ */ new Error("Login cancelled."));
484
+ process.once("SIGINT", cancel);
485
+ process.once("SIGTERM", cancel);
486
+ return {
487
+ redirectUri,
488
+ code,
489
+ close: () => {
490
+ clearTimeout(timer);
491
+ process.removeListener("SIGINT", cancel);
492
+ process.removeListener("SIGTERM", cancel);
493
+ server.closeAllConnections();
494
+ server.close();
495
+ }
496
+ };
497
+ }
498
+ //#endregion
499
+ //#region src/auth.ts
500
+ const DEFAULT_ORIGIN = "https://bulkgrid.com";
501
+ const identityScopes = "openid email profile";
502
+ const metadataSchema = z.object({
503
+ issuer: z.string().url(),
504
+ authorization_endpoint: z.string().url(),
505
+ token_endpoint: z.string().url(),
506
+ registration_endpoint: z.string().url(),
507
+ code_challenge_methods_supported: z.array(z.string())
508
+ });
509
+ const tokensSchema = z.object({
510
+ access_token: z.string().min(1),
511
+ refresh_token: z.string().min(1),
512
+ token_type: z.string().refine((value) => value.toLowerCase() === "bearer"),
513
+ expires_in: z.number().positive().finite()
514
+ });
515
+ const sessionSchema = z.object({
516
+ userId: z.string(),
517
+ workspaceId: z.string(),
518
+ clientName: z.string().nullable(),
519
+ scopes: z.array(z.string()),
520
+ collectionScope: z.enum(["all", "selected"])
521
+ });
522
+ var HttpError = class extends Error {
523
+ status;
524
+ constructor(status) {
525
+ super(`Authentication request failed (HTTP ${status}). Check the server configuration or run bulkgrid login again.`);
526
+ this.status = status;
527
+ }
528
+ };
529
+ function secureUrl(value) {
530
+ const url = new URL(value);
531
+ const local = [
532
+ "localhost",
533
+ "127.0.0.1",
534
+ "[::1]"
535
+ ].includes(url.hostname);
536
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && local) || url.username || url.password || url.hash) throw new Error("Authentication URLs must use HTTPS (HTTP is allowed only for local development).");
537
+ return url;
538
+ }
539
+ function resolveOrigin(value = process.env.BULKGRID_URL ?? DEFAULT_ORIGIN) {
540
+ const url = secureUrl(value);
541
+ if (url.pathname !== "/" || url.search) throw new Error("Use the Bulkgrid base URL, such as https://bulkgrid.com, without a path or query.");
542
+ return url.origin;
543
+ }
544
+ function openLoginBrowser(url) {
545
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "rundll32" : "xdg-open";
546
+ const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
547
+ const child = spawn(command, args, {
548
+ stdio: "ignore",
549
+ detached: true
550
+ });
551
+ child.on("error", () => console.log("Could not open a browser. Open the login URL above manually."));
552
+ child.unref();
553
+ }
554
+ var CliAuth = class {
555
+ storage;
556
+ fetcher;
557
+ constructor(storage = new AuthStorage(), fetcher = fetch) {
558
+ this.storage = storage;
559
+ this.fetcher = fetcher;
560
+ }
561
+ async request(url, init) {
562
+ secureUrl(url);
563
+ const response = await this.fetcher(url, {
564
+ ...init,
565
+ redirect: "error",
566
+ signal: AbortSignal.timeout(15e3)
567
+ });
568
+ if (!response.ok) throw new HttpError(response.status);
569
+ return response;
570
+ }
571
+ async requestJson(url, init) {
572
+ const response = await this.request(url, init);
573
+ try {
574
+ return await response.json();
575
+ } catch {
576
+ throw new Error("The authentication server returned invalid JSON.");
577
+ }
578
+ }
579
+ async discover(origin) {
580
+ const resource = `${origin}/api/v1/mcp`;
581
+ const response = await this.requestJson(`${origin}/.well-known/oauth-protected-resource/api/v1/mcp`);
582
+ const protectedResource = z.object({
583
+ resource: z.literal(resource),
584
+ authorization_servers: z.array(z.string().url()).length(1)
585
+ }).parse(response);
586
+ const issuer = secureUrl(protectedResource.authorization_servers[0]);
587
+ if (issuer.search) throw new Error("Invalid authorization server issuer.");
588
+ const discoveryUrl = `${issuer.origin}/.well-known/oauth-authorization-server${issuer.pathname === "/" ? "" : issuer.pathname}`;
589
+ const metadata = metadataSchema.parse(await this.requestJson(discoveryUrl));
590
+ if (metadata.issuer !== protectedResource.authorization_servers[0] || !metadata.code_challenge_methods_supported.includes("S256")) throw new Error("Authorization server discovery must match its issuer and support PKCE S256.");
591
+ for (const endpoint of [
592
+ metadata.authorization_endpoint,
593
+ metadata.token_endpoint,
594
+ metadata.registration_endpoint
595
+ ]) if (secureUrl(endpoint).origin !== issuer.origin) throw new Error("OAuth endpoints must belong to the discovered authorization server.");
596
+ return metadata;
597
+ }
598
+ async login(options = {}, openBrowser = openLoginBrowser) {
599
+ const origin = resolveOrigin(options.url);
600
+ return this.storage.exclusive(origin, async () => {
601
+ if (await this.storage.read(origin)) throw new Error("A CLI login is already saved for this server. Use bulkgrid status or bulkgrid logout first.");
602
+ const metadata = await this.discover(origin);
603
+ const verifier = randomBytes(32).toString("base64url");
604
+ const state = randomBytes(32).toString("base64url");
605
+ const callback = await listenForOAuthCallback(state, metadata.issuer);
606
+ try {
607
+ const registration = z.object({ client_id: z.string().min(1) }).parse(await this.requestJson(metadata.registration_endpoint, {
608
+ method: "POST",
609
+ headers: { "Content-Type": "application/json" },
610
+ body: JSON.stringify({
611
+ client_name: "Bulkgrid CLI",
612
+ client_uri: origin,
613
+ redirect_uris: [callback.redirectUri],
614
+ grant_types: ["authorization_code", "refresh_token"],
615
+ response_types: ["code"],
616
+ token_endpoint_auth_method: "none",
617
+ scope: identityScopes
618
+ })
619
+ }));
620
+ const authorizationUrl = new URL(metadata.authorization_endpoint);
621
+ authorizationUrl.search = new URLSearchParams({
622
+ response_type: "code",
623
+ client_id: registration.client_id,
624
+ redirect_uri: callback.redirectUri,
625
+ scope: identityScopes,
626
+ state,
627
+ code_challenge: createHash("sha256").update(verifier).digest("base64url"),
628
+ code_challenge_method: "S256",
629
+ prompt: "consent"
630
+ }).toString();
631
+ console.log(`Sign in and approve access:\n${authorizationUrl.href}`);
632
+ if (options.browser !== false) openBrowser(authorizationUrl.href);
633
+ const code = await callback.code;
634
+ const tokens = await this.exchange(metadata.token_endpoint, {
635
+ grant_type: "authorization_code",
636
+ client_id: registration.client_id,
637
+ code,
638
+ redirect_uri: callback.redirectUri,
639
+ code_verifier: verifier
640
+ });
641
+ const credentials = {
642
+ origin,
643
+ issuer: metadata.issuer,
644
+ tokenEndpoint: metadata.token_endpoint,
645
+ clientId: registration.client_id,
646
+ accessToken: tokens.access_token,
647
+ refreshToken: tokens.refresh_token,
648
+ expiresAt: Date.now() + tokens.expires_in * 1e3
649
+ };
650
+ await this.storage.save(credentials);
651
+ return await this.session(credentials);
652
+ } finally {
653
+ callback.close();
654
+ }
655
+ });
656
+ }
657
+ async exchange(endpoint, body) {
658
+ return tokensSchema.parse(await this.requestJson(endpoint, {
659
+ method: "POST",
660
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
661
+ body: new URLSearchParams(body)
662
+ }));
663
+ }
664
+ async credentials(origin) {
665
+ const saved = await this.storage.read(origin);
666
+ if (!saved) throw new Error("Not logged in. Run bulkgrid login.");
667
+ if (secureUrl(saved.tokenEndpoint).origin !== secureUrl(saved.issuer).origin) throw new Error("Saved token endpoint does not match the authorization server. Log out locally and sign in again.");
668
+ if (saved.expiresAt > Date.now() + 6e4) return saved;
669
+ const tokens = await this.exchange(saved.tokenEndpoint, {
670
+ grant_type: "refresh_token",
671
+ client_id: saved.clientId,
672
+ refresh_token: saved.refreshToken
673
+ });
674
+ const refreshed = {
675
+ ...saved,
676
+ accessToken: tokens.access_token,
677
+ refreshToken: tokens.refresh_token,
678
+ expiresAt: Date.now() + tokens.expires_in * 1e3
679
+ };
680
+ await this.storage.save(refreshed);
681
+ return refreshed;
682
+ }
683
+ async session(credentials) {
684
+ return sessionSchema.parse(await this.requestJson(`${credentials.origin}/api/v1/cli/session`, { headers: { Authorization: `Bearer ${credentials.accessToken}` } }));
685
+ }
686
+ async status(url) {
687
+ const origin = resolveOrigin(url);
688
+ return this.storage.exclusive(origin, async () => this.session(await this.credentials(origin)));
689
+ }
690
+ /** For CLI API consumers; tokens are never copied into agent/project configuration. */
691
+ async getAccessToken(url) {
692
+ const origin = resolveOrigin(url);
693
+ return this.storage.exclusive(origin, async () => {
694
+ const credentials = await this.credentials(origin);
695
+ await this.session(credentials);
696
+ return credentials.accessToken;
697
+ });
698
+ }
699
+ async logout(options = {}) {
700
+ const origin = resolveOrigin(options.url);
701
+ await this.storage.exclusive(origin, async () => {
702
+ if (options.local) {
703
+ await this.storage.clear(origin);
704
+ return;
705
+ }
706
+ if (!await this.storage.read(origin)) return;
707
+ try {
708
+ const credentials = await this.credentials(origin);
709
+ await this.request(`${origin}/api/v1/cli/session`, {
710
+ method: "DELETE",
711
+ headers: { Authorization: `Bearer ${credentials.accessToken}` }
712
+ });
713
+ } catch {
714
+ throw new Error("Could not revoke the CLI connection. Retry, or revoke it in Connected Apps and use bulkgrid logout --local.");
715
+ }
716
+ await this.storage.clear(origin);
717
+ });
718
+ }
719
+ };
720
+ //#endregion
721
+ export { resolveOrigin as n, runInitCommand as r, CliAuth as t };
722
+
723
+ //# sourceMappingURL=auth-C7pTl5XB.js.map