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
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()); }
package/src/api.mjs DELETED
@@ -1,110 +0,0 @@
1
- // Authenticated transport to the biblioplex worker.
2
- // - reads: GET /sync/bootstrap (full snapshot)
3
- // - writes: POST /sync/push (granular ops built locally from a diff)
4
- // - tools: POST /mcp JSON-RPC (search_card_printings, preview_*/apply, undo)
5
- // Refreshes the access token proactively (near expiry) and reactively (on 401),
6
- // persisting rotated tokens. The token is sent only in the Authorization header.
7
- import { refreshTokens } from './oauth.mjs';
8
- import { CliError, authError, rateLimitError } from './errors.mjs';
9
- import { CLIENT_LABEL } from './constants.mjs';
10
-
11
- export class Session {
12
- constructor({ base, credentials, persist, fetchImpl = fetch }) {
13
- this.base = base;
14
- this.creds = credentials;
15
- this.persist = persist || (() => {});
16
- this.fetchImpl = fetchImpl;
17
- this._rpcId = 0;
18
- }
19
-
20
- get scopes() { return (this.creds?.scope || '').split(/\s+/).filter(Boolean); }
21
- hasScope(scope) { return this.scopes.includes(scope); }
22
-
23
- async _refresh() {
24
- if (!this.creds?.refreshToken) throw authError('session expired — run `bp login`');
25
- this._absorb(await refreshTokens({ base: this.base, refreshToken: this.creds.refreshToken, fetchImpl: this.fetchImpl }));
26
- }
27
-
28
- _absorb(tokens) {
29
- this.creds = {
30
- ...this.creds,
31
- accessToken: tokens.access_token,
32
- refreshToken: tokens.refresh_token || this.creds.refreshToken,
33
- accessExpiresAt: Date.now() + (Number(tokens.expires_in) || 3600) * 1000,
34
- scope: tokens.scope || this.creds.scope,
35
- };
36
- this.persist(this.creds);
37
- }
38
-
39
- async _authedFetch(path, opts = {}, retried = false) {
40
- if (!this.creds?.accessToken) throw authError();
41
- if (!retried && this.creds.accessExpiresAt && this.creds.accessExpiresAt - Date.now() < 30000) {
42
- await this._refresh();
43
- }
44
- const headers = { ...(opts.headers || {}), Authorization: 'Bearer ' + this.creds.accessToken };
45
- const res = await this.fetchImpl(this.base + path, { ...opts, headers });
46
- if (res.status === 401 && !retried) { await this._refresh(); return this._authedFetch(path, opts, true); }
47
- if (res.status === 401) throw authError('session expired — run `bp login`');
48
- if (res.status === 403) {
49
- const body = await res.json().catch(() => ({}));
50
- const msg = body.error === 'insufficient_scope'
51
- ? 'this session lacks write access — run `bp login --write`'
52
- : (body.error || 'forbidden');
53
- throw new CliError(msg, 1, body);
54
- }
55
- if (res.status === 429) throw rateLimitError();
56
- return res;
57
- }
58
-
59
- async bootstrap() {
60
- const res = await this._authedFetch('/sync/bootstrap');
61
- if (!res.ok) throw new CliError('could not load collection (' + res.status + ')');
62
- return res.json();
63
- }
64
-
65
- async push({ ops, baseRevision }) {
66
- const res = await this._authedFetch('/sync/push', {
67
- method: 'POST',
68
- headers: { 'Content-Type': 'application/json' },
69
- body: JSON.stringify({ clientId: CLIENT_LABEL, baseRevision, requireBaseRevision: true, ops }),
70
- });
71
- if (res.status === 409) {
72
- const data = await res.json().catch(() => ({}));
73
- const err = new CliError('the cloud collection changed since this command started', 1, data);
74
- err.conflict = true;
75
- throw err;
76
- }
77
- if (!res.ok) {
78
- const data = await res.json().catch(() => ({}));
79
- throw new CliError('push failed: ' + (data.error || res.status), 1, data);
80
- }
81
- return res.json();
82
- }
83
-
84
- async mcp(method, params = {}) {
85
- const id = ++this._rpcId;
86
- const res = await this._authedFetch('/mcp', {
87
- method: 'POST',
88
- headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
89
- body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
90
- });
91
- if (!res.ok) throw new CliError('mcp request failed (' + res.status + ')');
92
- const data = await res.json().catch(() => ({}));
93
- if (data.error) {
94
- if (data.error.code === -32003 || /insufficient_scope/.test(data.error.message || '')) {
95
- throw new CliError('this session lacks write access — run `bp login --write`', 1, data.error);
96
- }
97
- throw new CliError(data.error.message || 'mcp error', 1, data.error);
98
- }
99
- return data.result;
100
- }
101
-
102
- async callTool(name, args = {}) {
103
- const result = await this.mcp('tools/call', { name, arguments: args });
104
- if (result?.isError) {
105
- const text = (result.content || []).map(c => c.text).filter(Boolean).join(' ');
106
- throw new CliError(text || ('tool ' + name + ' failed'), 1, result);
107
- }
108
- return result?.structuredContent ?? result ?? null;
109
- }
110
- }
@@ -1,83 +0,0 @@
1
- import { locationKey } from '../../vendor/collection.js';
2
- import { applyMutation } from '../mutate.mjs';
3
- import { parseContainerRef } from '../snapshot.mjs';
4
- import { boolFlag } from '../args.mjs';
5
- import { requireWrite, parseLocationFlag, ensureContainer, persistUndo, printWrite } from './writeHelpers.mjs';
6
- import { usageError, CliError } from '../errors.mjs';
7
-
8
- function resolveContainer(draft, ref) {
9
- const containers = draft.app.containers || {};
10
- const loc = parseLocationFlag(ref);
11
- if (loc && containers[loc.type + ':' + loc.name]) return { key: loc.type + ':' + loc.name, container: containers[loc.type + ':' + loc.name] };
12
- const name = parseContainerRef(ref).name;
13
- const matches = Object.entries(containers).filter(([, c]) => c.name === name);
14
- if (!matches.length) throw new CliError(`no container "${ref}"`);
15
- if (matches.length > 1) throw new CliError(`"${ref}" is ambiguous — use type:name (${matches.map(([k]) => k).join(', ')})`);
16
- return { key: matches[0][0], container: matches[0][1] };
17
- }
18
-
19
- export default {
20
- summary: 'create / rename / delete a container',
21
- help: [
22
- 'usage: bp container create <type:name> (type = deck|container)',
23
- ' bp container rename <type:name> <new-name>',
24
- ' bp container delete <type:name> [--force] (--force unfiles its cards)',
25
- '',
26
- 'legacy "binder:"/"box:" prefixes are accepted and treated as containers.',
27
- ].join('\n'),
28
- async run(ctx) {
29
- const { out, flags, args } = ctx;
30
- const session = ctx.makeSession();
31
- requireWrite(session);
32
- const [sub, ref, ...rest] = args;
33
-
34
- if (sub === 'create') {
35
- const loc = parseLocationFlag(ref);
36
- if (!loc) throw usageError('usage: bp container create <type:name>');
37
- const result = await applyMutation(session, (draft) => { ensureContainer(draft, loc); return { created: loc.type + ':' + loc.name }; }, { dryRun: boolFlag(flags, 'dry-run') });
38
- persistUndo(result);
39
- printWrite(out, result, () => out.info(out.c.green('✓ created') + ` ${loc.type}:${loc.name}`));
40
- return 0;
41
- }
42
-
43
- if (sub === 'rename') {
44
- const newName = parseContainerRef(rest.join(' ')).name;
45
- if (!ref || !newName) throw usageError('usage: bp container rename <type:name> <new-name>');
46
- const result = await applyMutation(session, (draft) => {
47
- const { key, container } = resolveContainer(draft, ref);
48
- const newKey = container.type + ':' + newName;
49
- if (draft.app.containers[newKey]) throw new CliError(`a ${container.type} named "${newName}" already exists`);
50
- const renamed = { ...container, name: newName };
51
- // Keep the deck's display title in sync if it tracked the old name.
52
- if (renamed.deck && (!renamed.deck.title || renamed.deck.title === container.name)) {
53
- renamed.deck = { ...renamed.deck, title: newName };
54
- }
55
- draft.app.containers[newKey] = renamed;
56
- delete draft.app.containers[key];
57
- for (const e of draft.app.collection) if (locationKey(e.location) === key) e.location = { type: container.type, name: newName };
58
- return { renamed: key, to: newKey };
59
- }, { dryRun: boolFlag(flags, 'dry-run') });
60
- persistUndo(result);
61
- printWrite(out, result, () => out.info(out.c.green('✓ renamed') + ` ${result.meta.renamed} → ${result.meta.to}`));
62
- return 0;
63
- }
64
-
65
- if (sub === 'delete') {
66
- const result = await applyMutation(session, (draft) => {
67
- const { key, container } = resolveContainer(draft, ref);
68
- const cards = draft.app.collection.filter(e => locationKey(e.location) === key);
69
- if (cards.length && !boolFlag(flags, 'force')) {
70
- throw new CliError(`${container.type}:${container.name} holds ${cards.length} stack(s) — pass --force to delete and unfile them`);
71
- }
72
- for (const e of draft.app.collection) if (locationKey(e.location) === key) { e.location = null; delete e.deckBoard; }
73
- delete draft.app.containers[key];
74
- return { deleted: key, unfiled: cards.length };
75
- }, { dryRun: boolFlag(flags, 'dry-run') });
76
- persistUndo(result);
77
- printWrite(out, result, () => out.info(out.c.green('✓ deleted') + ` ${result.meta.deleted}` + (result.meta.unfiled ? ` (unfiled ${result.meta.unfiled})` : '')));
78
- return 0;
79
- }
80
-
81
- throw usageError('usage: bp container <create|rename|delete> <type:name>');
82
- },
83
- };
@@ -1,48 +0,0 @@
1
- import { writeFileSync } from 'node:fs';
2
- import { loadSnapshot } from '../mutate.mjs';
3
- import { findContainer, containerCards } from '../snapshot.mjs';
4
- import { buildDeckExport } from '../../vendor/deckExport.js';
5
- import { strFlag } from '../args.mjs';
6
- import { usageError, CliError } from '../errors.mjs';
7
-
8
- // `bp deck export <name> [--preset ...] [--boards a,b] [--output file]`
9
- export async function runDeckExport(ctx, args) {
10
- const { out, flags } = ctx;
11
- const name = args.join(' ');
12
- if (!name) throw usageError('usage: bp deck export <name> [--preset plain|moxfield|arena|mtgo|csv|json]');
13
-
14
- const session = ctx.makeSession();
15
- const { snapshot } = await loadSnapshot(session);
16
- const { matches } = findContainer(snapshot, 'deck:' + name);
17
- const decks = matches.filter(m => m.type === 'deck');
18
- if (!decks.length) throw new CliError(`no deck named "${name}"`);
19
-
20
- const deck = decks[0];
21
- const cards = containerCards(snapshot, deck);
22
- const preset = strFlag(flags, 'preset') || 'plain';
23
- const boardsFlag = strFlag(flags, 'boards');
24
- const boards = boardsFlag ? boardsFlag.split(',').map(s => s.trim()).filter(Boolean) : undefined;
25
- const dm = deck.deck || {};
26
- // buildDeckExport expects commander/partner as plain name strings; the app
27
- // stores them as { name, scryfallId, ... } objects.
28
- const meta = {
29
- title: deck.name,
30
- format: dm.format,
31
- commander: dm.commander?.name || (typeof dm.commander === 'string' ? dm.commander : undefined),
32
- partner: dm.partner?.name || (typeof dm.partner === 'string' ? dm.partner : undefined),
33
- };
34
-
35
- const result = buildDeckExport(cards, meta, { preset, ...(boards ? { boards } : {}) });
36
- for (const w of result.warnings || []) out.info(out.c.yellow('! ' + w));
37
-
38
- const file = strFlag(flags, 'output', 'o');
39
- if (file) {
40
- writeFileSync(file, result.body.endsWith('\n') ? result.body : result.body + '\n');
41
- out.emit({ file, filename: result.filename, preset }, () => out.info(`wrote ${file}`));
42
- } else if (out.json) {
43
- out.emit({ preset, filename: result.filename, body: result.body, warnings: result.warnings });
44
- } else {
45
- out.raw(result.body);
46
- }
47
- return 0;
48
- }
@@ -1,33 +0,0 @@
1
- import { loadSnapshot } from '../mutate.mjs';
2
- import { findContainer, containerCards } from '../snapshot.mjs';
3
- import { runQuery } from '../snapshot.mjs';
4
- import { cardRow, CARD_COLUMNS, cardsToCsv } from '../render.mjs';
5
- import { strFlag, boolFlag } from '../args.mjs';
6
- import { usageError, CliError } from '../errors.mjs';
7
-
8
- export default {
9
- summary: 'show the cards in a container',
10
- help: 'usage: bp show <container> [--sort field] [--csv|--json]\n\ncontainer may be "deck:breya", "binder:rares", or just "breya" if unambiguous.',
11
- async run(ctx) {
12
- const { out, args, flags } = ctx;
13
- const ref = args.join(' ');
14
- if (!ref) throw usageError('usage: bp show <container>');
15
- const session = ctx.makeSession();
16
- const { snapshot } = await loadSnapshot(session);
17
- const { matches, name } = findContainer(snapshot, ref);
18
- if (!matches.length) throw new CliError(`no container named "${name}"`);
19
- if (matches.length > 1) {
20
- throw new CliError(`"${name}" is ambiguous — try ${matches.map(m => m.type + ':' + m.name).join(', ')}`);
21
- }
22
- const container = matches[0];
23
- const cards = runQuery(containerCards(snapshot, container), '', { sort: strFlag(flags, 'sort') || 'name', dir: boolFlag(flags, 'desc') ? 'desc' : 'asc' });
24
-
25
- if (boolFlag(flags, 'csv')) { out.raw(cardsToCsv(cards, strFlag(flags, 'format') || 'canonical')); return 0; }
26
- out.emit({ container: { type: container.type, name: container.name }, count: cards.length, cards }, () => {
27
- out.line(out.c.bold(container.type + ':' + container.name));
28
- if (!cards.length) { out.info('(empty)'); return; }
29
- out.table(CARD_COLUMNS, cards.map(cardRow));
30
- });
31
- return 0;
32
- },
33
- };
@@ -1,40 +0,0 @@
1
- import { collectionKey, normalizeTag } from '../../vendor/collection.js';
2
- import { applyMutation } from '../mutate.mjs';
3
- import { boolFlag } from '../args.mjs';
4
- import { requireWrite, selectorFrom, requireStacks, persistUndo, printWrite } from './writeHelpers.mjs';
5
- import { usageError } from '../errors.mjs';
6
-
7
- export default {
8
- summary: 'add or remove a tag on a stack',
9
- help: [
10
- 'usage: bp tag add <tag> <name> [--set --cn --finish ...] [--all]',
11
- ' bp tag rm <tag> <name> [...]',
12
- '',
13
- 'adds/removes a single tag on matching stacks.',
14
- ].join('\n'),
15
- async run(ctx) {
16
- const { out, flags, args } = ctx;
17
- const session = ctx.makeSession();
18
- requireWrite(session);
19
- const [op, rawTag, ...nameParts] = args;
20
- if ((op !== 'add' && op !== 'rm') || !rawTag) throw usageError('usage: bp tag <add|rm> <tag> <name>');
21
- const tag = normalizeTag(rawTag);
22
- const sel = selectorFrom(flags, nameParts);
23
-
24
- const result = await applyMutation(session, (draft) => {
25
- const stacks = requireStacks(draft.app.collection, sel, { all: boolFlag(flags, 'all') });
26
- const keys = new Set(stacks.map(collectionKey));
27
- for (const e of draft.app.collection) {
28
- if (!keys.has(collectionKey(e))) continue;
29
- const tags = new Set((e.tags || []).map(normalizeTag).filter(Boolean));
30
- if (op === 'add') tags.add(tag); else tags.delete(tag);
31
- e.tags = [...tags];
32
- }
33
- return { tagged: stacks.length, op, tag };
34
- }, { dryRun: boolFlag(flags, 'dry-run') });
35
-
36
- persistUndo(result);
37
- printWrite(out, result, () => out.info(out.c.green('✓ ' + (op === 'add' ? 'tagged' : 'untagged')) + ` ${result.meta.tagged} stack(s) (${tag})`));
38
- return 0;
39
- },
40
- };
@@ -1,82 +0,0 @@
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 };