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.
- package/README.md +44 -70
- package/package.json +17 -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/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/README.md
CHANGED
|
@@ -1,92 +1,66 @@
|
|
|
1
|
-
# biblioplex
|
|
1
|
+
# biblioplex
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
collection from the terminal — search, edit, import, and
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
cloud-first: every command reads and writes the same collection the web app uses,
|
|
8
|
-
so the cli and the website stay perfectly in sync.
|
|
3
|
+
Manage your [Biblioplex](https://biblioplex.bensonperry.com) Magic: The Gathering
|
|
4
|
+
collection from the terminal — search, edit, import/export, and recover, with
|
|
5
|
+
first-class machine-readable output for scripts and agents.
|
|
9
6
|
|
|
10
7
|
## install
|
|
11
8
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
# or, no install:
|
|
17
|
-
npx biblioplex login
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g biblioplex
|
|
11
|
+
# or
|
|
12
|
+
brew install benson/tap/biblioplex
|
|
18
13
|
```
|
|
19
14
|
|
|
20
|
-
|
|
21
|
-
> other tool already owns `bp` on your machine, just use `biblioplex` (or add your
|
|
22
|
-
> own alias).
|
|
15
|
+
Installs two commands: `biblioplex` and the short alias `bp`. Requires Node.js ≥ 22.
|
|
23
16
|
|
|
24
|
-
##
|
|
17
|
+
## quick start
|
|
25
18
|
|
|
26
|
-
```
|
|
27
|
-
bp login
|
|
28
|
-
bp
|
|
19
|
+
```bash
|
|
20
|
+
bp login # sign in via your browser (read-only by default)
|
|
21
|
+
bp summary # overview: unique/total counts, value, containers
|
|
22
|
+
bp search "finish=foil order by price desc" --limit 10
|
|
29
23
|
```
|
|
30
24
|
|
|
31
|
-
|
|
32
|
-
token locally so you stay signed in for ~30 days. `bp logout` revokes it.
|
|
33
|
-
|
|
34
|
-
on a headless box: `bp login --no-browser` prints a url to open elsewhere.
|
|
35
|
-
|
|
36
|
-
## examples
|
|
37
|
-
|
|
38
|
-
```sh
|
|
39
|
-
# search with the same query grammar as the web app
|
|
40
|
-
bp search "t:creature c:rg cmc<=3 -t:legendary"
|
|
41
|
-
bp search rare f:foil --sort price --desc --limit 20
|
|
25
|
+
## auth
|
|
42
26
|
|
|
43
|
-
|
|
44
|
-
bp
|
|
27
|
+
- **interactive** (people): `bp login` opens your browser (OAuth 2.0 + PKCE). Add
|
|
28
|
+
`--write` to allow changes; `bp logout` clears local credentials.
|
|
29
|
+
- **headless / CI** (no browser): set `BIBLIOPLEX_TOKEN` to a personal access token
|
|
30
|
+
created in the Biblioplex web app (account menu → access tokens).
|
|
45
31
|
|
|
46
|
-
|
|
47
|
-
bp ls decks
|
|
48
|
-
bp show "deck:breya"
|
|
49
|
-
bp deck show breya
|
|
50
|
-
bp deck export breya --preset moxfield > breya.txt
|
|
32
|
+
## commands
|
|
51
33
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
bp undo # revert the last cli change
|
|
34
|
+
| | |
|
|
35
|
+
| --------------------------- | ------------------------------------------------------------------- |
|
|
36
|
+
| **read** | `summary` `search` `ls` `deck` `value` `prices` `history` `resolve` |
|
|
37
|
+
| **write** (needs `--write`) | `add` `rm` `move` `edit` `import` `export` |
|
|
38
|
+
| **recover** | `undo` `recover` |
|
|
39
|
+
| **auth** | `login` `logout` `whoami` |
|
|
59
40
|
|
|
60
|
-
|
|
61
|
-
bp import moxfield-export.csv # auto-detects the format
|
|
62
|
-
bp export "f:foil" --format moxfield > foils.txt
|
|
63
|
-
bp export --archive --output backup.json
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
ambiguous card names list the matching stacks; narrow with
|
|
67
|
-
`--set/--cn/--finish/--condition/--location`, or apply to all with `--all`.
|
|
41
|
+
Run `bp help` or `bp <command> --help` for usage.
|
|
68
42
|
|
|
69
|
-
|
|
43
|
+
### writes are previewed
|
|
70
44
|
|
|
71
|
-
|
|
45
|
+
Every change shows a dry-run preview and asks for confirmation. Pass `--yes` to
|
|
46
|
+
skip the prompt or `--dry-run` to preview without applying. Changes apply
|
|
47
|
+
server-side with automatic recovery points, so `bp undo` and `bp recover` can roll
|
|
48
|
+
them back.
|
|
72
49
|
|
|
73
|
-
|
|
74
|
-
{ "ok": true, "data": { ... } }
|
|
75
|
-
{ "ok": false, "error": { "message": "..." } }
|
|
76
|
-
```
|
|
50
|
+
## for scripts & agents
|
|
77
51
|
|
|
78
|
-
|
|
79
|
-
|
|
52
|
+
- `--json` on any command emits a stable envelope: `{ "ok": true, "data": … }` or
|
|
53
|
+
`{ "ok": false, "error": … }`.
|
|
54
|
+
- meaningful exit codes: `0` ok · `2` usage · `3` auth/scope · `4` not found ·
|
|
55
|
+
`5` conflict · `75` rate-limited / retryable.
|
|
56
|
+
- override the API origin with `BIBLIOPLEX_API_ORIGIN` (e.g. a local dev worker).
|
|
80
57
|
|
|
81
|
-
##
|
|
58
|
+
## notes
|
|
82
59
|
|
|
83
|
-
-
|
|
84
|
-
|
|
85
|
-
-
|
|
60
|
+
- cloud-first: the CLI operates on your live Biblioplex collection (the web app is
|
|
61
|
+
the source of truth).
|
|
62
|
+
- zero runtime dependencies.
|
|
86
63
|
|
|
87
|
-
##
|
|
64
|
+
## license
|
|
88
65
|
|
|
89
|
-
|
|
90
|
-
- card search/sort, csv adapters, and the collection model are shared verbatim
|
|
91
|
-
with the web app (see `vendor/`), so behavior matches exactly.
|
|
92
|
-
- macOS and linux are supported in this release.
|
|
66
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,42 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "biblioplex",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.1",
|
|
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",
|
|
5
16
|
"type": "module",
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=22"
|
|
19
|
+
},
|
|
6
20
|
"bin": {
|
|
7
21
|
"biblioplex": "bin/biblioplex.mjs",
|
|
8
22
|
"bp": "bin/biblioplex.mjs"
|
|
9
23
|
},
|
|
10
|
-
"engines": {
|
|
11
|
-
"node": ">=20"
|
|
12
|
-
},
|
|
13
24
|
"files": [
|
|
14
25
|
"bin/",
|
|
15
26
|
"src/",
|
|
16
|
-
"vendor/",
|
|
17
27
|
"README.md"
|
|
18
28
|
],
|
|
19
29
|
"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"
|
|
30
|
+
"test": "node --test \"src/**/__tests__/*.test.js\""
|
|
41
31
|
}
|
|
42
32
|
}
|
|
@@ -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) {
|