@telebort/question-banks-cli 1.0.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/dist/index.js ADDED
@@ -0,0 +1,1097 @@
1
+ import { Command } from 'commander';
2
+ import pc from 'picocolors';
3
+ import open from 'open';
4
+ import { homedir } from 'os';
5
+ import { join } from 'path';
6
+ import ora from 'ora';
7
+ import Table from 'cli-table3';
8
+ import { z } from 'zod';
9
+ import { promises } from 'fs';
10
+ import { formatDistanceToNow } from 'date-fns';
11
+ import { createInterface } from 'readline';
12
+
13
+ // src/index.ts
14
+ var CONFIG_DIR = join(homedir(), ".question-banks");
15
+ var PATHS = {
16
+ configDir: CONFIG_DIR,
17
+ tokensFile: join(CONFIG_DIR, "tokens.json"),
18
+ configFile: join(CONFIG_DIR, "config.json"),
19
+ cacheDir: join(CONFIG_DIR, "cache")
20
+ };
21
+ var API_BASE_URL = process.env.QUESTION_BANKS_API_URL ?? "https://exit-tickets-cli-api.vercel.app/api";
22
+ var ENDPOINTS = {
23
+ // Auth
24
+ deviceCode: "/auth/device-code",
25
+ token: "/auth/token",
26
+ refresh: "/auth/refresh",
27
+ // Account
28
+ me: "/account/me",
29
+ usage: "/account/usage",
30
+ billingPortal: "/account/billing-portal",
31
+ // API Keys
32
+ apiKeys: "/api-keys",
33
+ apiKeyById: (id) => `/api-keys/${id}`,
34
+ rotateKey: (id) => `/api-keys/${id}/rotate`,
35
+ // Validation
36
+ validate: "/v1/validate"
37
+ };
38
+ var colors = {
39
+ // Text styles
40
+ bold: pc.bold,
41
+ dim: pc.dim,
42
+ italic: pc.italic,
43
+ underline: pc.underline,
44
+ // Colors
45
+ red: pc.red,
46
+ green: pc.green,
47
+ yellow: pc.yellow,
48
+ blue: pc.blue,
49
+ cyan: pc.cyan,
50
+ magenta: pc.magenta,
51
+ white: pc.white,
52
+ gray: pc.gray,
53
+ // Semantic colors
54
+ success: pc.green,
55
+ error: pc.red,
56
+ warning: pc.yellow,
57
+ info: pc.cyan,
58
+ muted: pc.dim,
59
+ // Composite styles
60
+ errorBold: (text) => pc.bold(pc.red(text)),
61
+ successBold: (text) => pc.bold(pc.green(text)),
62
+ warningBold: (text) => pc.bold(pc.yellow(text))
63
+ };
64
+ var symbols = {
65
+ success: colors.green("\u2713"),
66
+ error: colors.red("\u2717"),
67
+ warning: colors.yellow("\u26A0"),
68
+ info: colors.cyan("\u2139"),
69
+ arrow: colors.cyan("\u2192"),
70
+ bullet: colors.dim("\u2022")
71
+ };
72
+ function spinner(text) {
73
+ const spin = ora({
74
+ text,
75
+ spinner: "dots",
76
+ color: "cyan"
77
+ }).start();
78
+ return {
79
+ stop: () => spin.stop(),
80
+ succeed: (text2) => spin.succeed(text2),
81
+ fail: (text2) => spin.fail(text2),
82
+ warn: (text2) => spin.warn(text2),
83
+ info: (text2) => spin.info(text2),
84
+ text: (text2) => {
85
+ spin.text = text2;
86
+ }
87
+ };
88
+ }
89
+ var spinners = {
90
+ authenticating: () => spinner("Authenticating..."),
91
+ loading: () => spinner("Loading..."),
92
+ saving: () => spinner("Saving..."),
93
+ connecting: () => spinner("Connecting to API..."),
94
+ fetching: () => spinner("Fetching data..."),
95
+ validating: () => spinner("Validating...")
96
+ };
97
+ function createTable(options = {}) {
98
+ return new Table({
99
+ chars: {
100
+ "top": "\u2500",
101
+ "top-mid": "\u252C",
102
+ "top-left": "\u250C",
103
+ "top-right": "\u2510",
104
+ "bottom": "\u2500",
105
+ "bottom-mid": "\u2534",
106
+ "bottom-left": "\u2514",
107
+ "bottom-right": "\u2518",
108
+ "left": "\u2502",
109
+ "left-mid": "\u251C",
110
+ "mid": "\u2500",
111
+ "mid-mid": "\u253C",
112
+ "right": "\u2502",
113
+ "right-mid": "\u2524",
114
+ "middle": "\u2502"
115
+ },
116
+ style: {
117
+ "padding-left": 1,
118
+ "padding-right": 1,
119
+ head: options.style?.head ?? ["cyan"],
120
+ border: options.style?.border ?? ["dim"]
121
+ },
122
+ head: options.head,
123
+ colWidths: options.colWidths
124
+ });
125
+ }
126
+ function progressBar(current, total, width = 40) {
127
+ const percentage = Math.min(current / total, 1);
128
+ const filled = Math.floor(percentage * width);
129
+ const empty = width - filled;
130
+ const bar = "\u2588".repeat(filled) + "\u2591".repeat(empty);
131
+ const pct = (percentage * 100).toFixed(1);
132
+ let colorFn = colors.green;
133
+ if (percentage >= 0.85) colorFn = colors.red;
134
+ else if (percentage >= 0.7) colorFn = colors.yellow;
135
+ return `${colorFn(bar)} ${pct}%`;
136
+ }
137
+ function divider(width = 50) {
138
+ return colors.dim("\u2500".repeat(width));
139
+ }
140
+
141
+ // src/utils/error-handler.ts
142
+ var EXIT_CODES = {
143
+ SUCCESS: 0,
144
+ GENERAL_ERROR: 1,
145
+ AUTH_ERROR: 2,
146
+ VALIDATION_ERROR: 3,
147
+ NETWORK_ERROR: 4
148
+ };
149
+ var CLIError = class extends Error {
150
+ constructor(message, code = "GENERAL_ERROR", hint) {
151
+ super(message);
152
+ this.code = code;
153
+ this.hint = hint;
154
+ this.name = "CLIError";
155
+ }
156
+ };
157
+ var AuthError = class extends CLIError {
158
+ constructor(message, hint) {
159
+ super(message, "AUTH_ERROR", hint);
160
+ this.name = "AuthError";
161
+ }
162
+ };
163
+ var ValidationError = class extends CLIError {
164
+ constructor(message, hint) {
165
+ super(message, "VALIDATION_ERROR", hint);
166
+ this.name = "ValidationError";
167
+ }
168
+ };
169
+ var NetworkError = class extends CLIError {
170
+ constructor(message, hint) {
171
+ super(message, "NETWORK_ERROR", hint);
172
+ this.name = "NetworkError";
173
+ }
174
+ };
175
+ function formatError(error) {
176
+ console.error("");
177
+ console.error(`${symbols.error} ${colors.errorBold("Error:")} ${error.message}`);
178
+ if (error instanceof CLIError && error.hint) {
179
+ console.error(`${symbols.info} ${colors.dim("Hint:")} ${error.hint}`);
180
+ }
181
+ if (process.env.DEBUG === "true" && error.stack) {
182
+ console.error("");
183
+ console.error(colors.dim(error.stack));
184
+ }
185
+ console.error("");
186
+ }
187
+ function handleError(error) {
188
+ if (error instanceof CLIError) {
189
+ formatError(error);
190
+ process.exit(EXIT_CODES[error.code]);
191
+ }
192
+ if (error instanceof Error) {
193
+ formatError(error);
194
+ process.exit(EXIT_CODES.GENERAL_ERROR);
195
+ }
196
+ console.error(`${symbols.error} ${colors.errorBold("Unknown error:")}`, error);
197
+ process.exit(EXIT_CODES.GENERAL_ERROR);
198
+ }
199
+ function setupErrorHandlers() {
200
+ process.on("uncaughtException", (error) => {
201
+ console.error("");
202
+ console.error(`${symbols.error} ${colors.errorBold("Uncaught exception:")}`);
203
+ formatError(error);
204
+ process.exit(EXIT_CODES.GENERAL_ERROR);
205
+ });
206
+ process.on("unhandledRejection", (reason) => {
207
+ console.error("");
208
+ console.error(`${symbols.error} ${colors.errorBold("Unhandled rejection:")}`);
209
+ if (reason instanceof Error) {
210
+ formatError(reason);
211
+ } else {
212
+ console.error(reason);
213
+ }
214
+ process.exit(EXIT_CODES.GENERAL_ERROR);
215
+ });
216
+ process.on("SIGINT", () => {
217
+ console.log("");
218
+ console.log(colors.yellow("Interrupted"));
219
+ process.exit(0);
220
+ });
221
+ process.on("SIGTERM", () => {
222
+ console.log("");
223
+ console.log(colors.yellow("Terminated"));
224
+ process.exit(0);
225
+ });
226
+ }
227
+
228
+ // src/api/client.ts
229
+ var ApiClient = class {
230
+ baseUrl;
231
+ constructor(baseUrl = API_BASE_URL) {
232
+ this.baseUrl = baseUrl;
233
+ }
234
+ buildUrl(path, params) {
235
+ const url = new URL(path, this.baseUrl);
236
+ if (params) {
237
+ Object.entries(params).forEach(([key, value]) => {
238
+ url.searchParams.set(key, value);
239
+ });
240
+ }
241
+ return url.toString();
242
+ }
243
+ async request(method, path, body, options = {}) {
244
+ const url = this.buildUrl(path, options.params);
245
+ const timeout = options.timeout ?? 3e4;
246
+ try {
247
+ const response = await fetch(url, {
248
+ method,
249
+ headers: {
250
+ "Content-Type": "application/json",
251
+ ...options.headers
252
+ },
253
+ body: body ? JSON.stringify(body) : void 0,
254
+ signal: AbortSignal.timeout(timeout)
255
+ });
256
+ const data = await response.json();
257
+ if (!response.ok) {
258
+ const error = data;
259
+ throw new NetworkError(
260
+ error.message ?? `HTTP ${response.status}`,
261
+ `Check your network connection or try again later`
262
+ );
263
+ }
264
+ return {
265
+ data,
266
+ status: response.status
267
+ };
268
+ } catch (error) {
269
+ if (error instanceof NetworkError) {
270
+ throw error;
271
+ }
272
+ if (error instanceof TypeError && error.message.includes("fetch")) {
273
+ throw new NetworkError(
274
+ "Network error: Unable to connect to API",
275
+ "Check your internet connection"
276
+ );
277
+ }
278
+ if (error instanceof DOMException && error.name === "AbortError") {
279
+ throw new NetworkError(
280
+ "Request timed out",
281
+ "The API is taking too long to respond. Try again later."
282
+ );
283
+ }
284
+ throw error;
285
+ }
286
+ }
287
+ async get(path, options) {
288
+ return this.request("GET", path, void 0, options);
289
+ }
290
+ async post(path, body, options) {
291
+ return this.request("POST", path, body, options);
292
+ }
293
+ async delete(path, options) {
294
+ return this.request("DELETE", path, void 0, options);
295
+ }
296
+ async patch(path, body, options) {
297
+ return this.request("PATCH", path, body, options);
298
+ }
299
+ };
300
+ var apiClient = new ApiClient();
301
+ var DeviceCodeResponseSchema = z.object({
302
+ device_code: z.string(),
303
+ user_code: z.string(),
304
+ verification_uri: z.string().url(),
305
+ verification_uri_complete: z.string().url().optional(),
306
+ expires_in: z.number(),
307
+ // seconds
308
+ interval: z.number()
309
+ // seconds between polls
310
+ });
311
+ var TokenResponseSchema = z.object({
312
+ access_token: z.string(),
313
+ token_type: z.literal("Bearer"),
314
+ expires_in: z.number(),
315
+ // seconds
316
+ refresh_token: z.string(),
317
+ scope: z.string().optional()
318
+ });
319
+ var StoredTokenSchema = z.object({
320
+ access_token: z.string(),
321
+ refresh_token: z.string(),
322
+ expires_at: z.number()
323
+ // Unix timestamp (ms)
324
+ });
325
+ z.object({
326
+ error: z.enum([
327
+ "authorization_pending",
328
+ "slow_down",
329
+ "expired_token",
330
+ "access_denied"
331
+ ]),
332
+ error_description: z.string().optional()
333
+ });
334
+
335
+ // src/auth/device-flow.ts
336
+ var CLIENT_ID = "question-banks-cli";
337
+ var DeviceFlowAuth = class {
338
+ async authenticate() {
339
+ const deviceCode = await this.requestDeviceCode();
340
+ this.displayUserCode(deviceCode);
341
+ const opened = await this.openBrowser(deviceCode.verification_uri_complete ?? deviceCode.verification_uri);
342
+ if (!opened) {
343
+ console.log(
344
+ colors.yellow("Could not open browser automatically."),
345
+ colors.dim("Please visit the URL above manually.")
346
+ );
347
+ }
348
+ console.log("");
349
+ const spin = spinner("Waiting for authorization...");
350
+ try {
351
+ const token = await this.pollForToken(deviceCode);
352
+ spin.succeed("Authentication complete!");
353
+ return token;
354
+ } catch (error) {
355
+ spin.fail("Authentication failed");
356
+ throw error;
357
+ }
358
+ }
359
+ async requestDeviceCode() {
360
+ const { data } = await apiClient.post(ENDPOINTS.deviceCode, {
361
+ client_id: CLIENT_ID
362
+ });
363
+ return DeviceCodeResponseSchema.parse(data);
364
+ }
365
+ displayUserCode(deviceCode) {
366
+ console.log("");
367
+ console.log(colors.bold("First-time setup required"));
368
+ console.log("");
369
+ console.log(`${symbols.arrow} Visit: ${colors.cyan(colors.underline(deviceCode.verification_uri))}`);
370
+ console.log(`${symbols.arrow} Enter code: ${colors.bold(colors.yellow(deviceCode.user_code))}`);
371
+ console.log("");
372
+ }
373
+ async openBrowser(url) {
374
+ try {
375
+ await open(url);
376
+ return true;
377
+ } catch {
378
+ return false;
379
+ }
380
+ }
381
+ async pollForToken(deviceCode) {
382
+ const expiresAt = Date.now() + deviceCode.expires_in * 1e3;
383
+ let interval = deviceCode.interval * 1e3;
384
+ while (Date.now() < expiresAt) {
385
+ await this.sleep(interval);
386
+ try {
387
+ const { data } = await apiClient.post(ENDPOINTS.token, {
388
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
389
+ device_code: deviceCode.device_code,
390
+ client_id: CLIENT_ID
391
+ });
392
+ return TokenResponseSchema.parse(data);
393
+ } catch (error) {
394
+ if (this.isPollingError(error)) {
395
+ const errorCode = this.getErrorCode(error);
396
+ if (errorCode === "authorization_pending") {
397
+ continue;
398
+ }
399
+ if (errorCode === "slow_down") {
400
+ interval += 1e3;
401
+ continue;
402
+ }
403
+ if (errorCode === "access_denied") {
404
+ throw new AuthError(
405
+ "Authorization denied",
406
+ "You declined the authorization request"
407
+ );
408
+ }
409
+ if (errorCode === "expired_token") {
410
+ throw new AuthError(
411
+ "Authorization code expired",
412
+ "Please try again: question-banks login"
413
+ );
414
+ }
415
+ }
416
+ throw error;
417
+ }
418
+ }
419
+ throw new AuthError(
420
+ "Authorization timed out",
421
+ "Please try again: question-banks login"
422
+ );
423
+ }
424
+ isPollingError(error) {
425
+ return typeof error === "object" && error !== null && "error" in error && typeof error.error === "string";
426
+ }
427
+ getErrorCode(error) {
428
+ if (this.isPollingError(error)) {
429
+ return error.error;
430
+ }
431
+ return null;
432
+ }
433
+ sleep(ms) {
434
+ return new Promise((resolve) => setTimeout(resolve, ms));
435
+ }
436
+ };
437
+ var deviceFlowAuth = new DeviceFlowAuth();
438
+ var TokenManager = class {
439
+ cachedToken = null;
440
+ async save(token) {
441
+ await promises.mkdir(PATHS.configDir, { recursive: true });
442
+ const data = {
443
+ access_token: token.access_token,
444
+ refresh_token: token.refresh_token,
445
+ expires_at: Date.now() + token.expires_in * 1e3
446
+ };
447
+ await promises.writeFile(
448
+ PATHS.tokensFile,
449
+ JSON.stringify(data, null, 2),
450
+ { mode: 384 }
451
+ // Read/write for owner only
452
+ );
453
+ this.cachedToken = data;
454
+ }
455
+ async load() {
456
+ if (this.cachedToken) return this.cachedToken;
457
+ try {
458
+ const content = await promises.readFile(PATHS.tokensFile, "utf-8");
459
+ const parsed = JSON.parse(content);
460
+ this.cachedToken = StoredTokenSchema.parse(parsed);
461
+ return this.cachedToken;
462
+ } catch {
463
+ return null;
464
+ }
465
+ }
466
+ async clear() {
467
+ this.cachedToken = null;
468
+ try {
469
+ await promises.unlink(PATHS.tokensFile);
470
+ } catch {
471
+ }
472
+ }
473
+ async getValidToken() {
474
+ const token = await this.load();
475
+ if (!token) {
476
+ throw new AuthError(
477
+ "Not authenticated",
478
+ "Run: question-banks login"
479
+ );
480
+ }
481
+ const bufferMs = 5 * 60 * 1e3;
482
+ if (Date.now() < token.expires_at - bufferMs) {
483
+ return token.access_token;
484
+ }
485
+ return this.refreshToken(token.refresh_token);
486
+ }
487
+ async refreshToken(refreshToken) {
488
+ try {
489
+ const { data } = await apiClient.post(
490
+ ENDPOINTS.refresh,
491
+ {
492
+ grant_type: "refresh_token",
493
+ refresh_token: refreshToken
494
+ }
495
+ );
496
+ await this.save(data);
497
+ return data.access_token;
498
+ } catch (error) {
499
+ await this.clear();
500
+ throw new AuthError(
501
+ "Session expired",
502
+ "Please log in again: question-banks login"
503
+ );
504
+ }
505
+ }
506
+ async isAuthenticated() {
507
+ try {
508
+ await this.getValidToken();
509
+ return true;
510
+ } catch {
511
+ return false;
512
+ }
513
+ }
514
+ };
515
+ var tokenManager = new TokenManager();
516
+
517
+ // src/commands/login.ts
518
+ function loginCommand(program) {
519
+ program.command("login").description("Authenticate with Question Banks API").action(async () => {
520
+ try {
521
+ const isAuthenticated = await tokenManager.isAuthenticated();
522
+ if (isAuthenticated) {
523
+ console.log(colors.yellow("Already logged in."));
524
+ console.log(`Run ${colors.cyan("question-banks logout")} first to switch accounts.`);
525
+ return;
526
+ }
527
+ const token = await deviceFlowAuth.authenticate();
528
+ await tokenManager.save(token);
529
+ console.log("");
530
+ console.log(`${symbols.success} ${colors.successBold("Successfully authenticated!")}`);
531
+ console.log("");
532
+ console.log(`Run ${colors.cyan("question-banks whoami")} to verify your account.`);
533
+ } catch (error) {
534
+ handleError(error);
535
+ }
536
+ });
537
+ }
538
+
539
+ // src/commands/logout.ts
540
+ function logoutCommand(program) {
541
+ program.command("logout").description("Clear local authentication tokens").action(async () => {
542
+ await tokenManager.clear();
543
+ console.log(`${symbols.success} ${colors.green("Logged out successfully")}`);
544
+ });
545
+ }
546
+
547
+ // src/commands/whoami.ts
548
+ function whoamiCommand(program) {
549
+ program.command("whoami").description("Display current account information").option("--json", "Output as JSON").action(async (options) => {
550
+ const spin = spinners.fetching();
551
+ try {
552
+ const accessToken = await tokenManager.getValidToken();
553
+ const { data } = await apiClient.get(ENDPOINTS.me, {
554
+ headers: { Authorization: `Bearer ${accessToken}` }
555
+ });
556
+ spin.stop();
557
+ if (options.json) {
558
+ console.log(JSON.stringify(data, null, 2));
559
+ return;
560
+ }
561
+ console.log("");
562
+ console.log(colors.bold("Account Information"));
563
+ console.log(divider());
564
+ console.log("");
565
+ console.log(`${colors.dim("User:")} ${data.user.name ?? colors.dim("(not set)")} ${colors.dim(`<${data.user.email}>`)}`);
566
+ console.log(`${colors.dim("Organization:")} ${data.organization.name} ${colors.dim(`(${data.organization.slug})`)}`);
567
+ console.log("");
568
+ console.log(colors.bold("Subscription"));
569
+ console.log(divider());
570
+ console.log("");
571
+ const planColors = {
572
+ free: colors.dim,
573
+ standard: colors.cyan,
574
+ premium: colors.yellow,
575
+ enterprise: colors.magenta
576
+ };
577
+ const planColor = planColors[data.subscription.plan] ?? colors.white;
578
+ console.log(`${colors.dim("Plan:")} ${planColor(data.subscription.plan.toUpperCase())}`);
579
+ const statusColors = {
580
+ active: colors.green,
581
+ trialing: colors.cyan,
582
+ past_due: colors.red,
583
+ canceled: colors.dim
584
+ };
585
+ const statusColor = statusColors[data.subscription.status] ?? colors.white;
586
+ console.log(`${colors.dim("Status:")} ${statusColor(data.subscription.status)}`);
587
+ if (data.subscription.monthlyQuota) {
588
+ console.log(`${colors.dim("Quota:")} ${data.subscription.monthlyQuota.toLocaleString()} requests/month`);
589
+ }
590
+ if (data.subscription.trialEndsAt) {
591
+ const trialEnd = new Date(data.subscription.trialEndsAt);
592
+ const now = /* @__PURE__ */ new Date();
593
+ if (trialEnd > now) {
594
+ const daysLeft = Math.ceil((trialEnd.getTime() - now.getTime()) / (1e3 * 60 * 60 * 24));
595
+ console.log(`${colors.dim("Trial:")} ${colors.cyan(`${daysLeft} days remaining`)}`);
596
+ }
597
+ }
598
+ console.log("");
599
+ } catch (error) {
600
+ spin.stop();
601
+ handleError(error);
602
+ }
603
+ });
604
+ }
605
+
606
+ // src/commands/usage.ts
607
+ function usageCommand(program) {
608
+ program.command("usage").description("Display API usage statistics").option("--period <YYYY-MM>", "Billing period (default: current)").option("--daily", "Show daily breakdown").option("--json", "Output as JSON").action(async (options) => {
609
+ const spin = spinners.fetching();
610
+ try {
611
+ const accessToken = await tokenManager.getValidToken();
612
+ const { data } = await apiClient.get(ENDPOINTS.usage, {
613
+ headers: { Authorization: `Bearer ${accessToken}` },
614
+ params: options.period ? { period: options.period } : void 0
615
+ });
616
+ spin.stop();
617
+ if (options.json) {
618
+ console.log(JSON.stringify(data, null, 2));
619
+ return;
620
+ }
621
+ console.log("");
622
+ console.log(colors.bold(`Usage for ${data.period}`));
623
+ console.log(divider());
624
+ console.log("");
625
+ if (data.isUnlimited) {
626
+ console.log(`${colors.dim("Quota:")} ${colors.cyan("Unlimited")}`);
627
+ console.log(`${colors.dim("Used:")} ${data.used.toLocaleString()} requests`);
628
+ } else {
629
+ console.log(`${colors.dim("Quota:")} ${data.quota.toLocaleString()} requests`);
630
+ console.log(`${colors.dim("Used:")} ${data.used.toLocaleString()} ${colors.dim(`(${(data.used / data.quota * 100).toFixed(1)}%)`)}`);
631
+ console.log(`${colors.dim("Remaining:")} ${colors.green(data.remaining.toLocaleString())}`);
632
+ console.log("");
633
+ console.log(progressBar(data.used, data.quota));
634
+ const usagePercent = data.used / data.quota * 100;
635
+ if (usagePercent >= 85) {
636
+ console.log("");
637
+ console.log(`${symbols.warning} ${colors.yellow("You are at " + usagePercent.toFixed(0) + "% of your monthly limit.")}`);
638
+ console.log(` Consider upgrading: ${colors.cyan("question-banks billing")}`);
639
+ }
640
+ }
641
+ if (Object.keys(data.breakdown).length > 0) {
642
+ console.log("");
643
+ console.log(colors.bold("Breakdown by Endpoint"));
644
+ console.log(divider(30));
645
+ const table = createTable({
646
+ head: ["Endpoint", "Requests", "%"],
647
+ colWidths: [25, 15, 10]
648
+ });
649
+ Object.entries(data.breakdown).sort(([, a], [, b]) => b - a).forEach(([endpoint, count]) => {
650
+ const pct = data.used > 0 ? (count / data.used * 100).toFixed(1) : "0.0";
651
+ table.push([endpoint, count.toLocaleString(), `${pct}%`]);
652
+ });
653
+ console.log(table.toString());
654
+ }
655
+ if (options.daily && data.dailyUsage.length > 0) {
656
+ console.log("");
657
+ console.log(colors.bold("Daily Usage"));
658
+ console.log(divider(30));
659
+ const table = createTable({
660
+ head: ["Date", "Requests"],
661
+ colWidths: [15, 15]
662
+ });
663
+ data.dailyUsage.slice(-14).reverse().forEach(({ date, count }) => {
664
+ table.push([date, count.toLocaleString()]);
665
+ });
666
+ console.log(table.toString());
667
+ }
668
+ console.log("");
669
+ } catch (error) {
670
+ spin.stop();
671
+ handleError(error);
672
+ }
673
+ });
674
+ }
675
+ function billingCommand(program) {
676
+ program.command("billing").description("Open Stripe Customer Portal to manage subscription").option("--url-only", "Print URL instead of opening browser").action(async (options) => {
677
+ const spin = spinners.loading();
678
+ spin.text("Generating billing portal URL...");
679
+ try {
680
+ const accessToken = await tokenManager.getValidToken();
681
+ const { data } = await apiClient.post(
682
+ ENDPOINTS.billingPortal,
683
+ {},
684
+ { headers: { Authorization: `Bearer ${accessToken}` } }
685
+ );
686
+ spin.stop();
687
+ if (options.urlOnly) {
688
+ console.log(data.url);
689
+ return;
690
+ }
691
+ console.log(`${symbols.info} Opening billing portal in your browser...`);
692
+ try {
693
+ await open(data.url);
694
+ console.log(`${symbols.success} ${colors.green("Billing portal opened successfully")}`);
695
+ console.log("");
696
+ console.log(colors.dim("In the portal you can:"));
697
+ console.log(colors.dim(" \u2022 View and download invoices"));
698
+ console.log(colors.dim(" \u2022 Update payment methods"));
699
+ console.log(colors.dim(" \u2022 Upgrade or downgrade your plan"));
700
+ console.log(colors.dim(" \u2022 Cancel subscription"));
701
+ } catch {
702
+ console.log(colors.yellow("Could not open browser automatically."));
703
+ console.log("");
704
+ console.log("Visit this URL to manage your billing:");
705
+ console.log(colors.cyan(data.url));
706
+ }
707
+ console.log("");
708
+ } catch (error) {
709
+ spin.stop();
710
+ handleError(error);
711
+ }
712
+ });
713
+ }
714
+ var OptionFeedbackSchema = z.object({
715
+ short: z.string(),
716
+ detailed: z.string(),
717
+ socraticHint: z.string().optional()
718
+ });
719
+ var OptionSchema = z.object({
720
+ key: z.string(),
721
+ // "A", "B", "C", "D"
722
+ text: z.string().min(1),
723
+ isCorrect: z.boolean(),
724
+ misconceptionId: z.string().optional(),
725
+ feedback: OptionFeedbackSchema.optional()
726
+ });
727
+ var QuestionSchema = z.object({
728
+ questionId: z.string(),
729
+ globalId: z.string().optional(),
730
+ questionNumber: z.number().optional(),
731
+ questionType: z.string().optional(),
732
+ questionTypeLabel: z.string().optional(),
733
+ questionArchetype: z.string().optional(),
734
+ prompt: z.string().min(1),
735
+ hasCodeBlock: z.boolean().optional(),
736
+ codeLanguage: z.string().nullable().optional(),
737
+ codeContent: z.string().nullable().optional(),
738
+ misconceptionTargets: z.array(z.any()).optional(),
739
+ options: z.array(OptionSchema).min(2).max(6),
740
+ correctAnswer: z.string(),
741
+ // "A", "B", "C", "D"
742
+ correctAnswerText: z.string().optional(),
743
+ metadata: z.any().optional(),
744
+ version: z.string().optional()
745
+ });
746
+ var LegacyQuestionSchema = z.object({
747
+ id: z.string(),
748
+ type: z.string().optional(),
749
+ question: z.string().min(1),
750
+ options: z.array(z.string()).min(2).max(6),
751
+ correctAnswerIndex: z.number().min(0)
752
+ });
753
+ var LessonSchema = z.object({
754
+ lessonId: z.string(),
755
+ lessonNumber: z.number(),
756
+ lessonTitle: z.string(),
757
+ lessonSlug: z.string().optional(),
758
+ totalQuestions: z.number().optional(),
759
+ questions: z.array(QuestionSchema)
760
+ });
761
+ var CourseExportSchema = z.object({
762
+ courseId: z.string(),
763
+ courseName: z.string(),
764
+ courseCode: z.string(),
765
+ domain: z.string().optional(),
766
+ tier: z.string().optional(),
767
+ difficulty: z.number().or(z.string()).optional(),
768
+ totalLessons: z.number().optional(),
769
+ totalQuestions: z.number().optional(),
770
+ sourceFile: z.string().optional(),
771
+ lessons: z.array(LessonSchema)
772
+ });
773
+ var SimpleQuestionsSchema = z.object({
774
+ questions: z.array(QuestionSchema.or(LegacyQuestionSchema))
775
+ });
776
+ function validateCommand(program) {
777
+ program.command("validate <file>").description("Validate a question JSON file (local, no API required)").option("-v, --verbose", "Show detailed validation output").action(async (file, options) => {
778
+ const spin = spinners.validating();
779
+ try {
780
+ try {
781
+ await promises.access(file);
782
+ } catch {
783
+ throw new ValidationError(
784
+ `File not found: ${file}`,
785
+ "Make sure the file path is correct"
786
+ );
787
+ }
788
+ const content = await promises.readFile(file, "utf-8");
789
+ let data;
790
+ try {
791
+ data = JSON.parse(content);
792
+ } catch {
793
+ throw new ValidationError(
794
+ "Invalid JSON format",
795
+ "The file must contain valid JSON"
796
+ );
797
+ }
798
+ const courseResult = CourseExportSchema.safeParse(data);
799
+ const simpleResult = SimpleQuestionsSchema.safeParse(data);
800
+ spin.stop();
801
+ if (courseResult.success) {
802
+ const course = courseResult.data;
803
+ let totalQuestions = 0;
804
+ let lessonCount = course.lessons.length;
805
+ course.lessons.forEach((lesson) => {
806
+ totalQuestions += lesson.questions.length;
807
+ });
808
+ console.log(`${symbols.success} ${colors.green(`Course validated successfully`)}`);
809
+ console.log("");
810
+ console.log(` ${colors.bold("Course:")} ${course.courseName} (${course.courseCode})`);
811
+ console.log(` ${colors.bold("Lessons:")} ${lessonCount}`);
812
+ console.log(` ${colors.bold("Questions:")} ${totalQuestions}`);
813
+ if (options.verbose) {
814
+ console.log("");
815
+ course.lessons.forEach((lesson) => {
816
+ console.log(` ${colors.green("\u2713")} Lesson ${lesson.lessonNumber}: ${lesson.questions.length} questions`);
817
+ });
818
+ }
819
+ console.log("");
820
+ return;
821
+ }
822
+ if (simpleResult.success) {
823
+ const count = simpleResult.data.questions.length;
824
+ console.log(`${symbols.success} ${colors.green(`${count}/${count} questions validated successfully`)}`);
825
+ if (options.verbose) {
826
+ console.log("");
827
+ simpleResult.data.questions.forEach((q, i) => {
828
+ const id = "questionId" in q ? q.questionId : q.id;
829
+ console.log(` ${colors.green("\u2713")} Question ${i + 1}: ${colors.dim(id)}`);
830
+ });
831
+ }
832
+ console.log("");
833
+ return;
834
+ }
835
+ console.log(`${symbols.error} ${colors.red("Validation failed")}`);
836
+ console.log("");
837
+ const result = courseResult.error.issues.length <= simpleResult.error.issues.length ? courseResult : simpleResult;
838
+ const issues = result.error.issues.slice(0, 5);
839
+ issues.forEach((issue, i) => {
840
+ const path = issue.path.join(".");
841
+ console.log(`${colors.red(`Error[E${String(i + 1).padStart(3, "0")}]:`)} ${issue.message}`);
842
+ console.log(` ${colors.dim("-->")} ${file}:${colors.yellow(path || "root")}`);
843
+ if (issue.code === "too_small" && path.includes("options")) {
844
+ console.log(` ${colors.dim("Hint:")} Questions need at least 2 options`);
845
+ }
846
+ if (issue.code === "invalid_type" && path.includes("correctAnswerIndex")) {
847
+ console.log(` ${colors.dim("Hint:")} correctAnswerIndex must be a number (0-based)`);
848
+ }
849
+ console.log("");
850
+ });
851
+ if (result.error.issues.length > 5) {
852
+ console.log(colors.dim(`... and ${result.error.issues.length - 5} more errors`));
853
+ console.log("");
854
+ }
855
+ console.log(colors.dim(`Learn more: https://docs.question-banks.dev/validation`));
856
+ process.exit(1);
857
+ } catch (error) {
858
+ spin.stop();
859
+ handleError(error);
860
+ }
861
+ });
862
+ }
863
+ function apiKeysListCommand(parent) {
864
+ parent.command("list").description("List all API keys").option("--json", "Output as JSON").action(async (options) => {
865
+ const spin = spinners.fetching();
866
+ try {
867
+ const accessToken = await tokenManager.getValidToken();
868
+ const { data } = await apiClient.get(ENDPOINTS.apiKeys, {
869
+ headers: { Authorization: `Bearer ${accessToken}` }
870
+ });
871
+ spin.stop();
872
+ if (options.json) {
873
+ console.log(JSON.stringify(data, null, 2));
874
+ return;
875
+ }
876
+ if (data.keys.length === 0) {
877
+ console.log("");
878
+ console.log(colors.yellow("No API keys found."));
879
+ console.log(`Create one with: ${colors.cyan("question-banks api-keys create")}`);
880
+ console.log("");
881
+ return;
882
+ }
883
+ const table = createTable({
884
+ head: ["Name", "Key (partial)", "Mode", "Last Used", "Status"],
885
+ colWidths: [20, 22, 8, 18, 10]
886
+ });
887
+ data.keys.forEach((key) => {
888
+ const partial = key.publicKey.slice(0, 18) + "...";
889
+ const lastUsed = key.lastUsedAt ? formatDistanceToNow(new Date(key.lastUsedAt), { addSuffix: true }) : colors.dim("Never");
890
+ const status = key.isActive ? colors.green("Active") : colors.red("Revoked");
891
+ const mode = key.mode === "live" ? colors.yellow("live") : colors.dim("test");
892
+ table.push([
893
+ key.name || colors.dim("(unnamed)"),
894
+ partial,
895
+ mode,
896
+ lastUsed,
897
+ status
898
+ ]);
899
+ });
900
+ console.log("");
901
+ console.log(table.toString());
902
+ console.log("");
903
+ } catch (error) {
904
+ spin.stop();
905
+ handleError(error);
906
+ }
907
+ });
908
+ }
909
+
910
+ // src/commands/api-keys/create.ts
911
+ function apiKeysCreateCommand(parent) {
912
+ parent.command("create").description("Create a new API key").option("-n, --name <name>", 'Key name (e.g., "Production API Key")').option("-m, --mode <mode>", "Key mode: test or live", "test").action(async (options) => {
913
+ try {
914
+ if (!["test", "live"].includes(options.mode)) {
915
+ throw new ValidationError(
916
+ "Invalid mode",
917
+ 'Mode must be "test" or "live"'
918
+ );
919
+ }
920
+ if (options.mode === "live") {
921
+ console.log("");
922
+ console.log(`${symbols.warning} ${colors.yellow("Creating a LIVE API key")}`);
923
+ console.log(colors.dim("Live keys can access production data and count against your quota."));
924
+ console.log("");
925
+ }
926
+ const spin = spinners.saving();
927
+ spin.text("Creating API key...");
928
+ const accessToken = await tokenManager.getValidToken();
929
+ const { data } = await apiClient.post(
930
+ ENDPOINTS.apiKeys,
931
+ {
932
+ name: options.name || null,
933
+ mode: options.mode
934
+ },
935
+ { headers: { Authorization: `Bearer ${accessToken}` } }
936
+ );
937
+ spin.succeed("API key created successfully!");
938
+ console.log("");
939
+ console.log(divider());
940
+ console.log("");
941
+ console.log(colors.bold("Your new API key:"));
942
+ console.log("");
943
+ console.log(` ${colors.cyan(data.secret)}`);
944
+ console.log("");
945
+ console.log(divider());
946
+ console.log("");
947
+ console.log(`${symbols.warning} ${colors.yellow("Save this key now!")}`);
948
+ console.log(colors.dim("This is the only time you will see the complete key."));
949
+ console.log("");
950
+ console.log(colors.bold("Quick start:"));
951
+ console.log("");
952
+ console.log(colors.dim(" // JavaScript/TypeScript"));
953
+ console.log(` const client = new ValidationClient({`);
954
+ console.log(` apiKey: '${data.secret.slice(0, 20)}...'`);
955
+ console.log(` })`);
956
+ console.log("");
957
+ } catch (error) {
958
+ handleError(error);
959
+ }
960
+ });
961
+ }
962
+ async function confirm(message) {
963
+ const rl = createInterface({
964
+ input: process.stdin,
965
+ output: process.stdout
966
+ });
967
+ return new Promise((resolve) => {
968
+ rl.question(`${message} (y/N) `, (answer) => {
969
+ rl.close();
970
+ resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes");
971
+ });
972
+ });
973
+ }
974
+ function apiKeysRevokeCommand(parent) {
975
+ parent.command("revoke <key-id>").description("Revoke an API key").option("-y, --yes", "Skip confirmation prompt").action(async (keyId, options) => {
976
+ try {
977
+ if (!options.yes) {
978
+ console.log("");
979
+ console.log(`${symbols.warning} ${colors.yellow("This action cannot be undone.")}`);
980
+ console.log(colors.dim("Any applications using this key will stop working immediately."));
981
+ console.log("");
982
+ const confirmed = await confirm("Are you sure you want to revoke this key?");
983
+ if (!confirmed) {
984
+ console.log(colors.dim("Cancelled."));
985
+ return;
986
+ }
987
+ }
988
+ const spin = spinners.saving();
989
+ spin.text("Revoking API key...");
990
+ const accessToken = await tokenManager.getValidToken();
991
+ await apiClient.delete(ENDPOINTS.apiKeyById(keyId), {
992
+ headers: { Authorization: `Bearer ${accessToken}` }
993
+ });
994
+ spin.succeed("API key revoked successfully");
995
+ console.log("");
996
+ console.log(colors.dim("The key can no longer be used to access the API."));
997
+ console.log("");
998
+ } catch (error) {
999
+ handleError(error);
1000
+ }
1001
+ });
1002
+ }
1003
+
1004
+ // src/commands/api-keys/rotate.ts
1005
+ function apiKeysRotateCommand(parent) {
1006
+ parent.command("rotate <key-id>").description("Rotate an API key (creates new key, old key remains valid for grace period)").option("-g, --grace <duration>", "Grace period for old key (e.g., 24h, 7d)", "24h").action(async (keyId, options) => {
1007
+ try {
1008
+ const gracePeriod = parseGracePeriod(options.grace);
1009
+ if (!gracePeriod) {
1010
+ throw new ValidationError(
1011
+ "Invalid grace period format",
1012
+ "Use formats like: 1h, 24h, 7d, 30d"
1013
+ );
1014
+ }
1015
+ console.log("");
1016
+ console.log(`${symbols.info} ${colors.cyan("Rotating API key...")}`);
1017
+ console.log(colors.dim(`Old key will remain valid for ${options.grace}`));
1018
+ console.log("");
1019
+ const spin = spinners.saving();
1020
+ spin.text("Generating new key...");
1021
+ const accessToken = await tokenManager.getValidToken();
1022
+ const { data } = await apiClient.post(
1023
+ ENDPOINTS.rotateKey(keyId),
1024
+ { gracePeriod },
1025
+ { headers: { Authorization: `Bearer ${accessToken}` } }
1026
+ );
1027
+ spin.succeed("API key rotated successfully!");
1028
+ console.log("");
1029
+ console.log(divider());
1030
+ console.log("");
1031
+ console.log(colors.bold("Your new API key:"));
1032
+ console.log("");
1033
+ console.log(` ${colors.cyan(data.secret)}`);
1034
+ console.log("");
1035
+ console.log(divider());
1036
+ console.log("");
1037
+ console.log(`${symbols.warning} ${colors.yellow("Save this key now!")}`);
1038
+ console.log(colors.dim("This is the only time you will see the complete key."));
1039
+ console.log("");
1040
+ console.log(`${symbols.info} Old key expires: ${colors.yellow(new Date(data.oldKeyExpiresAt).toLocaleString())}`);
1041
+ console.log(colors.dim("Update your applications before the old key expires."));
1042
+ console.log("");
1043
+ } catch (error) {
1044
+ handleError(error);
1045
+ }
1046
+ });
1047
+ }
1048
+ function parseGracePeriod(duration) {
1049
+ const match = duration.match(/^(\d+)(h|d)$/);
1050
+ if (!match) return null;
1051
+ const value = parseInt(match[1], 10);
1052
+ const unit = match[2];
1053
+ if (unit === "h") return value * 60 * 60 * 1e3;
1054
+ if (unit === "d") return value * 24 * 60 * 60 * 1e3;
1055
+ return null;
1056
+ }
1057
+
1058
+ // src/index.ts
1059
+ var VERSION = "1.0.0";
1060
+ function createCLI() {
1061
+ const program = new Command();
1062
+ program.name("question-banks").description("CLI tool for Question Banks API - authentication, API keys, usage, billing").version(VERSION, "-v, --version", "Display version number").helpOption("-h, --help", "Display help information");
1063
+ loginCommand(program);
1064
+ logoutCommand(program);
1065
+ whoamiCommand(program);
1066
+ usageCommand(program);
1067
+ billingCommand(program);
1068
+ validateCommand(program);
1069
+ const apiKeys = program.command("api-keys").description("Manage API keys").alias("keys");
1070
+ apiKeysListCommand(apiKeys);
1071
+ apiKeysCreateCommand(apiKeys);
1072
+ apiKeysRevokeCommand(apiKeys);
1073
+ apiKeysRotateCommand(apiKeys);
1074
+ program.addHelpText("after", `
1075
+ ${pc.bold("Examples:")}
1076
+ $ question-banks login ${pc.dim("# Authenticate with Question Banks API")}
1077
+ $ question-banks whoami ${pc.dim("# Show account information")}
1078
+ $ question-banks usage ${pc.dim("# Display API usage statistics")}
1079
+ $ question-banks api-keys list ${pc.dim("# List all API keys")}
1080
+ $ question-banks billing ${pc.dim("# Open Stripe billing portal")}
1081
+ $ question-banks validate file.json ${pc.dim("# Validate questions locally")}
1082
+
1083
+ ${pc.bold("Documentation:")}
1084
+ https://docs.question-banks.dev
1085
+ `);
1086
+ return program;
1087
+ }
1088
+ async function run() {
1089
+ setupErrorHandlers();
1090
+ const program = createCLI();
1091
+ await program.parseAsync(process.argv);
1092
+ }
1093
+ run();
1094
+
1095
+ export { createCLI, run };
1096
+ //# sourceMappingURL=index.js.map
1097
+ //# sourceMappingURL=index.js.map