biblioplex 0.1.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/README.md +92 -0
- package/bin/biblioplex.mjs +9 -0
- package/package.json +42 -0
- package/src/api.mjs +110 -0
- package/src/args.mjs +77 -0
- package/src/cli.mjs +76 -0
- package/src/commands/add.mjs +57 -0
- package/src/commands/container.mjs +83 -0
- package/src/commands/deck.mjs +52 -0
- package/src/commands/deckExport.mjs +48 -0
- package/src/commands/edit.mjs +45 -0
- package/src/commands/export.mjs +56 -0
- package/src/commands/import.mjs +94 -0
- package/src/commands/index.mjs +40 -0
- package/src/commands/login.mjs +48 -0
- package/src/commands/logout.mjs +16 -0
- package/src/commands/ls.mjs +31 -0
- package/src/commands/move.mjs +41 -0
- package/src/commands/rm.mjs +37 -0
- package/src/commands/search.mjs +42 -0
- package/src/commands/show.mjs +33 -0
- package/src/commands/summary.mjs +34 -0
- package/src/commands/tag.mjs +40 -0
- package/src/commands/undo.mjs +34 -0
- package/src/commands/whoami.mjs +26 -0
- package/src/commands/writeHelpers.mjs +82 -0
- package/src/constants.mjs +25 -0
- package/src/errors.mjs +17 -0
- package/src/mutate.mjs +76 -0
- package/src/oauth.mjs +129 -0
- package/src/output.mjs +79 -0
- package/src/pkce.mjs +16 -0
- package/src/render.mjs +35 -0
- package/src/scryfall.mjs +81 -0
- package/src/snapshot.mjs +77 -0
- package/src/store.mjs +53 -0
- package/vendor/README.md +20 -0
- package/vendor/adapters.js +443 -0
- package/vendor/collection.js +665 -0
- package/vendor/deckExport.js +219 -0
- package/vendor/importMerge.js +22 -0
- package/vendor/importParsing.js +119 -0
- package/vendor/portableArchive.js +151 -0
- package/vendor/searchCore.js +223 -0
- package/vendor/state.js +91 -0
- package/vendor/storageSchema.js +75 -0
- package/vendor/syncOps.js +188 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { normalizeLocation, locationKey, makeContainer } from '../../vendor/collection.js';
|
|
2
|
+
import { saveUndo } from '../store.mjs';
|
|
3
|
+
import { CliError } from '../errors.mjs';
|
|
4
|
+
import { strFlag, intFlag } from '../args.mjs';
|
|
5
|
+
|
|
6
|
+
export function requireWrite(session) {
|
|
7
|
+
if (!session.hasScope('collection.write')) {
|
|
8
|
+
throw new CliError('this session is read-only — run `bp login --write`', 3);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function parseLocationFlag(value) {
|
|
13
|
+
return value ? normalizeLocation(value) : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Build a stack selector from the card name (positional) + qualifier flags.
|
|
17
|
+
export function selectorFrom(flags, nameParts) {
|
|
18
|
+
const name = (nameParts || []).join(' ').trim();
|
|
19
|
+
return {
|
|
20
|
+
name: name || null,
|
|
21
|
+
scryfallId: strFlag(flags, 'scryfall-id', 'id'),
|
|
22
|
+
set: strFlag(flags, 'set', 's'),
|
|
23
|
+
cn: strFlag(flags, 'cn'),
|
|
24
|
+
finish: strFlag(flags, 'finish'),
|
|
25
|
+
condition: strFlag(flags, 'condition', 'cond'),
|
|
26
|
+
location: parseLocationFlag(strFlag(flags, 'location', 'loc')),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function matchStack(c, sel) {
|
|
31
|
+
if (sel.scryfallId && c.scryfallId !== sel.scryfallId) return false;
|
|
32
|
+
if (sel.name && !(String(c.resolvedName || c.name || '').toLowerCase().includes(sel.name.toLowerCase()))) return false;
|
|
33
|
+
if (sel.set && String(c.setCode || '').toLowerCase() !== sel.set.toLowerCase()) return false;
|
|
34
|
+
if (sel.cn && String(c.cn) !== String(sel.cn)) return false;
|
|
35
|
+
if (sel.finish && c.finish !== sel.finish) return false;
|
|
36
|
+
if (sel.condition && c.condition !== sel.condition) return false;
|
|
37
|
+
if (sel.location && locationKey(c.location) !== locationKey(sel.location)) return false;
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function findStacks(collection, sel) {
|
|
42
|
+
return (collection || []).filter(c => matchStack(c, sel));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Resolve a selector to exactly one stack unless `all`, with a helpful
|
|
46
|
+
// disambiguation error listing the candidates.
|
|
47
|
+
export function requireStacks(collection, sel, { all = false } = {}) {
|
|
48
|
+
if (!sel.name && !sel.scryfallId && !sel.set) throw new CliError('specify a card (name, or --set/--cn, or --scryfall-id)');
|
|
49
|
+
const stacks = findStacks(collection, sel);
|
|
50
|
+
if (!stacks.length) throw new CliError('no matching card in your collection');
|
|
51
|
+
if (stacks.length > 1 && !all) {
|
|
52
|
+
const lines = stacks.slice(0, 10).map(c =>
|
|
53
|
+
` ${c.resolvedName || c.name} · ${(c.setCode || '').toUpperCase()} ${c.cn} · ${c.finish} · ${c.condition} · ${locationKey(c.location) || '—'}`);
|
|
54
|
+
throw new CliError(`${stacks.length} stacks match — narrow with --set/--cn/--finish/--condition/--location, or pass --all:\n${lines.join('\n')}`);
|
|
55
|
+
}
|
|
56
|
+
return stacks;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function ensureContainer(draft, location) {
|
|
60
|
+
if (!location) return;
|
|
61
|
+
const key = location.type + ':' + location.name;
|
|
62
|
+
if (!draft.app.containers) draft.app.containers = {};
|
|
63
|
+
if (!draft.app.containers[key]) draft.app.containers[key] = makeContainer({ type: location.type, name: location.name });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function persistUndo(result) {
|
|
67
|
+
if (result?.undo && !result.dryRun && !result.noop) saveUndo(result.undo);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function printWrite(out, result, humanFn) {
|
|
71
|
+
if (result.noop) { out.emit({ changed: false }, () => out.info('no change.')); return; }
|
|
72
|
+
if (result.dryRun) {
|
|
73
|
+
out.emit(
|
|
74
|
+
{ dryRun: true, ops: result.ops.length, opTypes: result.ops.map(o => o.type) },
|
|
75
|
+
() => out.info(out.c.dim(`dry run — ${result.ops.length} op(s): ${result.ops.map(o => o.type).join(', ')}`)),
|
|
76
|
+
);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
out.emit({ changed: true, revision: result.revision, ops: result.ops.length, ...(result.meta || {}) }, humanFn);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export { intFlag };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'));
|
|
7
|
+
|
|
8
|
+
export const VERSION = pkg.version;
|
|
9
|
+
export const DEFAULT_API_BASE = 'https://api.bensonperry.com';
|
|
10
|
+
export const CLI_CLIENT_ID = 'biblioplex-cli';
|
|
11
|
+
export const CLIENT_LABEL = 'biblioplex-cli';
|
|
12
|
+
export const READ_SCOPE = 'collection.read';
|
|
13
|
+
export const WRITE_SCOPE = 'collection.write';
|
|
14
|
+
|
|
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 };
|
|
17
|
+
|
|
18
|
+
export function configDir() {
|
|
19
|
+
if (process.env.BIBLIOPLEX_CONFIG_DIR) return process.env.BIBLIOPLEX_CONFIG_DIR;
|
|
20
|
+
if (process.platform === 'win32') return join(process.env.APPDATA || homedir(), 'biblioplex');
|
|
21
|
+
return join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'biblioplex');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function credentialsPath() { return join(configDir(), 'credentials.json'); }
|
|
25
|
+
export function configPath() { return join(configDir(), 'config.json'); }
|
package/src/errors.mjs
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { EXIT } from './constants.mjs';
|
|
2
|
+
|
|
3
|
+
// A controlled, user-facing failure. The dispatcher prints it (respecting
|
|
4
|
+
// --json) and exits with `code`. Anything else that throws is an unexpected
|
|
5
|
+
// bug and exits 1 with a stack trace under --verbose.
|
|
6
|
+
export class CliError extends Error {
|
|
7
|
+
constructor(message, code = EXIT.ERROR, extra = {}) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = 'CliError';
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.extra = extra;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
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);
|
package/src/mutate.mjs
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Unified write path. Every mutating command (add/rm/move/edit/tag/container/
|
|
2
|
+
// import) expresses itself as a pure transform of the snapshot; we diff before
|
|
3
|
+
// vs after into granular sync ops (reusing the app's diffSyncSnapshots) and push
|
|
4
|
+
// once with optimistic concurrency, retrying on a revision conflict by
|
|
5
|
+
// re-reading and re-diffing. This never emits snapshot.replace/history/ui ops,
|
|
6
|
+
// so it stays within what a CLI OAuth token is allowed to push.
|
|
7
|
+
import { diffSyncSnapshots } from '../vendor/syncOps.js';
|
|
8
|
+
import { collectionKey } from '../vendor/collection.js';
|
|
9
|
+
import { mergeIntoCollection } from '../vendor/importMerge.js';
|
|
10
|
+
import { emptySnapshot } from './snapshot.mjs';
|
|
11
|
+
import { CliError } from './errors.mjs';
|
|
12
|
+
|
|
13
|
+
const clone = (v) => JSON.parse(JSON.stringify(v));
|
|
14
|
+
|
|
15
|
+
// Capture the before-values of exactly the keys a set of ops touches, so `bp
|
|
16
|
+
// undo` can restore them without disturbing anything else.
|
|
17
|
+
function captureUndo(before, ops) {
|
|
18
|
+
const collectionKeys = new Set();
|
|
19
|
+
const containerKeys = new Set();
|
|
20
|
+
for (const op of ops) {
|
|
21
|
+
const p = op.payload || {};
|
|
22
|
+
if (op.type.startsWith('collection.')) {
|
|
23
|
+
for (const k of [p.key, p.beforeKey, p.afterKey]) if (k) collectionKeys.add(k);
|
|
24
|
+
} else if (op.type.startsWith('container.') && p.key) {
|
|
25
|
+
containerKeys.add(p.key);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const collection = {};
|
|
29
|
+
for (const key of collectionKeys) {
|
|
30
|
+
collection[key] = (before.app.collection || []).find(e => collectionKey(e) === key) || null;
|
|
31
|
+
}
|
|
32
|
+
const containers = {};
|
|
33
|
+
for (const key of containerKeys) containers[key] = (before.app.containers || {})[key] || null;
|
|
34
|
+
return { collection, containers };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function loadSnapshot(session) {
|
|
38
|
+
const boot = await session.bootstrap();
|
|
39
|
+
return {
|
|
40
|
+
snapshot: boot.hasCloudData && boot.snapshot ? boot.snapshot : emptySnapshot(),
|
|
41
|
+
revision: boot.revision || 0,
|
|
42
|
+
collectionId: boot.collectionId || null,
|
|
43
|
+
hasCloudData: !!boot.hasCloudData,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// mutate(draft) edits the snapshot clone in place. It may return metadata (e.g.
|
|
48
|
+
// a human summary) and may throw a CliError to abort. Returns { ops, revision,
|
|
49
|
+
// snapshot, noop, meta }. With dryRun, computes ops but does not push.
|
|
50
|
+
export async function applyMutation(session, mutate, { attempts = 4, dryRun = false } = {}) {
|
|
51
|
+
let lastConflict;
|
|
52
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
53
|
+
const { snapshot, revision } = await loadSnapshot(session);
|
|
54
|
+
const before = clone(snapshot);
|
|
55
|
+
const draft = clone(snapshot);
|
|
56
|
+
const meta = mutate(draft) || {};
|
|
57
|
+
// Coalesce any stacks the mutation made collide on the same collectionKey
|
|
58
|
+
// (e.g. moving a copy into a container that already holds it, or editing
|
|
59
|
+
// finish/condition to match another stack). Without this, diffSyncSnapshots
|
|
60
|
+
// keys before/after by collectionKey and would silently drop the colliding
|
|
61
|
+
// entry, losing quantity. mergeIntoCollection sums qty and unions tags.
|
|
62
|
+
draft.app.collection = mergeIntoCollection([], draft.app.collection || []);
|
|
63
|
+
const ops = diffSyncSnapshots(before, draft);
|
|
64
|
+
if (!ops.length) return { ops: [], revision, snapshot: draft, noop: true, meta };
|
|
65
|
+
const undo = captureUndo(before, ops);
|
|
66
|
+
if (dryRun) return { ops, revision, snapshot: draft, dryRun: true, meta, undo };
|
|
67
|
+
try {
|
|
68
|
+
const result = await session.push({ ops, baseRevision: revision });
|
|
69
|
+
return { ops, revision: result.revision, snapshot: result.snapshot, meta, undo: { ...undo, resultRevision: result.revision } };
|
|
70
|
+
} catch (err) {
|
|
71
|
+
if (err.conflict && attempt < attempts - 1) { lastConflict = err; continue; }
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
throw lastConflict || new CliError('could not apply change after several retries');
|
|
76
|
+
}
|
package/src/oauth.mjs
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
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.
|
|
4
|
+
import http from 'node:http';
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { createPkce, randomState } from './pkce.mjs';
|
|
7
|
+
import { CLI_CLIENT_ID } from './constants.mjs';
|
|
8
|
+
import { CliError } from './errors.mjs';
|
|
9
|
+
|
|
10
|
+
const SUCCESS_HTML = `<!doctype html><meta charset="utf-8"><title>biblioplex</title>
|
|
11
|
+
<body style="font-family:system-ui;max-width:30rem;margin:4rem auto;text-align:center;color:#222">
|
|
12
|
+
<h1 style="font-weight:500">you're signed in</h1>
|
|
13
|
+
<p>biblioplex cli has your authorization. you can close this tab and return to the terminal.</p>
|
|
14
|
+
</body>`;
|
|
15
|
+
|
|
16
|
+
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]];
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
execFile(cmds[0], cmds[1], (err) => resolve(!err));
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function postForm(base, path, body, fetchImpl) {
|
|
28
|
+
return fetchImpl(base + path, {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
31
|
+
body: JSON.stringify(body),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
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);
|
|
43
|
+
const data = await res.json().catch(() => ({}));
|
|
44
|
+
if (!res.ok) throw new CliError('token exchange failed: ' + (data.error_description || data.error || res.status));
|
|
45
|
+
return data;
|
|
46
|
+
}
|
|
47
|
+
|
|
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);
|
|
54
|
+
const data = await res.json().catch(() => ({}));
|
|
55
|
+
if (!res.ok) throw new CliError('session expired — run `bp login` again', 3);
|
|
56
|
+
return data;
|
|
57
|
+
}
|
|
58
|
+
|
|
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 */ }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Runs the interactive browser login. Returns the raw /token response.
|
|
66
|
+
export async function login({
|
|
67
|
+
base, scope, out, noBrowser = false,
|
|
68
|
+
openBrowser = defaultOpenBrowser, fetchImpl = fetch, timeoutMs = 300000,
|
|
69
|
+
}) {
|
|
70
|
+
const { verifier, challenge, method } = createPkce();
|
|
71
|
+
const state = randomState();
|
|
72
|
+
|
|
73
|
+
const { code, redirectUri } = await new Promise((resolve, reject) => {
|
|
74
|
+
let redirect = '';
|
|
75
|
+
let timer;
|
|
76
|
+
function cleanup() { clearTimeout(timer); try { server.close(); } catch {} }
|
|
77
|
+
|
|
78
|
+
const server = http.createServer((req, res) => {
|
|
79
|
+
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
|
+
|
|
83
|
+
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.
|
|
86
|
+
if (params.get('state') !== state) {
|
|
87
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
88
|
+
res.end('state mismatch');
|
|
89
|
+
cleanup(); reject(new CliError('login aborted: OAuth state mismatch (possible CSRF)', 3));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (params.get('error')) {
|
|
93
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
94
|
+
res.end('authorization failed: ' + params.get('error'));
|
|
95
|
+
cleanup(); reject(new CliError('authorization denied: ' + params.get('error'), 3));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
99
|
+
res.end(SUCCESS_HTML);
|
|
100
|
+
const code = params.get('code');
|
|
101
|
+
cleanup();
|
|
102
|
+
resolve({ code, redirectUri: redirect });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
server.on('error', (e) => { cleanup(); reject(new CliError('could not start local login listener: ' + e.message)); });
|
|
106
|
+
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)');
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (!code) throw new CliError('no authorization code received', 3);
|
|
128
|
+
return exchangeCode({ base, code, verifier, redirectUri, fetchImpl });
|
|
129
|
+
}
|
package/src/output.mjs
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Output layer. Two contracts at once:
|
|
2
|
+
// - humans get aligned tables / lines on stdout, colour only on a TTY
|
|
3
|
+
// - agents/scripts get a stable JSON envelope on stdout with --json
|
|
4
|
+
// Diagnostics (spinners, "logged in as…") always go to stderr so --json stdout
|
|
5
|
+
// stays a single clean JSON document.
|
|
6
|
+
|
|
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',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function createOutput({ json = false, color = true } = {}) {
|
|
13
|
+
const useColor = color && !!process.stdout.isTTY && !process.env.NO_COLOR;
|
|
14
|
+
const paint = (code, s) => (useColor ? code + s + COLORS.reset : String(s));
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
json,
|
|
18
|
+
useColor,
|
|
19
|
+
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),
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
// Primary success payload. JSON mode prints one envelope; otherwise calls
|
|
29
|
+
// humanFn to render whatever shape fits (table, lines, etc.).
|
|
30
|
+
emit(data, humanFn) {
|
|
31
|
+
if (json) {
|
|
32
|
+
process.stdout.write(JSON.stringify({ ok: true, data }, null, 2) + '\n');
|
|
33
|
+
} else if (humanFn) {
|
|
34
|
+
humanFn();
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
// A plain stdout line (human mode). No-op under --json.
|
|
39
|
+
line(str = '') {
|
|
40
|
+
if (!json) process.stdout.write(str + '\n');
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
// Diagnostics: always stderr, never part of the JSON document.
|
|
44
|
+
info(str) {
|
|
45
|
+
process.stderr.write(str + '\n');
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
// Render rows as an aligned table to stdout (human mode only).
|
|
49
|
+
table(columns, rows) {
|
|
50
|
+
if (json || !rows.length) return;
|
|
51
|
+
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');
|
|
61
|
+
for (const row of rows) process.stdout.write(fmt(row) + '\n');
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
// Raw text to stdout (e.g. CSV / deck export). Printed in both modes since
|
|
65
|
+
// it IS the requested artifact.
|
|
66
|
+
raw(str) {
|
|
67
|
+
process.stdout.write(str.endsWith('\n') ? str : str + '\n');
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
error(err) {
|
|
71
|
+
const message = err?.message || String(err);
|
|
72
|
+
if (json) {
|
|
73
|
+
process.stdout.write(JSON.stringify({ ok: false, error: { message, ...(err?.extra || {}) } }, null, 2) + '\n');
|
|
74
|
+
} else {
|
|
75
|
+
process.stderr.write(this.c.red('error: ') + message + '\n');
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
package/src/pkce.mjs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// PKCE (RFC 7636, S256) + CSRF state, using only node:crypto.
|
|
2
|
+
import { randomBytes, createHash } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
function base64url(buf) {
|
|
5
|
+
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function createPkce() {
|
|
9
|
+
const verifier = base64url(randomBytes(32));
|
|
10
|
+
const challenge = base64url(createHash('sha256').update(verifier).digest());
|
|
11
|
+
return { verifier, challenge, method: 'S256' };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function randomState() {
|
|
15
|
+
return base64url(randomBytes(24));
|
|
16
|
+
}
|
package/src/render.mjs
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
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' },
|
|
17
|
+
];
|
|
18
|
+
|
|
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
|
+
];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function cardsToCsv(cards, format = 'canonical') {
|
|
33
|
+
const adapter = getAdapter(format) || canonicalAdapter;
|
|
34
|
+
return adapter.export(cards);
|
|
35
|
+
}
|
package/src/scryfall.mjs
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Direct Scryfall lookups for resolving printings on `add` and `import`.
|
|
2
|
+
// Direct (not via the server's MCP tool) so bulk imports aren't throttled by the
|
|
3
|
+
// worker's 60/60s MCP limit; we apply our own polite delay instead. Scryfall is
|
|
4
|
+
// already the product's card-data source.
|
|
5
|
+
import { VERSION } from './constants.mjs';
|
|
6
|
+
import { getUsdPrice } from '../vendor/collection.js';
|
|
7
|
+
import { CliError } from './errors.mjs';
|
|
8
|
+
|
|
9
|
+
const SCRYFALL = 'https://api.scryfall.com';
|
|
10
|
+
const USER_AGENT = `biblioplex-cli/${VERSION} (+https://biblioplex.bensonperry.com)`;
|
|
11
|
+
|
|
12
|
+
export const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
13
|
+
|
|
14
|
+
async function sfGet(path, fetchImpl) {
|
|
15
|
+
const res = await fetchImpl(SCRYFALL + path, { headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' } });
|
|
16
|
+
if (res.status === 404) return null;
|
|
17
|
+
const data = await res.json().catch(() => ({}));
|
|
18
|
+
if (res.status === 429) throw new CliError('scryfall rate limit — slow down and retry', 4);
|
|
19
|
+
if (!res.ok) throw new CliError('scryfall error: ' + (data.details || res.status));
|
|
20
|
+
return data;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getById(id, fetchImpl = fetch) {
|
|
24
|
+
return sfGet('/cards/' + encodeURIComponent(id), fetchImpl);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getBySetCn(set, cn, fetchImpl = fetch) {
|
|
28
|
+
return sfGet(`/cards/${encodeURIComponent(String(set).toLowerCase())}/${encodeURIComponent(cn)}`, fetchImpl);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getByName(name, { set, fetchImpl = fetch } = {}) {
|
|
32
|
+
const q = new URLSearchParams({ fuzzy: name });
|
|
33
|
+
if (set) q.set('set', String(set).toLowerCase());
|
|
34
|
+
return sfGet('/cards/named?' + q.toString(), fetchImpl);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Resolve the most specific identifier available to a Scryfall card object.
|
|
38
|
+
export async function resolvePrinting({ scryfallId, set, cn, name, fetchImpl = fetch }) {
|
|
39
|
+
let card = null;
|
|
40
|
+
if (scryfallId) card = await getById(scryfallId, fetchImpl);
|
|
41
|
+
else if (set && cn) card = await getBySetCn(set, cn, fetchImpl);
|
|
42
|
+
else if (name) card = await getByName(name, { set, fetchImpl });
|
|
43
|
+
if (!card || card.object === 'error') return null;
|
|
44
|
+
return card;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Map a Scryfall card to the resolved-field shape normalizeCollectionEntry
|
|
48
|
+
// expects (handles double-faced cards for colors/image/oracle).
|
|
49
|
+
export function cardToFields(card, finish = 'normal') {
|
|
50
|
+
const faces = Array.isArray(card.card_faces) ? card.card_faces : [];
|
|
51
|
+
const front = faces[0] || {};
|
|
52
|
+
const back = faces[1];
|
|
53
|
+
const colors = card.colors ?? (faces.length ? [...new Set(faces.flatMap(f => f.colors || []))] : []);
|
|
54
|
+
// Match the web app's resolution: join faces with " // " and use getUsdPrice
|
|
55
|
+
// (which falls back to the non-foil price for foil/etched when the exact-finish
|
|
56
|
+
// price is missing, setting priceFallback).
|
|
57
|
+
const oracleText = card.oracle_text ?? (faces.length ? faces.map(f => f.oracle_text || '').join(' // ') : '');
|
|
58
|
+
const typeLine = card.type_line || (faces.length ? faces.map(f => f.type_line || '').filter(Boolean).join(' // ') : '') || '';
|
|
59
|
+
const { price, fallback } = getUsdPrice(card, finish);
|
|
60
|
+
return {
|
|
61
|
+
scryfallId: card.id,
|
|
62
|
+
setCode: card.set,
|
|
63
|
+
setName: card.set_name,
|
|
64
|
+
cn: card.collector_number,
|
|
65
|
+
name: card.name,
|
|
66
|
+
rarity: card.rarity,
|
|
67
|
+
cmc: card.cmc,
|
|
68
|
+
colors,
|
|
69
|
+
colorIdentity: card.color_identity || [],
|
|
70
|
+
typeLine,
|
|
71
|
+
oracleText,
|
|
72
|
+
legalities: card.legalities || {},
|
|
73
|
+
finishes: card.finishes || [],
|
|
74
|
+
imageUrl: card.image_uris?.normal || front.image_uris?.normal || null,
|
|
75
|
+
backImageUrl: back?.image_uris?.normal || null,
|
|
76
|
+
resolvedName: card.name,
|
|
77
|
+
scryfallUri: card.scryfall_uri || null,
|
|
78
|
+
price,
|
|
79
|
+
priceFallback: fallback,
|
|
80
|
+
};
|
|
81
|
+
}
|
package/src/snapshot.mjs
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Read-side helpers over a bootstrap snapshot, reusing the app's search core.
|
|
2
|
+
import { tokenizeSearch, matchSearch, passesMultiselectFilters, compareCards } from '../vendor/searchCore.js';
|
|
3
|
+
import { locationKey } from '../vendor/collection.js';
|
|
4
|
+
|
|
5
|
+
export function emptySnapshot() {
|
|
6
|
+
return {
|
|
7
|
+
app: { schemaVersion: 1, collection: [], containers: {}, ui: { selectedFormat: '' } },
|
|
8
|
+
history: [],
|
|
9
|
+
shares: [],
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function collectionOf(snapshot) {
|
|
14
|
+
return snapshot?.app?.collection || [];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function containersOf(snapshot) {
|
|
18
|
+
return snapshot?.app?.containers || {};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Filter + sort a collection with the exact app grammar and sort order.
|
|
22
|
+
export function runQuery(collection, query = '', { sort = 'name', dir = 'asc', filters = {} } = {}) {
|
|
23
|
+
const tokens = tokenizeSearch(query || '');
|
|
24
|
+
const filtered = (collection || []).filter(c => matchSearch(c, tokens) && passesMultiselectFilters(c, filters));
|
|
25
|
+
const sign = dir === 'desc' ? -1 : 1;
|
|
26
|
+
return [...filtered].sort((a, b) => sign * compareCards(a, b, sort));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Parse a container reference like "deck:breya", "breya", or "binder:rares".
|
|
30
|
+
// Returns { type, name } where type may be null if unqualified.
|
|
31
|
+
export function parseContainerRef(ref) {
|
|
32
|
+
const m = String(ref || '').trim().match(/^(deck|container|binder|box)\s*[:]\s*(.+)$/i);
|
|
33
|
+
if (m) {
|
|
34
|
+
const t = m[1].toLowerCase();
|
|
35
|
+
return { type: t === 'deck' ? 'deck' : 'container', name: m[2].trim().toLowerCase() };
|
|
36
|
+
}
|
|
37
|
+
return { type: null, name: String(ref || '').trim().toLowerCase() };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function listContainers(snapshot, type = null) {
|
|
41
|
+
const containers = Object.values(containersOf(snapshot));
|
|
42
|
+
const collection = collectionOf(snapshot);
|
|
43
|
+
return containers
|
|
44
|
+
.filter(c => !type || c.type === type)
|
|
45
|
+
.map(c => {
|
|
46
|
+
const key = c.type + ':' + c.name;
|
|
47
|
+
const cards = collection.filter(card => locationKey(card.location) === key);
|
|
48
|
+
const stats = summarize(cards);
|
|
49
|
+
return { type: c.type, name: c.name, unique: stats.unique, total: stats.total, value: stats.value, meta: c.deck || null };
|
|
50
|
+
})
|
|
51
|
+
.sort((a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function findContainer(snapshot, ref) {
|
|
55
|
+
const { type, name } = parseContainerRef(ref);
|
|
56
|
+
const containers = Object.values(containersOf(snapshot));
|
|
57
|
+
const matches = containers.filter(c => c.name === name && (!type || c.type === type));
|
|
58
|
+
return { matches, type, name };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function containerCards(snapshot, container) {
|
|
62
|
+
const key = container.type + ':' + container.name;
|
|
63
|
+
return collectionOf(snapshot).filter(card => locationKey(card.location) === key);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function summarize(collection) {
|
|
67
|
+
let unique = 0;
|
|
68
|
+
let total = 0;
|
|
69
|
+
let value = 0;
|
|
70
|
+
for (const c of collection || []) {
|
|
71
|
+
unique += 1;
|
|
72
|
+
const qty = parseInt(c.qty, 10) || 0;
|
|
73
|
+
total += qty;
|
|
74
|
+
if (typeof c.price === 'number') value += c.price * qty;
|
|
75
|
+
}
|
|
76
|
+
return { unique, total, value: Math.round(value * 100) / 100 };
|
|
77
|
+
}
|