vocabflow 0.0.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.
Files changed (78) hide show
  1. package/dist/__tests__/api-client.test.d.ts +2 -0
  2. package/dist/__tests__/api-client.test.d.ts.map +1 -0
  3. package/dist/__tests__/api-client.test.js +105 -0
  4. package/dist/__tests__/api-client.test.js.map +1 -0
  5. package/dist/__tests__/config.test.d.ts +2 -0
  6. package/dist/__tests__/config.test.d.ts.map +1 -0
  7. package/dist/__tests__/config.test.js +54 -0
  8. package/dist/__tests__/config.test.js.map +1 -0
  9. package/dist/__tests__/oauth-callback.test.d.ts +2 -0
  10. package/dist/__tests__/oauth-callback.test.d.ts.map +1 -0
  11. package/dist/__tests__/oauth-callback.test.js +46 -0
  12. package/dist/__tests__/oauth-callback.test.js.map +1 -0
  13. package/dist/__tests__/review.test.d.ts +2 -0
  14. package/dist/__tests__/review.test.d.ts.map +1 -0
  15. package/dist/__tests__/review.test.js +37 -0
  16. package/dist/__tests__/review.test.js.map +1 -0
  17. package/dist/__tests__/session.test.d.ts +2 -0
  18. package/dist/__tests__/session.test.d.ts.map +1 -0
  19. package/dist/__tests__/session.test.js +41 -0
  20. package/dist/__tests__/session.test.js.map +1 -0
  21. package/dist/commands/login.d.ts +2 -0
  22. package/dist/commands/login.d.ts.map +1 -0
  23. package/dist/commands/login.js +51 -0
  24. package/dist/commands/login.js.map +1 -0
  25. package/dist/commands/logout.d.ts +2 -0
  26. package/dist/commands/logout.d.ts.map +1 -0
  27. package/dist/commands/logout.js +28 -0
  28. package/dist/commands/logout.js.map +1 -0
  29. package/dist/commands/review.d.ts +2 -0
  30. package/dist/commands/review.d.ts.map +1 -0
  31. package/dist/commands/review.js +449 -0
  32. package/dist/commands/review.js.map +1 -0
  33. package/dist/commands/status.d.ts +2 -0
  34. package/dist/commands/status.d.ts.map +1 -0
  35. package/dist/commands/status.js +29 -0
  36. package/dist/commands/status.js.map +1 -0
  37. package/dist/index.d.ts +3 -0
  38. package/dist/index.d.ts.map +1 -0
  39. package/dist/index.js +42 -0
  40. package/dist/index.js.map +1 -0
  41. package/dist/lib/api-client.d.ts +55 -0
  42. package/dist/lib/api-client.d.ts.map +1 -0
  43. package/dist/lib/api-client.js +93 -0
  44. package/dist/lib/api-client.js.map +1 -0
  45. package/dist/lib/config.d.ts +20 -0
  46. package/dist/lib/config.d.ts.map +1 -0
  47. package/dist/lib/config.js +39 -0
  48. package/dist/lib/config.js.map +1 -0
  49. package/dist/lib/oauth-callback.d.ts +11 -0
  50. package/dist/lib/oauth-callback.d.ts.map +1 -0
  51. package/dist/lib/oauth-callback.js +83 -0
  52. package/dist/lib/oauth-callback.js.map +1 -0
  53. package/dist/lib/tty.d.ts +28 -0
  54. package/dist/lib/tty.d.ts.map +1 -0
  55. package/dist/lib/tty.js +59 -0
  56. package/dist/lib/tty.js.map +1 -0
  57. package/dist/types/review.d.ts +77 -0
  58. package/dist/types/review.d.ts.map +1 -0
  59. package/dist/types/review.js +2 -0
  60. package/dist/types/review.js.map +1 -0
  61. package/package.json +31 -0
  62. package/src/__tests__/api-client.test.ts +123 -0
  63. package/src/__tests__/config.test.ts +61 -0
  64. package/src/__tests__/oauth-callback.test.ts +54 -0
  65. package/src/__tests__/review.test.ts +41 -0
  66. package/src/__tests__/session.test.ts +53 -0
  67. package/src/commands/login.ts +60 -0
  68. package/src/commands/logout.ts +31 -0
  69. package/src/commands/review.ts +544 -0
  70. package/src/commands/status.ts +32 -0
  71. package/src/index.ts +45 -0
  72. package/src/lib/api-client.ts +126 -0
  73. package/src/lib/config.ts +53 -0
  74. package/src/lib/oauth-callback.ts +102 -0
  75. package/src/lib/tty.ts +64 -0
  76. package/src/types/review.ts +87 -0
  77. package/tsconfig.json +10 -0
  78. package/vitest.config.ts +24 -0
@@ -0,0 +1,93 @@
1
+ import ky from 'ky';
2
+ import { getToken } from './config.js';
3
+ // API base URL without /api prefix, endpoints include /api path
4
+ const API_BASE_URL = process.env.VOCAB_API_BASE_URL || 'https://api.enbook.site';
5
+ // Webapp URL for login flow
6
+ const WEBAPP_URL = process.env.VOCAB_WEBAPP_URL || 'https://vocab.now';
7
+ // Create Ky instance with Bearer token injection
8
+ const cliApiClient = ky.create({
9
+ prefixUrl: API_BASE_URL,
10
+ timeout: 30000,
11
+ hooks: {
12
+ beforeRequest: [
13
+ (request) => {
14
+ const token = getToken();
15
+ if (token) {
16
+ request.headers.set('Authorization', `Bearer ${token}`);
17
+ }
18
+ },
19
+ ],
20
+ },
21
+ });
22
+ // Type-safe API methods
23
+ export const cliApi = {
24
+ get: (url) => cliApiClient.get(url).json(),
25
+ post: (url, data) => cliApiClient.post(url, { json: data }).json(),
26
+ put: (url, data) => cliApiClient.put(url, { json: data }).json(),
27
+ delete: (url) => cliApiClient.delete(url).json(),
28
+ };
29
+ export class ApiError extends Error {
30
+ status;
31
+ response;
32
+ constructor(status, message, response) {
33
+ super(message);
34
+ this.status = status;
35
+ this.response = response;
36
+ this.name = 'ApiError';
37
+ }
38
+ }
39
+ export { WEBAPP_URL };
40
+ export function isApiSuccess(code) {
41
+ return code === 0 || code === 200;
42
+ }
43
+ export async function validateSession() {
44
+ try {
45
+ const response = await cliApi.get('api/users/me');
46
+ if (isApiSuccess(response.code) && response.data) {
47
+ return { valid: true, user: response.data };
48
+ }
49
+ return { valid: false, error: 'Invalid response from server' };
50
+ }
51
+ catch (err) {
52
+ if (err?.response?.status === 401) {
53
+ return { valid: false, error: 'Session expired. Please run `vocab login`.' };
54
+ }
55
+ if (err?.cause?.code === 'ECONNREFUSED' || err?.name === 'FetchError') {
56
+ return { valid: false, error: 'Unable to connect. Check your network.' };
57
+ }
58
+ return { valid: false, error: 'Unable to connect. Check your network.' };
59
+ }
60
+ }
61
+ // Require authenticated session - throws and exits if invalid
62
+ export async function requireAuth() {
63
+ const { valid, user, error } = await validateSession();
64
+ if (!valid) {
65
+ console.error(error);
66
+ process.exit(1);
67
+ }
68
+ return user;
69
+ }
70
+ // Review API endpoints
71
+ export const reviewApi = {
72
+ /**
73
+ * GET /api/review/today
74
+ * Fetch all words for today's review session
75
+ */
76
+ getTodayReview: () => cliApi.get('api/review/today'),
77
+ /**
78
+ * GET /api/review/questions/{wordId}?mode=FLASHCARD_FORWARD
79
+ * Fetch detailed word info for reveal
80
+ */
81
+ getReviewQuestion: (wordId) => cliApi.get(`api/review/questions/${wordId}?mode=FLASHCARD_FORWARD`),
82
+ /**
83
+ * POST /api/review/complete
84
+ * Submit rating for a word
85
+ */
86
+ submitReview: (data) => cliApi.post('api/review/complete', data),
87
+ /**
88
+ * DELETE /api/words/{wordId}
89
+ * Delete a word from user's wordbook
90
+ */
91
+ deleteWord: (wordId) => cliApi.delete(`api/words/${wordId}`),
92
+ };
93
+ //# sourceMappingURL=api-client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api-client.js","sourceRoot":"","sources":["../../src/lib/api-client.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,20 @@
1
+ import Conf from 'conf';
2
+ export interface ConfigSchema {
3
+ token: string | null;
4
+ email: string | null;
5
+ expiresAt: number | null;
6
+ }
7
+ declare const config: Conf<ConfigSchema>;
8
+ export declare function getToken(): string | null;
9
+ export declare function getEmail(): string | null;
10
+ export declare function getExpiresAt(): number | null;
11
+ export declare function setAuth(data: {
12
+ token: string;
13
+ email: string;
14
+ expiresAt: number | null;
15
+ }): void;
16
+ export declare function clearAuth(): void;
17
+ export declare function hasToken(): boolean;
18
+ export declare function isTokenExpired(): boolean;
19
+ export { config };
20
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/lib/config.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,MAAM,CAAC;AAExB,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED,QAAA,MAAM,MAAM,oBAOV,CAAC;AAEH,wBAAgB,QAAQ,IAAI,MAAM,GAAG,IAAI,CAExC;AAED,wBAAgB,QAAQ,IAAI,MAAM,GAAG,IAAI,CAExC;AAED,wBAAgB,YAAY,IAAI,MAAM,GAAG,IAAI,CAE5C;AAED,wBAAgB,OAAO,CAAC,IAAI,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,GAAG,IAAI,CAI9F;AAED,wBAAgB,SAAS,IAAI,IAAI,CAEhC;AAED,wBAAgB,QAAQ,IAAI,OAAO,CAGlC;AAED,wBAAgB,cAAc,IAAI,OAAO,CAMxC;AAED,OAAO,EAAE,MAAM,EAAE,CAAC"}
@@ -0,0 +1,39 @@
1
+ import Conf from 'conf';
2
+ const config = new Conf({
3
+ projectName: 'vocab-cli',
4
+ defaults: {
5
+ token: null,
6
+ email: null,
7
+ expiresAt: null,
8
+ },
9
+ });
10
+ export function getToken() {
11
+ return config.get('token');
12
+ }
13
+ export function getEmail() {
14
+ return config.get('email');
15
+ }
16
+ export function getExpiresAt() {
17
+ return config.get('expiresAt');
18
+ }
19
+ export function setAuth(data) {
20
+ config.set('token', data.token);
21
+ config.set('email', data.email);
22
+ config.set('expiresAt', data.expiresAt);
23
+ }
24
+ export function clearAuth() {
25
+ config.clear();
26
+ }
27
+ export function hasToken() {
28
+ const token = config.get('token');
29
+ return token !== null && token !== undefined;
30
+ }
31
+ export function isTokenExpired() {
32
+ const expiresAt = config.get('expiresAt');
33
+ if (expiresAt === null || expiresAt === undefined) {
34
+ return false; // No expiry stored, assume valid until server says otherwise
35
+ }
36
+ return Date.now() > expiresAt;
37
+ }
38
+ export { config };
39
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../../src/lib/config.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,MAAM,CAAC;AAQxB,MAAM,MAAM,GAAG,IAAI,IAAI,CAAe;IACpC,WAAW,EAAE,WAAW;IACxB,QAAQ,EAAE;QACR,KAAK,EAAE,IAAI;QACX,KAAK,EAAE,IAAI;QACX,SAAS,EAAE,IAAI;KAChB;CACF,CAAC,CAAC;AAEH,MAAM,UAAU,QAAQ;IACtB,OAAO,MAAM,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;AAC7B,CAAC;AAED,MAAM,UAAU,QAAQ;IACtB,OAAO,MAAM,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;AAC7B,CAAC;AAED,MAAM,UAAU,YAAY;IAC1B,OAAO,MAAM,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;AACjC,CAAC;AAED,MAAM,UAAU,OAAO,CAAC,IAAgE;IACtF,MAAM,CAAC,GAAG,CAAC,OAAO,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC;IAChC,MAAM,CAAC,GAAG,CAAC,OAAO,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC;IAChC,MAAM,CAAC,GAAG,CAAC,WAAW,EAAE,IAAI,CAAC,SAAS,CAAC,CAAC;AAC1C,CAAC;AAED,MAAM,UAAU,SAAS;IACvB,MAAM,CAAC,KAAK,EAAE,CAAC;AACjB,CAAC;AAED,MAAM,UAAU,QAAQ;IACtB,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;IAClC,OAAO,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,SAAS,CAAC;AAC/C,CAAC;AAED,MAAM,UAAU,cAAc;IAC5B,MAAM,SAAS,GAAG,MAAM,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;IAC1C,IAAI,SAAS,KAAK,IAAI,IAAI,SAAS,KAAK,SAAS,EAAE,CAAC;QAClD,OAAO,KAAK,CAAC,CAAC,6DAA6D;IAC7E,CAAC;IACD,OAAO,IAAI,CAAC,GAAG,EAAE,GAAG,SAAS,CAAC;AAChC,CAAC;AAED,OAAO,EAAE,MAAM,EAAE,CAAC"}
@@ -0,0 +1,11 @@
1
+ export interface CallbackSuccess {
2
+ type: 'success';
3
+ token: string;
4
+ }
5
+ export interface CallbackError {
6
+ type: 'error';
7
+ message: string;
8
+ }
9
+ export type CallbackResult = CallbackSuccess | CallbackError;
10
+ export declare function startCallbackServer(): Promise<CallbackResult>;
11
+ //# sourceMappingURL=oauth-callback.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oauth-callback.d.ts","sourceRoot":"","sources":["../../src/lib/oauth-callback.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,SAAS,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,cAAc,GAAG,eAAe,GAAG,aAAa,CAAC;AAE7D,wBAAsB,mBAAmB,IAAI,OAAO,CAAC,cAAc,CAAC,CAmFnE"}
@@ -0,0 +1,83 @@
1
+ import http from 'node:http';
2
+ import { URL } from 'node:url';
3
+ const CALLBACK_PORT = 58421;
4
+ const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
5
+ export async function startCallbackServer() {
6
+ return new Promise((resolve, reject) => {
7
+ const timer = setTimeout(() => {
8
+ server.close();
9
+ reject(new Error('Authentication timed out. Please try again.'));
10
+ }, CALLBACK_TIMEOUT_MS);
11
+ const server = http.createServer((req, res) => {
12
+ // Handle CORS preflight
13
+ if (req.method === 'OPTIONS') {
14
+ res.writeHead(200, {
15
+ 'Access-Control-Allow-Origin': '*',
16
+ 'Access-Control-Allow-Methods': 'GET, OPTIONS',
17
+ 'Access-Control-Allow-Headers': 'Content-Type',
18
+ });
19
+ res.end();
20
+ return;
21
+ }
22
+ const url = new URL(req.url ?? '/', `http://localhost:${CALLBACK_PORT}`);
23
+ const token = url.searchParams.get('token');
24
+ const error = url.searchParams.get('error');
25
+ const errorDescription = url.searchParams.get('error_description');
26
+ if (error || errorDescription) {
27
+ const msg = errorDescription ?? error ?? 'Unknown error';
28
+ res.writeHead(400, {
29
+ 'Content-Type': 'text/html',
30
+ 'Access-Control-Allow-Origin': '*',
31
+ });
32
+ res.end(`
33
+ <html>
34
+ <head><title>Authentication Failed</title></head>
35
+ <body style="font-family: sans-serif; padding: 40px; text-align: center;">
36
+ <h1 style="color: #d97757;">Authentication Failed</h1>
37
+ <p>${msg}</p>
38
+ <p>You can close this window.</p>
39
+ </body>
40
+ </html>
41
+ `);
42
+ clearTimeout(timer);
43
+ server.close();
44
+ resolve({ type: 'error', message: msg });
45
+ return;
46
+ }
47
+ if (token) {
48
+ res.writeHead(200, {
49
+ 'Content-Type': 'text/html',
50
+ 'Access-Control-Allow-Origin': '*',
51
+ });
52
+ res.end(`
53
+ <html>
54
+ <head><title>Authentication Successful</title></head>
55
+ <body style="font-family: sans-serif; padding: 40px; text-align: center;">
56
+ <h1 style="color: #22c55e;">Authentication Successful</h1>
57
+ <p>You can close this window and return to the CLI.</p>
58
+ </body>
59
+ </html>
60
+ `);
61
+ clearTimeout(timer);
62
+ server.close();
63
+ resolve({ type: 'success', token });
64
+ return;
65
+ }
66
+ // Unknown route
67
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
68
+ res.end('Not found. Expected callback with ?token=xxx or ?error=xxx');
69
+ });
70
+ server.on('error', (err) => {
71
+ if (err.code === 'EADDRINUSE') {
72
+ reject(new Error(`Port ${CALLBACK_PORT} is already in use. Please close other vocab processes and try again.`));
73
+ }
74
+ else {
75
+ reject(err);
76
+ }
77
+ });
78
+ server.listen(CALLBACK_PORT, () => {
79
+ // Server started successfully
80
+ });
81
+ });
82
+ }
83
+ //# sourceMappingURL=oauth-callback.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oauth-callback.js","sourceRoot":"","sources":["../../src/lib/oauth-callback.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Check if stdin is a TTY (interactive terminal)
3
+ */
4
+ export declare function isInteractive(): boolean;
5
+ /**
6
+ * Setup raw mode for keypress capture.
7
+ * MUST check isInteractive() first - setRawMode only works with real TTY.
8
+ * Call cleanup() when done to restore normal terminal behavior.
9
+ */
10
+ export declare function setupRawMode(): void;
11
+ /**
12
+ * Cleanup raw mode - restore normal terminal behavior.
13
+ * Call this on exit (success, error, or quit).
14
+ */
15
+ export declare function cleanup(): void;
16
+ /**
17
+ * Clear the current line in terminal
18
+ */
19
+ export declare function clearLine(): void;
20
+ /**
21
+ * Move cursor to top-left of terminal
22
+ */
23
+ export declare function moveCursorTop(): void;
24
+ /**
25
+ * Clear screen from cursor position to bottom
26
+ */
27
+ export declare function clearScreen(): void;
28
+ //# sourceMappingURL=tty.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tty.d.ts","sourceRoot":"","sources":["../../src/lib/tty.ts"],"names":[],"mappings":"AAEA;;GAEG;AACH,wBAAgB,aAAa,IAAI,OAAO,CAEvC;AAED;;;;GAIG;AACH,wBAAgB,YAAY,IAAI,IAAI,CAOnC;AAED;;;GAGG;AACH,wBAAgB,OAAO,IAAI,IAAI,CAM9B;AAED;;GAEG;AACH,wBAAgB,SAAS,IAAI,IAAI,CAGhC;AAED;;GAEG;AACH,wBAAgB,aAAa,IAAI,IAAI,CAMpC;AAED;;GAEG;AACH,wBAAgB,WAAW,IAAI,IAAI,CAMlC"}
@@ -0,0 +1,59 @@
1
+ import * as readline from 'readline';
2
+ /**
3
+ * Check if stdin is a TTY (interactive terminal)
4
+ */
5
+ export function isInteractive() {
6
+ return process.stdin.isTTY === true;
7
+ }
8
+ /**
9
+ * Setup raw mode for keypress capture.
10
+ * MUST check isInteractive() first - setRawMode only works with real TTY.
11
+ * Call cleanup() when done to restore normal terminal behavior.
12
+ */
13
+ export function setupRawMode() {
14
+ if (!isInteractive()) {
15
+ return;
16
+ }
17
+ readline.emitKeypressEvents(process.stdin);
18
+ process.stdin.setRawMode?.(true);
19
+ process.stdin.resume?.();
20
+ }
21
+ /**
22
+ * Cleanup raw mode - restore normal terminal behavior.
23
+ * Call this on exit (success, error, or quit).
24
+ */
25
+ export function cleanup() {
26
+ if (!isInteractive()) {
27
+ return;
28
+ }
29
+ process.stdin.setRawMode?.(false);
30
+ process.stdin.pause?.();
31
+ }
32
+ /**
33
+ * Clear the current line in terminal
34
+ */
35
+ export function clearLine() {
36
+ readline.cursorTo(process.stdout, 0);
37
+ readline.clearLine(process.stdout, 0);
38
+ }
39
+ /**
40
+ * Move cursor to top-left of terminal
41
+ */
42
+ export function moveCursorTop() {
43
+ if (!process.stdout.isTTY) {
44
+ return;
45
+ }
46
+ // ANSI cursor-home is more consistent across Windows + Git Bash terminals.
47
+ process.stdout.write('\u001B[H');
48
+ }
49
+ /**
50
+ * Clear screen from cursor position to bottom
51
+ */
52
+ export function clearScreen() {
53
+ if (!process.stdout.isTTY) {
54
+ return;
55
+ }
56
+ // ANSI full-screen clear avoids stale lines in some TTY implementations.
57
+ process.stdout.write('\u001B[2J');
58
+ }
59
+ //# sourceMappingURL=tty.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tty.js","sourceRoot":"","sources":["../../src/lib/tty.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,QAAQ,MAAM,UAAU,CAAC;AAErC;;GAEG;AACH,MAAM,UAAU,aAAa;IAC3B,OAAO,OAAO,CAAC,KAAK,CAAC,KAAK,KAAK,IAAI,CAAC;AACtC,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,YAAY;IAC1B,IAAI,CAAC,aAAa,EAAE,EAAE,CAAC;QACrB,OAAO;IACT,CAAC;IACD,QAAQ,CAAC,kBAAkB,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;IAC3C,OAAO,CAAC,KAAK,CAAC,UAAU,EAAE,CAAC,IAAI,CAAC,CAAC;IACjC,OAAO,CAAC,KAAK,CAAC,MAAM,EAAE,EAAE,CAAC;AAC3B,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,OAAO;IACrB,IAAI,CAAC,aAAa,EAAE,EAAE,CAAC;QACrB,OAAO;IACT,CAAC;IACD,OAAO,CAAC,KAAK,CAAC,UAAU,EAAE,CAAC,KAAK,CAAC,CAAC;IAClC,OAAO,CAAC,KAAK,CAAC,KAAK,EAAE,EAAE,CAAC;AAC1B,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,SAAS;IACvB,QAAQ,CAAC,QAAQ,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;IACrC,QAAQ,CAAC,SAAS,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;AACxC,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,aAAa;IAC3B,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC;QAC1B,OAAO;IACT,CAAC;IACD,2EAA2E;IAC3E,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;AACnC,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,WAAW;IACzB,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC;QAC1B,OAAO;IACT,CAAC;IACD,yEAAyE;IACzE,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC;AACpC,CAAC"}
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Response from GET /api/review/today
3
+ */
4
+ export interface TodayReviewVO {
5
+ id: number;
6
+ wordText: string;
7
+ phoneticUs?: string;
8
+ briefExplanation: string;
9
+ nextReviewTime: string;
10
+ reviewCount: number;
11
+ intervalDays: number;
12
+ easeFactor: number;
13
+ }
14
+ export interface PagedResponseTodayReviewVO {
15
+ datas?: TodayReviewVO[];
16
+ totalElements?: number;
17
+ page?: number;
18
+ size?: number;
19
+ totalPages?: number;
20
+ first?: boolean;
21
+ last?: boolean;
22
+ words?: TodayReviewVO[];
23
+ total?: number;
24
+ }
25
+ export interface ApiResponsePagedTodayReviewVO {
26
+ code: number;
27
+ message: string;
28
+ data: PagedResponseTodayReviewVO;
29
+ timestamp: number;
30
+ }
31
+ /**
32
+ * Response from GET /api/review/questions/{wordId}
33
+ */
34
+ export interface ReviewQuestionVO {
35
+ wordId: number;
36
+ mode: string;
37
+ question: Record<string, unknown>;
38
+ answer: Record<string, unknown>;
39
+ }
40
+ export interface ApiResponseReviewQuestionVO {
41
+ code: number;
42
+ message: string;
43
+ data: ReviewQuestionVO;
44
+ timestamp: number;
45
+ }
46
+ /**
47
+ * Request body for POST /api/review/complete
48
+ */
49
+ export type ReviewResult = 'UNKNOWN' | 'VAGUE' | 'KNOWN' | 'CORRECT' | 'INCORRECT';
50
+ export type ReviewMode = 'FLASHCARD_FORWARD' | 'FLASHCARD_BACKWARD' | 'SPELLING' | 'CHOICE_EN2CN' | 'CHOICE_CN2EN';
51
+ export interface SubmitReviewRequest {
52
+ wordId: number;
53
+ result: ReviewResult;
54
+ mode: ReviewMode;
55
+ userAnswer?: string;
56
+ timeCost?: number;
57
+ }
58
+ /**
59
+ * Response from POST /api/review/complete
60
+ */
61
+ export interface ReviewResponseDto {
62
+ wordId: number;
63
+ wordText: string;
64
+ easeFactor: number;
65
+ intervalDays: number;
66
+ nextReviewTime: string;
67
+ reviewCount: number;
68
+ correctCount: number;
69
+ lastReviewResult: ReviewResult;
70
+ }
71
+ export interface ApiResponseReviewResponseDto {
72
+ code: number;
73
+ message: string;
74
+ data: ReviewResponseDto;
75
+ timestamp: number;
76
+ }
77
+ //# sourceMappingURL=review.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"review.d.ts","sourceRoot":"","sources":["../../src/types/review.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gBAAgB,EAAE,MAAM,CAAC;IACzB,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,0BAA0B;IAEzC,KAAK,CAAC,EAAE,aAAa,EAAE,CAAC;IACxB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,IAAI,CAAC,EAAE,OAAO,CAAC;IAGf,KAAK,CAAC,EAAE,aAAa,EAAE,CAAC;IACxB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,6BAA6B;IAC5C,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,0BAA0B,CAAC;IACjC,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAClC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACjC;AAED,MAAM,WAAW,2BAA2B;IAC1C,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,gBAAgB,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;GAEG;AACH,MAAM,MAAM,YAAY,GAAG,SAAS,GAAG,OAAO,GAAG,OAAO,GAAG,SAAS,GAAG,WAAW,CAAC;AACnF,MAAM,MAAM,UAAU,GAAG,mBAAmB,GAAG,oBAAoB,GAAG,UAAU,GAAG,cAAc,GAAG,cAAc,CAAC;AAEnH,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,YAAY,CAAC;IACrB,IAAI,EAAE,UAAU,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,MAAM,CAAC;IACrB,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,gBAAgB,EAAE,YAAY,CAAC;CAChC;AAED,MAAM,WAAW,4BAA4B;IAC3C,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,iBAAiB,CAAC;IACxB,SAAS,EAAE,MAAM,CAAC;CACnB"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=review.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"review.js","sourceRoot":"","sources":["../../src/types/review.ts"],"names":[],"mappings":""}
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "vocabflow",
3
+ "version": "0.0.1",
4
+ "private": false,
5
+ "type": "module",
6
+ "bin": {
7
+ "vocabflow": "./dist/index.js",
8
+ "vocab": "./dist/index.js"
9
+ },
10
+ "scripts": {
11
+ "dev": "tsx watch src/index.ts",
12
+ "build": "tsc",
13
+ "type-check": "tsc --noEmit",
14
+ "test": "vitest run",
15
+ "test:watch": "vitest"
16
+ },
17
+ "dependencies": {
18
+ "conf": "^15.1.0",
19
+ "ky": "^1.14.3",
20
+ "open": "^11.0.0",
21
+ "picocolors": "^1.1.1"
22
+ },
23
+ "devDependencies": {
24
+ "@repo/api-client": "workspace:*",
25
+ "@repo/utils": "workspace:*",
26
+ "@types/node": "^20.11.0",
27
+ "tsx": "^4.7.0",
28
+ "typescript": "^5.3.0",
29
+ "vitest": "^1.2.0"
30
+ }
31
+ }
@@ -0,0 +1,123 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const mockGetToken = vi.fn();
4
+ const mockGet = vi.fn();
5
+ const mockPost = vi.fn();
6
+ const mockPut = vi.fn();
7
+ const mockDelete = vi.fn();
8
+ const mockKyCreate = vi.fn();
9
+
10
+ vi.mock('../lib/config.js', () => ({
11
+ getToken: mockGetToken,
12
+ }));
13
+
14
+ vi.mock('ky', () => ({
15
+ default: {
16
+ create: mockKyCreate,
17
+ },
18
+ }));
19
+
20
+ describe('CLI API client endpoint compatibility', () => {
21
+ beforeEach(() => {
22
+ vi.resetModules();
23
+ vi.clearAllMocks();
24
+
25
+ mockGetToken.mockReturnValue('test-token');
26
+ mockGet.mockImplementation((_url: string) => ({
27
+ json: vi.fn(),
28
+ }));
29
+ mockPost.mockImplementation((_url: string, _opts?: unknown) => ({
30
+ json: vi.fn(),
31
+ }));
32
+ mockPut.mockImplementation((_url: string, _opts?: unknown) => ({
33
+ json: vi.fn(),
34
+ }));
35
+ mockDelete.mockImplementation((_url: string) => ({
36
+ json: vi.fn(),
37
+ }));
38
+ mockKyCreate.mockReturnValue({
39
+ get: mockGet,
40
+ post: mockPost,
41
+ put: mockPut,
42
+ delete: mockDelete,
43
+ });
44
+ });
45
+
46
+ it('validateSession success should call relative endpoint without leading slash', async () => {
47
+ const jsonMock = vi.fn().mockResolvedValue({
48
+ code: 0,
49
+ message: 'ok',
50
+ data: { id: 'u1', email: 'test@example.com', name: 'Test User' },
51
+ });
52
+ mockGet.mockReturnValueOnce({ json: jsonMock });
53
+
54
+ const { validateSession } = await import('../lib/api-client.js');
55
+ const result = await validateSession();
56
+
57
+ expect(mockGet).toHaveBeenCalledWith('api/users/me');
58
+ expect(mockGet).not.toHaveBeenCalledWith('/api/users/me');
59
+ expect(result).toEqual({
60
+ valid: true,
61
+ user: { id: 'u1', email: 'test@example.com', name: 'Test User' },
62
+ });
63
+ });
64
+
65
+ it('isApiSuccess should accept both 0 and 200', async () => {
66
+ const { isApiSuccess } = await import('../lib/api-client.js');
67
+ expect(isApiSuccess(0)).toBe(true);
68
+ expect(isApiSuccess(200)).toBe(true);
69
+ expect(isApiSuccess(401)).toBe(false);
70
+ });
71
+
72
+ it('validateSession failure should return session-expired message for 401', async () => {
73
+ const jsonMock = vi.fn().mockRejectedValue({ response: { status: 401 } });
74
+ mockGet.mockReturnValueOnce({ json: jsonMock });
75
+
76
+ const { validateSession } = await import('../lib/api-client.js');
77
+ const result = await validateSession();
78
+
79
+ expect(mockGet).toHaveBeenCalledWith('api/users/me');
80
+ expect(result).toEqual({
81
+ valid: false,
82
+ error: 'Session expired. Please run `vocab login`.',
83
+ });
84
+ });
85
+
86
+ it('reviewApi should keep all endpoints relative to prefixUrl', async () => {
87
+ const getJsonMock = vi.fn().mockResolvedValue({ code: 200, data: {} });
88
+ const postJsonMock = vi.fn().mockResolvedValue({ code: 200, data: {} });
89
+ const deleteJsonMock = vi.fn().mockResolvedValue({ code: 200, data: {} });
90
+
91
+ mockGet.mockReturnValue({ json: getJsonMock });
92
+ mockPost.mockReturnValue({ json: postJsonMock });
93
+ mockDelete.mockReturnValue({ json: deleteJsonMock });
94
+
95
+ const { reviewApi } = await import('../lib/api-client.js');
96
+ await reviewApi.getTodayReview();
97
+ await reviewApi.getReviewQuestion(123);
98
+ await reviewApi.submitReview({
99
+ wordId: 123,
100
+ result: 'KNOWN',
101
+ mode: 'FLASHCARD_FORWARD',
102
+ });
103
+ await reviewApi.deleteWord(123);
104
+
105
+ expect(mockGet).toHaveBeenCalledWith('api/review/today');
106
+ expect(mockGet).toHaveBeenCalledWith('api/review/questions/123?mode=FLASHCARD_FORWARD');
107
+ expect(mockPost).toHaveBeenCalledWith(
108
+ 'api/review/complete',
109
+ expect.objectContaining({
110
+ json: {
111
+ wordId: 123,
112
+ result: 'KNOWN',
113
+ mode: 'FLASHCARD_FORWARD',
114
+ },
115
+ })
116
+ );
117
+ expect(mockDelete).toHaveBeenCalledWith('api/words/123');
118
+ expect(mockGet).not.toHaveBeenCalledWith('/api/review/today');
119
+ expect(mockGet).not.toHaveBeenCalledWith('/api/review/questions/123?mode=FLASHCARD_FORWARD');
120
+ expect(mockPost).not.toHaveBeenCalledWith('/api/review/complete', expect.anything());
121
+ expect(mockDelete).not.toHaveBeenCalledWith('/api/words/123');
122
+ });
123
+ });