biblioplex 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.
Files changed (57) hide show
  1. package/package.json +23 -27
  2. package/src/__tests__/args.test.js +39 -0
  3. package/src/__tests__/csv.test.js +33 -0
  4. package/src/__tests__/mcp.test.js +84 -0
  5. package/src/__tests__/mutate.test.js +91 -0
  6. package/src/__tests__/render.test.js +103 -0
  7. package/src/args.mjs +29 -6
  8. package/src/cli.mjs +38 -25
  9. package/src/commands/add.mjs +30 -51
  10. package/src/commands/deck.mjs +13 -47
  11. package/src/commands/edit.mjs +26 -38
  12. package/src/commands/export.mjs +57 -41
  13. package/src/commands/history.mjs +24 -0
  14. package/src/commands/import.mjs +52 -80
  15. package/src/commands/index.mjs +44 -20
  16. package/src/commands/login.mjs +29 -38
  17. package/src/commands/logout.mjs +13 -10
  18. package/src/commands/ls.mjs +13 -25
  19. package/src/commands/move.mjs +15 -35
  20. package/src/commands/prices.mjs +21 -0
  21. package/src/commands/recover.mjs +53 -0
  22. package/src/commands/resolve.mjs +37 -0
  23. package/src/commands/rm.mjs +15 -32
  24. package/src/commands/search.mjs +25 -35
  25. package/src/commands/summary.mjs +25 -28
  26. package/src/commands/undo.mjs +13 -28
  27. package/src/commands/value.mjs +30 -0
  28. package/src/commands/whoami.mjs +27 -19
  29. package/src/constants.mjs +34 -7
  30. package/src/csv.mjs +71 -0
  31. package/src/errors.mjs +13 -2
  32. package/src/mcp.mjs +227 -0
  33. package/src/mutate.mjs +142 -67
  34. package/src/oauth.mjs +200 -62
  35. package/src/output.mjs +27 -18
  36. package/src/render.mjs +135 -30
  37. package/src/store.mjs +39 -23
  38. package/README.md +0 -92
  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
package/src/oauth.mjs CHANGED
@@ -1,10 +1,13 @@
1
- // OAuth 2.1 authorization-code + PKCE with an RFC 8252 loopback redirect, plus
2
- // refresh and revoke. Talks to the biblioplex worker's /authorize, /token and
3
- // /revoke endpoints. fetchImpl + openBrowser are injectable for tests.
1
+ // OAuth 2.0 authorization-code + PKCE (S256) against the Biblioplex MCP OAuth
2
+ // endpoints, using dynamic client registration (RFC 7591) and an RFC 8252
3
+ // loopback redirect. The Biblioplex worker has no revocation endpoint, so
4
+ // logout is local-only (see commands/logout.mjs).
5
+ //
6
+ // fetchImpl + openBrowser are injectable for tests.
4
7
  import http from 'node:http';
5
8
  import { execFile } from 'node:child_process';
6
9
  import { createPkce, randomState } from './pkce.mjs';
7
- import { CLI_CLIENT_ID } from './constants.mjs';
10
+ import { CLIENT_LABEL } from './constants.mjs';
8
11
  import { CliError } from './errors.mjs';
9
12
 
10
13
  const SUCCESS_HTML = `<!doctype html><meta charset="utf-8"><title>biblioplex</title>
@@ -14,116 +17,251 @@ const SUCCESS_HTML = `<!doctype html><meta charset="utf-8"><title>biblioplex</ti
14
17
  </body>`;
15
18
 
16
19
  export function defaultOpenBrowser(url) {
17
- const cmds = process.platform === 'darwin'
18
- ? ['open', [url]]
19
- : process.platform === 'win32'
20
- ? ['cmd', ['/c', 'start', '', url]]
21
- : ['xdg-open', [url]];
20
+ const cmds =
21
+ process.platform === 'darwin'
22
+ ? ['open', [url]]
23
+ : process.platform === 'win32'
24
+ ? ['cmd', ['/c', 'start', '', url]]
25
+ : ['xdg-open', [url]];
22
26
  return new Promise((resolve) => {
23
27
  execFile(cmds[0], cmds[1], (err) => resolve(!err));
24
28
  });
25
29
  }
26
30
 
27
- function postForm(base, path, body, fetchImpl) {
28
- return fetchImpl(base + path, {
31
+ function postJson(url, body, fetchImpl) {
32
+ return fetchImpl(url, {
29
33
  method: 'POST',
30
34
  headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
31
35
  body: JSON.stringify(body),
32
36
  });
33
37
  }
34
38
 
35
- export async function exchangeCode({ base, code, verifier, redirectUri, fetchImpl = fetch }) {
36
- const res = await postForm(base, '/token', {
37
- grant_type: 'authorization_code',
38
- code,
39
- client_id: CLI_CLIENT_ID,
40
- redirect_uri: redirectUri,
41
- code_verifier: verifier,
42
- }, fetchImpl);
39
+ // An endpoint advertised in server metadata may be absolute or origin-relative.
40
+ function resolveEndpoint(origin, value, fallbackPath) {
41
+ if (!value) return origin + fallbackPath;
42
+ try {
43
+ return new URL(value).href;
44
+ } catch {
45
+ return origin + (value.startsWith('/') ? value : '/' + value);
46
+ }
47
+ }
48
+
49
+ // GET /.well-known/oauth-authorization-server -> normalized endpoint set.
50
+ export async function discover(origin, fetchImpl = fetch) {
51
+ let res;
52
+ try {
53
+ res = await fetchImpl(origin + '/.well-known/oauth-authorization-server', {
54
+ headers: { Accept: 'application/json' },
55
+ });
56
+ } catch (e) {
57
+ throw new CliError(`could not reach ${origin}: ${e.message}`);
58
+ }
59
+ if (!res.ok) throw new CliError(`OAuth discovery failed (${res.status}) at ${origin}`);
60
+ const meta = await res.json().catch(() => ({}));
61
+ const methods = meta.code_challenge_methods_supported || ['S256'];
62
+ if (!methods.includes('S256')) {
63
+ throw new CliError('server does not advertise PKCE S256 — cannot log in safely');
64
+ }
65
+ return {
66
+ authorizationEndpoint: resolveEndpoint(origin, meta.authorization_endpoint, '/authorize'),
67
+ tokenEndpoint: resolveEndpoint(origin, meta.token_endpoint, '/token'),
68
+ registrationEndpoint: resolveEndpoint(origin, meta.registration_endpoint, '/register'),
69
+ raw: meta,
70
+ };
71
+ }
72
+
73
+ // Dynamic client registration (RFC 7591) for a public, loopback client.
74
+ export async function registerClient({
75
+ registrationEndpoint,
76
+ redirectUris,
77
+ scope,
78
+ fetchImpl = fetch,
79
+ }) {
80
+ const res = await postJson(
81
+ registrationEndpoint,
82
+ {
83
+ client_name: CLIENT_LABEL,
84
+ redirect_uris: redirectUris,
85
+ token_endpoint_auth_method: 'none',
86
+ grant_types: ['authorization_code', 'refresh_token'],
87
+ response_types: ['code'],
88
+ scope,
89
+ },
90
+ fetchImpl,
91
+ );
43
92
  const data = await res.json().catch(() => ({}));
44
- if (!res.ok) throw new CliError('token exchange failed: ' + (data.error_description || data.error || res.status));
93
+ if (!res.ok || !data.client_id) {
94
+ throw new CliError(
95
+ 'client registration failed: ' + (data.error_description || data.error || res.status),
96
+ 3,
97
+ );
98
+ }
45
99
  return data;
46
100
  }
47
101
 
48
- export async function refreshTokens({ base, refreshToken, fetchImpl = fetch }) {
49
- const res = await postForm(base, '/token', {
50
- grant_type: 'refresh_token',
51
- refresh_token: refreshToken,
52
- client_id: CLI_CLIENT_ID,
53
- }, fetchImpl);
102
+ export async function exchangeCode({
103
+ tokenEndpoint,
104
+ clientId,
105
+ code,
106
+ verifier,
107
+ redirectUri,
108
+ fetchImpl = fetch,
109
+ }) {
110
+ const res = await postJson(
111
+ tokenEndpoint,
112
+ {
113
+ grant_type: 'authorization_code',
114
+ code,
115
+ client_id: clientId,
116
+ redirect_uri: redirectUri,
117
+ code_verifier: verifier,
118
+ },
119
+ fetchImpl,
120
+ );
54
121
  const data = await res.json().catch(() => ({}));
55
- if (!res.ok) throw new CliError('session expired — run `bp login` again', 3);
122
+ if (!res.ok) {
123
+ throw new CliError(
124
+ 'token exchange failed: ' + (data.error_description || data.error || res.status),
125
+ 3,
126
+ );
127
+ }
56
128
  return data;
57
129
  }
58
130
 
59
- export async function revokeToken({ base, token, fetchImpl = fetch }) {
60
- try {
61
- await postForm(base, '/revoke', { token }, fetchImpl);
62
- } catch { /* logout is best-effort; local creds are cleared regardless */ }
131
+ // Single-use rotating refresh. On invalid_client the caller should re-login
132
+ // (the dynamic registration likely expired). Distinguishes the error cause so
133
+ // the caller can react.
134
+ export async function refreshTokens({ tokenEndpoint, clientId, refreshToken, fetchImpl = fetch }) {
135
+ const res = await postJson(
136
+ tokenEndpoint,
137
+ { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: clientId },
138
+ fetchImpl,
139
+ );
140
+ const data = await res.json().catch(() => ({}));
141
+ if (!res.ok) {
142
+ const err = new CliError('session expired — run `bp login` again', 3);
143
+ err.oauthError = data.error || String(res.status);
144
+ err.invalidClient = data.error === 'invalid_client';
145
+ throw err;
146
+ }
147
+ return data;
63
148
  }
64
149
 
65
- // Runs the interactive browser login. Returns the raw /token response.
150
+ // Interactive browser login. Starts a loopback listener, registers a public
151
+ // client bound to that exact redirect URI, runs the authorization-code+PKCE
152
+ // flow, and exchanges the code. Returns { tokens, clientId } to persist.
66
153
  export async function login({
67
- base, scope, out, noBrowser = false,
68
- openBrowser = defaultOpenBrowser, fetchImpl = fetch, timeoutMs = 300000,
154
+ origin,
155
+ scope,
156
+ out,
157
+ noBrowser = false,
158
+ openBrowser = defaultOpenBrowser,
159
+ fetchImpl = fetch,
160
+ timeoutMs = 300000,
69
161
  }) {
162
+ const meta = await discover(origin, fetchImpl);
70
163
  const { verifier, challenge, method } = createPkce();
71
164
  const state = randomState();
72
165
 
166
+ let clientId = null;
73
167
  const { code, redirectUri } = await new Promise((resolve, reject) => {
74
168
  let redirect = '';
75
169
  let timer;
76
- function cleanup() { clearTimeout(timer); try { server.close(); } catch {} }
170
+ const cleanup = () => {
171
+ clearTimeout(timer);
172
+ try {
173
+ server.close();
174
+ } catch {
175
+ /* already closed */
176
+ }
177
+ };
77
178
 
78
179
  const server = http.createServer((req, res) => {
79
180
  const url = new URL(req.url, 'http://127.0.0.1');
80
- if (url.pathname === '/favicon.ico') { res.writeHead(204); res.end(); return; }
81
- if (url.pathname !== '/callback') { res.writeHead(404); res.end('not found'); return; }
82
-
181
+ if (url.pathname === '/favicon.ico') {
182
+ res.writeHead(204);
183
+ res.end();
184
+ return;
185
+ }
186
+ if (url.pathname !== '/callback') {
187
+ res.writeHead(404);
188
+ res.end('not found');
189
+ return;
190
+ }
83
191
  const params = url.searchParams;
84
- // Validate CSRF state first — including on error responses — so a stray
85
- // local request can't abort or influence the in-flight login.
192
+ // Validate CSRF state first — even on error responses — so a stray local
193
+ // request cannot abort or influence the in-flight login.
86
194
  if (params.get('state') !== state) {
87
195
  res.writeHead(400, { 'Content-Type': 'text/plain' });
88
196
  res.end('state mismatch');
89
- cleanup(); reject(new CliError('login aborted: OAuth state mismatch (possible CSRF)', 3));
197
+ cleanup();
198
+ reject(new CliError('login aborted: OAuth state mismatch (possible CSRF)', 3));
90
199
  return;
91
200
  }
92
201
  if (params.get('error')) {
93
202
  res.writeHead(400, { 'Content-Type': 'text/plain' });
94
203
  res.end('authorization failed: ' + params.get('error'));
95
- cleanup(); reject(new CliError('authorization denied: ' + params.get('error'), 3));
204
+ cleanup();
205
+ reject(new CliError('authorization denied: ' + params.get('error'), 3));
96
206
  return;
97
207
  }
98
208
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
99
209
  res.end(SUCCESS_HTML);
100
- const code = params.get('code');
210
+ const received = params.get('code');
101
211
  cleanup();
102
- resolve({ code, redirectUri: redirect });
212
+ resolve({ code: received, redirectUri: redirect });
213
+ });
214
+
215
+ server.on('error', (e) => {
216
+ cleanup();
217
+ reject(new CliError('could not start local login listener: ' + e.message));
103
218
  });
104
219
 
105
- server.on('error', (e) => { cleanup(); reject(new CliError('could not start local login listener: ' + e.message)); });
106
220
  server.listen(0, '127.0.0.1', async () => {
107
- redirect = `http://127.0.0.1:${server.address().port}/callback`;
108
- const authUrl = new URL(base + '/authorize');
109
- authUrl.searchParams.set('response_type', 'code');
110
- authUrl.searchParams.set('client_id', CLI_CLIENT_ID);
111
- authUrl.searchParams.set('redirect_uri', redirect);
112
- authUrl.searchParams.set('code_challenge', challenge);
113
- authUrl.searchParams.set('code_challenge_method', method);
114
- authUrl.searchParams.set('scope', scope);
115
- authUrl.searchParams.set('state', state);
116
-
117
- out.info('opening your browser to sign in…');
118
- out.info('if it does not open, visit:\n ' + authUrl.href + '\n');
119
- timer = setTimeout(() => { cleanup(); reject(new CliError('login timed out after ' + Math.round(timeoutMs / 1000) + 's', 3)); }, timeoutMs);
120
- if (!noBrowser) {
121
- const opened = await openBrowser(authUrl.href);
122
- if (!opened) out.info('(could not launch a browser automatically — open the link above)');
221
+ try {
222
+ redirect = `http://127.0.0.1:${server.address().port}/callback`;
223
+ const reg = await registerClient({
224
+ registrationEndpoint: meta.registrationEndpoint,
225
+ redirectUris: [redirect],
226
+ scope,
227
+ fetchImpl,
228
+ });
229
+ clientId = reg.client_id;
230
+
231
+ const authUrl = new URL(meta.authorizationEndpoint);
232
+ authUrl.searchParams.set('response_type', 'code');
233
+ authUrl.searchParams.set('client_id', clientId);
234
+ authUrl.searchParams.set('redirect_uri', redirect);
235
+ authUrl.searchParams.set('code_challenge', challenge);
236
+ authUrl.searchParams.set('code_challenge_method', method);
237
+ authUrl.searchParams.set('scope', scope);
238
+ authUrl.searchParams.set('state', state);
239
+
240
+ out.info('opening your browser to sign in…');
241
+ out.info('if it does not open, visit:\n ' + authUrl.href + '\n');
242
+ timer = setTimeout(() => {
243
+ cleanup();
244
+ reject(new CliError('login timed out after ' + Math.round(timeoutMs / 1000) + 's', 3));
245
+ }, timeoutMs);
246
+ if (!noBrowser) {
247
+ const opened = await openBrowser(authUrl.href);
248
+ if (!opened) out.info('(could not launch a browser automatically — open the link above)');
249
+ }
250
+ } catch (e) {
251
+ cleanup();
252
+ reject(e);
123
253
  }
124
254
  });
125
255
  });
126
256
 
127
257
  if (!code) throw new CliError('no authorization code received', 3);
128
- return exchangeCode({ base, code, verifier, redirectUri, fetchImpl });
258
+ const tokens = await exchangeCode({
259
+ tokenEndpoint: meta.tokenEndpoint,
260
+ clientId,
261
+ code,
262
+ verifier,
263
+ redirectUri,
264
+ fetchImpl,
265
+ });
266
+ return { tokens, clientId, tokenEndpoint: meta.tokenEndpoint };
129
267
  }
package/src/output.mjs CHANGED
@@ -5,8 +5,13 @@
5
5
  // stays a single clean JSON document.
6
6
 
7
7
  const COLORS = {
8
- reset: '\x1b[0m', dim: '\x1b[2m', bold: '\x1b[1m',
9
- red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', cyan: '\x1b[36m',
8
+ reset: '\x1b[0m',
9
+ dim: '\x1b[2m',
10
+ bold: '\x1b[1m',
11
+ red: '\x1b[31m',
12
+ green: '\x1b[32m',
13
+ yellow: '\x1b[33m',
14
+ cyan: '\x1b[36m',
10
15
  };
11
16
 
12
17
  export function createOutput({ json = false, color = true } = {}) {
@@ -17,12 +22,12 @@ export function createOutput({ json = false, color = true } = {}) {
17
22
  json,
18
23
  useColor,
19
24
  c: {
20
- dim: s => paint(COLORS.dim, s),
21
- bold: s => paint(COLORS.bold, s),
22
- red: s => paint(COLORS.red, s),
23
- green: s => paint(COLORS.green, s),
24
- yellow: s => paint(COLORS.yellow, s),
25
- cyan: s => paint(COLORS.cyan, s),
25
+ dim: (s) => paint(COLORS.dim, s),
26
+ bold: (s) => paint(COLORS.bold, s),
27
+ red: (s) => paint(COLORS.red, s),
28
+ green: (s) => paint(COLORS.green, s),
29
+ yellow: (s) => paint(COLORS.yellow, s),
30
+ cyan: (s) => paint(COLORS.cyan, s),
26
31
  },
27
32
 
28
33
  // Primary success payload. JSON mode prints one envelope; otherwise calls
@@ -49,15 +54,17 @@ export function createOutput({ json = false, color = true } = {}) {
49
54
  table(columns, rows) {
50
55
  if (json || !rows.length) return;
51
56
  const widths = columns.map((col, i) =>
52
- Math.max(col.header.length, ...rows.map(r => String(r[i] ?? '').length)));
53
- const fmt = cells => cells
54
- .map((cell, i) => {
55
- const s = String(cell ?? '');
56
- return columns[i].align === 'right' ? s.padStart(widths[i]) : s.padEnd(widths[i]);
57
- })
58
- .join(' ')
59
- .replace(/\s+$/, '');
60
- process.stdout.write(this.c.dim(fmt(columns.map(c => c.header))) + '\n');
57
+ Math.max(col.header.length, ...rows.map((r) => String(r[i] ?? '').length)),
58
+ );
59
+ const fmt = (cells) =>
60
+ cells
61
+ .map((cell, i) => {
62
+ const s = String(cell ?? '');
63
+ return columns[i].align === 'right' ? s.padStart(widths[i]) : s.padEnd(widths[i]);
64
+ })
65
+ .join(' ')
66
+ .replace(/\s+$/, '');
67
+ process.stdout.write(this.c.dim(fmt(columns.map((c) => c.header))) + '\n');
61
68
  for (const row of rows) process.stdout.write(fmt(row) + '\n');
62
69
  },
63
70
 
@@ -70,7 +77,9 @@ export function createOutput({ json = false, color = true } = {}) {
70
77
  error(err) {
71
78
  const message = err?.message || String(err);
72
79
  if (json) {
73
- process.stdout.write(JSON.stringify({ ok: false, error: { message, ...(err?.extra || {}) } }, null, 2) + '\n');
80
+ process.stdout.write(
81
+ JSON.stringify({ ok: false, error: { message, ...(err?.extra || {}) } }, null, 2) + '\n',
82
+ );
74
83
  } else {
75
84
  process.stderr.write(this.c.red('error: ') + message + '\n');
76
85
  }
package/src/render.mjs CHANGED
@@ -1,35 +1,140 @@
1
- // Shared rendering for card lists: aligned table rows and CSV (via the app's
2
- // adapters, so exports round-trip with the web app).
3
- import { formatLocationLabel } from '../vendor/collection.js';
4
- import { getAdapter, canonicalAdapter } from '../vendor/adapters.js';
5
-
6
- const COND_ABBR = { near_mint: 'NM', lightly_played: 'LP', moderately_played: 'MP', heavily_played: 'HP', damaged: 'DMG' };
7
- const FINISH_LABEL = { normal: '', foil: 'foil', etched: 'etched' };
8
-
9
- export const CARD_COLUMNS = [
10
- { header: 'qty', align: 'right' },
11
- { header: 'name' },
12
- { header: 'set' },
13
- { header: 'finish' },
14
- { header: 'cond' },
15
- { header: 'price', align: 'right' },
16
- { header: 'location' },
1
+ // Shape-tolerant rendering for MCP structuredContent results. The --json path
2
+ // always emits the raw structuredContent (the stable, agent-facing contract);
3
+ // human mode renders a best-effort table. Error statuses map to exit codes.
4
+ import { EXIT } from './constants.mjs';
5
+ import { CliError } from './errors.mjs';
6
+
7
+ const ROW_KEYS = [
8
+ 'rows',
9
+ 'results',
10
+ 'items',
11
+ 'cards',
12
+ 'inventory',
13
+ 'containers',
14
+ 'decklist',
15
+ 'changes',
16
+ 'history',
17
+ 'candidates',
18
+ 'data',
17
19
  ];
20
+ const ERROR_STATUSES = new Set([
21
+ 'parse_error',
22
+ 'needs_input',
23
+ 'invalid',
24
+ 'not_found',
25
+ 'unsafe',
26
+ 'error',
27
+ ]);
28
+
29
+ export function pickRows(sc) {
30
+ if (Array.isArray(sc)) return sc;
31
+ if (!sc || typeof sc !== 'object') return [];
32
+ for (const k of ROW_KEYS) if (Array.isArray(sc[k])) return sc[k];
33
+ return [];
34
+ }
35
+
36
+ function statusToExit(status) {
37
+ switch (status) {
38
+ case 'parse_error':
39
+ case 'needs_input':
40
+ case 'invalid':
41
+ return EXIT.USAGE;
42
+ case 'not_found':
43
+ return EXIT.NOT_FOUND;
44
+ case 'unsafe':
45
+ return EXIT.CONFLICT;
46
+ default:
47
+ return EXIT.ERROR;
48
+ }
49
+ }
18
50
 
19
- export function cardRow(c) {
20
- const price = typeof c.price === 'number' ? '$' + c.price.toFixed(2) : '';
21
- return [
22
- String(c.qty ?? ''),
23
- c.resolvedName || c.name || '',
24
- ((c.setCode || '').toUpperCase() + ' ' + (c.cn || '')).trim(),
25
- FINISH_LABEL[c.finish] ?? c.finish ?? '',
26
- COND_ABBR[c.condition] || c.condition || '',
27
- price,
28
- formatLocationLabel(c.location),
29
- ];
51
+ export function fmtMoney(v) {
52
+ if (v == null || v === '') return '';
53
+ const n = typeof v === 'number' ? v : Number(v);
54
+ return Number.isFinite(n) ? '$' + n.toFixed(2) : String(v);
30
55
  }
31
56
 
32
- export function cardsToCsv(cards, format = 'canonical') {
33
- const adapter = getAdapter(format) || canonicalAdapter;
34
- return adapter.export(cards);
57
+ function locLabel(v) {
58
+ if (!v) return '';
59
+ if (typeof v === 'string') return v;
60
+ if (typeof v === 'object') {
61
+ if (v.name) return v.type ? `${v.type}:${v.name}` : v.name;
62
+ return v.type || JSON.stringify(v);
63
+ }
64
+ return String(v);
35
65
  }
66
+
67
+ function cell(value, col) {
68
+ if (value == null) return '';
69
+ if (col.loc) return locLabel(value);
70
+ if (col.money) return fmtMoney(value);
71
+ if (Array.isArray(value)) return value.join(',');
72
+ if (typeof value === 'object') return JSON.stringify(value);
73
+ return String(value);
74
+ }
75
+
76
+ function inferColumns(row) {
77
+ return Object.keys(row)
78
+ .slice(0, 6)
79
+ .map((key) => ({ key, header: key }));
80
+ }
81
+
82
+ export function renderTable(out, rows, columns) {
83
+ if (!rows.length) {
84
+ out.line('(no results)');
85
+ return;
86
+ }
87
+ // Keep only columns actually present in the data, so a projected SELECT or a
88
+ // shape we didn't anticipate still renders sensibly.
89
+ let cols =
90
+ columns && columns.length
91
+ ? columns.filter((c) => c.get || rows.some((r) => r[c.key] != null))
92
+ : [];
93
+ if (!cols.length) cols = inferColumns(rows[0]);
94
+ out.table(
95
+ cols.map((c) => ({ header: c.header || c.key, align: c.align })),
96
+ rows.map((r) => cols.map((c) => cell(c.get ? c.get(r) : r[c.key], c))),
97
+ );
98
+ }
99
+
100
+ // Standard read handler: raise error statuses (mapped to exit codes), else emit
101
+ // JSON or a human table. Returns the exit code.
102
+ export function emitResult(ctx, sc, { columns, render } = {}) {
103
+ const status = sc && typeof sc === 'object' ? sc.status : undefined;
104
+ if (status && ERROR_STATUSES.has(status)) {
105
+ throw new CliError(sc.message || sc.error || `request ${status}`, statusToExit(status), sc);
106
+ }
107
+ ctx.out.emit(sc, () => {
108
+ if (render) render(ctx.out, sc);
109
+ else renderTable(ctx.out, pickRows(sc), columns);
110
+ if (sc && (sc.hasMore || sc.nextCursor)) {
111
+ ctx.out.info(`… more available${sc.nextCursor ? ` (--cursor ${sc.nextCursor})` : ''}`);
112
+ }
113
+ });
114
+ return EXIT.OK;
115
+ }
116
+
117
+ export const INVENTORY_COLUMNS = [
118
+ { key: 'qty', header: 'qty', align: 'right' },
119
+ { key: 'name', header: 'name' },
120
+ { key: 'setCode', header: 'set' },
121
+ { key: 'finish', header: 'finish' },
122
+ { key: 'location', header: 'loc', loc: true },
123
+ { key: 'price', header: 'price', align: 'right', money: true },
124
+ ];
125
+
126
+ export const CONTAINER_COLUMNS = [
127
+ { key: 'name', header: 'name' },
128
+ { key: 'type', header: 'type' },
129
+ // Physical cards in the container live in `stats`; deckListCount is the deck
130
+ // recipe size (0 for storage), so it can't be the "cards" count.
131
+ { header: 'cards', align: 'right', get: (r) => r.stats?.total ?? r.deckListCount ?? 0 },
132
+ { header: 'value', align: 'right', money: true, get: (r) => r.stats?.value },
133
+ ];
134
+
135
+ export const DECKLIST_COLUMNS = [
136
+ { key: 'qty', header: 'qty', align: 'right' },
137
+ { key: 'name', header: 'name' },
138
+ { key: 'board', header: 'board' },
139
+ { key: 'typeLine', header: 'type' },
140
+ ];
package/src/store.mjs CHANGED
@@ -1,22 +1,51 @@
1
1
  // Local persistence for credentials and config. The refresh token is a 30-day
2
2
  // bearer secret, so the file is 0600 inside a 0700 directory on POSIX. (Windows
3
- // relies on per-user ACLs; chmod is skipped there.)
4
- import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync, chmodSync } from 'node:fs';
5
- import { join } from 'node:path';
3
+ // relies on per-user ACLs; chmod is skipped there.) Credential writes are
4
+ // atomic (tmp + rename) so an interrupted refresh can't truncate the file.
5
+ import {
6
+ readFileSync,
7
+ writeFileSync,
8
+ mkdirSync,
9
+ rmSync,
10
+ existsSync,
11
+ chmodSync,
12
+ renameSync,
13
+ } from 'node:fs';
6
14
  import { credentialsPath, configPath, configDir } from './constants.mjs';
7
15
 
8
- const undoPath = () => join(configDir(), 'undo.json');
9
-
10
16
  const POSIX = process.platform !== 'win32';
11
17
 
12
18
  function readJson(path) {
13
- try { return JSON.parse(readFileSync(path, 'utf8')); }
14
- catch { return null; }
19
+ try {
20
+ return JSON.parse(readFileSync(path, 'utf8'));
21
+ } catch {
22
+ return null;
23
+ }
15
24
  }
16
25
 
17
26
  function ensureDir() {
18
27
  mkdirSync(configDir(), { recursive: true });
19
- if (POSIX) { try { chmodSync(configDir(), 0o700); } catch {} }
28
+ if (POSIX) {
29
+ try {
30
+ chmodSync(configDir(), 0o700);
31
+ } catch {
32
+ /* best effort */
33
+ }
34
+ }
35
+ }
36
+
37
+ function writeAtomic(path, data) {
38
+ ensureDir();
39
+ const tmp = path + '.tmp';
40
+ writeFileSync(tmp, data, { mode: 0o600 });
41
+ if (POSIX) {
42
+ try {
43
+ chmodSync(tmp, 0o600);
44
+ } catch {
45
+ /* best effort */
46
+ }
47
+ }
48
+ renameSync(tmp, path);
20
49
  }
21
50
 
22
51
  export function loadCredentials() {
@@ -24,10 +53,7 @@ export function loadCredentials() {
24
53
  }
25
54
 
26
55
  export function saveCredentials(creds) {
27
- ensureDir();
28
- const path = credentialsPath();
29
- writeFileSync(path, JSON.stringify(creds, null, 2) + '\n', { mode: 0o600 });
30
- if (POSIX) { try { chmodSync(path, 0o600); } catch {} }
56
+ writeAtomic(credentialsPath(), JSON.stringify(creds, null, 2) + '\n');
31
57
  }
32
58
 
33
59
  export function clearCredentials() {
@@ -39,15 +65,5 @@ export function loadConfig() {
39
65
  }
40
66
 
41
67
  export function saveConfig(config) {
42
- ensureDir();
43
- writeFileSync(configPath(), JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
68
+ writeAtomic(configPath(), JSON.stringify(config, null, 2) + '\n');
44
69
  }
45
-
46
- export function saveUndo(record) {
47
- ensureDir();
48
- writeFileSync(undoPath(), JSON.stringify(record, null, 2) + '\n', { mode: 0o600 });
49
- }
50
-
51
- export function loadUndo() { return readJson(undoPath()); }
52
-
53
- export function clearUndo() { if (existsSync(undoPath())) rmSync(undoPath()); }