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.
- package/package.json +23 -27
- package/src/__tests__/args.test.js +39 -0
- package/src/__tests__/csv.test.js +33 -0
- package/src/__tests__/mcp.test.js +84 -0
- package/src/__tests__/mutate.test.js +91 -0
- package/src/__tests__/render.test.js +103 -0
- package/src/args.mjs +29 -6
- package/src/cli.mjs +38 -25
- package/src/commands/add.mjs +30 -51
- package/src/commands/deck.mjs +13 -47
- package/src/commands/edit.mjs +26 -38
- package/src/commands/export.mjs +57 -41
- package/src/commands/history.mjs +24 -0
- package/src/commands/import.mjs +52 -80
- package/src/commands/index.mjs +44 -20
- package/src/commands/login.mjs +29 -38
- package/src/commands/logout.mjs +13 -10
- package/src/commands/ls.mjs +13 -25
- package/src/commands/move.mjs +15 -35
- package/src/commands/prices.mjs +21 -0
- package/src/commands/recover.mjs +53 -0
- package/src/commands/resolve.mjs +37 -0
- package/src/commands/rm.mjs +15 -32
- package/src/commands/search.mjs +25 -35
- package/src/commands/summary.mjs +25 -28
- package/src/commands/undo.mjs +13 -28
- package/src/commands/value.mjs +30 -0
- package/src/commands/whoami.mjs +27 -19
- package/src/constants.mjs +34 -7
- package/src/csv.mjs +71 -0
- package/src/errors.mjs +13 -2
- package/src/mcp.mjs +227 -0
- package/src/mutate.mjs +142 -67
- package/src/oauth.mjs +200 -62
- package/src/output.mjs +27 -18
- package/src/render.mjs +135 -30
- package/src/store.mjs +39 -23
- package/README.md +0 -92
- package/src/api.mjs +0 -110
- package/src/commands/container.mjs +0 -83
- package/src/commands/deckExport.mjs +0 -48
- package/src/commands/show.mjs +0 -33
- package/src/commands/tag.mjs +0 -40
- package/src/commands/writeHelpers.mjs +0 -82
- package/src/scryfall.mjs +0 -81
- package/src/snapshot.mjs +0 -77
- package/vendor/README.md +0 -20
- package/vendor/adapters.js +0 -443
- package/vendor/collection.js +0 -665
- package/vendor/deckExport.js +0 -219
- package/vendor/importMerge.js +0 -22
- package/vendor/importParsing.js +0 -119
- package/vendor/portableArchive.js +0 -151
- package/vendor/searchCore.js +0 -223
- package/vendor/state.js +0 -91
- package/vendor/storageSchema.js +0 -75
- 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
|
-
|
|
17
|
-
export const
|
|
16
|
+
|
|
17
|
+
export const authError = (message = 'not logged in — run `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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
let
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
}
|