biblioplex 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +44 -70
  2. package/package.json +17 -27
  3. package/src/__tests__/args.test.js +39 -0
  4. package/src/__tests__/csv.test.js +33 -0
  5. package/src/__tests__/mcp.test.js +84 -0
  6. package/src/__tests__/mutate.test.js +91 -0
  7. package/src/__tests__/render.test.js +103 -0
  8. package/src/args.mjs +29 -6
  9. package/src/cli.mjs +38 -25
  10. package/src/commands/add.mjs +30 -51
  11. package/src/commands/deck.mjs +13 -47
  12. package/src/commands/edit.mjs +26 -38
  13. package/src/commands/export.mjs +57 -41
  14. package/src/commands/history.mjs +24 -0
  15. package/src/commands/import.mjs +52 -80
  16. package/src/commands/index.mjs +44 -20
  17. package/src/commands/login.mjs +29 -38
  18. package/src/commands/logout.mjs +13 -10
  19. package/src/commands/ls.mjs +13 -25
  20. package/src/commands/move.mjs +15 -35
  21. package/src/commands/prices.mjs +21 -0
  22. package/src/commands/recover.mjs +53 -0
  23. package/src/commands/resolve.mjs +37 -0
  24. package/src/commands/rm.mjs +15 -32
  25. package/src/commands/search.mjs +25 -35
  26. package/src/commands/summary.mjs +25 -28
  27. package/src/commands/undo.mjs +13 -28
  28. package/src/commands/value.mjs +30 -0
  29. package/src/commands/whoami.mjs +27 -19
  30. package/src/constants.mjs +34 -7
  31. package/src/csv.mjs +71 -0
  32. package/src/errors.mjs +13 -2
  33. package/src/mcp.mjs +227 -0
  34. package/src/mutate.mjs +142 -67
  35. package/src/oauth.mjs +200 -62
  36. package/src/output.mjs +27 -18
  37. package/src/render.mjs +135 -30
  38. package/src/store.mjs +39 -23
  39. package/src/api.mjs +0 -110
  40. package/src/commands/container.mjs +0 -83
  41. package/src/commands/deckExport.mjs +0 -48
  42. package/src/commands/show.mjs +0 -33
  43. package/src/commands/tag.mjs +0 -40
  44. package/src/commands/writeHelpers.mjs +0 -82
  45. package/src/scryfall.mjs +0 -81
  46. package/src/snapshot.mjs +0 -77
  47. package/vendor/README.md +0 -20
  48. package/vendor/adapters.js +0 -443
  49. package/vendor/collection.js +0 -665
  50. package/vendor/deckExport.js +0 -219
  51. package/vendor/importMerge.js +0 -22
  52. package/vendor/importParsing.js +0 -119
  53. package/vendor/portableArchive.js +0 -151
  54. package/vendor/searchCore.js +0 -223
  55. package/vendor/state.js +0 -91
  56. package/vendor/storageSchema.js +0 -75
  57. package/vendor/syncOps.js +0 -188
package/README.md CHANGED
@@ -1,92 +1,66 @@
1
- # biblioplex cli
1
+ # biblioplex
2
2
 
3
- manage your [biblioplex](https://biblioplex.bensonperry.com) magic: the gathering
4
- collection from the terminal — search, edit, import, and export, all against your
5
- live cloud collection.
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
- requires **node 20+**.
13
-
14
- ```sh
15
- npm install -g biblioplex # then run `biblioplex` or `bp`
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
- > the global command is `biblioplex`. it also installs a short `bp` alias; if some
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
- ## sign in
17
+ ## quick start
25
18
 
26
- ```sh
27
- bp login # read-only (search/export)
28
- bp login --write # also allow edits/imports
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
- `bp login` opens your browser once to authorize the cli, then stores a refresh
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
- # totals and your most valuable cards
44
- bp summary
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
- # containers
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
- # edits (need `bp login --write`)
53
- bp add "Sol Ring" --set c21 --cn 263 --qty 2 --location "box:bulk"
54
- bp edit "Sol Ring" --condition lightly_played
55
- bp move "Sol Ring" --to "deck:breya"
56
- bp tag add trade "Sol Ring"
57
- bp rm "Sol Ring"
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
- # bulk import / export (cloud stays the source of truth)
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
- ## scripting / agents
43
+ ### writes are previewed
70
44
 
71
- every command accepts `--json` and prints a stable envelope to stdout:
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
- ```json
74
- { "ok": true, "data": { ... } }
75
- { "ok": false, "error": { "message": "..." } }
76
- ```
50
+ ## for scripts & agents
77
51
 
78
- diagnostics go to stderr, so `--json` stdout is always a single json document.
79
- exit codes: `0` ok · `1` error · `2` usage · `3` auth (run `bp login`) · `4` rate-limited.
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
- ## configuration
58
+ ## notes
82
59
 
83
- - credentials: `~/.config/biblioplex/credentials.json` (mode 600)
84
- - override the api endpoint: `--api <url>` or `BIBLIOPLEX_API_BASE`
85
- - override the config dir: `BIBLIOPLEX_CONFIG_DIR`
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
- ## notes
64
+ ## license
88
65
 
89
- - zero runtime dependencies (node built-ins only).
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.1.1",
4
- "description": "Command-line tool to manage your biblioplex Magic: The Gathering collection.",
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
- "sync-vendor": "node scripts/sync-vendor.mjs",
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', 'yes', 'y', 'help', 'h', 'version', 'v',
8
- 'csv', 'table', 'dry-run', 'no-browser', 'no-color', 'all', 'force', 'verbose',
9
- 'desc', 'no-resolve', 'archive', 'write',
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 === '--') { noMoreFlags = true; i += 1; continue; }
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) { value = raw.slice(eq + 1); raw = raw.slice(0, eq); }
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) {