biblioplex 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/package.json
CHANGED
|
@@ -1,42 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "biblioplex",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Manage your Biblioplex Magic: The Gathering collection from the terminal.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"mtg",
|
|
7
|
+
"magic-the-gathering",
|
|
8
|
+
"collection",
|
|
9
|
+
"deck",
|
|
10
|
+
"cli",
|
|
11
|
+
"biblioplex"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "Benson Perry",
|
|
15
|
+
"homepage": "https://biblioplex.bensonperry.com",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/benson/biblioplex.git",
|
|
19
|
+
"directory": "apps/cli"
|
|
20
|
+
},
|
|
21
|
+
"bugs": "https://github.com/benson/biblioplex/issues",
|
|
5
22
|
"type": "module",
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=22"
|
|
25
|
+
},
|
|
6
26
|
"bin": {
|
|
7
27
|
"biblioplex": "bin/biblioplex.mjs",
|
|
8
28
|
"bp": "bin/biblioplex.mjs"
|
|
9
29
|
},
|
|
10
|
-
"engines": {
|
|
11
|
-
"node": ">=20"
|
|
12
|
-
},
|
|
13
30
|
"files": [
|
|
14
31
|
"bin/",
|
|
15
32
|
"src/",
|
|
16
|
-
"vendor/",
|
|
17
33
|
"README.md"
|
|
18
34
|
],
|
|
19
35
|
"scripts": {
|
|
20
|
-
"
|
|
21
|
-
"test": "node --test __tests__/*.test.js",
|
|
22
|
-
"prepublishOnly": "node scripts/sync-vendor.mjs && node --test __tests__/*.test.js"
|
|
23
|
-
},
|
|
24
|
-
"keywords": [
|
|
25
|
-
"mtg",
|
|
26
|
-
"magic-the-gathering",
|
|
27
|
-
"collection",
|
|
28
|
-
"cli",
|
|
29
|
-
"biblioplex",
|
|
30
|
-
"scryfall"
|
|
31
|
-
],
|
|
32
|
-
"license": "MIT",
|
|
33
|
-
"homepage": "https://biblioplex.bensonperry.com",
|
|
34
|
-
"bugs": {
|
|
35
|
-
"url": "https://github.com/benson/benson.github.io/issues"
|
|
36
|
-
},
|
|
37
|
-
"repository": {
|
|
38
|
-
"type": "git",
|
|
39
|
-
"url": "git+https://github.com/benson/benson.github.io.git",
|
|
40
|
-
"directory": "mtgcollection/cli"
|
|
36
|
+
"test": "node --test \"src/**/__tests__/*.test.js\""
|
|
41
37
|
}
|
|
42
38
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { parseArgs, boolFlag, strFlag, intFlag } from '../args.mjs';
|
|
4
|
+
|
|
5
|
+
test('parseArgs: positionals, booleans, =value, bare value', () => {
|
|
6
|
+
const { positionals, flags } = parseArgs([
|
|
7
|
+
'add',
|
|
8
|
+
'sol ring',
|
|
9
|
+
'--yes',
|
|
10
|
+
'--qty',
|
|
11
|
+
'2',
|
|
12
|
+
'--to=deck:breya',
|
|
13
|
+
]);
|
|
14
|
+
assert.deepEqual(positionals, ['add', 'sol ring']);
|
|
15
|
+
assert.equal(flags.yes, true);
|
|
16
|
+
assert.equal(flags.qty, '2');
|
|
17
|
+
assert.equal(flags.to, 'deck:breya');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('parseArgs: -- ends flag parsing', () => {
|
|
21
|
+
const { positionals } = parseArgs(['search', '--', '--not-a-flag']);
|
|
22
|
+
assert.deepEqual(positionals, ['search', '--not-a-flag']);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('parseArgs: known boolean flags do not swallow the next token', () => {
|
|
26
|
+
const { positionals, flags } = parseArgs(['add', '--dry-run', 'sol ring']);
|
|
27
|
+
assert.equal(flags['dry-run'], true);
|
|
28
|
+
assert.deepEqual(positionals, ['add', 'sol ring']);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('boolFlag / strFlag / intFlag', () => {
|
|
32
|
+
const { flags } = parseArgs(['x', '--json', '--limit', '5', '--name', 'bolt']);
|
|
33
|
+
assert.equal(boolFlag(flags, 'json'), true);
|
|
34
|
+
assert.equal(boolFlag(flags, 'missing'), false);
|
|
35
|
+
assert.equal(strFlag(flags, 'name'), 'bolt');
|
|
36
|
+
assert.equal(strFlag(flags, 'json'), null); // a boolean true is not a string value
|
|
37
|
+
assert.equal(intFlag(flags, 'limit'), 5);
|
|
38
|
+
assert.equal(intFlag(flags, 'missing', 9), 9);
|
|
39
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { toCsv, fromCsv } from '../csv.mjs';
|
|
4
|
+
|
|
5
|
+
test('toCsv quotes fields with commas/quotes/newlines and joins arrays', () => {
|
|
6
|
+
const csv = toCsv(
|
|
7
|
+
[{ name: 'A, B', note: 'he said "hi"', tags: ['x', 'y'], qty: 2 }],
|
|
8
|
+
['name', 'note', 'tags', 'qty'],
|
|
9
|
+
);
|
|
10
|
+
assert.equal(csv, 'name,note,tags,qty\n"A, B","he said ""hi""",x;y,2\n');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('fromCsv parses header + quoted fields with embedded commas/quotes', () => {
|
|
14
|
+
const rows = fromCsv('name,note\n"A, B","he said ""hi"""\nplain,ok\n');
|
|
15
|
+
assert.deepEqual(rows, [
|
|
16
|
+
{ name: 'A, B', note: 'he said "hi"' },
|
|
17
|
+
{ name: 'plain', note: 'ok' },
|
|
18
|
+
]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('round-trips a typical import shape', () => {
|
|
22
|
+
const original = [
|
|
23
|
+
{ setCode: 'lea', cn: '161', finish: 'nonfoil', qty: '1', location: 'box:red' },
|
|
24
|
+
{ setCode: 'cmm', cn: '2', finish: 'foil', qty: '3', location: 'deck:breya' },
|
|
25
|
+
];
|
|
26
|
+
const parsed = fromCsv(toCsv(original));
|
|
27
|
+
assert.deepEqual(parsed, original);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('fromCsv ignores blank trailing lines', () => {
|
|
31
|
+
assert.equal(fromCsv('a,b\n1,2\n\n').length, 1);
|
|
32
|
+
assert.equal(fromCsv('').length, 0);
|
|
33
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { test, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { createSession } from '../mcp.mjs';
|
|
6
|
+
import { EXIT } from '../constants.mjs';
|
|
7
|
+
|
|
8
|
+
const realFetch = globalThis.fetch;
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
globalThis.fetch = realFetch;
|
|
11
|
+
delete process.env.BIBLIOPLEX_TOKEN;
|
|
12
|
+
delete process.env.BIBLIOPLEX_CONFIG_DIR;
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
function jsonResponse(obj, status = 200) {
|
|
16
|
+
return new Response(JSON.stringify(obj), {
|
|
17
|
+
status,
|
|
18
|
+
headers: { 'content-type': 'application/json' },
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test('PAT session: posts to /mcp with Bearer + tools/call, returns structuredContent', async () => {
|
|
23
|
+
const calls = [];
|
|
24
|
+
globalThis.fetch = async (url, opts) => {
|
|
25
|
+
calls.push({ url, opts });
|
|
26
|
+
return jsonResponse({
|
|
27
|
+
jsonrpc: '2.0',
|
|
28
|
+
id: 1,
|
|
29
|
+
result: { structuredContent: { rows: [{ name: 'X' }] } },
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
process.env.BIBLIOPLEX_TOKEN = 'pat-xyz';
|
|
33
|
+
const s = await createSession({ origin: 'https://api.test' });
|
|
34
|
+
const sc = await s.call('query_collection', { from: 'inventory' });
|
|
35
|
+
assert.deepEqual(sc, { rows: [{ name: 'X' }] });
|
|
36
|
+
assert.equal(calls[0].url, 'https://api.test/mcp');
|
|
37
|
+
assert.equal(calls[0].opts.headers.Authorization, 'Bearer pat-xyz');
|
|
38
|
+
const body = JSON.parse(calls[0].opts.body);
|
|
39
|
+
assert.equal(body.method, 'tools/call');
|
|
40
|
+
assert.equal(body.params.name, 'query_collection');
|
|
41
|
+
assert.deepEqual(body.params.arguments, { from: 'inventory' });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('PAT session: HTTP 401 -> auth error (exit AUTH), no retry', async () => {
|
|
45
|
+
let n = 0;
|
|
46
|
+
globalThis.fetch = async () => {
|
|
47
|
+
n += 1;
|
|
48
|
+
return jsonResponse({ error: 'unauthorized' }, 401);
|
|
49
|
+
};
|
|
50
|
+
process.env.BIBLIOPLEX_TOKEN = 'bad';
|
|
51
|
+
const s = await createSession({ origin: 'https://api.test' });
|
|
52
|
+
await assert.rejects(s.call('query_collection', {}), (e) => e.code === EXIT.AUTH);
|
|
53
|
+
assert.equal(n, 1);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('JSON-RPC -32003 -> scope error (exit AUTH) mentioning write', async () => {
|
|
57
|
+
globalThis.fetch = async () =>
|
|
58
|
+
jsonResponse({
|
|
59
|
+
jsonrpc: '2.0',
|
|
60
|
+
id: 1,
|
|
61
|
+
error: {
|
|
62
|
+
code: -32003,
|
|
63
|
+
message: 'insufficient scope',
|
|
64
|
+
data: { requiredScope: 'collection.write' },
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
process.env.BIBLIOPLEX_TOKEN = 'pat';
|
|
68
|
+
const s = await createSession({ origin: 'https://api.test' });
|
|
69
|
+
await assert.rejects(
|
|
70
|
+
s.call('mutate_inventory', {}),
|
|
71
|
+
(e) => e.code === EXIT.AUTH && /write/.test(e.message),
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('no PAT and no stored creds -> auth error before any network', async () => {
|
|
76
|
+
let fetched = false;
|
|
77
|
+
globalThis.fetch = async () => {
|
|
78
|
+
fetched = true;
|
|
79
|
+
return jsonResponse({});
|
|
80
|
+
};
|
|
81
|
+
process.env.BIBLIOPLEX_CONFIG_DIR = join(tmpdir(), 'bp-empty-test-dir-xyz');
|
|
82
|
+
await assert.rejects(createSession({ origin: 'https://api.test' }), (e) => e.code === EXIT.AUTH);
|
|
83
|
+
assert.equal(fetched, false);
|
|
84
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { runMutation } from '../mutate.mjs';
|
|
4
|
+
import { EXIT } from '../constants.mjs';
|
|
5
|
+
import { CliError } from '../errors.mjs';
|
|
6
|
+
|
|
7
|
+
function fakeOut(json = false) {
|
|
8
|
+
return {
|
|
9
|
+
json,
|
|
10
|
+
emit(d, fn) {
|
|
11
|
+
if (json) this.emitted = d;
|
|
12
|
+
else if (fn) fn();
|
|
13
|
+
return undefined;
|
|
14
|
+
},
|
|
15
|
+
line() {},
|
|
16
|
+
info() {},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function recSession(applyBehavior) {
|
|
21
|
+
const calls = [];
|
|
22
|
+
let applyCount = 0;
|
|
23
|
+
return {
|
|
24
|
+
calls,
|
|
25
|
+
call: async (tool, args) => {
|
|
26
|
+
calls.push({ tool, args: { ...args } });
|
|
27
|
+
if (tool === 'mutate_inventory' && args.dryRun) {
|
|
28
|
+
return {
|
|
29
|
+
status: 'preview',
|
|
30
|
+
previews: [{ summary: 'preview' }],
|
|
31
|
+
idempotencyKey: 'idem-1',
|
|
32
|
+
expectedRevision: 7,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
if (tool === 'mutate_inventory') {
|
|
36
|
+
applyCount += 1;
|
|
37
|
+
return applyBehavior ? applyBehavior(applyCount) : { status: 'applied' };
|
|
38
|
+
}
|
|
39
|
+
return {};
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const ctx = (flags, out) => ({ out, flags });
|
|
45
|
+
|
|
46
|
+
test('preview is dryRun; apply reuses idempotencyKey + expectedRevision and omits dryRun', async () => {
|
|
47
|
+
const s = recSession();
|
|
48
|
+
await runMutation(ctx({ yes: true, json: true }, fakeOut(true)), s, {
|
|
49
|
+
operation: 'add',
|
|
50
|
+
scryfallId: 'x',
|
|
51
|
+
});
|
|
52
|
+
assert.deepEqual(
|
|
53
|
+
s.calls.map((c) => c.tool + (c.args.dryRun ? ':dry' : '')),
|
|
54
|
+
['mutate_inventory:dry', 'mutate_inventory'],
|
|
55
|
+
);
|
|
56
|
+
assert.equal(s.calls[1].args.idempotencyKey, 'idem-1');
|
|
57
|
+
assert.equal(s.calls[1].args.expectedRevision, 7);
|
|
58
|
+
assert.equal(s.calls[1].args.dryRun, undefined);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('--dry-run stops before apply', async () => {
|
|
62
|
+
const s = recSession();
|
|
63
|
+
await runMutation(ctx({ 'dry-run': true, json: true }, fakeOut(true)), s, {
|
|
64
|
+
operation: 'add',
|
|
65
|
+
scryfallId: 'x',
|
|
66
|
+
});
|
|
67
|
+
assert.equal(s.calls.filter((c) => c.tool === 'mutate_inventory' && !c.args.dryRun).length, 0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('--json refuses to apply without --yes (exit USAGE), nothing applied', async () => {
|
|
71
|
+
const s = recSession();
|
|
72
|
+
await assert.rejects(
|
|
73
|
+
runMutation(ctx({ json: true }, fakeOut(true)), s, { operation: 'add', scryfallId: 'x' }),
|
|
74
|
+
(e) => e instanceof CliError && e.code === EXIT.USAGE,
|
|
75
|
+
);
|
|
76
|
+
assert.equal(s.calls.filter((c) => c.tool === 'mutate_inventory' && !c.args.dryRun).length, 0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('409 conflict re-previews then re-applies', async () => {
|
|
80
|
+
const s = recSession((n) => {
|
|
81
|
+
if (n === 1)
|
|
82
|
+
throw new CliError('conflict', EXIT.CONFLICT, { data: { recoveryStrategy: 're_preview' } });
|
|
83
|
+
return { status: 'applied' };
|
|
84
|
+
});
|
|
85
|
+
await runMutation(ctx({ yes: true, json: true }, fakeOut(true)), s, {
|
|
86
|
+
operation: 'add',
|
|
87
|
+
scryfallId: 'x',
|
|
88
|
+
});
|
|
89
|
+
assert.equal(s.calls.filter((c) => c.tool === 'mutate_inventory' && c.args.dryRun).length, 2);
|
|
90
|
+
assert.equal(s.calls.filter((c) => c.tool === 'mutate_inventory' && !c.args.dryRun).length, 2);
|
|
91
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { pickRows, fmtMoney, emitResult, renderTable, CONTAINER_COLUMNS } from '../render.mjs';
|
|
4
|
+
import { EXIT } from '../constants.mjs';
|
|
5
|
+
|
|
6
|
+
function fakeOut(json = false) {
|
|
7
|
+
return {
|
|
8
|
+
json,
|
|
9
|
+
_lines: [],
|
|
10
|
+
_tables: [],
|
|
11
|
+
emitted: undefined,
|
|
12
|
+
emit(d, fn) {
|
|
13
|
+
if (json) this.emitted = d;
|
|
14
|
+
else if (fn) fn();
|
|
15
|
+
},
|
|
16
|
+
line(s = '') {
|
|
17
|
+
this._lines.push(s);
|
|
18
|
+
},
|
|
19
|
+
info() {},
|
|
20
|
+
table(c, r) {
|
|
21
|
+
this._tables.push({ c, r });
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test('pickRows finds the first array among known keys', () => {
|
|
27
|
+
assert.deepEqual(pickRows({ rows: [1, 2] }), [1, 2]);
|
|
28
|
+
assert.deepEqual(pickRows({ containers: [{ a: 1 }] }), [{ a: 1 }]);
|
|
29
|
+
assert.deepEqual(pickRows([3, 4]), [3, 4]);
|
|
30
|
+
assert.deepEqual(pickRows({ nope: 1 }), []);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('fmtMoney', () => {
|
|
34
|
+
assert.equal(fmtMoney(12.5), '$12.50');
|
|
35
|
+
assert.equal(fmtMoney(null), '—');
|
|
36
|
+
assert.equal(fmtMoney('3.2'), '$3.20');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('emitResult maps error statuses to exit codes', () => {
|
|
40
|
+
const ctx = { out: fakeOut() };
|
|
41
|
+
assert.throws(
|
|
42
|
+
() => emitResult(ctx, { status: 'parse_error', message: 'bad' }),
|
|
43
|
+
(e) => e.code === EXIT.USAGE,
|
|
44
|
+
);
|
|
45
|
+
assert.throws(
|
|
46
|
+
() => emitResult(ctx, { status: 'not_found' }),
|
|
47
|
+
(e) => e.code === EXIT.NOT_FOUND,
|
|
48
|
+
);
|
|
49
|
+
assert.throws(
|
|
50
|
+
() => emitResult(ctx, { status: 'unsafe' }),
|
|
51
|
+
(e) => e.code === EXIT.CONFLICT,
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('emitResult emits raw structuredContent under --json', () => {
|
|
56
|
+
const ctx = { out: fakeOut(true) };
|
|
57
|
+
const sc = { rows: [{ name: 'X' }], hasMore: false };
|
|
58
|
+
emitResult(ctx, sc, { columns: [] });
|
|
59
|
+
assert.deepEqual(ctx.out.emitted, sc);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('renderTable keeps present columns and formats money/location', () => {
|
|
63
|
+
const out = fakeOut();
|
|
64
|
+
renderTable(
|
|
65
|
+
out,
|
|
66
|
+
[{ qty: 1, name: 'Bolt', location: { type: 'box', name: 'red' }, price: 2 }],
|
|
67
|
+
[
|
|
68
|
+
{ key: 'qty', align: 'right' },
|
|
69
|
+
{ key: 'name' },
|
|
70
|
+
{ key: 'location', header: 'loc', loc: true },
|
|
71
|
+
{ key: 'price', money: true },
|
|
72
|
+
{ key: 'absent' },
|
|
73
|
+
],
|
|
74
|
+
);
|
|
75
|
+
const { c, r } = out._tables[0];
|
|
76
|
+
assert.deepEqual(
|
|
77
|
+
c.map((x) => x.header),
|
|
78
|
+
['qty', 'name', 'loc', 'price'],
|
|
79
|
+
); // 'absent' column dropped (no data)
|
|
80
|
+
assert.deepEqual(r[0], ['1', 'Bolt', 'box:red', '$2.00']);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('container columns read cards/value from stats, not deckListCount', () => {
|
|
84
|
+
const out = fakeOut();
|
|
85
|
+
renderTable(
|
|
86
|
+
out,
|
|
87
|
+
[
|
|
88
|
+
{
|
|
89
|
+
name: 'bulk pile',
|
|
90
|
+
type: 'box',
|
|
91
|
+
stats: { unique: 161, total: 195, value: 242.3 },
|
|
92
|
+
deckListCount: 0,
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
CONTAINER_COLUMNS,
|
|
96
|
+
);
|
|
97
|
+
const { c, r } = out._tables[0];
|
|
98
|
+
assert.deepEqual(
|
|
99
|
+
c.map((x) => x.header),
|
|
100
|
+
['name', 'type', 'cards', 'value'],
|
|
101
|
+
);
|
|
102
|
+
assert.deepEqual(r[0], ['bulk pile', 'box', '195', '$242.30']);
|
|
103
|
+
});
|
package/src/args.mjs
CHANGED
|
@@ -4,9 +4,25 @@
|
|
|
4
4
|
// or the next token looks like another flag. `--` ends flag parsing.
|
|
5
5
|
|
|
6
6
|
const BOOLEAN_FLAGS = new Set([
|
|
7
|
-
'json',
|
|
8
|
-
'
|
|
9
|
-
'
|
|
7
|
+
'json',
|
|
8
|
+
'yes',
|
|
9
|
+
'y',
|
|
10
|
+
'help',
|
|
11
|
+
'h',
|
|
12
|
+
'version',
|
|
13
|
+
'v',
|
|
14
|
+
'csv',
|
|
15
|
+
'table',
|
|
16
|
+
'dry-run',
|
|
17
|
+
'no-browser',
|
|
18
|
+
'no-color',
|
|
19
|
+
'all',
|
|
20
|
+
'force',
|
|
21
|
+
'verbose',
|
|
22
|
+
'desc',
|
|
23
|
+
'no-resolve',
|
|
24
|
+
'archive',
|
|
25
|
+
'write',
|
|
10
26
|
]);
|
|
11
27
|
|
|
12
28
|
const SHORT_ALIASES = { y: 'yes', h: 'help', v: 'version' };
|
|
@@ -24,12 +40,19 @@ export function parseArgs(argv) {
|
|
|
24
40
|
i += 1;
|
|
25
41
|
continue;
|
|
26
42
|
}
|
|
27
|
-
if (tok === '--') {
|
|
43
|
+
if (tok === '--') {
|
|
44
|
+
noMoreFlags = true;
|
|
45
|
+
i += 1;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
28
48
|
|
|
29
49
|
let raw = tok.startsWith('--') ? tok.slice(2) : tok.slice(1);
|
|
30
50
|
let value;
|
|
31
51
|
const eq = raw.indexOf('=');
|
|
32
|
-
if (eq !== -1) {
|
|
52
|
+
if (eq !== -1) {
|
|
53
|
+
value = raw.slice(eq + 1);
|
|
54
|
+
raw = raw.slice(0, eq);
|
|
55
|
+
}
|
|
33
56
|
|
|
34
57
|
const name = SHORT_ALIASES[raw] || raw;
|
|
35
58
|
|
|
@@ -66,7 +89,7 @@ export function strFlag(flags, ...names) {
|
|
|
66
89
|
}
|
|
67
90
|
|
|
68
91
|
export function boolFlag(flags, ...names) {
|
|
69
|
-
return names.some(n => flags[n] === true || flags[n] === 'true');
|
|
92
|
+
return names.some((n) => flags[n] === true || flags[n] === 'true');
|
|
70
93
|
}
|
|
71
94
|
|
|
72
95
|
export function intFlag(flags, name, fallback = null) {
|
package/src/cli.mjs
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { parseArgs, boolFlag, strFlag } from './args.mjs';
|
|
2
2
|
import { createOutput } from './output.mjs';
|
|
3
3
|
import { CliError } from './errors.mjs';
|
|
4
|
-
import { EXIT, VERSION,
|
|
5
|
-
import {
|
|
6
|
-
import { Session } from './api.mjs';
|
|
4
|
+
import { EXIT, VERSION, DEFAULT_API_ORIGIN } from './constants.mjs';
|
|
5
|
+
import { loadConfig } from './store.mjs';
|
|
7
6
|
import { commands, commandOrder } from './commands/index.mjs';
|
|
8
7
|
|
|
9
|
-
function
|
|
10
|
-
const
|
|
11
|
-
|
|
8
|
+
function resolveApiOrigin(flags) {
|
|
9
|
+
const origin =
|
|
10
|
+
strFlag(flags, 'api') ||
|
|
11
|
+
process.env.BIBLIOPLEX_API_ORIGIN ||
|
|
12
|
+
loadConfig().apiOrigin ||
|
|
13
|
+
DEFAULT_API_ORIGIN;
|
|
14
|
+
return origin.replace(/\/+$/, '');
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
function printHelp(out) {
|
|
@@ -16,19 +19,22 @@ function printHelp(out) {
|
|
|
16
19
|
out.line('');
|
|
17
20
|
out.line('usage: bp <command> [options]');
|
|
18
21
|
out.line('');
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
if (commandOrder.length) {
|
|
23
|
+
out.line('commands:');
|
|
24
|
+
for (const name of commandOrder) {
|
|
25
|
+
out.line(' ' + name.padEnd(12) + commands[name].summary);
|
|
26
|
+
}
|
|
27
|
+
out.line('');
|
|
22
28
|
}
|
|
23
|
-
out.line('');
|
|
24
29
|
out.line('global options:');
|
|
25
|
-
out.line(' --json
|
|
26
|
-
out.line(' --no-color
|
|
27
|
-
out.line(' --api <
|
|
28
|
-
out.line(' --help
|
|
29
|
-
out.line(' --version
|
|
30
|
+
out.line(' --json machine-readable output ({ok,data}|{ok,error})');
|
|
31
|
+
out.line(' --no-color disable colored output');
|
|
32
|
+
out.line(' --api <origin> override the API origin (or set BIBLIOPLEX_API_ORIGIN)');
|
|
33
|
+
out.line(' --help show help (also: bp <command> --help)');
|
|
34
|
+
out.line(' --version print the version');
|
|
30
35
|
out.line('');
|
|
31
|
-
out.line('
|
|
36
|
+
out.line('auth: run `bp login`, or set BIBLIOPLEX_TOKEN to a personal access');
|
|
37
|
+
out.line('token for headless / CI use.');
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
export async function run(argv) {
|
|
@@ -36,13 +42,18 @@ export async function run(argv) {
|
|
|
36
42
|
const out = createOutput({ json: boolFlag(flags, 'json'), color: !boolFlag(flags, 'no-color') });
|
|
37
43
|
const command = positionals[0];
|
|
38
44
|
|
|
39
|
-
if (!command) {
|
|
40
|
-
if (boolFlag(flags, 'version')) {
|
|
45
|
+
if (!command || command === 'help') {
|
|
46
|
+
if (boolFlag(flags, 'version')) {
|
|
47
|
+
out.line(VERSION);
|
|
48
|
+
return EXIT.OK;
|
|
49
|
+
}
|
|
41
50
|
printHelp(out);
|
|
42
51
|
return EXIT.OK;
|
|
43
52
|
}
|
|
44
|
-
if (
|
|
45
|
-
|
|
53
|
+
if (boolFlag(flags, 'version')) {
|
|
54
|
+
out.line(VERSION);
|
|
55
|
+
return EXIT.OK;
|
|
56
|
+
}
|
|
46
57
|
|
|
47
58
|
const cmd = commands[command];
|
|
48
59
|
if (!cmd) {
|
|
@@ -58,11 +69,13 @@ export async function run(argv) {
|
|
|
58
69
|
out,
|
|
59
70
|
flags,
|
|
60
71
|
args: positionals.slice(1),
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
72
|
+
apiOrigin: resolveApiOrigin(flags),
|
|
73
|
+
// Lazily build an MCP session. Uses BIBLIOPLEX_TOKEN (a personal access
|
|
74
|
+
// token) when set, otherwise the stored OAuth credentials. Imported on
|
|
75
|
+
// demand so `help`/`version` work without auth wiring.
|
|
76
|
+
async makeSession({ write = false } = {}) {
|
|
77
|
+
const { createSession } = await import('./mcp.mjs');
|
|
78
|
+
return createSession({ origin: this.apiOrigin, requireWrite: write, out: this.out });
|
|
66
79
|
},
|
|
67
80
|
};
|
|
68
81
|
|
package/src/commands/add.mjs
CHANGED
|
@@ -1,57 +1,36 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { applyMutation } from '../mutate.mjs';
|
|
5
|
-
import { strFlag, intFlag, boolFlag } from '../args.mjs';
|
|
6
|
-
import { requireWrite, parseLocationFlag, ensureContainer, persistUndo, printWrite } from './writeHelpers.mjs';
|
|
7
|
-
import { usageError, CliError } from '../errors.mjs';
|
|
1
|
+
import { intFlag, strFlag } from '../args.mjs';
|
|
2
|
+
import { usageError } from '../errors.mjs';
|
|
3
|
+
import { runMutation, resolveOnePrinting } from '../mutate.mjs';
|
|
8
4
|
|
|
9
5
|
export default {
|
|
10
6
|
summary: 'add a card to your collection',
|
|
11
|
-
help: [
|
|
12
|
-
'usage: bp add <name> [--set xxx --cn 123] [--finish foil] [--condition lightly_played]',
|
|
13
|
-
' [--qty 2] [--location "deck:breya"] [--tags trade,wishlist] [--dry-run]',
|
|
14
|
-
' bp add --scryfall-id <id> [...]',
|
|
15
|
-
'',
|
|
16
|
-
'resolves the printing on scryfall (give --set + --cn to pin an exact printing),',
|
|
17
|
-
'then adds it to your cloud collection. coalesces with an identical existing stack.',
|
|
18
|
-
].join('\n'),
|
|
19
|
-
async run(ctx) {
|
|
20
|
-
const { out, flags, args } = ctx;
|
|
21
|
-
const session = ctx.makeSession();
|
|
22
|
-
requireWrite(session);
|
|
23
|
-
|
|
24
|
-
const scryfallId = strFlag(flags, 'scryfall-id', 'id');
|
|
25
|
-
const set = strFlag(flags, 'set', 's');
|
|
26
|
-
const cn = strFlag(flags, 'cn');
|
|
27
|
-
const name = args.join(' ').trim();
|
|
28
|
-
if (!scryfallId && !(set && cn) && !name) throw usageError('usage: bp add <name> [--set --cn] | --scryfall-id <id>');
|
|
29
|
-
|
|
30
|
-
const finish = strFlag(flags, 'finish') || 'normal';
|
|
31
|
-
const card = await resolvePrinting({ scryfallId, set, cn, name });
|
|
32
|
-
if (!card) throw new CliError("couldn't find that printing — try specifying --set and --cn");
|
|
7
|
+
help: `bp add <name> [--set CODE] [--cn NUMBER] [--finish foil|nonfoil|etched] [--qty N] [--to LOCATION] [--tag a,b] [--yes] [--dry-run]
|
|
33
8
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
9
|
+
Resolves a printing, then adds it. For an exact printing use --set + --cn.
|
|
10
|
+
Examples:
|
|
11
|
+
bp add "sol ring" --to deck:breya
|
|
12
|
+
bp add --set sld --cn 2144 --finish foil --qty 2`,
|
|
13
|
+
async run(ctx) {
|
|
14
|
+
const session = await ctx.makeSession({ write: true });
|
|
15
|
+
const query = ctx.args.join(' ').trim();
|
|
16
|
+
const setCode = strFlag(ctx.flags, 'set');
|
|
17
|
+
const cn = strFlag(ctx.flags, 'cn');
|
|
18
|
+
const finish = strFlag(ctx.flags, 'finish');
|
|
19
|
+
if (!query && !(setCode && cn)) {
|
|
20
|
+
throw usageError('usage: bp add <name> (or --set CODE --cn NUMBER)');
|
|
21
|
+
}
|
|
22
|
+
const { scryfallId } = await resolveOnePrinting(session, { query, setCode, cn, finish });
|
|
23
|
+
const args = { operation: 'add', scryfallId, qty: intFlag(ctx.flags, 'qty', 1) };
|
|
24
|
+
if (finish) args.finish = finish;
|
|
25
|
+
const to = strFlag(ctx.flags, 'to');
|
|
26
|
+
if (to) args.location = to;
|
|
27
|
+
const tagStr = strFlag(ctx.flags, 'tag', 'tags');
|
|
28
|
+
if (tagStr) {
|
|
29
|
+
args.tags = tagStr
|
|
30
|
+
.split(',')
|
|
31
|
+
.map((s) => s.trim())
|
|
32
|
+
.filter(Boolean);
|
|
33
|
+
}
|
|
34
|
+
return runMutation(ctx, session, args, { verb: 'add' });
|
|
56
35
|
},
|
|
57
36
|
};
|