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.
Files changed (57) hide show
  1. package/package.json +23 -27
  2. package/src/__tests__/args.test.js +39 -0
  3. package/src/__tests__/csv.test.js +33 -0
  4. package/src/__tests__/mcp.test.js +84 -0
  5. package/src/__tests__/mutate.test.js +91 -0
  6. package/src/__tests__/render.test.js +103 -0
  7. package/src/args.mjs +29 -6
  8. package/src/cli.mjs +38 -25
  9. package/src/commands/add.mjs +30 -51
  10. package/src/commands/deck.mjs +13 -47
  11. package/src/commands/edit.mjs +26 -38
  12. package/src/commands/export.mjs +57 -41
  13. package/src/commands/history.mjs +24 -0
  14. package/src/commands/import.mjs +52 -80
  15. package/src/commands/index.mjs +44 -20
  16. package/src/commands/login.mjs +29 -38
  17. package/src/commands/logout.mjs +13 -10
  18. package/src/commands/ls.mjs +13 -25
  19. package/src/commands/move.mjs +15 -35
  20. package/src/commands/prices.mjs +21 -0
  21. package/src/commands/recover.mjs +53 -0
  22. package/src/commands/resolve.mjs +37 -0
  23. package/src/commands/rm.mjs +15 -32
  24. package/src/commands/search.mjs +25 -35
  25. package/src/commands/summary.mjs +25 -28
  26. package/src/commands/undo.mjs +13 -28
  27. package/src/commands/value.mjs +30 -0
  28. package/src/commands/whoami.mjs +27 -19
  29. package/src/constants.mjs +34 -7
  30. package/src/csv.mjs +71 -0
  31. package/src/errors.mjs +13 -2
  32. package/src/mcp.mjs +227 -0
  33. package/src/mutate.mjs +142 -67
  34. package/src/oauth.mjs +200 -62
  35. package/src/output.mjs +27 -18
  36. package/src/render.mjs +135 -30
  37. package/src/store.mjs +39 -23
  38. package/README.md +0 -92
  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
@@ -1,52 +1,18 @@
1
- import { loadSnapshot } from '../mutate.mjs';
2
- import { findContainer, containerCards } from '../snapshot.mjs';
3
- import { cardRow, CARD_COLUMNS } from '../render.mjs';
4
- import { usageError, CliError } from '../errors.mjs';
5
- import { runDeckExport } from './deckExport.mjs';
6
-
7
- const BOARDS = ['main', 'sideboard', 'maybe'];
8
-
9
- async function deckShow(ctx, name) {
10
- const { out } = ctx;
11
- if (!name) throw usageError('usage: bp deck show <name>');
12
- const session = ctx.makeSession();
13
- const { snapshot } = await loadSnapshot(session);
14
- const { matches } = findContainer(snapshot, 'deck:' + name);
15
- const decks = matches.filter(m => m.type === 'deck');
16
- if (!decks.length) throw new CliError(`no deck named "${name}"`);
17
-
18
- const deck = decks[0];
19
- const cards = containerCards(snapshot, deck);
20
- const boards = { main: [], sideboard: [], maybe: [] };
21
- for (const c of cards) (boards[c.deckBoard] || boards.main).push(c);
22
-
23
- out.emit({ deck: { name: deck.name, meta: deck.deck || null }, boards }, () => {
24
- out.line(out.c.bold('deck: ' + deck.name));
25
- const meta = deck.deck || {};
26
- if (meta.format) out.line(' format: ' + meta.format);
27
- if (meta.commander?.name) out.line(' commander: ' + meta.commander.name);
28
- for (const b of BOARDS) {
29
- const list = boards[b];
30
- if (!list.length) continue;
31
- const n = list.reduce((s, c) => s + (parseInt(c.qty, 10) || 0), 0);
32
- out.line('');
33
- out.line(out.c.bold(`${b} (${n})`));
34
- out.table(CARD_COLUMNS, list.map(cardRow));
35
- }
36
- });
37
- return 0;
38
- }
1
+ import { emitResult, DECKLIST_COLUMNS, CONTAINER_COLUMNS } from '../render.mjs';
39
2
 
40
3
  export default {
41
- summary: 'inspect or export a deck',
42
- help: [
43
- 'usage: bp deck show <name>',
44
- ' bp deck export <name> [--preset plain|moxfield|arena|mtgo|csv|json] [--boards main,sideboard,maybe]',
45
- ].join('\n'),
4
+ summary: 'list decks or show a decklist',
5
+ help: `bp deck [name]
6
+
7
+ With no name, lists your decks/containers. With a deck name, shows its decklist.`,
46
8
  async run(ctx) {
47
- const [sub, ...rest] = ctx.args;
48
- if (sub === 'show') return deckShow(ctx, rest.join(' '));
49
- if (sub === 'export') return runDeckExport(ctx, rest);
50
- throw usageError('usage: bp deck <show|export> <name>');
9
+ const session = await ctx.makeSession();
10
+ const name = ctx.args.join(' ').trim();
11
+ if (!name) {
12
+ const sc = await session.call('query_collection', { from: 'containers' });
13
+ return emitResult(ctx, sc, { columns: CONTAINER_COLUMNS });
14
+ }
15
+ const sc = await session.call('query_collection', { from: 'decklist', deckName: name });
16
+ return emitResult(ctx, sc, { columns: DECKLIST_COLUMNS });
51
17
  },
52
18
  };
@@ -1,45 +1,33 @@
1
- import { collectionKey, normalizeCondition, normalizeFinish } from '../../vendor/collection.js';
2
- import { applyMutation } from '../mutate.mjs';
3
- import { strFlag, intFlag, boolFlag } from '../args.mjs';
4
- import { requireWrite, selectorFrom, requireStacks, persistUndo, printWrite } from './writeHelpers.mjs';
1
+ import { 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: 'edit a stack (condition / finish / qty / tags)',
9
- help: [
10
- 'usage: bp edit <name> [--set --cn ...] [--condition lightly_played] [--finish foil]',
11
- ' [--qty 3] [--tags trade,foil] [--all] [--dry-run]',
12
- '',
13
- 'updates fields on a matching stack. --tags replaces the tag set ("" clears).',
14
- ].join('\n'),
6
+ summary: 'edit a card (finish, condition, tags)',
7
+ help: `bp edit <itemKey> [--finish ...] [--condition ...] [--tag a,b,c] [--yes] [--dry-run]
8
+
9
+ Edit an inventory row. Tags use set-semantics: pass the full desired list
10
+ (empty --tag clears them).`,
15
11
  async run(ctx) {
16
- const { out, flags, args } = ctx;
17
- const session = ctx.makeSession();
18
- requireWrite(session);
19
- const sel = selectorFrom(flags, args);
20
- const newCond = strFlag(flags, 'condition', 'cond');
21
- const newFinish = strFlag(flags, 'finish');
22
- const newQty = intFlag(flags, 'qty', null);
23
- const setTags = strFlag(flags, 'tags');
24
- if (newCond == null && newFinish == null && newQty == null && setTags == null) {
25
- throw usageError('nothing to edit — pass --condition, --finish, --qty, or --tags');
12
+ const itemKey = ctx.args[0] || strFlag(ctx.flags, 'item');
13
+ if (!itemKey)
14
+ throw usageError('usage: bp edit <itemKey> [--finish ...] [--condition ...] [--tag ...]');
15
+ const session = await ctx.makeSession({ write: true });
16
+ const args = { operation: 'edit', itemKey };
17
+ const finish = strFlag(ctx.flags, 'finish');
18
+ if (finish) args.finish = finish;
19
+ const condition = strFlag(ctx.flags, 'condition');
20
+ if (condition) args.condition = condition;
21
+ const tagStr = strFlag(ctx.flags, 'tag', 'tags');
22
+ if (tagStr != null) {
23
+ args.tags = tagStr
24
+ .split(',')
25
+ .map((s) => s.trim())
26
+ .filter(Boolean);
26
27
  }
27
-
28
- const result = await applyMutation(session, (draft) => {
29
- const stacks = requireStacks(draft.app.collection, sel, { all: boolFlag(flags, 'all') });
30
- const keys = new Set(stacks.map(collectionKey));
31
- for (const e of draft.app.collection) {
32
- if (!keys.has(collectionKey(e))) continue;
33
- if (newCond) e.condition = normalizeCondition(newCond);
34
- if (newFinish) e.finish = normalizeFinish(newFinish);
35
- if (newQty != null) e.qty = Math.max(1, newQty);
36
- if (setTags != null) e.tags = setTags ? setTags.split(',').map(s => s.trim()).filter(Boolean) : [];
37
- }
38
- return { edited: stacks.length };
39
- }, { dryRun: boolFlag(flags, 'dry-run') });
40
-
41
- persistUndo(result);
42
- printWrite(out, result, () => out.info(out.c.green('✓ edited') + ` ${result.meta.edited} stack(s)`));
43
- return 0;
28
+ if (!finish && !condition && tagStr == null) {
29
+ throw usageError('nothing to edit — pass --finish, --condition, or --tag');
30
+ }
31
+ return runMutation(ctx, session, args, { verb: 'edit' });
44
32
  },
45
33
  };
@@ -1,56 +1,72 @@
1
1
  import { writeFileSync } from 'node:fs';
2
- import { loadSnapshot } from '../mutate.mjs';
3
- import { runQuery, collectionOf } from '../snapshot.mjs';
4
- import { getAdapter } from '../../vendor/adapters.js';
5
- import { buildPortableArchive, portableArchiveToJson } from '../../vendor/portableArchive.js';
6
- import { strFlag, boolFlag } from '../args.mjs';
7
- import { usageError, CliError } from '../errors.mjs';
2
+ import { strFlag, boolFlag, intFlag } from '../args.mjs';
3
+ import { toCsv } from '../csv.mjs';
4
+ import { pickRows } from '../render.mjs';
5
+ import { EXIT } from '../constants.mjs';
8
6
 
9
- const CSV_FORMATS = ['canonical', 'moxfield', 'manabox', 'deckbox'];
7
+ const INVENTORY_EXPORT_COLUMNS = [
8
+ 'name',
9
+ 'setCode',
10
+ 'cn',
11
+ 'finish',
12
+ 'condition',
13
+ 'qty',
14
+ 'location',
15
+ 'tags',
16
+ 'price',
17
+ ];
10
18
 
11
19
  export default {
12
- summary: 'export your collection to csv / json',
13
- help: [
14
- 'usage: bp export [query] [--format canonical|moxfield|manabox|deckbox|json] [--output file]',
15
- ' bp export --archive [--output backup.json] (full round-trippable backup)',
16
- '',
17
- 'with a query, exports only the matching cards (e.g. bp export "f:foil" --format moxfield).',
18
- 'writes to stdout unless --output is given.',
19
- ].join('\n'),
20
+ summary: 'export your collection (CSV/JSON)',
21
+ help: `bp export [--deck NAME] [--format csv|json] [--all] [--output FILE]
22
+
23
+ Export your inventory (default) or a deck's list (--deck NAME). CSV by default.
24
+ --all pages the entire collection (otherwise one page). Writes to --output, or
25
+ stdout if omitted. The inventory CSV round-trips with \`bp import\`.`,
20
26
  async run(ctx) {
21
- const { out, flags, args } = ctx;
22
- const session = ctx.makeSession();
23
- const { snapshot } = await loadSnapshot(session);
24
- const file = strFlag(flags, 'output', 'o');
27
+ const session = await ctx.makeSession();
28
+ const deck = strFlag(ctx.flags, 'deck');
29
+ const format = (
30
+ strFlag(ctx.flags, 'format') || (boolFlag(ctx.flags, 'json') ? 'json' : 'csv')
31
+ ).toLowerCase();
25
32
 
26
- let body;
33
+ const rows = [];
27
34
  let meta;
28
- if (boolFlag(flags, 'archive')) {
29
- const archive = buildPortableArchive({ snapshot });
30
- if (!archive) throw new CliError('could not build archive');
31
- body = portableArchiveToJson(archive);
32
- meta = { kind: 'archive' };
35
+ if (deck) {
36
+ const sc = await session.call('query_collection', { from: 'decklist', deckName: deck });
37
+ rows.push(...pickRows(sc));
38
+ meta = { deck };
33
39
  } else {
34
- const cards = runQuery(collectionOf(snapshot), args.join(' '));
35
- const format = strFlag(flags, 'format') || 'canonical';
36
- if (format === 'json') {
37
- body = JSON.stringify(cards, null, 2);
38
- } else if (CSV_FORMATS.includes(format)) {
39
- body = getAdapter(format).export(cards);
40
- } else {
41
- throw usageError(`unknown format: ${format} (use ${CSV_FORMATS.join('/')}, json, or --archive)`);
42
- }
43
- meta = { format, count: cards.length };
40
+ const all = boolFlag(ctx.flags, 'all');
41
+ const limit = intFlag(ctx.flags, 'limit');
42
+ let cursor;
43
+ let pages = 0;
44
+ do {
45
+ const args = { from: 'inventory' };
46
+ if (limit) args.limit = limit;
47
+ if (cursor) args.cursor = cursor;
48
+ const sc = await session.call('query_collection', args);
49
+ rows.push(...pickRows(sc));
50
+ cursor = all ? sc?.nextCursor : undefined;
51
+ pages += 1;
52
+ } while (cursor && pages < 1000);
53
+ meta = { count: rows.length };
44
54
  }
45
55
 
56
+ const payload =
57
+ format === 'json'
58
+ ? JSON.stringify({ ...meta, rows }, null, 2) + '\n'
59
+ : toCsv(rows, deck ? undefined : INVENTORY_EXPORT_COLUMNS);
60
+
61
+ const file = strFlag(ctx.flags, 'output');
46
62
  if (file) {
47
- writeFileSync(file, body.endsWith('\n') ? body : body + '\n');
48
- out.emit({ file, ...meta }, () => out.info(`wrote ${file}`));
49
- } else if (out.json) {
50
- out.emit({ ...meta, body });
63
+ writeFileSync(file, payload);
64
+ ctx.out.emit({ written: file, count: rows.length }, () =>
65
+ ctx.out.info(`wrote ${rows.length} rows to ${file}`),
66
+ );
51
67
  } else {
52
- out.raw(body);
68
+ ctx.out.raw(payload.replace(/\n$/, ''));
53
69
  }
54
- return 0;
70
+ return EXIT.OK;
55
71
  },
56
72
  };
@@ -0,0 +1,24 @@
1
+ import { intFlag } from '../args.mjs';
2
+ import { emitResult } from '../render.mjs';
3
+
4
+ export default {
5
+ summary: 'recent collection changes',
6
+ help: `bp history [--limit N]
7
+
8
+ Recent change events for your collection.`,
9
+ async run(ctx) {
10
+ const session = await ctx.makeSession();
11
+ const args = { from: 'history' };
12
+ const limit = intFlag(ctx.flags, 'limit');
13
+ if (limit != null) args.limit = limit;
14
+ const sc = await session.call('query_collection', args);
15
+ return emitResult(ctx, sc, {
16
+ columns: [
17
+ { key: 'type', header: 'type' },
18
+ { key: 'summary', header: 'summary' },
19
+ { key: 'description', header: 'description' },
20
+ { key: 'at', header: 'when' },
21
+ ],
22
+ });
23
+ },
24
+ };
@@ -1,94 +1,66 @@
1
1
  import { readFileSync } from 'node:fs';
2
- import { parseCsv } from '../../vendor/importParsing.js';
3
- import { detectAdapter } from '../../vendor/adapters.js';
4
- import { mergeIntoCollection } from '../../vendor/importMerge.js';
5
- import { normalizeCollectionEntry } from '../../vendor/collection.js';
6
- import { resolvePrinting, cardToFields, sleep } from '../scryfall.mjs';
7
- import { applyMutation } from '../mutate.mjs';
8
- import { boolFlag } from '../args.mjs';
9
- import { requireWrite, ensureContainer, printWrite } from './writeHelpers.mjs';
2
+ import { strFlag } from '../args.mjs';
10
3
  import { usageError, CliError } from '../errors.mjs';
4
+ import { fromCsv } from '../csv.mjs';
5
+ import { runMutation } from '../mutate.mjs';
6
+ import { EXIT } from '../constants.mjs';
11
7
 
12
- // Pushed in chunks so a large import stays within the worker's per-request CPU
13
- // and payload limits; each chunk is one revision. Not undoable (too large).
14
- const CHUNK = 150;
8
+ // The server caps mutate_inventory at 200 items/call; keep v1 import to a single
9
+ // call and tell the user to split larger files (auto-chunking can come later).
10
+ const MAX_ROWS = 200;
15
11
 
16
12
  export default {
17
- summary: 'import cards from a csv file (moxfield/manabox/deckbox/canonical)',
18
- help: [
19
- 'usage: bp import <file.csv> [--no-resolve] [--dry-run]',
20
- '',
21
- 'auto-detects the csv format and merges the cards into your cloud collection.',
22
- 'by default each card is resolved on scryfall so it is fully searchable; pass',
23
- '--no-resolve to import as-is (faster, but no oracle/color/price data).',
24
- ].join('\n'),
25
- async run(ctx) {
26
- const { out, flags, args } = ctx;
27
- const session = ctx.makeSession();
28
- requireWrite(session);
29
- const path = args[0];
30
- if (!path) throw usageError('usage: bp import <file.csv>');
13
+ summary: 'import cards from a CSV file',
14
+ help: `bp import <file.csv> [--to LOCATION] [--yes] [--dry-run]
31
15
 
16
+ Bulk-add cards from CSV (previewed and confirmed like any write). Columns:
17
+ setCode (or set), cn, finish, qty, condition, tags (; or | separated), location.
18
+ --to overrides the location for every row. Up to ${MAX_ROWS} rows per file.`,
19
+ async run(ctx) {
20
+ const file = ctx.args[0] || strFlag(ctx.flags, 'input');
21
+ if (!file) throw usageError('usage: bp import <file.csv> [--to LOCATION]');
32
22
  let text;
33
- try { text = readFileSync(path, 'utf8'); } catch { throw new CliError('cannot read file: ' + path); }
34
- const rows = parseCsv(text);
35
- if (rows.length < 2) throw new CliError('no rows found in ' + path);
36
- const adapter = detectAdapter(rows[0]);
37
- if (!adapter) throw new CliError('could not detect a supported csv format (moxfield/manabox/deckbox/canonical)');
38
- let entries = adapter.parse(rows);
39
- if (!entries.length) throw new CliError('no cards parsed from ' + path);
40
-
41
- let resolved = 0;
42
- let unresolved = 0;
43
- if (!boolFlag(flags, 'no-resolve')) {
44
- out.info(`resolving ${entries.length} card(s) on scryfall…`);
45
- const next = [];
46
- for (let i = 0; i < entries.length; i += 1) {
47
- const e = entries[i];
48
- let card = null;
49
- try { card = await resolvePrinting({ scryfallId: e.scryfallId || null, set: e.setCode, cn: e.cn, name: e.name }); } catch { /* leave unresolved */ }
50
- if (card) {
51
- resolved += 1;
52
- next.push(normalizeCollectionEntry({
53
- ...cardToFields(card, e.finish), finish: e.finish, qty: e.qty,
54
- condition: e.condition, language: e.language, location: e.location, tags: e.tags,
55
- ...(e._source ? { _source: e._source } : {}),
56
- }, { preserveResolvedFields: true }));
57
- } else {
58
- unresolved += 1;
59
- next.push(e);
60
- }
61
- if ((i + 1) % 25 === 0) out.info(out.c.dim(` ${i + 1}/${entries.length}`));
62
- await sleep(80);
63
- }
64
- entries = next;
23
+ try {
24
+ text = readFileSync(file, 'utf8');
25
+ } catch (e) {
26
+ throw new CliError(`cannot read ${file}: ${e.message}`, EXIT.USAGE);
65
27
  }
66
-
67
- if (boolFlag(flags, 'dry-run')) {
68
- const result = await applyMutation(session, (draft) => {
69
- for (const e of entries) ensureContainer(draft, e.location);
70
- draft.app.collection = mergeIntoCollection(draft.app.collection, entries);
71
- return { format: adapter.id, imported: entries.length, resolved, unresolved };
72
- }, { dryRun: true });
73
- printWrite(out, result, () => out.info(out.c.dim(`dry run — would import ${entries.length} card(s) (${adapter.id})`)));
74
- return 0;
28
+ const rows = fromCsv(text);
29
+ if (!rows.length) throw new CliError(`no rows found in ${file}`, EXIT.USAGE);
30
+ if (rows.length > MAX_ROWS) {
31
+ throw new CliError(
32
+ `${rows.length} rows exceeds the ${MAX_ROWS}-row per-import cap — split the file`,
33
+ EXIT.USAGE,
34
+ );
75
35
  }
76
36
 
77
- let revision = 0;
78
- for (let i = 0; i < entries.length; i += CHUNK) {
79
- const chunk = entries.slice(i, i + CHUNK);
80
- const result = await applyMutation(session, (draft) => {
81
- for (const e of chunk) ensureContainer(draft, e.location);
82
- draft.app.collection = mergeIntoCollection(draft.app.collection, chunk);
83
- });
84
- revision = result.revision || revision;
85
- if (entries.length > CHUNK) out.info(out.c.dim(` pushed ${Math.min(i + CHUNK, entries.length)}/${entries.length}`));
86
- }
37
+ const to = strFlag(ctx.flags, 'to');
38
+ const items = rows.map((r) => {
39
+ const item = {};
40
+ const set = r.setCode || r.set;
41
+ if (set) item.setCode = set;
42
+ if (r.cn) item.cn = r.cn;
43
+ if (r.finish) item.finish = r.finish;
44
+ const qty = parseInt(r.qty, 10);
45
+ item.qty = Number.isFinite(qty) && qty > 0 ? qty : 1;
46
+ if (r.condition) item.condition = r.condition;
47
+ if (r.tags) {
48
+ item.tags = String(r.tags)
49
+ .split(/[;|]/)
50
+ .map((s) => s.trim())
51
+ .filter(Boolean);
52
+ }
53
+ const loc = to || r.location;
54
+ if (loc) item.location = loc;
55
+ return item;
56
+ });
87
57
 
88
- out.emit(
89
- { changed: true, imported: entries.length, format: adapter.id, resolved, unresolved, revision },
90
- () => out.info(out.c.green('✓ imported') + ` ${entries.length} card(s) (${adapter.id})` + (unresolved ? `, ${unresolved} unresolved` : '')),
58
+ const session = await ctx.makeSession({ write: true });
59
+ return runMutation(
60
+ ctx,
61
+ session,
62
+ { operation: 'add', items },
63
+ { verb: `import ${items.length} rows` },
91
64
  );
92
- return 0;
93
65
  },
94
66
  };
@@ -1,40 +1,64 @@
1
- import login from './login.mjs';
2
- import logout from './logout.mjs';
3
- import whoami from './whoami.mjs';
4
- import search from './search.mjs';
1
+ // Command registry. Each command is { summary, help?, run(ctx) }.
5
2
  import summary from './summary.mjs';
3
+ import search from './search.mjs';
6
4
  import ls from './ls.mjs';
7
- import show from './show.mjs';
8
5
  import deck from './deck.mjs';
6
+ import value from './value.mjs';
7
+ import prices from './prices.mjs';
8
+ import history from './history.mjs';
9
+ import resolve from './resolve.mjs';
10
+ import exportCmd from './export.mjs';
11
+ import importCmd from './import.mjs';
9
12
  import add from './add.mjs';
10
13
  import rm from './rm.mjs';
11
14
  import move from './move.mjs';
12
15
  import edit from './edit.mjs';
13
- import tag from './tag.mjs';
14
- import container from './container.mjs';
15
16
  import undo from './undo.mjs';
16
- import importCmd from './import.mjs';
17
- import exportCmd from './export.mjs';
17
+ import recover from './recover.mjs';
18
+ import login from './login.mjs';
19
+ import logout from './logout.mjs';
20
+ import whoami from './whoami.mjs';
18
21
 
19
- // Registry. commandOrder controls help-listing order.
20
22
  export const commands = {
21
- login,
22
- logout,
23
- whoami,
24
- search,
25
23
  summary,
24
+ search,
26
25
  ls,
27
- show,
28
26
  deck,
27
+ value,
28
+ prices,
29
+ history,
30
+ resolve,
31
+ export: exportCmd,
32
+ import: importCmd,
29
33
  add,
30
34
  rm,
31
35
  move,
32
36
  edit,
33
- tag,
34
- container,
35
37
  undo,
36
- import: importCmd,
37
- export: exportCmd,
38
+ recover,
39
+ login,
40
+ logout,
41
+ whoami,
38
42
  };
39
43
 
40
- export const commandOrder = Object.keys(commands);
44
+ export const commandOrder = [
45
+ 'summary',
46
+ 'search',
47
+ 'ls',
48
+ 'deck',
49
+ 'value',
50
+ 'prices',
51
+ 'history',
52
+ 'resolve',
53
+ 'export',
54
+ 'import',
55
+ 'add',
56
+ 'rm',
57
+ 'move',
58
+ 'edit',
59
+ 'undo',
60
+ 'recover',
61
+ 'login',
62
+ 'logout',
63
+ 'whoami',
64
+ ];
@@ -1,48 +1,39 @@
1
- import { login as oauthLogin } from '../oauth.mjs';
1
+ import { login } from '../oauth.mjs';
2
2
  import { saveCredentials } from '../store.mjs';
3
- import { Session } from '../api.mjs';
4
- import { READ_SCOPE, WRITE_SCOPE } from '../constants.mjs';
3
+ import { READ_SCOPE, WRITE_SCOPE, EXIT } from '../constants.mjs';
5
4
  import { boolFlag } from '../args.mjs';
6
- import { loadSnapshot } from '../mutate.mjs';
7
- import { summarize, collectionOf } from '../snapshot.mjs';
8
5
 
9
6
  export default {
10
- summary: 'sign in to your collection',
11
- help: [
12
- 'usage: bp login [--write] [--no-browser]',
13
- '',
14
- 'opens your browser once to authorize the cli, then stores a refresh token',
15
- 'locally so future commands stay signed in (~30 days).',
16
- '',
17
- ' --write also request permission to make changes (add/edit/import)',
18
- ' --no-browser print the url to open manually instead of launching a browser',
19
- ].join('\n'),
20
- async run(ctx) {
21
- const { out, flags, apiBase } = ctx;
22
- const scope = boolFlag(flags, 'write') ? `${READ_SCOPE} ${WRITE_SCOPE}` : READ_SCOPE;
23
- const tokens = await oauthLogin({ base: apiBase, scope, out, noBrowser: boolFlag(flags, 'no-browser') });
7
+ summary: 'sign in via your browser',
8
+ help: `bp login [--write] [--no-browser]
9
+
10
+ Authenticate via your browser (OAuth 2.0 + PKCE) and store credentials locally.
11
+ Requests read-only access by default; --write also allows modifying your
12
+ collection. --no-browser just prints the URL to open manually (useful over SSH).
24
13
 
25
- const creds = {
14
+ For headless / CI use, skip login and set BIBLIOPLEX_TOKEN to a personal access
15
+ token (created in the Biblioplex web app) instead.`,
16
+ async run(ctx) {
17
+ const scope = boolFlag(ctx.flags, 'write') ? `${READ_SCOPE} ${WRITE_SCOPE}` : READ_SCOPE;
18
+ const { tokens, clientId, tokenEndpoint } = await login({
19
+ origin: ctx.apiOrigin,
20
+ scope,
21
+ out: ctx.out,
22
+ noBrowser: boolFlag(ctx.flags, 'no-browser'),
23
+ });
24
+ const granted = tokens.scope || scope;
25
+ saveCredentials({
26
26
  accessToken: tokens.access_token,
27
27
  refreshToken: tokens.refresh_token,
28
- accessExpiresAt: Date.now() + (Number(tokens.expires_in) || 3600) * 1000,
29
- scope: tokens.scope || scope,
30
- apiBase,
31
- loggedInAt: new Date().toISOString(),
32
- };
33
- saveCredentials(creds);
34
-
35
- let stats = null;
36
- try {
37
- const session = new Session({ base: apiBase, credentials: creds, persist: saveCredentials });
38
- const { snapshot } = await loadSnapshot(session);
39
- stats = summarize(collectionOf(snapshot));
40
- } catch { /* confirmation is best-effort */ }
41
-
42
- out.emit({ loggedIn: true, scope: creds.scope, collection: stats }, () => {
43
- out.info(out.c.green('✓ signed in') + ' — scope: ' + creds.scope);
44
- if (stats) out.info(`cloud collection: ${stats.unique} unique · ${stats.total} cards · $${stats.value}`);
28
+ expiresAt: Date.now() + (tokens.expires_in ? tokens.expires_in * 1000 : 3_600_000),
29
+ scope: granted,
30
+ clientId,
31
+ tokenEndpoint,
32
+ origin: ctx.apiOrigin,
45
33
  });
46
- return 0;
34
+ ctx.out.emit({ status: 'signed_in', scope: granted, origin: ctx.apiOrigin }, () =>
35
+ ctx.out.line(`signed in (${granted})`),
36
+ );
37
+ return EXIT.OK;
47
38
  },
48
39
  };
@@ -1,16 +1,19 @@
1
- import { loadCredentials, clearCredentials } from '../store.mjs';
2
- import { revokeToken } from '../oauth.mjs';
1
+ import { clearCredentials } from '../store.mjs';
2
+ import { EXIT } from '../constants.mjs';
3
3
 
4
4
  export default {
5
- summary: 'sign out and revoke the session',
6
- help: 'usage: bp logout\n\nrevokes the stored tokens server-side and deletes the local credentials file.',
5
+ summary: 'sign out (clears local credentials)',
6
+ help: `bp logout
7
+
8
+ Remove locally stored credentials. Biblioplex has no server-side token
9
+ revocation, so existing tokens remain valid until they expire (access ~1h,
10
+ refresh ~30d). To kill a credential sooner, revoke the relevant personal access
11
+ token in the Biblioplex web app.`,
7
12
  async run(ctx) {
8
- const { out, apiBase } = ctx;
9
- const creds = loadCredentials();
10
- if (creds?.refreshToken) await revokeToken({ base: apiBase, token: creds.refreshToken });
11
- if (creds?.accessToken) await revokeToken({ base: apiBase, token: creds.accessToken });
12
13
  clearCredentials();
13
- out.emit({ loggedOut: true }, () => out.info(out.c.green('✓ signed out')));
14
- return 0;
14
+ ctx.out.emit({ status: 'signed_out' }, () =>
15
+ ctx.out.line('signed out (local credentials cleared)'),
16
+ );
17
+ return EXIT.OK;
15
18
  },
16
19
  };