biblioplex 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -70
- package/package.json +17 -27
- package/src/__tests__/args.test.js +39 -0
- package/src/__tests__/csv.test.js +33 -0
- package/src/__tests__/mcp.test.js +84 -0
- package/src/__tests__/mutate.test.js +91 -0
- package/src/__tests__/render.test.js +103 -0
- package/src/args.mjs +29 -6
- package/src/cli.mjs +38 -25
- package/src/commands/add.mjs +30 -51
- package/src/commands/deck.mjs +13 -47
- package/src/commands/edit.mjs +26 -38
- package/src/commands/export.mjs +57 -41
- package/src/commands/history.mjs +24 -0
- package/src/commands/import.mjs +52 -80
- package/src/commands/index.mjs +44 -20
- package/src/commands/login.mjs +29 -38
- package/src/commands/logout.mjs +13 -10
- package/src/commands/ls.mjs +13 -25
- package/src/commands/move.mjs +15 -35
- package/src/commands/prices.mjs +21 -0
- package/src/commands/recover.mjs +53 -0
- package/src/commands/resolve.mjs +37 -0
- package/src/commands/rm.mjs +15 -32
- package/src/commands/search.mjs +25 -35
- package/src/commands/summary.mjs +25 -28
- package/src/commands/undo.mjs +13 -28
- package/src/commands/value.mjs +30 -0
- package/src/commands/whoami.mjs +27 -19
- package/src/constants.mjs +34 -7
- package/src/csv.mjs +71 -0
- package/src/errors.mjs +13 -2
- package/src/mcp.mjs +227 -0
- package/src/mutate.mjs +142 -67
- package/src/oauth.mjs +200 -62
- package/src/output.mjs +27 -18
- package/src/render.mjs +135 -30
- package/src/store.mjs +39 -23
- package/src/api.mjs +0 -110
- package/src/commands/container.mjs +0 -83
- package/src/commands/deckExport.mjs +0 -48
- package/src/commands/show.mjs +0 -33
- package/src/commands/tag.mjs +0 -40
- package/src/commands/writeHelpers.mjs +0 -82
- package/src/scryfall.mjs +0 -81
- package/src/snapshot.mjs +0 -77
- package/vendor/README.md +0 -20
- package/vendor/adapters.js +0 -443
- package/vendor/collection.js +0 -665
- package/vendor/deckExport.js +0 -219
- package/vendor/importMerge.js +0 -22
- package/vendor/importParsing.js +0 -119
- package/vendor/portableArchive.js +0 -151
- package/vendor/searchCore.js +0 -223
- package/vendor/state.js +0 -91
- package/vendor/storageSchema.js +0 -75
- package/vendor/syncOps.js +0 -188
package/src/mutate.mjs
CHANGED
|
@@ -1,76 +1,151 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
let
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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 {
|
|
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 =
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
28
|
-
return fetchImpl(
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
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
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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)
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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') {
|
|
81
|
-
|
|
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 —
|
|
85
|
-
//
|
|
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();
|
|
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();
|
|
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
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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',
|
|
9
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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(
|
|
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
|
}
|