biblioplex 0.1.1 → 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/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
+ }
package/src/mutate.mjs CHANGED
@@ -1,76 +1,151 @@
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
- }
1
+ // Safe write flow for mutate_inventory. Public clients can't use the two-phase
2
+ // changeToken path (the server never issues a token to non-chat clients), so we
3
+ // use single-call mutate_inventory: ALWAYS preview with dryRun, let the user
4
+ // confirm, then apply WITHOUT dryRun reusing the dryRun-suggested
5
+ // idempotencyKey + expectedRevision. Safety (recovery point, 409 concurrency,
6
+ // idempotent retry) is enforced server-side; we just drive it correctly.
7
+ import { createInterface } from 'node:readline';
8
+ import { boolFlag } from './args.mjs';
9
+ import { CliError, usageError } from './errors.mjs';
10
+ import { EXIT } from './constants.mjs';
11
+ import { pickRows } from './render.mjs';
12
+
13
+ const STATUS_EXIT = {
14
+ parse_error: EXIT.USAGE,
15
+ needs_input: EXIT.USAGE,
16
+ invalid: EXIT.USAGE,
17
+ not_found: EXIT.NOT_FOUND,
18
+ unsafe: EXIT.CONFLICT,
19
+ };
20
+
21
+ function isErrorStatus(sc) {
22
+ const s = sc && typeof sc === 'object' ? sc.status : undefined;
23
+ return !!s && s in STATUS_EXIT;
24
+ }
25
+
26
+ function statusError(sc) {
27
+ return new CliError(
28
+ sc.message || sc.error || `mutation ${sc.status}`,
29
+ STATUS_EXIT[sc.status] || EXIT.ERROR,
30
+ sc,
31
+ );
32
+ }
33
+
34
+ function isConflict(err) {
35
+ return (
36
+ err?.code === EXIT.CONFLICT ||
37
+ err?.extra?.data?.recoveryStrategy === 're_preview' ||
38
+ err?.extra?.status === 409
39
+ );
40
+ }
41
+
42
+ function promptYesNo(question) {
43
+ return new Promise((resolve) => {
44
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
45
+ rl.question(`${question} [y/N] `, (ans) => {
46
+ rl.close();
47
+ resolve(/^y(es)?$/i.test(ans.trim()));
48
+ });
49
+ });
50
+ }
51
+
52
+ // Confirm a side-effecting action. --yes/--force skips; under --json or a
53
+ // non-TTY we refuse rather than hang waiting for input.
54
+ export async function confirm(ctx, question) {
55
+ if (boolFlag(ctx.flags, 'yes', 'force')) return true;
56
+ if (ctx.out.json || !process.stdin.isTTY) {
57
+ throw usageError('refusing to proceed without confirmation — pass --yes (or --dry-run)');
58
+ }
59
+ return promptYesNo(question);
60
+ }
61
+
62
+ function showPreview(out, preview) {
63
+ const items = preview.previews || preview.preview || pickRows(preview);
64
+ if (Array.isArray(items) && items.length) {
65
+ for (const p of items) out.line(' • ' + (p.summary || p.description || JSON.stringify(p)));
66
+ } else if (preview.summary) {
67
+ out.line(' • ' + preview.summary);
27
68
  }
28
- const collection = {};
29
- for (const key of collectionKeys) {
30
- collection[key] = (before.app.collection || []).find(e => collectionKey(e) === key) || null;
69
+ if (preview.tagsCleanup)
70
+ out.info('note: tags normalized — ' + JSON.stringify(preview.tagsCleanup));
71
+ const removes = preview.removeCount ?? preview.totalRemoves;
72
+ if (typeof removes === 'number' && removes >= 20) {
73
+ out.info(`warning: this removes ${removes} rows (server caps removes at 25/operation).`);
31
74
  }
32
- const containers = {};
33
- for (const key of containerKeys) containers[key] = (before.app.containers || {})[key] || null;
34
- return { collection, containers };
35
75
  }
36
76
 
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
- };
77
+ function applyArgsFrom(baseArgs, preview) {
78
+ const out = { ...baseArgs };
79
+ const key = preview.idempotencyKey || preview.suggestedIdempotencyKey;
80
+ if (key) out.idempotencyKey = key;
81
+ const rev = preview.expectedRevision ?? preview.revision;
82
+ if (rev != null) out.expectedRevision = rev;
83
+ return out;
45
84
  }
46
85
 
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;
86
+ // Drive a mutate_inventory operation. baseArgs is everything except dryRun.
87
+ export async function runMutation(ctx, session, baseArgs, { verb = 'apply' } = {}) {
88
+ const { out } = ctx;
89
+
90
+ let preview = await session.call('mutate_inventory', { ...baseArgs, dryRun: true });
91
+ if (isErrorStatus(preview)) throw statusError(preview);
92
+
93
+ out.line(`${verb}:`);
94
+ showPreview(out, preview);
95
+
96
+ if (boolFlag(ctx.flags, 'dry-run')) {
97
+ return out.emit(preview, () => out.line('(dry run nothing applied)')) ?? EXIT.OK;
98
+ }
99
+
100
+ if (!(await confirm(ctx, 'apply this change?'))) {
101
+ out.info('aborted.');
102
+ return EXIT.OK;
103
+ }
104
+
105
+ let applied;
106
+ try {
107
+ applied = await session.call('mutate_inventory', applyArgsFrom(baseArgs, preview));
108
+ } catch (err) {
109
+ if (!isConflict(err)) throw err;
110
+ // Collection changed between preview and apply: re-preview, re-confirm, re-apply.
111
+ out.info('collection changed since preview — re-previewing…');
112
+ preview = await session.call('mutate_inventory', { ...baseArgs, dryRun: true });
113
+ if (isErrorStatus(preview)) throw statusError(preview);
114
+ showPreview(out, preview);
115
+ if (!(await confirm(ctx, 're-apply with the updated state?'))) {
116
+ out.info('aborted.');
117
+ return EXIT.OK;
73
118
  }
119
+ applied = await session.call('mutate_inventory', applyArgsFrom(baseArgs, preview));
120
+ }
121
+
122
+ if (isErrorStatus(applied)) throw statusError(applied);
123
+ return (
124
+ out.emit(applied, () => {
125
+ out.line('done.');
126
+ showPreview(out, applied);
127
+ }) ?? EXIT.OK
128
+ );
129
+ }
130
+
131
+ // Resolve a single concrete printing for `add`. Throws with guidance when
132
+ // ambiguous so the user can narrow with --set/--cn.
133
+ export async function resolveOnePrinting(session, { query, setCode, cn, finish }) {
134
+ const args = {};
135
+ if (query) args.query = query;
136
+ if (setCode) args.setCode = setCode;
137
+ if (cn) args.cn = cn;
138
+ if (finish) args.finish = finish;
139
+ const res = await session.call('resolve_printing', args);
140
+ if (res?.status === 'not_found') {
141
+ throw new CliError('no printing matched — try `bp resolve` to explore', EXIT.NOT_FOUND);
142
+ }
143
+ const candidates = pickRows(res);
144
+ if (res?.status === 'ambiguous' || candidates.length > 1) {
145
+ throw usageError('multiple printings match — narrow with --set and --cn (see `bp resolve`)');
74
146
  }
75
- throw lastConflict || new CliError('could not apply change after several retries');
147
+ const one = candidates[0] || res;
148
+ const scryfallId = one.scryfallId || one.id || res.scryfallId;
149
+ if (!scryfallId) throw new CliError('could not resolve a printing id', EXIT.ERROR, res);
150
+ return { scryfallId, candidate: one };
76
151
  }