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/src/mutate.mjs CHANGED
@@ -1,76 +1,151 @@
1
- // Unified write path. Every mutating command (add/rm/move/edit/tag/container/
2
- // import) expresses itself as a pure transform of the snapshot; we diff before
3
- // vs after into granular sync ops (reusing the app's diffSyncSnapshots) and push
4
- // once with optimistic concurrency, retrying on a revision conflict by
5
- // re-reading and re-diffing. This never emits snapshot.replace/history/ui ops,
6
- // so it stays within what a CLI OAuth token is allowed to push.
7
- import { diffSyncSnapshots } from '../vendor/syncOps.js';
8
- import { collectionKey } from '../vendor/collection.js';
9
- import { mergeIntoCollection } from '../vendor/importMerge.js';
10
- import { emptySnapshot } from './snapshot.mjs';
11
- import { CliError } from './errors.mjs';
12
-
13
- const clone = (v) => JSON.parse(JSON.stringify(v));
14
-
15
- // Capture the before-values of exactly the keys a set of ops touches, so `bp
16
- // undo` can restore them without disturbing anything else.
17
- function captureUndo(before, ops) {
18
- const collectionKeys = new Set();
19
- const containerKeys = new Set();
20
- for (const op of ops) {
21
- const p = op.payload || {};
22
- if (op.type.startsWith('collection.')) {
23
- for (const k of [p.key, p.beforeKey, p.afterKey]) if (k) collectionKeys.add(k);
24
- } else if (op.type.startsWith('container.') && p.key) {
25
- containerKeys.add(p.key);
26
- }
1
+ // Safe write flow for mutate_inventory. Public clients can't use the two-phase
2
+ // changeToken path (the server never issues a token to non-chat clients), so we
3
+ // use single-call mutate_inventory: ALWAYS preview with dryRun, let the user
4
+ // confirm, then apply WITHOUT dryRun reusing the dryRun-suggested
5
+ // idempotencyKey + expectedRevision. Safety (recovery point, 409 concurrency,
6
+ // idempotent retry) is enforced server-side; we just drive it correctly.
7
+ import { createInterface } from 'node:readline';
8
+ import { boolFlag } from './args.mjs';
9
+ import { CliError, usageError } from './errors.mjs';
10
+ import { EXIT } from './constants.mjs';
11
+ import { pickRows } from './render.mjs';
12
+
13
+ const STATUS_EXIT = {
14
+ parse_error: EXIT.USAGE,
15
+ needs_input: EXIT.USAGE,
16
+ invalid: EXIT.USAGE,
17
+ not_found: EXIT.NOT_FOUND,
18
+ unsafe: EXIT.CONFLICT,
19
+ };
20
+
21
+ function isErrorStatus(sc) {
22
+ const s = sc && typeof sc === 'object' ? sc.status : undefined;
23
+ return !!s && s in STATUS_EXIT;
24
+ }
25
+
26
+ function statusError(sc) {
27
+ return new CliError(
28
+ sc.message || sc.error || `mutation ${sc.status}`,
29
+ STATUS_EXIT[sc.status] || EXIT.ERROR,
30
+ sc,
31
+ );
32
+ }
33
+
34
+ function isConflict(err) {
35
+ return (
36
+ err?.code === EXIT.CONFLICT ||
37
+ err?.extra?.data?.recoveryStrategy === 're_preview' ||
38
+ err?.extra?.status === 409
39
+ );
40
+ }
41
+
42
+ function promptYesNo(question) {
43
+ return new Promise((resolve) => {
44
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
45
+ rl.question(`${question} [y/N] `, (ans) => {
46
+ rl.close();
47
+ resolve(/^y(es)?$/i.test(ans.trim()));
48
+ });
49
+ });
50
+ }
51
+
52
+ // Confirm a side-effecting action. --yes/--force skips; under --json or a
53
+ // non-TTY we refuse rather than hang waiting for input.
54
+ export async function confirm(ctx, question) {
55
+ if (boolFlag(ctx.flags, 'yes', 'force')) return true;
56
+ if (ctx.out.json || !process.stdin.isTTY) {
57
+ throw usageError('refusing to proceed without confirmation — pass --yes (or --dry-run)');
58
+ }
59
+ return promptYesNo(question);
60
+ }
61
+
62
+ function showPreview(out, preview) {
63
+ const items = preview.previews || preview.preview || pickRows(preview);
64
+ if (Array.isArray(items) && items.length) {
65
+ for (const p of items) out.line(' • ' + (p.summary || p.description || JSON.stringify(p)));
66
+ } else if (preview.summary) {
67
+ out.line(' • ' + preview.summary);
27
68
  }
28
- const collection = {};
29
- for (const key of collectionKeys) {
30
- collection[key] = (before.app.collection || []).find(e => collectionKey(e) === key) || null;
69
+ if (preview.tagsCleanup)
70
+ out.info('note: tags normalized — ' + JSON.stringify(preview.tagsCleanup));
71
+ const removes = preview.removeCount ?? preview.totalRemoves;
72
+ if (typeof removes === 'number' && removes >= 20) {
73
+ out.info(`warning: this removes ${removes} rows (server caps removes at 25/operation).`);
31
74
  }
32
- const containers = {};
33
- for (const key of containerKeys) containers[key] = (before.app.containers || {})[key] || null;
34
- return { collection, containers };
35
75
  }
36
76
 
37
- export async function loadSnapshot(session) {
38
- const boot = await session.bootstrap();
39
- return {
40
- snapshot: boot.hasCloudData && boot.snapshot ? boot.snapshot : emptySnapshot(),
41
- revision: boot.revision || 0,
42
- collectionId: boot.collectionId || null,
43
- hasCloudData: !!boot.hasCloudData,
44
- };
77
+ function applyArgsFrom(baseArgs, preview) {
78
+ const out = { ...baseArgs };
79
+ const key = preview.idempotencyKey || preview.suggestedIdempotencyKey;
80
+ if (key) out.idempotencyKey = key;
81
+ const rev = preview.expectedRevision ?? preview.revision;
82
+ if (rev != null) out.expectedRevision = rev;
83
+ return out;
45
84
  }
46
85
 
47
- // mutate(draft) edits the snapshot clone in place. It may return metadata (e.g.
48
- // a human summary) and may throw a CliError to abort. Returns { ops, revision,
49
- // snapshot, noop, meta }. With dryRun, computes ops but does not push.
50
- export async function applyMutation(session, mutate, { attempts = 4, dryRun = false } = {}) {
51
- let lastConflict;
52
- for (let attempt = 0; attempt < attempts; attempt += 1) {
53
- const { snapshot, revision } = await loadSnapshot(session);
54
- const before = clone(snapshot);
55
- const draft = clone(snapshot);
56
- const meta = mutate(draft) || {};
57
- // Coalesce any stacks the mutation made collide on the same collectionKey
58
- // (e.g. moving a copy into a container that already holds it, or editing
59
- // finish/condition to match another stack). Without this, diffSyncSnapshots
60
- // keys before/after by collectionKey and would silently drop the colliding
61
- // entry, losing quantity. mergeIntoCollection sums qty and unions tags.
62
- draft.app.collection = mergeIntoCollection([], draft.app.collection || []);
63
- const ops = diffSyncSnapshots(before, draft);
64
- if (!ops.length) return { ops: [], revision, snapshot: draft, noop: true, meta };
65
- const undo = captureUndo(before, ops);
66
- if (dryRun) return { ops, revision, snapshot: draft, dryRun: true, meta, undo };
67
- try {
68
- const result = await session.push({ ops, baseRevision: revision });
69
- return { ops, revision: result.revision, snapshot: result.snapshot, meta, undo: { ...undo, resultRevision: result.revision } };
70
- } catch (err) {
71
- if (err.conflict && attempt < attempts - 1) { lastConflict = err; continue; }
72
- throw err;
86
+ // Drive a mutate_inventory operation. baseArgs is everything except dryRun.
87
+ export async function runMutation(ctx, session, baseArgs, { verb = 'apply' } = {}) {
88
+ const { out } = ctx;
89
+
90
+ let preview = await session.call('mutate_inventory', { ...baseArgs, dryRun: true });
91
+ if (isErrorStatus(preview)) throw statusError(preview);
92
+
93
+ out.line(`${verb}:`);
94
+ showPreview(out, preview);
95
+
96
+ if (boolFlag(ctx.flags, 'dry-run')) {
97
+ return out.emit(preview, () => out.line('(dry run nothing applied)')) ?? EXIT.OK;
98
+ }
99
+
100
+ if (!(await confirm(ctx, 'apply this change?'))) {
101
+ out.info('aborted.');
102
+ return EXIT.OK;
103
+ }
104
+
105
+ let applied;
106
+ try {
107
+ applied = await session.call('mutate_inventory', applyArgsFrom(baseArgs, preview));
108
+ } catch (err) {
109
+ if (!isConflict(err)) throw err;
110
+ // Collection changed between preview and apply: re-preview, re-confirm, re-apply.
111
+ out.info('collection changed since preview — re-previewing…');
112
+ preview = await session.call('mutate_inventory', { ...baseArgs, dryRun: true });
113
+ if (isErrorStatus(preview)) throw statusError(preview);
114
+ showPreview(out, preview);
115
+ if (!(await confirm(ctx, 're-apply with the updated state?'))) {
116
+ out.info('aborted.');
117
+ return EXIT.OK;
73
118
  }
119
+ applied = await session.call('mutate_inventory', applyArgsFrom(baseArgs, preview));
120
+ }
121
+
122
+ if (isErrorStatus(applied)) throw statusError(applied);
123
+ return (
124
+ out.emit(applied, () => {
125
+ out.line('done.');
126
+ showPreview(out, applied);
127
+ }) ?? EXIT.OK
128
+ );
129
+ }
130
+
131
+ // Resolve a single concrete printing for `add`. Throws with guidance when
132
+ // ambiguous so the user can narrow with --set/--cn.
133
+ export async function resolveOnePrinting(session, { query, setCode, cn, finish }) {
134
+ const args = {};
135
+ if (query) args.query = query;
136
+ if (setCode) args.setCode = setCode;
137
+ if (cn) args.cn = cn;
138
+ if (finish) args.finish = finish;
139
+ const res = await session.call('resolve_printing', args);
140
+ if (res?.status === 'not_found') {
141
+ throw new CliError('no printing matched — try `bp resolve` to explore', EXIT.NOT_FOUND);
142
+ }
143
+ const candidates = pickRows(res);
144
+ if (res?.status === 'ambiguous' || candidates.length > 1) {
145
+ throw usageError('multiple printings match — narrow with --set and --cn (see `bp resolve`)');
74
146
  }
75
- throw lastConflict || new CliError('could not apply change after several retries');
147
+ const one = candidates[0] || res;
148
+ const scryfallId = one.scryfallId || one.id || res.scryfallId;
149
+ if (!scryfallId) throw new CliError('could not resolve a printing id', EXIT.ERROR, res);
150
+ return { scryfallId, candidate: one };
76
151
  }
package/src/oauth.mjs CHANGED
@@ -1,10 +1,13 @@
1
- // OAuth 2.1 authorization-code + PKCE with an RFC 8252 loopback redirect, plus
2
- // refresh and revoke. Talks to the biblioplex worker's /authorize, /token and
3
- // /revoke endpoints. fetchImpl + openBrowser are injectable for tests.
1
+ // OAuth 2.0 authorization-code + PKCE (S256) against the Biblioplex MCP OAuth
2
+ // endpoints, using dynamic client registration (RFC 7591) and an RFC 8252
3
+ // loopback redirect. The Biblioplex worker has no revocation endpoint, so
4
+ // logout is local-only (see commands/logout.mjs).
5
+ //
6
+ // fetchImpl + openBrowser are injectable for tests.
4
7
  import http from 'node:http';
5
8
  import { execFile } from 'node:child_process';
6
9
  import { createPkce, randomState } from './pkce.mjs';
7
- import { CLI_CLIENT_ID } from './constants.mjs';
10
+ import { CLIENT_LABEL } from './constants.mjs';
8
11
  import { CliError } from './errors.mjs';
9
12
 
10
13
  const SUCCESS_HTML = `<!doctype html><meta charset="utf-8"><title>biblioplex</title>
@@ -14,116 +17,251 @@ const SUCCESS_HTML = `<!doctype html><meta charset="utf-8"><title>biblioplex</ti
14
17
  </body>`;
15
18
 
16
19
  export function defaultOpenBrowser(url) {
17
- const cmds = process.platform === 'darwin'
18
- ? ['open', [url]]
19
- : process.platform === 'win32'
20
- ? ['cmd', ['/c', 'start', '', url]]
21
- : ['xdg-open', [url]];
20
+ const cmds =
21
+ process.platform === 'darwin'
22
+ ? ['open', [url]]
23
+ : process.platform === 'win32'
24
+ ? ['cmd', ['/c', 'start', '', url]]
25
+ : ['xdg-open', [url]];
22
26
  return new Promise((resolve) => {
23
27
  execFile(cmds[0], cmds[1], (err) => resolve(!err));
24
28
  });
25
29
  }
26
30
 
27
- function postForm(base, path, body, fetchImpl) {
28
- return fetchImpl(base + path, {
31
+ function postJson(url, body, fetchImpl) {
32
+ return fetchImpl(url, {
29
33
  method: 'POST',
30
34
  headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
31
35
  body: JSON.stringify(body),
32
36
  });
33
37
  }
34
38
 
35
- export async function exchangeCode({ base, code, verifier, redirectUri, fetchImpl = fetch }) {
36
- const res = await postForm(base, '/token', {
37
- grant_type: 'authorization_code',
38
- code,
39
- client_id: CLI_CLIENT_ID,
40
- redirect_uri: redirectUri,
41
- code_verifier: verifier,
42
- }, fetchImpl);
39
+ // An endpoint advertised in server metadata may be absolute or origin-relative.
40
+ function resolveEndpoint(origin, value, fallbackPath) {
41
+ if (!value) return origin + fallbackPath;
42
+ try {
43
+ return new URL(value).href;
44
+ } catch {
45
+ return origin + (value.startsWith('/') ? value : '/' + value);
46
+ }
47
+ }
48
+
49
+ // GET /.well-known/oauth-authorization-server -> normalized endpoint set.
50
+ export async function discover(origin, fetchImpl = fetch) {
51
+ let res;
52
+ try {
53
+ res = await fetchImpl(origin + '/.well-known/oauth-authorization-server', {
54
+ headers: { Accept: 'application/json' },
55
+ });
56
+ } catch (e) {
57
+ throw new CliError(`could not reach ${origin}: ${e.message}`);
58
+ }
59
+ if (!res.ok) throw new CliError(`OAuth discovery failed (${res.status}) at ${origin}`);
60
+ const meta = await res.json().catch(() => ({}));
61
+ const methods = meta.code_challenge_methods_supported || ['S256'];
62
+ if (!methods.includes('S256')) {
63
+ throw new CliError('server does not advertise PKCE S256 — cannot log in safely');
64
+ }
65
+ return {
66
+ authorizationEndpoint: resolveEndpoint(origin, meta.authorization_endpoint, '/authorize'),
67
+ tokenEndpoint: resolveEndpoint(origin, meta.token_endpoint, '/token'),
68
+ registrationEndpoint: resolveEndpoint(origin, meta.registration_endpoint, '/register'),
69
+ raw: meta,
70
+ };
71
+ }
72
+
73
+ // Dynamic client registration (RFC 7591) for a public, loopback client.
74
+ export async function registerClient({
75
+ registrationEndpoint,
76
+ redirectUris,
77
+ scope,
78
+ fetchImpl = fetch,
79
+ }) {
80
+ const res = await postJson(
81
+ registrationEndpoint,
82
+ {
83
+ client_name: CLIENT_LABEL,
84
+ redirect_uris: redirectUris,
85
+ token_endpoint_auth_method: 'none',
86
+ grant_types: ['authorization_code', 'refresh_token'],
87
+ response_types: ['code'],
88
+ scope,
89
+ },
90
+ fetchImpl,
91
+ );
43
92
  const data = await res.json().catch(() => ({}));
44
- if (!res.ok) throw new CliError('token exchange failed: ' + (data.error_description || data.error || res.status));
93
+ if (!res.ok || !data.client_id) {
94
+ throw new CliError(
95
+ 'client registration failed: ' + (data.error_description || data.error || res.status),
96
+ 3,
97
+ );
98
+ }
45
99
  return data;
46
100
  }
47
101
 
48
- export async function refreshTokens({ base, refreshToken, fetchImpl = fetch }) {
49
- const res = await postForm(base, '/token', {
50
- grant_type: 'refresh_token',
51
- refresh_token: refreshToken,
52
- client_id: CLI_CLIENT_ID,
53
- }, fetchImpl);
102
+ export async function exchangeCode({
103
+ tokenEndpoint,
104
+ clientId,
105
+ code,
106
+ verifier,
107
+ redirectUri,
108
+ fetchImpl = fetch,
109
+ }) {
110
+ const res = await postJson(
111
+ tokenEndpoint,
112
+ {
113
+ grant_type: 'authorization_code',
114
+ code,
115
+ client_id: clientId,
116
+ redirect_uri: redirectUri,
117
+ code_verifier: verifier,
118
+ },
119
+ fetchImpl,
120
+ );
54
121
  const data = await res.json().catch(() => ({}));
55
- if (!res.ok) throw new CliError('session expired — run `bp login` again', 3);
122
+ if (!res.ok) {
123
+ throw new CliError(
124
+ 'token exchange failed: ' + (data.error_description || data.error || res.status),
125
+ 3,
126
+ );
127
+ }
56
128
  return data;
57
129
  }
58
130
 
59
- export async function revokeToken({ base, token, fetchImpl = fetch }) {
60
- try {
61
- await postForm(base, '/revoke', { token }, fetchImpl);
62
- } catch { /* logout is best-effort; local creds are cleared regardless */ }
131
+ // Single-use rotating refresh. On invalid_client the caller should re-login
132
+ // (the dynamic registration likely expired). Distinguishes the error cause so
133
+ // the caller can react.
134
+ export async function refreshTokens({ tokenEndpoint, clientId, refreshToken, fetchImpl = fetch }) {
135
+ const res = await postJson(
136
+ tokenEndpoint,
137
+ { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: clientId },
138
+ fetchImpl,
139
+ );
140
+ const data = await res.json().catch(() => ({}));
141
+ if (!res.ok) {
142
+ const err = new CliError('session expired — run `bp login` again', 3);
143
+ err.oauthError = data.error || String(res.status);
144
+ err.invalidClient = data.error === 'invalid_client';
145
+ throw err;
146
+ }
147
+ return data;
63
148
  }
64
149
 
65
- // Runs the interactive browser login. Returns the raw /token response.
150
+ // Interactive browser login. Starts a loopback listener, registers a public
151
+ // client bound to that exact redirect URI, runs the authorization-code+PKCE
152
+ // flow, and exchanges the code. Returns { tokens, clientId } to persist.
66
153
  export async function login({
67
- base, scope, out, noBrowser = false,
68
- openBrowser = defaultOpenBrowser, fetchImpl = fetch, timeoutMs = 300000,
154
+ origin,
155
+ scope,
156
+ out,
157
+ noBrowser = false,
158
+ openBrowser = defaultOpenBrowser,
159
+ fetchImpl = fetch,
160
+ timeoutMs = 300000,
69
161
  }) {
162
+ const meta = await discover(origin, fetchImpl);
70
163
  const { verifier, challenge, method } = createPkce();
71
164
  const state = randomState();
72
165
 
166
+ let clientId = null;
73
167
  const { code, redirectUri } = await new Promise((resolve, reject) => {
74
168
  let redirect = '';
75
169
  let timer;
76
- function cleanup() { clearTimeout(timer); try { server.close(); } catch {} }
170
+ const cleanup = () => {
171
+ clearTimeout(timer);
172
+ try {
173
+ server.close();
174
+ } catch {
175
+ /* already closed */
176
+ }
177
+ };
77
178
 
78
179
  const server = http.createServer((req, res) => {
79
180
  const url = new URL(req.url, 'http://127.0.0.1');
80
- if (url.pathname === '/favicon.ico') { res.writeHead(204); res.end(); return; }
81
- if (url.pathname !== '/callback') { res.writeHead(404); res.end('not found'); return; }
82
-
181
+ if (url.pathname === '/favicon.ico') {
182
+ res.writeHead(204);
183
+ res.end();
184
+ return;
185
+ }
186
+ if (url.pathname !== '/callback') {
187
+ res.writeHead(404);
188
+ res.end('not found');
189
+ return;
190
+ }
83
191
  const params = url.searchParams;
84
- // Validate CSRF state first — including on error responses — so a stray
85
- // local request can't abort or influence the in-flight login.
192
+ // Validate CSRF state first — even on error responses — so a stray local
193
+ // request cannot abort or influence the in-flight login.
86
194
  if (params.get('state') !== state) {
87
195
  res.writeHead(400, { 'Content-Type': 'text/plain' });
88
196
  res.end('state mismatch');
89
- cleanup(); reject(new CliError('login aborted: OAuth state mismatch (possible CSRF)', 3));
197
+ cleanup();
198
+ reject(new CliError('login aborted: OAuth state mismatch (possible CSRF)', 3));
90
199
  return;
91
200
  }
92
201
  if (params.get('error')) {
93
202
  res.writeHead(400, { 'Content-Type': 'text/plain' });
94
203
  res.end('authorization failed: ' + params.get('error'));
95
- cleanup(); reject(new CliError('authorization denied: ' + params.get('error'), 3));
204
+ cleanup();
205
+ reject(new CliError('authorization denied: ' + params.get('error'), 3));
96
206
  return;
97
207
  }
98
208
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
99
209
  res.end(SUCCESS_HTML);
100
- const code = params.get('code');
210
+ const received = params.get('code');
101
211
  cleanup();
102
- resolve({ code, redirectUri: redirect });
212
+ resolve({ code: received, redirectUri: redirect });
213
+ });
214
+
215
+ server.on('error', (e) => {
216
+ cleanup();
217
+ reject(new CliError('could not start local login listener: ' + e.message));
103
218
  });
104
219
 
105
- server.on('error', (e) => { cleanup(); reject(new CliError('could not start local login listener: ' + e.message)); });
106
220
  server.listen(0, '127.0.0.1', async () => {
107
- redirect = `http://127.0.0.1:${server.address().port}/callback`;
108
- const authUrl = new URL(base + '/authorize');
109
- authUrl.searchParams.set('response_type', 'code');
110
- authUrl.searchParams.set('client_id', CLI_CLIENT_ID);
111
- authUrl.searchParams.set('redirect_uri', redirect);
112
- authUrl.searchParams.set('code_challenge', challenge);
113
- authUrl.searchParams.set('code_challenge_method', method);
114
- authUrl.searchParams.set('scope', scope);
115
- authUrl.searchParams.set('state', state);
116
-
117
- out.info('opening your browser to sign in…');
118
- out.info('if it does not open, visit:\n ' + authUrl.href + '\n');
119
- timer = setTimeout(() => { cleanup(); reject(new CliError('login timed out after ' + Math.round(timeoutMs / 1000) + 's', 3)); }, timeoutMs);
120
- if (!noBrowser) {
121
- const opened = await openBrowser(authUrl.href);
122
- if (!opened) out.info('(could not launch a browser automatically — open the link above)');
221
+ try {
222
+ redirect = `http://127.0.0.1:${server.address().port}/callback`;
223
+ const reg = await registerClient({
224
+ registrationEndpoint: meta.registrationEndpoint,
225
+ redirectUris: [redirect],
226
+ scope,
227
+ fetchImpl,
228
+ });
229
+ clientId = reg.client_id;
230
+
231
+ const authUrl = new URL(meta.authorizationEndpoint);
232
+ authUrl.searchParams.set('response_type', 'code');
233
+ authUrl.searchParams.set('client_id', clientId);
234
+ authUrl.searchParams.set('redirect_uri', redirect);
235
+ authUrl.searchParams.set('code_challenge', challenge);
236
+ authUrl.searchParams.set('code_challenge_method', method);
237
+ authUrl.searchParams.set('scope', scope);
238
+ authUrl.searchParams.set('state', state);
239
+
240
+ out.info('opening your browser to sign in…');
241
+ out.info('if it does not open, visit:\n ' + authUrl.href + '\n');
242
+ timer = setTimeout(() => {
243
+ cleanup();
244
+ reject(new CliError('login timed out after ' + Math.round(timeoutMs / 1000) + 's', 3));
245
+ }, timeoutMs);
246
+ if (!noBrowser) {
247
+ const opened = await openBrowser(authUrl.href);
248
+ if (!opened) out.info('(could not launch a browser automatically — open the link above)');
249
+ }
250
+ } catch (e) {
251
+ cleanup();
252
+ reject(e);
123
253
  }
124
254
  });
125
255
  });
126
256
 
127
257
  if (!code) throw new CliError('no authorization code received', 3);
128
- return exchangeCode({ base, code, verifier, redirectUri, fetchImpl });
258
+ const tokens = await exchangeCode({
259
+ tokenEndpoint: meta.tokenEndpoint,
260
+ clientId,
261
+ code,
262
+ verifier,
263
+ redirectUri,
264
+ fetchImpl,
265
+ });
266
+ return { tokens, clientId, tokenEndpoint: meta.tokenEndpoint };
129
267
  }
package/src/output.mjs CHANGED
@@ -5,8 +5,13 @@
5
5
  // stays a single clean JSON document.
6
6
 
7
7
  const COLORS = {
8
- reset: '\x1b[0m', dim: '\x1b[2m', bold: '\x1b[1m',
9
- red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', cyan: '\x1b[36m',
8
+ reset: '\x1b[0m',
9
+ dim: '\x1b[2m',
10
+ bold: '\x1b[1m',
11
+ red: '\x1b[31m',
12
+ green: '\x1b[32m',
13
+ yellow: '\x1b[33m',
14
+ cyan: '\x1b[36m',
10
15
  };
11
16
 
12
17
  export function createOutput({ json = false, color = true } = {}) {
@@ -17,12 +22,12 @@ export function createOutput({ json = false, color = true } = {}) {
17
22
  json,
18
23
  useColor,
19
24
  c: {
20
- dim: s => paint(COLORS.dim, s),
21
- bold: s => paint(COLORS.bold, s),
22
- red: s => paint(COLORS.red, s),
23
- green: s => paint(COLORS.green, s),
24
- yellow: s => paint(COLORS.yellow, s),
25
- cyan: s => paint(COLORS.cyan, s),
25
+ dim: (s) => paint(COLORS.dim, s),
26
+ bold: (s) => paint(COLORS.bold, s),
27
+ red: (s) => paint(COLORS.red, s),
28
+ green: (s) => paint(COLORS.green, s),
29
+ yellow: (s) => paint(COLORS.yellow, s),
30
+ cyan: (s) => paint(COLORS.cyan, s),
26
31
  },
27
32
 
28
33
  // Primary success payload. JSON mode prints one envelope; otherwise calls
@@ -49,15 +54,17 @@ export function createOutput({ json = false, color = true } = {}) {
49
54
  table(columns, rows) {
50
55
  if (json || !rows.length) return;
51
56
  const widths = columns.map((col, i) =>
52
- Math.max(col.header.length, ...rows.map(r => String(r[i] ?? '').length)));
53
- const fmt = cells => cells
54
- .map((cell, i) => {
55
- const s = String(cell ?? '');
56
- return columns[i].align === 'right' ? s.padStart(widths[i]) : s.padEnd(widths[i]);
57
- })
58
- .join(' ')
59
- .replace(/\s+$/, '');
60
- process.stdout.write(this.c.dim(fmt(columns.map(c => c.header))) + '\n');
57
+ Math.max(col.header.length, ...rows.map((r) => String(r[i] ?? '').length)),
58
+ );
59
+ const fmt = (cells) =>
60
+ cells
61
+ .map((cell, i) => {
62
+ const s = String(cell ?? '');
63
+ return columns[i].align === 'right' ? s.padStart(widths[i]) : s.padEnd(widths[i]);
64
+ })
65
+ .join(' ')
66
+ .replace(/\s+$/, '');
67
+ process.stdout.write(this.c.dim(fmt(columns.map((c) => c.header))) + '\n');
61
68
  for (const row of rows) process.stdout.write(fmt(row) + '\n');
62
69
  },
63
70
 
@@ -70,7 +77,9 @@ export function createOutput({ json = false, color = true } = {}) {
70
77
  error(err) {
71
78
  const message = err?.message || String(err);
72
79
  if (json) {
73
- process.stdout.write(JSON.stringify({ ok: false, error: { message, ...(err?.extra || {}) } }, null, 2) + '\n');
80
+ process.stdout.write(
81
+ JSON.stringify({ ok: false, error: { message, ...(err?.extra || {}) } }, null, 2) + '\n',
82
+ );
74
83
  } else {
75
84
  process.stderr.write(this.c.red('error: ') + message + '\n');
76
85
  }