biblioplex 0.1.1 → 0.2.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 (57) hide show
  1. package/README.md +44 -70
  2. package/package.json +17 -27
  3. package/src/__tests__/args.test.js +39 -0
  4. package/src/__tests__/csv.test.js +33 -0
  5. package/src/__tests__/mcp.test.js +84 -0
  6. package/src/__tests__/mutate.test.js +91 -0
  7. package/src/__tests__/render.test.js +103 -0
  8. package/src/args.mjs +29 -6
  9. package/src/cli.mjs +38 -25
  10. package/src/commands/add.mjs +30 -51
  11. package/src/commands/deck.mjs +13 -47
  12. package/src/commands/edit.mjs +26 -38
  13. package/src/commands/export.mjs +57 -41
  14. package/src/commands/history.mjs +24 -0
  15. package/src/commands/import.mjs +52 -80
  16. package/src/commands/index.mjs +44 -20
  17. package/src/commands/login.mjs +29 -38
  18. package/src/commands/logout.mjs +13 -10
  19. package/src/commands/ls.mjs +13 -25
  20. package/src/commands/move.mjs +15 -35
  21. package/src/commands/prices.mjs +21 -0
  22. package/src/commands/recover.mjs +53 -0
  23. package/src/commands/resolve.mjs +37 -0
  24. package/src/commands/rm.mjs +15 -32
  25. package/src/commands/search.mjs +25 -35
  26. package/src/commands/summary.mjs +25 -28
  27. package/src/commands/undo.mjs +13 -28
  28. package/src/commands/value.mjs +30 -0
  29. package/src/commands/whoami.mjs +27 -19
  30. package/src/constants.mjs +34 -7
  31. package/src/csv.mjs +71 -0
  32. package/src/errors.mjs +13 -2
  33. package/src/mcp.mjs +227 -0
  34. package/src/mutate.mjs +142 -67
  35. package/src/oauth.mjs +200 -62
  36. package/src/output.mjs +27 -18
  37. package/src/render.mjs +135 -30
  38. package/src/store.mjs +39 -23
  39. package/src/api.mjs +0 -110
  40. package/src/commands/container.mjs +0 -83
  41. package/src/commands/deckExport.mjs +0 -48
  42. package/src/commands/show.mjs +0 -33
  43. package/src/commands/tag.mjs +0 -40
  44. package/src/commands/writeHelpers.mjs +0 -82
  45. package/src/scryfall.mjs +0 -81
  46. package/src/snapshot.mjs +0 -77
  47. package/vendor/README.md +0 -20
  48. package/vendor/adapters.js +0 -443
  49. package/vendor/collection.js +0 -665
  50. package/vendor/deckExport.js +0 -219
  51. package/vendor/importMerge.js +0 -22
  52. package/vendor/importParsing.js +0 -119
  53. package/vendor/portableArchive.js +0 -151
  54. package/vendor/searchCore.js +0 -223
  55. package/vendor/state.js +0 -91
  56. package/vendor/storageSchema.js +0 -75
  57. package/vendor/syncOps.js +0 -188
@@ -0,0 +1,30 @@
1
+ import { strFlag } from '../args.mjs';
2
+ import { emitResult, fmtMoney } from '../render.mjs';
3
+
4
+ export default {
5
+ summary: 'collection value totals',
6
+ help: `bp value [--source usd|eur|tix]
7
+
8
+ Priced/unpriced counts and total value for a price source (default usd).`,
9
+ async run(ctx) {
10
+ const session = await ctx.makeSession();
11
+ const source = strFlag(ctx.flags, 'source') || 'usd';
12
+ const sc = await session.call('collection_value', { source });
13
+ return emitResult(ctx, sc, {
14
+ render: (out) => {
15
+ const get = (...k) => {
16
+ for (const key of k) if (sc?.[key] != null) return sc[key];
17
+ return undefined;
18
+ };
19
+ out.line(`source: ${get('source') ?? source}`);
20
+ const total = get('totalValue', 'total', 'value');
21
+ if (total != null) out.line(`total value: ${fmtMoney(total)}`);
22
+ const priced = get('pricedCount', 'priced');
23
+ const unpriced = get('unpricedCount', 'unpriced');
24
+ if (priced != null || unpriced != null) {
25
+ out.line(`priced: ${priced ?? '?'} unpriced: ${unpriced ?? '?'}`);
26
+ }
27
+ },
28
+ });
29
+ },
30
+ };
@@ -1,26 +1,34 @@
1
1
  import { loadCredentials } from '../store.mjs';
2
- import { authError } from '../errors.mjs';
2
+ import { READ_SCOPE, EXIT } from '../constants.mjs';
3
3
 
4
4
  export default {
5
- summary: 'show the current session',
6
- help: 'usage: bp whoami\n\nshows the api endpoint, granted scopes, and when you signed in.',
5
+ summary: 'show local auth status',
6
+ help: `bp whoami
7
+
8
+ Show your current authentication state. Does not contact the server.`,
7
9
  async run(ctx) {
8
- const { out, apiBase } = ctx;
10
+ const pat = (process.env.BIBLIOPLEX_TOKEN || '').trim();
11
+ if (pat) {
12
+ ctx.out.emit({ authenticated: true, method: 'token', origin: ctx.apiOrigin }, () =>
13
+ ctx.out.line('authenticated via BIBLIOPLEX_TOKEN (personal access token)'),
14
+ );
15
+ return EXIT.OK;
16
+ }
9
17
  const creds = loadCredentials();
10
- if (!creds?.accessToken) throw authError();
11
- const data = {
12
- apiBase,
13
- scope: creds.scope,
14
- canWrite: (creds.scope || '').split(/\s+/).includes('collection.write'),
15
- loggedInAt: creds.loggedInAt || null,
16
- accessExpiresAt: creds.accessExpiresAt ? new Date(creds.accessExpiresAt).toISOString() : null,
17
- };
18
- out.emit(data, () => {
19
- out.line('api: ' + apiBase);
20
- out.line('scope: ' + creds.scope);
21
- out.line('access: ' + (data.canWrite ? 'read + write' : 'read only'));
22
- if (creds.loggedInAt) out.line('since: ' + creds.loggedInAt);
23
- });
24
- return 0;
18
+ if (!creds?.accessToken && !creds?.refreshToken) {
19
+ ctx.out.emit({ authenticated: false }, () => ctx.out.line('not logged in — run `bp login`'));
20
+ return EXIT.OK;
21
+ }
22
+ ctx.out.emit(
23
+ {
24
+ authenticated: true,
25
+ method: 'oauth',
26
+ scope: creds.scope || READ_SCOPE,
27
+ origin: creds.origin || ctx.apiOrigin,
28
+ },
29
+ () =>
30
+ ctx.out.line(`logged in · ${creds.scope || READ_SCOPE} · ${creds.origin || ctx.apiOrigin}`),
31
+ );
32
+ return EXIT.OK;
25
33
  },
26
34
  };
package/src/constants.mjs CHANGED
@@ -3,17 +3,39 @@ import { join, dirname } from 'node:path';
3
3
  import { readFileSync } from 'node:fs';
4
4
  import { fileURLToPath } from 'node:url';
5
5
 
6
- const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'));
6
+ const pkg = JSON.parse(
7
+ readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'),
8
+ );
7
9
 
8
10
  export const VERSION = pkg.version;
9
- export const DEFAULT_API_BASE = 'https://api.bensonperry.com';
10
- export const CLI_CLIENT_ID = 'biblioplex-cli';
11
+
12
+ // The Biblioplex API origin (MCP + OAuth live here). Override for local
13
+ // `wrangler dev` via BIBLIOPLEX_API_ORIGIN (e.g. http://localhost:8765).
14
+ export const DEFAULT_API_ORIGIN = 'https://biblioplex-api.bensonperry.com';
15
+
16
+ // Sent as client_name during dynamic OAuth client registration.
11
17
  export const CLIENT_LABEL = 'biblioplex-cli';
18
+
12
19
  export const READ_SCOPE = 'collection.read';
13
20
  export const WRITE_SCOPE = 'collection.write';
14
21
 
15
- // Process exit codes. 3 = auth so scripts can detect "needs `bp login`".
16
- export const EXIT = { OK: 0, ERROR: 1, USAGE: 2, AUTH: 3, RATE_LIMIT: 4 };
22
+ // Process exit codes the CLI's contract for scripts and agents:
23
+ // 0 ok
24
+ // 1 generic error
25
+ // 2 usage / parse error / needs input
26
+ // 3 auth — not logged in, or insufficient scope (run `bp login [--write]`)
27
+ // 4 not found
28
+ // 5 conflict — unsafe undo, or revision mismatch (re-run after re-preview)
29
+ // 75 temp-fail — rate limited or retryable server error (EX_TEMPFAIL)
30
+ export const EXIT = {
31
+ OK: 0,
32
+ ERROR: 1,
33
+ USAGE: 2,
34
+ AUTH: 3,
35
+ NOT_FOUND: 4,
36
+ CONFLICT: 5,
37
+ TEMPFAIL: 75,
38
+ };
17
39
 
18
40
  export function configDir() {
19
41
  if (process.env.BIBLIOPLEX_CONFIG_DIR) return process.env.BIBLIOPLEX_CONFIG_DIR;
@@ -21,5 +43,10 @@ export function configDir() {
21
43
  return join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'biblioplex');
22
44
  }
23
45
 
24
- export function credentialsPath() { return join(configDir(), 'credentials.json'); }
25
- export function configPath() { return join(configDir(), 'config.json'); }
46
+ export function credentialsPath() {
47
+ return join(configDir(), 'credentials.json');
48
+ }
49
+
50
+ export function configPath() {
51
+ return join(configDir(), 'config.json');
52
+ }
package/src/csv.mjs ADDED
@@ -0,0 +1,71 @@
1
+ // Minimal, dependency-free CSV (RFC-4180-ish): quotes fields containing
2
+ // comma/quote/newline, doubles embedded quotes, and round-trips cleanly. Good
3
+ // enough for collection import/export; the CLI deliberately owns this rather
4
+ // than depend on the web app's adapters.
5
+
6
+ export function toCsv(rows, columns) {
7
+ const cols = columns && columns.length ? columns : rows[0] ? Object.keys(rows[0]) : [];
8
+ const esc = (v) => {
9
+ const s =
10
+ v == null
11
+ ? ''
12
+ : Array.isArray(v)
13
+ ? v.join(';')
14
+ : typeof v === 'object'
15
+ ? JSON.stringify(v)
16
+ : String(v);
17
+ return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
18
+ };
19
+ const lines = [cols.join(',')];
20
+ for (const r of rows) lines.push(cols.map((c) => esc(r[c])).join(','));
21
+ return lines.join('\n') + '\n';
22
+ }
23
+
24
+ export function fromCsv(text) {
25
+ const records = [];
26
+ let field = '';
27
+ let record = [];
28
+ let inQuotes = false;
29
+ let started = false;
30
+ const pushField = () => {
31
+ record.push(field);
32
+ field = '';
33
+ };
34
+ const pushRecord = () => {
35
+ if (started || record.length) {
36
+ pushField();
37
+ records.push(record);
38
+ record = [];
39
+ started = false;
40
+ }
41
+ };
42
+ for (let i = 0; i < text.length; i += 1) {
43
+ const ch = text[i];
44
+ started = true;
45
+ if (inQuotes) {
46
+ if (ch === '"') {
47
+ if (text[i + 1] === '"') {
48
+ field += '"';
49
+ i += 1;
50
+ } else {
51
+ inQuotes = false;
52
+ }
53
+ } else {
54
+ field += ch;
55
+ }
56
+ continue;
57
+ }
58
+ if (ch === '"') inQuotes = true;
59
+ else if (ch === ',') pushField();
60
+ else if (ch === '\r') continue;
61
+ else if (ch === '\n') pushRecord();
62
+ else field += ch;
63
+ }
64
+ pushRecord();
65
+ if (!records.length) return [];
66
+ const header = records[0].map((h) => h.trim());
67
+ return records
68
+ .slice(1)
69
+ .filter((r) => r.some((c) => c !== ''))
70
+ .map((r) => Object.fromEntries(header.map((h, idx) => [h, r[idx] ?? ''])));
71
+ }
package/src/errors.mjs CHANGED
@@ -13,5 +13,16 @@ export class CliError extends Error {
13
13
  }
14
14
 
15
15
  export const usageError = (message) => new CliError(message, EXIT.USAGE);
16
- export const authError = (message = 'not logged in — run `bp login`') => new CliError(message, EXIT.AUTH);
17
- export const rateLimitError = (message = 'rate limited by the server try again shortly') => new CliError(message, EXIT.RATE_LIMIT);
16
+
17
+ export const authError = (message = 'not logged inrun `bp login`') =>
18
+ new CliError(message, EXIT.AUTH);
19
+
20
+ export const scopeError = (message = 'this needs write access — run `bp login --write`') =>
21
+ new CliError(message, EXIT.AUTH);
22
+
23
+ export const notFoundError = (message) => new CliError(message, EXIT.NOT_FOUND);
24
+
25
+ export const conflictError = (message) => new CliError(message, EXIT.CONFLICT);
26
+
27
+ export const rateLimitError = (message = 'rate limited by the server — try again shortly') =>
28
+ new CliError(message, EXIT.TEMPFAIL);
package/src/mcp.mjs ADDED
@@ -0,0 +1,227 @@
1
+ // JSON-RPC 2.0 client + session for the Biblioplex MCP endpoint (POST /mcp).
2
+ // All reads and writes go through here. Auth is a Bearer token — either a
3
+ // personal access token (BIBLIOPLEX_TOKEN, for headless/CI) or stored OAuth
4
+ // credentials, which are refreshed on demand. The whole endpoint is Bearer-
5
+ // gated and stateless (no MCP session handshake), so each tools/call is a
6
+ // standalone authenticated POST.
7
+ import { openSync, closeSync, unlinkSync, statSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { loadCredentials, saveCredentials } from './store.mjs';
10
+ import { configDir, WRITE_SCOPE } from './constants.mjs';
11
+ import { discover, refreshTokens } from './oauth.mjs';
12
+ import { CliError, authError, scopeError, rateLimitError } from './errors.mjs';
13
+
14
+ const REFRESH_SKEW_MS = 60_000;
15
+
16
+ const delay = (ms) => new Promise((r) => setTimeout(r, ms));
17
+
18
+ function scopeHasWrite(scope) {
19
+ return typeof scope === 'string' && scope.split(/\s+/).includes(WRITE_SCOPE);
20
+ }
21
+
22
+ // ---- refresh lock: serialize refreshes across concurrent CLI processes, so a
23
+ // single-use rotating refresh token can't be burned by a race (which would
24
+ // revoke the whole token family server-side). ----
25
+ function lockPath() {
26
+ return join(configDir(), 'refresh.lock');
27
+ }
28
+
29
+ async function withRefreshLock(fn, { timeoutMs = 15_000 } = {}) {
30
+ const path = lockPath();
31
+ const start = Date.now();
32
+ let fd;
33
+ for (;;) {
34
+ try {
35
+ fd = openSync(path, 'wx');
36
+ break;
37
+ } catch (e) {
38
+ if (e.code !== 'EEXIST') throw e;
39
+ // Steal an obviously-stale lock (a crashed prior refresh).
40
+ try {
41
+ if (Date.now() - statSync(path).mtimeMs > 30_000) {
42
+ unlinkSync(path);
43
+ continue;
44
+ }
45
+ } catch {
46
+ /* lock vanished — retry the open */
47
+ }
48
+ if (Date.now() - start > timeoutMs) {
49
+ throw new CliError('timed out waiting for a concurrent `bp` refresh — try again', 75);
50
+ }
51
+ await delay(120);
52
+ }
53
+ }
54
+ try {
55
+ return await fn();
56
+ } finally {
57
+ try {
58
+ closeSync(fd);
59
+ } catch {
60
+ /* already closed */
61
+ }
62
+ try {
63
+ unlinkSync(path);
64
+ } catch {
65
+ /* already gone */
66
+ }
67
+ }
68
+ }
69
+
70
+ async function ensureFreshToken(origin, creds, { force = false } = {}) {
71
+ return withRefreshLock(async () => {
72
+ // Re-read inside the lock: another process may have just refreshed.
73
+ const latest = loadCredentials() || creds;
74
+ if (
75
+ !force &&
76
+ latest.accessToken &&
77
+ latest.expiresAt &&
78
+ Date.now() < latest.expiresAt - REFRESH_SKEW_MS
79
+ ) {
80
+ return latest;
81
+ }
82
+ if (!latest.refreshToken) throw authError('session expired — run `bp login`');
83
+ const tokenEndpoint = latest.tokenEndpoint || (await discover(origin)).tokenEndpoint;
84
+ let tokens;
85
+ try {
86
+ tokens = await refreshTokens({
87
+ tokenEndpoint,
88
+ clientId: latest.clientId,
89
+ refreshToken: latest.refreshToken,
90
+ });
91
+ } catch (e) {
92
+ if (e.invalidClient) throw authError('your CLI registration expired — run `bp login` again');
93
+ throw e;
94
+ }
95
+ const updated = {
96
+ ...latest,
97
+ accessToken: tokens.access_token,
98
+ refreshToken: tokens.refresh_token || latest.refreshToken,
99
+ expiresAt: Date.now() + (tokens.expires_in ? tokens.expires_in * 1000 : 3_600_000),
100
+ scope: tokens.scope || latest.scope,
101
+ tokenEndpoint,
102
+ };
103
+ saveCredentials(updated);
104
+ return updated;
105
+ });
106
+ }
107
+
108
+ function mapJsonRpcError(error) {
109
+ const code = error?.code;
110
+ const message = error?.message || 'request failed';
111
+ if (code === -32003) {
112
+ const need = error?.data?.requiredScope || WRITE_SCOPE;
113
+ return scopeError(`needs ${need} — run \`bp login --write\` (or use a write-scoped token)`);
114
+ }
115
+ if (code === -32601) return new CliError('unsupported by this server: ' + message, 2);
116
+ if (code === -32602) return new CliError('invalid request: ' + message, 2);
117
+ if (code === -32000) return rateLimitError('server busy — ' + message);
118
+ return new CliError(message, 1, error?.data ? { data: error.data } : {});
119
+ }
120
+
121
+ // MCP tools/call returns { content:[{type:'text',text}], structuredContent }.
122
+ // Prefer structuredContent; fall back to parsing the text content.
123
+ function readStructured(result) {
124
+ if (result && result.structuredContent !== undefined) return result.structuredContent;
125
+ const text = result?.content?.find?.((c) => c?.type === 'text')?.text;
126
+ if (text) {
127
+ try {
128
+ return JSON.parse(text);
129
+ } catch {
130
+ return { text };
131
+ }
132
+ }
133
+ return result ?? {};
134
+ }
135
+
136
+ async function backoff(res, attempt) {
137
+ const ra = Number(res.headers.get('retry-after'));
138
+ const ms = Number.isFinite(ra) && ra > 0 ? ra * 1000 : Math.min(8000, 500 * 2 ** attempt);
139
+ await delay(ms);
140
+ }
141
+
142
+ // Build an authenticated MCP session. Resolves auth eagerly enough to fail fast
143
+ // with a clear message, but defers the network until a tool is called.
144
+ export async function createSession({ origin, requireWrite = false } = {}) {
145
+ const mcpUrl = origin + '/mcp';
146
+ const pat = (process.env.BIBLIOPLEX_TOKEN || '').trim();
147
+
148
+ let kind;
149
+ let getToken;
150
+ let refresh = null;
151
+
152
+ if (pat) {
153
+ kind = 'pat';
154
+ getToken = async () => pat; // a PAT's scope is unknown client-side; -32003 surfaces it
155
+ } else {
156
+ kind = 'oauth';
157
+ let current = loadCredentials();
158
+ if (!current?.accessToken && !current?.refreshToken) throw authError();
159
+ if (requireWrite && current.scope && !scopeHasWrite(current.scope)) throw scopeError();
160
+ getToken = async () => {
161
+ if (
162
+ current.accessToken &&
163
+ current.expiresAt &&
164
+ Date.now() < current.expiresAt - REFRESH_SKEW_MS
165
+ ) {
166
+ return current.accessToken;
167
+ }
168
+ current = await ensureFreshToken(origin, current);
169
+ return current.accessToken;
170
+ };
171
+ refresh = async () => {
172
+ current = await ensureFreshToken(origin, current, { force: true });
173
+ return current.accessToken;
174
+ };
175
+ }
176
+
177
+ let rpcId = 0;
178
+ async function call(name, args = {}) {
179
+ let token = await getToken();
180
+ for (let attempt = 0; ; attempt++) {
181
+ let res;
182
+ try {
183
+ res = await fetch(mcpUrl, {
184
+ method: 'POST',
185
+ headers: {
186
+ 'Content-Type': 'application/json',
187
+ Accept: 'application/json',
188
+ Authorization: 'Bearer ' + token,
189
+ },
190
+ body: JSON.stringify({
191
+ jsonrpc: '2.0',
192
+ id: ++rpcId,
193
+ method: 'tools/call',
194
+ params: { name, arguments: args },
195
+ }),
196
+ });
197
+ } catch (e) {
198
+ throw new CliError(`could not reach ${origin}: ${e.message}`, 75);
199
+ }
200
+
201
+ if (res.status === 401) {
202
+ if (kind === 'oauth' && refresh && attempt === 0) {
203
+ token = await refresh();
204
+ continue;
205
+ }
206
+ throw authError(
207
+ kind === 'pat'
208
+ ? 'personal access token rejected — check BIBLIOPLEX_TOKEN'
209
+ : 'not authorized — run `bp login`',
210
+ );
211
+ }
212
+ if (res.status === 429) {
213
+ if (attempt < 4) {
214
+ await backoff(res, attempt);
215
+ continue;
216
+ }
217
+ throw rateLimitError();
218
+ }
219
+
220
+ const body = await res.json().catch(() => ({}));
221
+ if (body.error) throw mapJsonRpcError(body.error);
222
+ return readStructured(body.result || {});
223
+ }
224
+ }
225
+
226
+ return { call, origin, kind, requireWrite };
227
+ }