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/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
|
}
|
package/src/render.mjs
CHANGED
|
@@ -1,35 +1,140 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
1
|
+
// Shape-tolerant rendering for MCP structuredContent results. The --json path
|
|
2
|
+
// always emits the raw structuredContent (the stable, agent-facing contract);
|
|
3
|
+
// human mode renders a best-effort table. Error statuses map to exit codes.
|
|
4
|
+
import { EXIT } from './constants.mjs';
|
|
5
|
+
import { CliError } from './errors.mjs';
|
|
6
|
+
|
|
7
|
+
const ROW_KEYS = [
|
|
8
|
+
'rows',
|
|
9
|
+
'results',
|
|
10
|
+
'items',
|
|
11
|
+
'cards',
|
|
12
|
+
'inventory',
|
|
13
|
+
'containers',
|
|
14
|
+
'decklist',
|
|
15
|
+
'changes',
|
|
16
|
+
'history',
|
|
17
|
+
'candidates',
|
|
18
|
+
'data',
|
|
17
19
|
];
|
|
20
|
+
const ERROR_STATUSES = new Set([
|
|
21
|
+
'parse_error',
|
|
22
|
+
'needs_input',
|
|
23
|
+
'invalid',
|
|
24
|
+
'not_found',
|
|
25
|
+
'unsafe',
|
|
26
|
+
'error',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
export function pickRows(sc) {
|
|
30
|
+
if (Array.isArray(sc)) return sc;
|
|
31
|
+
if (!sc || typeof sc !== 'object') return [];
|
|
32
|
+
for (const k of ROW_KEYS) if (Array.isArray(sc[k])) return sc[k];
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function statusToExit(status) {
|
|
37
|
+
switch (status) {
|
|
38
|
+
case 'parse_error':
|
|
39
|
+
case 'needs_input':
|
|
40
|
+
case 'invalid':
|
|
41
|
+
return EXIT.USAGE;
|
|
42
|
+
case 'not_found':
|
|
43
|
+
return EXIT.NOT_FOUND;
|
|
44
|
+
case 'unsafe':
|
|
45
|
+
return EXIT.CONFLICT;
|
|
46
|
+
default:
|
|
47
|
+
return EXIT.ERROR;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
18
50
|
|
|
19
|
-
export function
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
c.resolvedName || c.name || '',
|
|
24
|
-
((c.setCode || '').toUpperCase() + ' ' + (c.cn || '')).trim(),
|
|
25
|
-
FINISH_LABEL[c.finish] ?? c.finish ?? '',
|
|
26
|
-
COND_ABBR[c.condition] || c.condition || '',
|
|
27
|
-
price,
|
|
28
|
-
formatLocationLabel(c.location),
|
|
29
|
-
];
|
|
51
|
+
export function fmtMoney(v) {
|
|
52
|
+
if (v == null || v === '') return '—';
|
|
53
|
+
const n = typeof v === 'number' ? v : Number(v);
|
|
54
|
+
return Number.isFinite(n) ? '$' + n.toFixed(2) : String(v);
|
|
30
55
|
}
|
|
31
56
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
57
|
+
function locLabel(v) {
|
|
58
|
+
if (!v) return '';
|
|
59
|
+
if (typeof v === 'string') return v;
|
|
60
|
+
if (typeof v === 'object') {
|
|
61
|
+
if (v.name) return v.type ? `${v.type}:${v.name}` : v.name;
|
|
62
|
+
return v.type || JSON.stringify(v);
|
|
63
|
+
}
|
|
64
|
+
return String(v);
|
|
35
65
|
}
|
|
66
|
+
|
|
67
|
+
function cell(value, col) {
|
|
68
|
+
if (value == null) return '';
|
|
69
|
+
if (col.loc) return locLabel(value);
|
|
70
|
+
if (col.money) return fmtMoney(value);
|
|
71
|
+
if (Array.isArray(value)) return value.join(',');
|
|
72
|
+
if (typeof value === 'object') return JSON.stringify(value);
|
|
73
|
+
return String(value);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function inferColumns(row) {
|
|
77
|
+
return Object.keys(row)
|
|
78
|
+
.slice(0, 6)
|
|
79
|
+
.map((key) => ({ key, header: key }));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function renderTable(out, rows, columns) {
|
|
83
|
+
if (!rows.length) {
|
|
84
|
+
out.line('(no results)');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
// Keep only columns actually present in the data, so a projected SELECT or a
|
|
88
|
+
// shape we didn't anticipate still renders sensibly.
|
|
89
|
+
let cols =
|
|
90
|
+
columns && columns.length
|
|
91
|
+
? columns.filter((c) => c.get || rows.some((r) => r[c.key] != null))
|
|
92
|
+
: [];
|
|
93
|
+
if (!cols.length) cols = inferColumns(rows[0]);
|
|
94
|
+
out.table(
|
|
95
|
+
cols.map((c) => ({ header: c.header || c.key, align: c.align })),
|
|
96
|
+
rows.map((r) => cols.map((c) => cell(c.get ? c.get(r) : r[c.key], c))),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Standard read handler: raise error statuses (mapped to exit codes), else emit
|
|
101
|
+
// JSON or a human table. Returns the exit code.
|
|
102
|
+
export function emitResult(ctx, sc, { columns, render } = {}) {
|
|
103
|
+
const status = sc && typeof sc === 'object' ? sc.status : undefined;
|
|
104
|
+
if (status && ERROR_STATUSES.has(status)) {
|
|
105
|
+
throw new CliError(sc.message || sc.error || `request ${status}`, statusToExit(status), sc);
|
|
106
|
+
}
|
|
107
|
+
ctx.out.emit(sc, () => {
|
|
108
|
+
if (render) render(ctx.out, sc);
|
|
109
|
+
else renderTable(ctx.out, pickRows(sc), columns);
|
|
110
|
+
if (sc && (sc.hasMore || sc.nextCursor)) {
|
|
111
|
+
ctx.out.info(`… more available${sc.nextCursor ? ` (--cursor ${sc.nextCursor})` : ''}`);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
return EXIT.OK;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const INVENTORY_COLUMNS = [
|
|
118
|
+
{ key: 'qty', header: 'qty', align: 'right' },
|
|
119
|
+
{ key: 'name', header: 'name' },
|
|
120
|
+
{ key: 'setCode', header: 'set' },
|
|
121
|
+
{ key: 'finish', header: 'finish' },
|
|
122
|
+
{ key: 'location', header: 'loc', loc: true },
|
|
123
|
+
{ key: 'price', header: 'price', align: 'right', money: true },
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
export const CONTAINER_COLUMNS = [
|
|
127
|
+
{ key: 'name', header: 'name' },
|
|
128
|
+
{ key: 'type', header: 'type' },
|
|
129
|
+
// Physical cards in the container live in `stats`; deckListCount is the deck
|
|
130
|
+
// recipe size (0 for storage), so it can't be the "cards" count.
|
|
131
|
+
{ header: 'cards', align: 'right', get: (r) => r.stats?.total ?? r.deckListCount ?? 0 },
|
|
132
|
+
{ header: 'value', align: 'right', money: true, get: (r) => r.stats?.value },
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
export const DECKLIST_COLUMNS = [
|
|
136
|
+
{ key: 'qty', header: 'qty', align: 'right' },
|
|
137
|
+
{ key: 'name', header: 'name' },
|
|
138
|
+
{ key: 'board', header: 'board' },
|
|
139
|
+
{ key: 'typeLine', header: 'type' },
|
|
140
|
+
];
|
package/src/store.mjs
CHANGED
|
@@ -1,22 +1,51 @@
|
|
|
1
1
|
// Local persistence for credentials and config. The refresh token is a 30-day
|
|
2
2
|
// bearer secret, so the file is 0600 inside a 0700 directory on POSIX. (Windows
|
|
3
|
-
// relies on per-user ACLs; chmod is skipped there.)
|
|
4
|
-
|
|
5
|
-
import {
|
|
3
|
+
// relies on per-user ACLs; chmod is skipped there.) Credential writes are
|
|
4
|
+
// atomic (tmp + rename) so an interrupted refresh can't truncate the file.
|
|
5
|
+
import {
|
|
6
|
+
readFileSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
existsSync,
|
|
11
|
+
chmodSync,
|
|
12
|
+
renameSync,
|
|
13
|
+
} from 'node:fs';
|
|
6
14
|
import { credentialsPath, configPath, configDir } from './constants.mjs';
|
|
7
15
|
|
|
8
|
-
const undoPath = () => join(configDir(), 'undo.json');
|
|
9
|
-
|
|
10
16
|
const POSIX = process.platform !== 'win32';
|
|
11
17
|
|
|
12
18
|
function readJson(path) {
|
|
13
|
-
try {
|
|
14
|
-
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
15
24
|
}
|
|
16
25
|
|
|
17
26
|
function ensureDir() {
|
|
18
27
|
mkdirSync(configDir(), { recursive: true });
|
|
19
|
-
if (POSIX) {
|
|
28
|
+
if (POSIX) {
|
|
29
|
+
try {
|
|
30
|
+
chmodSync(configDir(), 0o700);
|
|
31
|
+
} catch {
|
|
32
|
+
/* best effort */
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function writeAtomic(path, data) {
|
|
38
|
+
ensureDir();
|
|
39
|
+
const tmp = path + '.tmp';
|
|
40
|
+
writeFileSync(tmp, data, { mode: 0o600 });
|
|
41
|
+
if (POSIX) {
|
|
42
|
+
try {
|
|
43
|
+
chmodSync(tmp, 0o600);
|
|
44
|
+
} catch {
|
|
45
|
+
/* best effort */
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
renameSync(tmp, path);
|
|
20
49
|
}
|
|
21
50
|
|
|
22
51
|
export function loadCredentials() {
|
|
@@ -24,10 +53,7 @@ export function loadCredentials() {
|
|
|
24
53
|
}
|
|
25
54
|
|
|
26
55
|
export function saveCredentials(creds) {
|
|
27
|
-
|
|
28
|
-
const path = credentialsPath();
|
|
29
|
-
writeFileSync(path, JSON.stringify(creds, null, 2) + '\n', { mode: 0o600 });
|
|
30
|
-
if (POSIX) { try { chmodSync(path, 0o600); } catch {} }
|
|
56
|
+
writeAtomic(credentialsPath(), JSON.stringify(creds, null, 2) + '\n');
|
|
31
57
|
}
|
|
32
58
|
|
|
33
59
|
export function clearCredentials() {
|
|
@@ -39,15 +65,5 @@ export function loadConfig() {
|
|
|
39
65
|
}
|
|
40
66
|
|
|
41
67
|
export function saveConfig(config) {
|
|
42
|
-
|
|
43
|
-
writeFileSync(configPath(), JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
|
|
68
|
+
writeAtomic(configPath(), JSON.stringify(config, null, 2) + '\n');
|
|
44
69
|
}
|
|
45
|
-
|
|
46
|
-
export function saveUndo(record) {
|
|
47
|
-
ensureDir();
|
|
48
|
-
writeFileSync(undoPath(), JSON.stringify(record, null, 2) + '\n', { mode: 0o600 });
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function loadUndo() { return readJson(undoPath()); }
|
|
52
|
-
|
|
53
|
-
export function clearUndo() { if (existsSync(undoPath())) rmSync(undoPath()); }
|