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/commands/ls.mjs
CHANGED
|
@@ -1,31 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { listContainers } from '../snapshot.mjs';
|
|
3
|
-
import { usageError } from '../errors.mjs';
|
|
4
|
-
|
|
5
|
-
// binder/box are legacy aliases for the unified container type.
|
|
6
|
-
const TYPES = { decks: 'deck', deck: 'deck', containers: 'container', container: 'container', binders: 'container', boxes: 'container', binder: 'container', box: 'container' };
|
|
1
|
+
import { emitResult, INVENTORY_COLUMNS, CONTAINER_COLUMNS } from '../render.mjs';
|
|
7
2
|
|
|
8
3
|
export default {
|
|
9
|
-
summary: 'list containers
|
|
10
|
-
help:
|
|
4
|
+
summary: 'list containers and their cards',
|
|
5
|
+
help: `bp ls [container]
|
|
6
|
+
|
|
7
|
+
With no argument, lists your containers (decks, boxes, binders). With a
|
|
8
|
+
container name, lists the cards stored in it.`,
|
|
11
9
|
async run(ctx) {
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
if (
|
|
15
|
-
|
|
16
|
-
|
|
10
|
+
const session = await ctx.makeSession();
|
|
11
|
+
const name = ctx.args.join(' ').trim();
|
|
12
|
+
if (!name) {
|
|
13
|
+
const sc = await session.call('query_collection', { from: 'containers' });
|
|
14
|
+
return emitResult(ctx, sc, { columns: CONTAINER_COLUMNS });
|
|
17
15
|
}
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
const rows = listContainers(snapshot, type);
|
|
21
|
-
|
|
22
|
-
out.emit({ containers: rows }, () => {
|
|
23
|
-
if (!rows.length) { out.info('no containers.'); return; }
|
|
24
|
-
out.table(
|
|
25
|
-
[{ header: 'type' }, { header: 'name' }, { header: 'cards', align: 'right' }, { header: 'value', align: 'right' }],
|
|
26
|
-
rows.map(r => [r.type, r.name, String(r.total), '$' + r.value.toFixed(2)]),
|
|
27
|
-
);
|
|
28
|
-
});
|
|
29
|
-
return 0;
|
|
16
|
+
const sc = await session.call('query_collection', { from: 'inventory', location: name });
|
|
17
|
+
return emitResult(ctx, sc, { columns: INVENTORY_COLUMNS });
|
|
30
18
|
},
|
|
31
19
|
};
|
package/src/commands/move.mjs
CHANGED
|
@@ -1,41 +1,21 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { applyMutation } from '../mutate.mjs';
|
|
3
|
-
import { strFlag, boolFlag } from '../args.mjs';
|
|
4
|
-
import { requireWrite, selectorFrom, requireStacks, parseLocationFlag, ensureContainer, persistUndo, printWrite } from './writeHelpers.mjs';
|
|
1
|
+
import { intFlag, strFlag } from '../args.mjs';
|
|
5
2
|
import { usageError } from '../errors.mjs';
|
|
3
|
+
import { runMutation } from '../mutate.mjs';
|
|
6
4
|
|
|
7
5
|
export default {
|
|
8
|
-
summary: 'move
|
|
9
|
-
help: [
|
|
10
|
-
'usage: bp move <name> --to "deck:breya" [--board sideboard] [--set --cn --finish ...] [--all] [--dry-run]',
|
|
11
|
-
'',
|
|
12
|
-
'changes a stack\'s location. --to accepts "deck:x", "container:x", or a bare name',
|
|
13
|
-
'(treated as a container). --board sets the deck board (main/sideboard/maybe).',
|
|
14
|
-
].join('\n'),
|
|
15
|
-
async run(ctx) {
|
|
16
|
-
const { out, flags, args } = ctx;
|
|
17
|
-
const session = ctx.makeSession();
|
|
18
|
-
requireWrite(session);
|
|
19
|
-
const to = parseLocationFlag(strFlag(flags, 'to'));
|
|
20
|
-
if (!to) throw usageError('move requires --to <container>');
|
|
21
|
-
const board = strFlag(flags, 'board');
|
|
22
|
-
const sel = selectorFrom(flags, args);
|
|
23
|
-
|
|
24
|
-
const result = await applyMutation(session, (draft) => {
|
|
25
|
-
const stacks = requireStacks(draft.app.collection, sel, { all: boolFlag(flags, 'all') });
|
|
26
|
-
ensureContainer(draft, to);
|
|
27
|
-
const keys = new Set(stacks.map(collectionKey));
|
|
28
|
-
for (const e of draft.app.collection) {
|
|
29
|
-
if (!keys.has(collectionKey(e))) continue;
|
|
30
|
-
e.location = { type: to.type, name: to.name };
|
|
31
|
-
if (to.type === 'deck') e.deckBoard = board || e.deckBoard || 'main';
|
|
32
|
-
else delete e.deckBoard;
|
|
33
|
-
}
|
|
34
|
-
return { moved: stacks.length, to: to.type + ':' + to.name };
|
|
35
|
-
}, { dryRun: boolFlag(flags, 'dry-run') });
|
|
6
|
+
summary: 'move cards between locations',
|
|
7
|
+
help: `bp move <itemKey> --to LOCATION [--qty N] [--yes] [--dry-run]
|
|
36
8
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
9
|
+
Move an inventory row to another location (e.g. deck:breya, box:red).
|
|
10
|
+
Get the itemKey from \`bp search ... --json\`.`,
|
|
11
|
+
async run(ctx) {
|
|
12
|
+
const itemKey = ctx.args[0] || strFlag(ctx.flags, 'item');
|
|
13
|
+
const to = strFlag(ctx.flags, 'to');
|
|
14
|
+
if (!itemKey || !to) throw usageError('usage: bp move <itemKey> --to LOCATION [--qty N]');
|
|
15
|
+
const session = await ctx.makeSession({ write: true });
|
|
16
|
+
const args = { operation: 'move', itemKey, toLocation: to };
|
|
17
|
+
const qty = intFlag(ctx.flags, 'qty');
|
|
18
|
+
if (qty != null) args.qty = qty;
|
|
19
|
+
return runMutation(ctx, session, args, { verb: 'move' });
|
|
40
20
|
},
|
|
41
21
|
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { strFlag } from '../args.mjs';
|
|
2
|
+
import { usageError } from '../errors.mjs';
|
|
3
|
+
import { emitResult } from '../render.mjs';
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
summary: 'price snapshots for a printing',
|
|
7
|
+
help: `bp prices <scryfallId> [--finish foil|nonfoil|etched]
|
|
8
|
+
|
|
9
|
+
Per-source price snapshots for one owned printing (by Scryfall id).
|
|
10
|
+
Find ids with: bp search "..." --json`,
|
|
11
|
+
async run(ctx) {
|
|
12
|
+
const scryfallId = ctx.args[0];
|
|
13
|
+
if (!scryfallId) throw usageError('usage: bp prices <scryfallId> [--finish ...]');
|
|
14
|
+
const session = await ctx.makeSession();
|
|
15
|
+
const args = { scryfallId };
|
|
16
|
+
const finish = strFlag(ctx.flags, 'finish');
|
|
17
|
+
if (finish) args.finish = finish;
|
|
18
|
+
const sc = await session.call('card_prices', args);
|
|
19
|
+
return emitResult(ctx, sc);
|
|
20
|
+
},
|
|
21
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { EXIT } from '../constants.mjs';
|
|
2
|
+
import { usageError } from '../errors.mjs';
|
|
3
|
+
import { emitResult } from '../render.mjs';
|
|
4
|
+
import { confirm } from '../mutate.mjs';
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
summary: 'list / preview / restore recovery points',
|
|
8
|
+
help: `bp recover list
|
|
9
|
+
bp recover preview <id>
|
|
10
|
+
bp recover restore <id> [--yes]
|
|
11
|
+
|
|
12
|
+
Recovery points are server-side snapshots taken automatically before each
|
|
13
|
+
change — your safety net if undo isn't enough.`,
|
|
14
|
+
async run(ctx) {
|
|
15
|
+
const sub = ctx.args[0] || 'list';
|
|
16
|
+
|
|
17
|
+
if (sub === 'list') {
|
|
18
|
+
const session = await ctx.makeSession();
|
|
19
|
+
const sc = await session.call('list_mcp_recovery_points', {});
|
|
20
|
+
return emitResult(ctx, sc, {
|
|
21
|
+
columns: [
|
|
22
|
+
{ key: 'id', header: 'id' },
|
|
23
|
+
{ key: 'createdAt', header: 'when' },
|
|
24
|
+
{ key: 'summary', header: 'summary' },
|
|
25
|
+
],
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const id = ctx.args[1];
|
|
30
|
+
if (!id) throw usageError(`usage: bp recover ${sub} <id>`);
|
|
31
|
+
|
|
32
|
+
if (sub === 'preview') {
|
|
33
|
+
const session = await ctx.makeSession({ write: true });
|
|
34
|
+
const sc = await session.call('restore_mcp_recovery_point', { id, dryRun: true });
|
|
35
|
+
return emitResult(ctx, sc);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (sub === 'restore') {
|
|
39
|
+
const session = await ctx.makeSession({ write: true });
|
|
40
|
+
const preview = await session.call('restore_mcp_recovery_point', { id, dryRun: true });
|
|
41
|
+
ctx.out.line('restore preview:');
|
|
42
|
+
ctx.out.line(' • ' + (preview.summary || preview.message || JSON.stringify(preview)));
|
|
43
|
+
if (!(await confirm(ctx, `restore recovery point ${id}?`))) {
|
|
44
|
+
ctx.out.info('aborted.');
|
|
45
|
+
return EXIT.OK;
|
|
46
|
+
}
|
|
47
|
+
const sc = await session.call('restore_mcp_recovery_point', { id });
|
|
48
|
+
return ctx.out.emit(sc, () => ctx.out.line('restored.')) ?? EXIT.OK;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
throw usageError('usage: bp recover list | preview <id> | restore <id>');
|
|
52
|
+
},
|
|
53
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { strFlag } from '../args.mjs';
|
|
2
|
+
import { usageError } from '../errors.mjs';
|
|
3
|
+
import { emitResult } from '../render.mjs';
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
summary: 'resolve a Scryfall printing',
|
|
7
|
+
help: `bp resolve <query> [--set CODE] [--cn NUMBER] [--finish ...]
|
|
8
|
+
|
|
9
|
+
Resolve a card to concrete printings (for adding/replacing). Examples:
|
|
10
|
+
bp resolve "lightning bolt"
|
|
11
|
+
bp resolve --set sld --cn 2144`,
|
|
12
|
+
async run(ctx) {
|
|
13
|
+
const session = await ctx.makeSession();
|
|
14
|
+
const args = {};
|
|
15
|
+
const query = ctx.args.join(' ').trim();
|
|
16
|
+
if (query) args.query = query;
|
|
17
|
+
const set = strFlag(ctx.flags, 'set');
|
|
18
|
+
const cn = strFlag(ctx.flags, 'cn');
|
|
19
|
+
const finish = strFlag(ctx.flags, 'finish');
|
|
20
|
+
if (set) args.setCode = set;
|
|
21
|
+
if (cn) args.cn = cn;
|
|
22
|
+
if (finish) args.finish = finish;
|
|
23
|
+
if (!query && !(set && cn)) {
|
|
24
|
+
throw usageError('usage: bp resolve <query> (or --set CODE --cn NUMBER)');
|
|
25
|
+
}
|
|
26
|
+
const sc = await session.call('resolve_printing', args);
|
|
27
|
+
return emitResult(ctx, sc, {
|
|
28
|
+
columns: [
|
|
29
|
+
{ key: 'name', header: 'name' },
|
|
30
|
+
{ key: 'setCode', header: 'set' },
|
|
31
|
+
{ key: 'cn', header: 'cn' },
|
|
32
|
+
{ key: 'finish', header: 'finish' },
|
|
33
|
+
{ key: 'price', header: 'price', align: 'right', money: true },
|
|
34
|
+
],
|
|
35
|
+
});
|
|
36
|
+
},
|
|
37
|
+
};
|
package/src/commands/rm.mjs
CHANGED
|
@@ -1,37 +1,20 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { requireWrite, selectorFrom, requireStacks, persistUndo, printWrite } from './writeHelpers.mjs';
|
|
1
|
+
import { intFlag, strFlag } from '../args.mjs';
|
|
2
|
+
import { usageError } from '../errors.mjs';
|
|
3
|
+
import { runMutation } from '../mutate.mjs';
|
|
5
4
|
|
|
6
5
|
export default {
|
|
7
|
-
summary: 'remove
|
|
8
|
-
help: [
|
|
9
|
-
'usage: bp rm <name> [--set --cn --finish --condition --location] [--qty n] [--all] [--dry-run]',
|
|
10
|
-
'',
|
|
11
|
-
'removes whole matching stacks. --qty n removes only n copies. --all removes every',
|
|
12
|
-
'matching stack when a name is ambiguous.',
|
|
13
|
-
].join('\n'),
|
|
14
|
-
async run(ctx) {
|
|
15
|
-
const { out, flags, args } = ctx;
|
|
16
|
-
const session = ctx.makeSession();
|
|
17
|
-
requireWrite(session);
|
|
18
|
-
const sel = selectorFrom(flags, args);
|
|
19
|
-
const removeQty = intFlag(flags, 'qty', null);
|
|
20
|
-
|
|
21
|
-
const result = await applyMutation(session, (draft) => {
|
|
22
|
-
const stacks = requireStacks(draft.app.collection, sel, { all: boolFlag(flags, 'all') });
|
|
23
|
-
const keys = new Set(stacks.map(collectionKey));
|
|
24
|
-
if (removeQty != null) {
|
|
25
|
-
for (const e of draft.app.collection) if (keys.has(collectionKey(e))) e.qty = Math.max(0, (parseInt(e.qty, 10) || 0) - removeQty);
|
|
26
|
-
draft.app.collection = draft.app.collection.filter(e => (parseInt(e.qty, 10) || 0) > 0);
|
|
27
|
-
} else {
|
|
28
|
-
draft.app.collection = draft.app.collection.filter(e => !keys.has(collectionKey(e)));
|
|
29
|
-
}
|
|
30
|
-
return { removed: stacks.length };
|
|
31
|
-
}, { dryRun: boolFlag(flags, 'dry-run') });
|
|
6
|
+
summary: 'remove cards from your collection',
|
|
7
|
+
help: `bp rm <itemKey> [--qty N] [--yes] [--dry-run]
|
|
32
8
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
9
|
+
Delete an inventory row. Get its itemKey from \`bp search ... --json\`.
|
|
10
|
+
Without --qty, removes the whole row.`,
|
|
11
|
+
async run(ctx) {
|
|
12
|
+
const itemKey = ctx.args[0] || strFlag(ctx.flags, 'item');
|
|
13
|
+
if (!itemKey) throw usageError('usage: bp rm <itemKey> [--qty N]');
|
|
14
|
+
const session = await ctx.makeSession({ write: true });
|
|
15
|
+
const args = { operation: 'delete', itemKey };
|
|
16
|
+
const qty = intFlag(ctx.flags, 'qty');
|
|
17
|
+
if (qty != null) args.qty = qty;
|
|
18
|
+
return runMutation(ctx, session, args, { verb: 'remove' });
|
|
36
19
|
},
|
|
37
20
|
};
|
package/src/commands/search.mjs
CHANGED
|
@@ -1,42 +1,32 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { cardRow, CARD_COLUMNS, cardsToCsv } from '../render.mjs';
|
|
4
|
-
import { strFlag, intFlag, boolFlag } from '../args.mjs';
|
|
1
|
+
import { intFlag, strFlag, boolFlag } from '../args.mjs';
|
|
2
|
+
import { emitResult, INVENTORY_COLUMNS } from '../render.mjs';
|
|
5
3
|
|
|
6
4
|
export default {
|
|
7
|
-
summary: 'search your
|
|
8
|
-
help: [
|
|
9
|
-
'usage: bp search <query> [--sort field] [--desc] [--limit n] [--csv|--json]',
|
|
10
|
-
'',
|
|
11
|
-
'uses the same query grammar as the web app, e.g.:',
|
|
12
|
-
' bp search "t:creature c:rg cmc<=3 -t:legendary"',
|
|
13
|
-
' bp search rare f:foil --sort price --desc',
|
|
14
|
-
'',
|
|
15
|
-
'fields: t/type c/color ci/identity cmc/mv o/oracle r/rarity s/set f/finish',
|
|
16
|
-
' loc/location tag cond/condition lang qty (prefix with - to negate)',
|
|
17
|
-
'sort: name set cn finish rarity condition location qty price cmc',
|
|
18
|
-
].join('\n'),
|
|
19
|
-
async run(ctx) {
|
|
20
|
-
const { out, flags, args } = ctx;
|
|
21
|
-
const session = ctx.makeSession();
|
|
22
|
-
const { snapshot } = await loadSnapshot(session);
|
|
23
|
-
const query = args.join(' ');
|
|
24
|
-
const sort = strFlag(flags, 'sort') || 'name';
|
|
25
|
-
const dir = boolFlag(flags, 'desc') ? 'desc' : (strFlag(flags, 'dir') || 'asc');
|
|
5
|
+
summary: 'search your inventory',
|
|
6
|
+
help: `bp search [query] [--limit N] [--sort FIELD] [--desc] [--cursor C]
|
|
26
7
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
8
|
+
Query physical inventory with Biblioplex's SQL-ish expression. Examples:
|
|
9
|
+
bp search "cmc=1 colors=blue"
|
|
10
|
+
bp search "finish=foil order by price desc" --limit 10
|
|
11
|
+
bp search 'select name,price from inventory where oracleText like "draw a card"'
|
|
30
12
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
13
|
+
Fields: name,setCode,finish,condition,qty,location,tags,typeLine,setName,cmc,
|
|
14
|
+
colors,colorIdentity,oracleText,price,totalValue.`,
|
|
15
|
+
async run(ctx) {
|
|
16
|
+
const session = await ctx.makeSession();
|
|
17
|
+
const args = { from: 'inventory' };
|
|
18
|
+
const query = ctx.args.join(' ').trim();
|
|
19
|
+
if (query) args.query = query;
|
|
20
|
+
const limit = intFlag(ctx.flags, 'limit');
|
|
21
|
+
if (limit != null) args.limit = limit;
|
|
22
|
+
const sort = strFlag(ctx.flags, 'sort');
|
|
23
|
+
if (sort) {
|
|
24
|
+
args.sortBy = sort;
|
|
25
|
+
args.sortDirection = boolFlag(ctx.flags, 'desc') ? 'desc' : 'asc';
|
|
34
26
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
});
|
|
40
|
-
return 0;
|
|
27
|
+
const cursor = strFlag(ctx.flags, 'cursor');
|
|
28
|
+
if (cursor) args.cursor = cursor;
|
|
29
|
+
const sc = await session.call('query_collection', args);
|
|
30
|
+
return emitResult(ctx, sc, { columns: INVENTORY_COLUMNS });
|
|
41
31
|
},
|
|
42
32
|
};
|
package/src/commands/summary.mjs
CHANGED
|
@@ -1,34 +1,31 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { collectionOf, containersOf, summarize } from '../snapshot.mjs';
|
|
1
|
+
import { emitResult, renderTable, fmtMoney, CONTAINER_COLUMNS } from '../render.mjs';
|
|
3
2
|
|
|
4
3
|
export default {
|
|
5
|
-
summary: 'collection
|
|
6
|
-
help:
|
|
7
|
-
async run(ctx) {
|
|
8
|
-
const { out } = ctx;
|
|
9
|
-
const session = ctx.makeSession();
|
|
10
|
-
const { snapshot } = await loadSnapshot(session);
|
|
11
|
-
const collection = collectionOf(snapshot);
|
|
12
|
-
const stats = summarize(collection);
|
|
13
|
-
const containers = Object.values(containersOf(snapshot));
|
|
14
|
-
const top = collection
|
|
15
|
-
.filter(c => typeof c.price === 'number')
|
|
16
|
-
.map(c => ({ name: c.resolvedName || c.name, set: (c.setCode || '').toUpperCase(), qty: c.qty, price: c.price, value: Math.round(c.price * (c.qty || 1) * 100) / 100 }))
|
|
17
|
-
.sort((a, b) => b.value - a.value)
|
|
18
|
-
.slice(0, 10);
|
|
4
|
+
summary: 'collection overview (counts, value, containers)',
|
|
5
|
+
help: `bp summary
|
|
19
6
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
7
|
+
One-shot overview: totals, recent changes, and your containers. Use --json for
|
|
8
|
+
the full structured payload.`,
|
|
9
|
+
async run(ctx) {
|
|
10
|
+
const session = await ctx.makeSession();
|
|
11
|
+
const sc = await session.call('get_collection_overview', {});
|
|
12
|
+
return emitResult(ctx, sc, {
|
|
13
|
+
render: (out) => {
|
|
14
|
+
const s = sc.summary || sc.overview || sc;
|
|
15
|
+
const get = (...keys) => {
|
|
16
|
+
for (const k of keys) if (s?.[k] != null) return s[k];
|
|
17
|
+
return undefined;
|
|
18
|
+
};
|
|
19
|
+
const unique = get('uniqueCount', 'unique', 'uniqueCards');
|
|
20
|
+
const total = get('totalCount', 'total', 'totalCards', 'count');
|
|
21
|
+
const value = get('totalValue', 'value');
|
|
22
|
+
out.line(`unique ${unique ?? '?'} · total ${total ?? '?'} · value ${fmtMoney(value)}`);
|
|
23
|
+
if (Array.isArray(sc.containers) && sc.containers.length) {
|
|
24
|
+
out.line('');
|
|
25
|
+
out.line('containers:');
|
|
26
|
+
renderTable(out, sc.containers, CONTAINER_COLUMNS);
|
|
27
|
+
}
|
|
28
|
+
},
|
|
31
29
|
});
|
|
32
|
-
return 0;
|
|
33
30
|
},
|
|
34
31
|
};
|
package/src/commands/undo.mjs
CHANGED
|
@@ -1,34 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { applyMutation } from '../mutate.mjs';
|
|
3
|
-
import { loadUndo, clearUndo } from '../store.mjs';
|
|
4
|
-
import { requireWrite, printWrite } from './writeHelpers.mjs';
|
|
1
|
+
import { EXIT } from '../constants.mjs';
|
|
5
2
|
import { CliError } from '../errors.mjs';
|
|
6
3
|
|
|
7
4
|
export default {
|
|
8
|
-
summary: 'undo
|
|
9
|
-
help:
|
|
10
|
-
async run(ctx) {
|
|
11
|
-
const { out } = ctx;
|
|
12
|
-
const session = ctx.makeSession();
|
|
13
|
-
requireWrite(session);
|
|
14
|
-
const record = loadUndo();
|
|
15
|
-
if (!record) throw new CliError('nothing to undo');
|
|
16
|
-
|
|
17
|
-
const result = await applyMutation(session, (draft) => {
|
|
18
|
-
for (const [key, before] of Object.entries(record.collection || {})) {
|
|
19
|
-
draft.app.collection = draft.app.collection.filter(e => collectionKey(e) !== key);
|
|
20
|
-
if (before) draft.app.collection.push(before);
|
|
21
|
-
}
|
|
22
|
-
if (Object.keys(record.containers || {}).length && !draft.app.containers) draft.app.containers = {};
|
|
23
|
-
for (const [key, before] of Object.entries(record.containers || {})) {
|
|
24
|
-
if (before) draft.app.containers[key] = before;
|
|
25
|
-
else delete draft.app.containers[key];
|
|
26
|
-
}
|
|
27
|
-
return { undone: true };
|
|
28
|
-
});
|
|
5
|
+
summary: 'undo your last collection change',
|
|
6
|
+
help: `bp undo
|
|
29
7
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
8
|
+
Undo the most recent change you made via the CLI/MCP. Only works if it is still
|
|
9
|
+
the newest change; otherwise use \`bp recover\`.`,
|
|
10
|
+
async run(ctx) {
|
|
11
|
+
const session = await ctx.makeSession({ write: true });
|
|
12
|
+
const sc = await session.call('undo_last_mcp_change', {});
|
|
13
|
+
if (sc?.status === 'not_found') throw new CliError('nothing to undo', EXIT.NOT_FOUND, sc);
|
|
14
|
+
if (sc?.status === 'unsafe') {
|
|
15
|
+
throw new CliError('not the newest change — use `bp recover` instead', EXIT.CONFLICT, sc);
|
|
16
|
+
}
|
|
17
|
+
return ctx.out.emit(sc, () => ctx.out.line('undone.')) ?? EXIT.OK;
|
|
33
18
|
},
|
|
34
19
|
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { strFlag } from '../args.mjs';
|
|
2
|
+
import { emitResult, fmtMoney } from '../render.mjs';
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
summary: 'collection value totals',
|
|
6
|
+
help: `bp value [--source usd|eur|tix]
|
|
7
|
+
|
|
8
|
+
Priced/unpriced counts and total value for a price source (default usd).`,
|
|
9
|
+
async run(ctx) {
|
|
10
|
+
const session = await ctx.makeSession();
|
|
11
|
+
const source = strFlag(ctx.flags, 'source') || 'usd';
|
|
12
|
+
const sc = await session.call('collection_value', { source });
|
|
13
|
+
return emitResult(ctx, sc, {
|
|
14
|
+
render: (out) => {
|
|
15
|
+
const get = (...k) => {
|
|
16
|
+
for (const key of k) if (sc?.[key] != null) return sc[key];
|
|
17
|
+
return undefined;
|
|
18
|
+
};
|
|
19
|
+
out.line(`source: ${get('source') ?? source}`);
|
|
20
|
+
const total = get('totalValue', 'total', 'value');
|
|
21
|
+
if (total != null) out.line(`total value: ${fmtMoney(total)}`);
|
|
22
|
+
const priced = get('pricedCount', 'priced');
|
|
23
|
+
const unpriced = get('unpricedCount', 'unpriced');
|
|
24
|
+
if (priced != null || unpriced != null) {
|
|
25
|
+
out.line(`priced: ${priced ?? '?'} unpriced: ${unpriced ?? '?'}`);
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
},
|
|
30
|
+
};
|
package/src/commands/whoami.mjs
CHANGED
|
@@ -1,26 +1,34 @@
|
|
|
1
1
|
import { loadCredentials } from '../store.mjs';
|
|
2
|
-
import {
|
|
2
|
+
import { READ_SCOPE, EXIT } from '../constants.mjs';
|
|
3
3
|
|
|
4
4
|
export default {
|
|
5
|
-
summary: 'show
|
|
6
|
-
help:
|
|
5
|
+
summary: 'show local auth status',
|
|
6
|
+
help: `bp whoami
|
|
7
|
+
|
|
8
|
+
Show your current authentication state. Does not contact the server.`,
|
|
7
9
|
async run(ctx) {
|
|
8
|
-
const
|
|
10
|
+
const pat = (process.env.BIBLIOPLEX_TOKEN || '').trim();
|
|
11
|
+
if (pat) {
|
|
12
|
+
ctx.out.emit({ authenticated: true, method: 'token', origin: ctx.apiOrigin }, () =>
|
|
13
|
+
ctx.out.line('authenticated via BIBLIOPLEX_TOKEN (personal access token)'),
|
|
14
|
+
);
|
|
15
|
+
return EXIT.OK;
|
|
16
|
+
}
|
|
9
17
|
const creds = loadCredentials();
|
|
10
|
-
if (!creds?.accessToken
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
return
|
|
18
|
+
if (!creds?.accessToken && !creds?.refreshToken) {
|
|
19
|
+
ctx.out.emit({ authenticated: false }, () => ctx.out.line('not logged in — run `bp login`'));
|
|
20
|
+
return EXIT.OK;
|
|
21
|
+
}
|
|
22
|
+
ctx.out.emit(
|
|
23
|
+
{
|
|
24
|
+
authenticated: true,
|
|
25
|
+
method: 'oauth',
|
|
26
|
+
scope: creds.scope || READ_SCOPE,
|
|
27
|
+
origin: creds.origin || ctx.apiOrigin,
|
|
28
|
+
},
|
|
29
|
+
() =>
|
|
30
|
+
ctx.out.line(`logged in · ${creds.scope || READ_SCOPE} · ${creds.origin || ctx.apiOrigin}`),
|
|
31
|
+
);
|
|
32
|
+
return EXIT.OK;
|
|
25
33
|
},
|
|
26
34
|
};
|
package/src/constants.mjs
CHANGED
|
@@ -3,17 +3,39 @@ import { join, dirname } from 'node:path';
|
|
|
3
3
|
import { readFileSync } from 'node:fs';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
|
|
6
|
-
const pkg = JSON.parse(
|
|
6
|
+
const pkg = JSON.parse(
|
|
7
|
+
readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'),
|
|
8
|
+
);
|
|
7
9
|
|
|
8
10
|
export const VERSION = pkg.version;
|
|
9
|
-
|
|
10
|
-
|
|
11
|
+
|
|
12
|
+
// The Biblioplex API origin (MCP + OAuth live here). Override for local
|
|
13
|
+
// `wrangler dev` via BIBLIOPLEX_API_ORIGIN (e.g. http://localhost:8765).
|
|
14
|
+
export const DEFAULT_API_ORIGIN = 'https://biblioplex-api.bensonperry.com';
|
|
15
|
+
|
|
16
|
+
// Sent as client_name during dynamic OAuth client registration.
|
|
11
17
|
export const CLIENT_LABEL = 'biblioplex-cli';
|
|
18
|
+
|
|
12
19
|
export const READ_SCOPE = 'collection.read';
|
|
13
20
|
export const WRITE_SCOPE = 'collection.write';
|
|
14
21
|
|
|
15
|
-
// Process exit codes
|
|
16
|
-
|
|
22
|
+
// Process exit codes — the CLI's contract for scripts and agents:
|
|
23
|
+
// 0 ok
|
|
24
|
+
// 1 generic error
|
|
25
|
+
// 2 usage / parse error / needs input
|
|
26
|
+
// 3 auth — not logged in, or insufficient scope (run `bp login [--write]`)
|
|
27
|
+
// 4 not found
|
|
28
|
+
// 5 conflict — unsafe undo, or revision mismatch (re-run after re-preview)
|
|
29
|
+
// 75 temp-fail — rate limited or retryable server error (EX_TEMPFAIL)
|
|
30
|
+
export const EXIT = {
|
|
31
|
+
OK: 0,
|
|
32
|
+
ERROR: 1,
|
|
33
|
+
USAGE: 2,
|
|
34
|
+
AUTH: 3,
|
|
35
|
+
NOT_FOUND: 4,
|
|
36
|
+
CONFLICT: 5,
|
|
37
|
+
TEMPFAIL: 75,
|
|
38
|
+
};
|
|
17
39
|
|
|
18
40
|
export function configDir() {
|
|
19
41
|
if (process.env.BIBLIOPLEX_CONFIG_DIR) return process.env.BIBLIOPLEX_CONFIG_DIR;
|
|
@@ -21,5 +43,10 @@ export function configDir() {
|
|
|
21
43
|
return join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'biblioplex');
|
|
22
44
|
}
|
|
23
45
|
|
|
24
|
-
export function credentialsPath() {
|
|
25
|
-
|
|
46
|
+
export function credentialsPath() {
|
|
47
|
+
return join(configDir(), 'credentials.json');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function configPath() {
|
|
51
|
+
return join(configDir(), 'config.json');
|
|
52
|
+
}
|