@purveyors/cli 0.30.0 → 0.32.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 (48) hide show
  1. package/README.md +12 -12
  2. package/dist/commands/auth.d.ts +23 -19
  3. package/dist/commands/auth.d.ts.map +1 -1
  4. package/dist/commands/auth.js +234 -355
  5. package/dist/commands/auth.js.map +1 -1
  6. package/dist/commands/inventory.js +4 -4
  7. package/dist/commands/inventory.js.map +1 -1
  8. package/dist/commands/roast.d.ts +4 -4
  9. package/dist/commands/roast.d.ts.map +1 -1
  10. package/dist/commands/roast.js +30 -30
  11. package/dist/commands/roast.js.map +1 -1
  12. package/dist/commands/sales.d.ts +8 -10
  13. package/dist/commands/sales.d.ts.map +1 -1
  14. package/dist/commands/sales.js +9 -9
  15. package/dist/commands/sales.js.map +1 -1
  16. package/dist/commands/tasting.js +10 -10
  17. package/dist/commands/tasting.js.map +1 -1
  18. package/dist/lib/ai.d.ts +18 -9
  19. package/dist/lib/ai.d.ts.map +1 -1
  20. package/dist/lib/ai.js +20 -31
  21. package/dist/lib/ai.js.map +1 -1
  22. package/dist/lib/auth-client.d.ts +16 -18
  23. package/dist/lib/auth-client.d.ts.map +1 -1
  24. package/dist/lib/auth-client.js +4 -6
  25. package/dist/lib/auth-client.js.map +1 -1
  26. package/dist/lib/auth-guard.d.ts +3 -3
  27. package/dist/lib/auth-guard.d.ts.map +1 -1
  28. package/dist/lib/auth-guard.js +9 -9
  29. package/dist/lib/auth-guard.js.map +1 -1
  30. package/dist/lib/catalog.js +2 -2
  31. package/dist/lib/catalog.js.map +1 -1
  32. package/dist/lib/interactive/watch.d.ts +3 -3
  33. package/dist/lib/interactive/watch.d.ts.map +1 -1
  34. package/dist/lib/interactive/watch.js +11 -8
  35. package/dist/lib/interactive/watch.js.map +1 -1
  36. package/dist/lib/manifest.d.ts.map +1 -1
  37. package/dist/lib/manifest.js +6 -5
  38. package/dist/lib/manifest.js.map +1 -1
  39. package/dist/lib/market.d.ts.map +1 -1
  40. package/dist/lib/parchment-base.d.ts.map +1 -1
  41. package/dist/lib/parchment-base.js +1 -3
  42. package/dist/lib/parchment-base.js.map +1 -1
  43. package/dist/lib/parchment.d.ts +2 -2
  44. package/dist/lib/parchment.js +9 -9
  45. package/dist/lib/parchment.js.map +1 -1
  46. package/dist/lib/roast.js +1 -1
  47. package/dist/lib/roast.js.map +1 -1
  48. package/package.json +2 -2
@@ -1,333 +1,260 @@
1
1
  import { Command } from 'commander';
2
- import { createServer } from 'http';
2
+ import { createHash, randomBytes } from 'crypto';
3
+ import { hostname } from 'os';
4
+ import { spawn } from 'child_process';
5
+ import { createParchmentClient } from '@purveyors/sdk';
6
+ import chalk from 'chalk';
7
+ import ora from 'ora';
3
8
  import { validateSession } from '../lib/auth-client.js';
4
9
  import { writeCredentials, deleteCredentials } from '../lib/config.js';
10
+ import { getParchmentBaseUrl } from '../lib/parchment-base.js';
5
11
  import { outputData, shouldUseInteractiveOutput, success, info, warn } from '../lib/output.js';
6
12
  import { withErrorHandling, AuthError, exitCodeForError } from '../lib/errors.js';
7
- import chalk from 'chalk';
8
- import ora from 'ora';
9
- import { createInterface } from 'readline';
10
- import { createParchmentClient } from '@purveyors/sdk';
11
- import { getParchmentBaseUrl } from '../lib/parchment-base.js';
12
- import { hostname } from 'os';
13
- import { randomUUID } from 'crypto';
14
- const DEFAULT_CALLBACK_HOST = 'localhost';
15
- const CALLBACK_PATH = '/auth/callback';
16
- const SUPABASE_AUTH_URL = process.env.PURVEYORS_SUPABASE_URL || 'https://bjblfzfdtfvuitqdbodn.supabase.co';
17
- const CLI_API_KEY_SCOPES = [
18
- // The canonical API uses catalog:read for catalog, Market Index,
19
- // Price Index, and procurement reads; those data planes do not define
20
- // separate key scopes.
21
- 'catalog:read',
22
- 'inventory:read',
23
- 'inventory:write',
24
- 'roast:read',
25
- 'roast:write',
26
- 'sales:read',
27
- 'sales:write',
28
- 'tasting:read',
29
- 'tasting:write',
30
- ];
31
- export function createOAuthUrl(redirectTo) {
32
- const url = new URL('/auth/v1/authorize', SUPABASE_AUTH_URL);
33
- url.searchParams.set('provider', 'google');
34
- url.searchParams.set('redirect_to', redirectTo);
35
- return url.toString();
13
+ const DEFAULT_POLL_INTERVAL_SECONDS = 5;
14
+ const EXCHANGE_CANCELLATION_TIMEOUT_MS = 30_000;
15
+ /** Generate an RFC 7636 verifier and its S256 challenge. */
16
+ export function createPkcePair() {
17
+ const verifier = randomBytes(32).toString('base64url');
18
+ const challenge = createHash('sha256').update(verifier).digest('base64url');
19
+ return { verifier, challenge };
36
20
  }
37
- function decodeSessionIdentity(accessToken) {
38
- try {
39
- const payload = JSON.parse(Buffer.from(accessToken.split('.')[1] ?? '', 'base64url').toString('utf8'));
40
- return {
41
- id: typeof payload.sub === 'string' ? payload.sub : 'unknown',
42
- email: typeof payload.email === 'string' ? payload.email : 'unknown',
43
- role: typeof payload.role === 'string' ? payload.role : undefined,
44
- };
45
- }
46
- catch {
47
- return { id: 'unknown', email: 'unknown' };
48
- }
21
+ function apiError(result) {
22
+ if (!result.error || typeof result.error !== 'object')
23
+ return {};
24
+ const envelope = result.error;
25
+ if (envelope.error && typeof envelope.error === 'object')
26
+ return envelope.error;
27
+ // Be tolerant of hand-written/mock clients that return the inner body directly.
28
+ return result.error;
49
29
  }
50
- export async function exchangeOAuthSessionForApiKey(accessToken, clientOverride) {
51
- const client = clientOverride ?? createParchmentClient({ baseUrl: getParchmentBaseUrl(), token: accessToken });
52
- const me = await client.me();
53
- if (!me.response.ok || me.error || !me.data?.authenticated || !me.data.userId) {
54
- throw new AuthError('OAuth session could not be verified by Parchment.');
55
- }
56
- const keyName = `purvey-cli-${hostname()}`;
57
- const existing = await client.apiKeys.list();
58
- if (!existing.response.ok || existing.error) {
59
- throw new AuthError('Failed to inspect existing CLI API keys.', existing.error);
60
- }
61
- const supersededKeys = (existing.data?.data ?? []).filter((key) => key.name === keyName && key.isActive);
62
- const result = await client.apiKeys.create({
63
- name: keyName,
64
- scopes: [...CLI_API_KEY_SCOPES],
65
- });
66
- if (!result.response.ok || result.error || !result.data?.apiKey) {
67
- throw new AuthError('Failed to create a scoped Parchment API key.', result.error);
68
- }
69
- for (const key of supersededKeys) {
70
- const revoked = await client.apiKeys.revoke(key.id);
71
- if (!revoked.response.ok || revoked.error) {
72
- await client.apiKeys.revoke(result.data.key.id).catch(() => undefined);
73
- throw new AuthError('Failed to replace the existing CLI API key.', revoked.error);
30
+ /** Normalize hostnames to Parchment's stable, human-readable machine-name contract. */
31
+ export function normalizeMachineName(value) {
32
+ const normalized = value
33
+ .trim()
34
+ .replace(/[^A-Za-z0-9._ -]+/g, '-')
35
+ .replace(/-+/g, '-')
36
+ .slice(0, 64)
37
+ .trim()
38
+ .replace(/^-+|-+$/g, '');
39
+ return normalized || 'unknown-machine';
40
+ }
41
+ function wait(milliseconds, signal) {
42
+ return new Promise((resolve, reject) => {
43
+ if (signal?.aborted) {
44
+ reject(new AuthError('Login cancelled.'));
45
+ return;
74
46
  }
75
- }
76
- const identity = decodeSessionIdentity(accessToken);
77
- return {
78
- apiKey: result.data.apiKey,
79
- keyId: result.data.key.id,
80
- createdAt: result.data.key.createdAt ?? new Date().toISOString(),
81
- user: {
82
- ...identity,
83
- id: me.data.userId,
84
- role: me.data.primaryAppRole ?? identity.role,
85
- },
86
- };
47
+ const onAbort = () => {
48
+ clearTimeout(timer);
49
+ reject(new AuthError('Login cancelled.'));
50
+ };
51
+ const timer = setTimeout(() => {
52
+ signal?.removeEventListener('abort', onAbort);
53
+ resolve();
54
+ }, milliseconds);
55
+ signal?.addEventListener('abort', onAbort, { once: true });
56
+ });
87
57
  }
88
- /**
89
- * Start a one-shot local HTTP server to receive the Supabase OAuth callback.
90
- *
91
- * Supabase delivers tokens in the URL fragment (#access_token=...) which is
92
- * client-side only. We serve a minimal HTML page that extracts the fragment
93
- * values and POSTs them back to the local server, then resolves the promise.
94
- */
95
- function startCallbackServer() {
58
+ function awaitAbortable(promise, signal) {
59
+ if (!signal)
60
+ return promise;
61
+ if (signal.aborted)
62
+ return Promise.reject(new AuthError('Login cancelled.'));
96
63
  return new Promise((resolve, reject) => {
97
- const state = randomUUID();
98
- let tokenResolve;
99
- let tokenReject;
100
- const tokenPromise = new Promise((res, rej) => {
101
- tokenResolve = res;
102
- tokenReject = rej;
64
+ const onAbort = () => {
65
+ reject(new AuthError('Login cancelled.'));
66
+ };
67
+ signal.addEventListener('abort', onAbort, { once: true });
68
+ promise.then((value) => {
69
+ signal.removeEventListener('abort', onAbort);
70
+ resolve(value);
71
+ }, (error) => {
72
+ signal.removeEventListener('abort', onAbort);
73
+ reject(error);
103
74
  });
104
- const server = createServer((req, res) => {
105
- const url = new URL(req.url ?? '/', `http://${DEFAULT_CALLBACK_HOST}`);
106
- if (url.pathname === CALLBACK_PATH && url.searchParams.get('state') === state) {
107
- res.writeHead(200, { 'Content-Type': 'text/html' });
108
- res.end(`<!DOCTYPE html>
109
- <html>
110
- <head><title>Authenticating...</title></head>
111
- <body>
112
- <p>Completing authentication, please wait...</p>
113
- <script>
114
- const hash = window.location.hash.substring(1);
115
- const params = new URLSearchParams(hash);
116
- fetch('/auth/token', {
117
- method: 'POST',
118
- headers: { 'Content-Type': 'application/json' },
119
- body: JSON.stringify({
120
- access_token: params.get('access_token'),
121
- refresh_token: params.get('refresh_token'),
122
- expires_in: params.get('expires_in'),
123
- state: new URLSearchParams(window.location.search).get('state'),
124
- })
125
- }).then(() => {
126
- document.body.innerHTML =
127
- '<h2 style="font-family:sans-serif;color:#2d6a4f;">✔ Authenticated! You can close this tab.</h2>';
128
- });
129
- </script>
130
- </body>
131
- </html>`);
132
- return;
133
- }
134
- if (url.pathname === '/auth/token' && req.method === 'POST') {
135
- let body = '';
136
- req.on('data', (chunk) => (body += chunk));
137
- req.on('end', () => {
138
- res.writeHead(200);
139
- res.end('ok');
140
- server.close();
141
- try {
142
- const { access_token, refresh_token, expires_in, state: callbackState, } = JSON.parse(body);
143
- if (!access_token || !refresh_token || callbackState !== state) {
144
- tokenReject(new AuthError('OAuth callback did not include tokens. Try again.'));
145
- return;
146
- }
147
- tokenResolve({
148
- accessToken: access_token,
149
- refreshToken: refresh_token,
150
- expiresIn: parseInt(expires_in ?? '3600', 10),
151
- });
152
- }
153
- catch {
154
- tokenReject(new AuthError('Failed to parse OAuth callback response.'));
155
- }
156
- });
75
+ });
76
+ }
77
+ function awaitExchangeSettlement(promise, options) {
78
+ const { signal } = options;
79
+ if (!signal)
80
+ return promise;
81
+ return new Promise((resolve, reject) => {
82
+ let settled = false;
83
+ let cancellationTimer;
84
+ const finish = (callback) => {
85
+ if (settled)
157
86
  return;
158
- }
159
- res.writeHead(404);
160
- res.end();
161
- });
162
- server.listen(0, DEFAULT_CALLBACK_HOST, () => {
163
- const addr = server.address();
164
- if (!addr || typeof addr === 'string') {
165
- reject(new AuthError('Failed to start callback server.'));
87
+ settled = true;
88
+ signal.removeEventListener('abort', onAbort);
89
+ if (cancellationTimer)
90
+ clearTimeout(cancellationTimer);
91
+ callback();
92
+ };
93
+ const onAbort = () => {
94
+ if (settled || cancellationTimer)
166
95
  return;
167
- }
168
- resolve({
169
- port: addr.port,
170
- state,
171
- tokenPromise,
172
- close: () => {
173
- try {
174
- server.close();
175
- }
176
- catch {
177
- // Server may already be closed after an automatic callback.
178
- }
179
- },
96
+ options.onExchangeCancellationWait?.();
97
+ cancellationTimer = setTimeout(() => {
98
+ finish(() => reject(new AuthError('Login cancellation timed out while Parchment was issuing the one-time key. ' +
99
+ 'The prior key may have been replaced; run `purvey auth login` again.')));
100
+ }, options.exchangeCancellationTimeoutMs ?? EXCHANGE_CANCELLATION_TIMEOUT_MS);
101
+ };
102
+ signal.addEventListener('abort', onAbort, { once: true });
103
+ if (signal.aborted)
104
+ onAbort();
105
+ promise.then((value) => finish(() => resolve(value)), (error) => finish(() => reject(error)));
106
+ });
107
+ }
108
+ /** Open the verification page, returning false when no browser command is available. */
109
+ export async function openVerificationPage(url) {
110
+ const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
111
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
112
+ return new Promise((resolve) => {
113
+ try {
114
+ const child = spawn(command, args, { detached: true, stdio: 'ignore' });
115
+ child.once('spawn', () => {
116
+ child.unref();
117
+ resolve(true);
180
118
  });
181
- });
182
- server.on('error', (err) => reject(err));
119
+ child.once('error', () => resolve(false));
120
+ }
121
+ catch {
122
+ resolve(false);
123
+ }
183
124
  });
184
125
  }
126
+ function toStoredCredentials(data) {
127
+ return {
128
+ apiKey: data.apiKey,
129
+ keyId: data.key.id,
130
+ createdAt: data.key.createdAt ?? new Date().toISOString(),
131
+ user: data.user,
132
+ };
133
+ }
185
134
  /**
186
- * Extract Supabase OAuth tokens from a full callback URL, URL fragment, or
187
- * query string. Exported for auth-flow contract tests.
135
+ * Run the Parchment-owned browser/device authorization protocol.
136
+ * The verifier and signed request token remain memory-only and are discarded on exit.
188
137
  */
189
- export function parseOAuthCallbackUrl(callbackUrl, expectedState) {
190
- const trimmed = callbackUrl.trim();
191
- if (!trimmed) {
192
- throw new AuthError('No URL provided.');
138
+ export async function performDeviceLogin(options) {
139
+ if (options.signal?.aborted)
140
+ throw new AuthError('Login cancelled.');
141
+ const client = options.client ?? createParchmentClient({ baseUrl: getParchmentBaseUrl() });
142
+ const { verifier, challenge } = createPkcePair();
143
+ let created;
144
+ try {
145
+ created = await awaitAbortable(client.cliAuth.create({
146
+ machineName: normalizeMachineName(options.machineName ?? hostname()),
147
+ codeChallenge: challenge,
148
+ }), options.signal);
193
149
  }
194
- // Supabase usually puts tokens in the fragment:
195
- // #access_token=...&refresh_token=...
196
- let tokenStr = '';
197
- if (trimmed.includes('#')) {
198
- tokenStr = trimmed.split('#')[1] ?? '';
150
+ catch (error) {
151
+ if (error instanceof AuthError)
152
+ throw error;
153
+ if (options.signal?.aborted)
154
+ throw new AuthError('Login cancelled.');
155
+ throw new AuthError('Could not contact Parchment to start login.', error);
199
156
  }
200
- else if (trimmed.includes('?')) {
201
- tokenStr = trimmed.split('?').slice(1).join('?');
157
+ if (!created.response.ok || created.error || !created.data) {
158
+ const detail = apiError(created).message;
159
+ throw new AuthError(detail ?? 'Parchment could not start the login request.', created.error);
202
160
  }
203
- else {
204
- // Allow pasting just the fragment/query content.
205
- tokenStr = trimmed;
161
+ const { requestToken, verificationUri, expiresAt } = created.data;
162
+ const intervalSeconds = Math.max(1, Number.isFinite(created.data.intervalSeconds)
163
+ ? created.data.intervalSeconds
164
+ : DEFAULT_POLL_INTERVAL_SECONDS);
165
+ if (options.headless) {
166
+ console.log(chalk.bold('\n Headless Login\n'));
167
+ console.log(' Open this URL in a browser and approve access:\n');
168
+ console.log(` ${chalk.cyan(verificationUri)}\n`);
206
169
  }
207
- const params = new URLSearchParams(tokenStr);
208
- const accessToken = params.get('access_token');
209
- const refreshToken = params.get('refresh_token');
210
- const expiresIn = params.get('expires_in');
211
- if (expectedState) {
212
- const parsedUrl = new URL(trimmed, 'http://localhost');
213
- if (parsedUrl.searchParams.get('state') !== expectedState) {
214
- throw new AuthError('OAuth callback state did not match this login attempt.');
170
+ else {
171
+ console.log(chalk.bold('\n Opening Purveyors in your browser...'));
172
+ const opened = await (options.openBrowser ?? openVerificationPage)(verificationUri);
173
+ if (!opened) {
174
+ warn('Could not open a browser. Open this URL to continue:');
175
+ console.log(` ${chalk.cyan(verificationUri)}\n`);
176
+ }
177
+ else {
178
+ console.log(chalk.dim(` If the browser did not open, visit:\n ${verificationUri}\n`));
215
179
  }
216
180
  }
217
- if (!accessToken || !refreshToken) {
218
- throw new AuthError('Could not extract tokens from the URL.\n' +
219
- ' Make sure you copied the full callback URL including the #access_token=... part.');
181
+ const expiresAtMs = Date.parse(expiresAt);
182
+ if (!Number.isFinite(expiresAtMs)) {
183
+ throw new AuthError('Parchment returned an invalid login expiration time.');
220
184
  }
221
- return {
222
- accessToken,
223
- refreshToken,
224
- expiresIn: parseInt(expiresIn ?? '3600', 10),
225
- };
226
- }
227
- export function createManualCallbackReaderForQuestion(question, closeQuestion, canRead, expectedState) {
228
- if (!canRead)
229
- return null;
230
- let closed = false;
231
- const prompt = chalk.bold(' If the browser cannot return here, paste the full callback URL: ');
232
- const promise = new Promise((resolve) => {
233
- const ask = () => {
234
- if (closed)
235
- return;
236
- question(prompt, (answer) => {
237
- if (closed)
238
- return;
239
- try {
240
- const callbackResult = parseOAuthCallbackUrl(answer, expectedState);
241
- closed = true;
242
- closeQuestion();
243
- resolve(callbackResult);
244
- }
245
- catch (error) {
246
- const message = error instanceof Error ? error.message : 'Failed to parse callback URL.';
247
- console.log(chalk.yellow(` Ignoring invalid callback URL: ${message}`));
248
- console.log(chalk.dim(' Still waiting for the browser callback; paste a valid URL to retry.'));
249
- ask();
250
- }
251
- });
252
- };
253
- ask();
254
- });
255
- return {
256
- promise,
257
- close: () => {
258
- if (closed)
259
- return;
260
- closed = true;
261
- closeQuestion();
262
- },
263
- };
264
- }
265
- function createManualCallbackReader(expectedState) {
266
- if (!process.stdin.isTTY)
267
- return null;
268
- const rl = createInterface({
269
- input: process.stdin,
270
- output: process.stdout,
271
- });
272
- return createManualCallbackReaderForQuestion(rl.question.bind(rl), () => rl.close(), process.stdin.isTTY, expectedState);
273
- }
274
- /**
275
- * `purvey auth login`
276
- * Opens a browser for Google OAuth via Supabase, captures the callback token.
277
- */
278
- const loginAction = withErrorHandling(async () => {
279
- const callbackServer = await startCallbackServer();
280
- const { port, state, tokenPromise } = callbackServer;
281
- const redirectTo = `http://${DEFAULT_CALLBACK_HOST}:${port}${CALLBACK_PATH}?state=${encodeURIComponent(state)}`;
282
- try {
283
- const oauthUrl = createOAuthUrl(redirectTo);
284
- console.log(chalk.bold('\n Opening browser for authentication...'));
285
- console.log(chalk.dim(` If the browser does not open, visit:\n ${oauthUrl}\n`));
286
- console.log(chalk.dim(' Waiting for the browser callback. If the browser lands on a localhost URL but\n' +
287
- ' the CLI does not finish, copy that full URL and paste it below.\n'));
288
- // Open browser cross-platform (graceful fallback for headless)
289
- try {
290
- const { spawn } = await import('child_process');
291
- const openCmd = process.platform === 'darwin'
292
- ? 'open'
293
- : process.platform === 'win32'
294
- ? 'start'
295
- : 'xdg-open';
296
- const child = spawn(openCmd, [oauthUrl], { detached: true, stdio: 'ignore' });
297
- child.on('error', () => {
298
- // Browser open failed (headless environment) — user can visit URL manually
299
- });
300
- child.unref();
185
+ const sleep = options.sleep ?? wait;
186
+ while (true) {
187
+ const remainingMs = expiresAtMs - Date.now();
188
+ if (remainingMs <= 0) {
189
+ throw new AuthError('Login request expired. Run `purvey auth login` to try again.');
301
190
  }
302
- catch {
303
- // Gracefully ignore — URL is already printed above
191
+ await sleep(Math.min(intervalSeconds * 1000, remainingMs), options.signal);
192
+ if (Date.now() >= expiresAtMs) {
193
+ throw new AuthError('Login request expired. Run `purvey auth login` to try again.');
304
194
  }
305
- const manualReader = createManualCallbackReader(state);
306
- const spinner = ora({ text: 'Waiting for authentication...', stream: process.stderr }).start();
307
- let callbackResult;
195
+ if (options.signal?.aborted)
196
+ throw new AuthError('Login cancelled.');
197
+ let exchanged;
308
198
  try {
309
- callbackResult = await Promise.race([
310
- tokenPromise,
311
- ...(manualReader ? [manualReader.promise] : []),
312
- ]);
199
+ exchanged = await awaitExchangeSettlement(client.cliAuth.exchange({
200
+ requestToken,
201
+ codeVerifier: verifier,
202
+ }), options);
203
+ }
204
+ catch (error) {
205
+ if (error instanceof AuthError)
206
+ throw error;
207
+ if (options.signal?.aborted)
208
+ throw new AuthError('Login cancelled.');
209
+ throw new AuthError('Could not contact Parchment while waiting for approval.', error);
210
+ }
211
+ if (exchanged.response.ok && !exchanged.error && exchanged.data) {
212
+ return toStoredCredentials(exchanged.data);
213
+ }
214
+ if (options.signal?.aborted)
215
+ throw new AuthError('Login cancelled.');
216
+ const error = apiError(exchanged);
217
+ if (error.code === 'authorization_pending')
218
+ continue;
219
+ if (error.code === 'request_expired') {
220
+ throw new AuthError('Login request expired. Run `purvey auth login` to try again.');
221
+ }
222
+ if (error.code === 'request_consumed') {
223
+ throw new AuthError('Login request was already used. Run `purvey auth login` to try again.');
313
224
  }
314
- finally {
315
- manualReader?.close();
225
+ if (error.code === 'invalid_request' || error.code === 'request_conflict') {
226
+ throw new AuthError(error.message ?? 'Parchment rejected the login request.');
316
227
  }
317
- const { accessToken } = callbackResult;
318
- spinner.succeed('Authentication received');
319
- const creds = await exchangeOAuthSessionForApiKey(accessToken);
320
- await writeCredentials(creds);
321
- success(`Logged in as ${chalk.bold(creds.user.email)}`);
228
+ throw new AuthError(error.message ?? 'Parchment could not complete login.', exchanged.error);
229
+ }
230
+ }
231
+ async function login(headless) {
232
+ const controller = new AbortController();
233
+ const cancel = () => controller.abort();
234
+ process.once('SIGINT', cancel);
235
+ const spinner = ora({ text: 'Waiting for browser approval...', stream: process.stderr });
236
+ try {
237
+ const credentialsPromise = performDeviceLogin({
238
+ headless,
239
+ signal: controller.signal,
240
+ onExchangeCancellationWait: () => {
241
+ spinner.text =
242
+ 'Cancellation requested; waiting up to 30s for the one-time exchange to settle safely. Press Ctrl-C again to force exit.';
243
+ },
244
+ });
245
+ spinner.start();
246
+ const credentials = await credentialsPromise;
247
+ await writeCredentials(credentials);
248
+ spinner.succeed(`Logged in as ${chalk.bold(credentials.user.email)}`);
249
+ }
250
+ catch (error) {
251
+ spinner.stop();
252
+ throw error;
322
253
  }
323
254
  finally {
324
- callbackServer.close();
255
+ process.removeListener('SIGINT', cancel);
325
256
  }
326
- });
327
- /**
328
- * `purvey auth status`
329
- * Shows current login state and token info.
330
- */
257
+ }
331
258
  const statusAction = withErrorHandling(async (_, cmd) => {
332
259
  const opts = cmd.optsWithGlobals();
333
260
  const isInteractive = shouldUseInteractiveOutput(opts);
@@ -366,79 +293,31 @@ const statusAction = withErrorHandling(async (_, cmd) => {
366
293
  }
367
294
  outputData(result, opts);
368
295
  });
369
- /**
370
- * `purvey auth login --headless`
371
- * Headless login for agents and servers. Generates OAuth URL, user pastes back callback.
372
- */
373
- const headlessLoginAction = withErrorHandling(async () => {
374
- // Generate OAuth URL with purveyors.io callback page as redirect
375
- const state = randomUUID();
376
- const redirectTo = new URL('https://purveyors.io/auth/cli-callback');
377
- redirectTo.searchParams.set('state', state);
378
- const oauthUrl = createOAuthUrl(redirectTo.toString());
379
- console.log(chalk.bold('\n Headless Login\n'));
380
- console.log(' 1. Open this URL in a browser and sign in:\n');
381
- console.log(` ${chalk.cyan(oauthUrl)}\n`);
382
- console.log(" 2. After login, you'll see a page with a callback URL.");
383
- console.log(' 3. Copy the full callback URL and paste it below.\n');
384
- // Read the callback URL from stdin
385
- const { createInterface } = await import('readline');
386
- const rl = createInterface({ input: process.stdin, output: process.stdout });
387
- const callbackUrl = await new Promise((resolve) => {
388
- rl.question(chalk.bold(' Paste callback URL: '), (answer) => {
389
- rl.close();
390
- resolve(answer.trim());
391
- });
392
- });
393
- if (!callbackUrl) {
394
- throw new AuthError('No URL provided.');
395
- }
396
- const { accessToken } = parseOAuthCallbackUrl(callbackUrl, state);
397
- const spinner = ora({ text: 'Validating session...', stream: process.stderr }).start();
398
- const creds = await exchangeOAuthSessionForApiKey(accessToken);
399
- await writeCredentials(creds);
400
- spinner.succeed(`Logged in as ${chalk.bold(creds.user.email)}`);
401
- });
402
- /**
403
- * `purvey auth logout`
404
- * Clears stored credentials from disk.
405
- */
406
296
  const logoutAction = withErrorHandling(async () => {
407
297
  await deleteCredentials();
408
298
  warn('Local credentials cleared. The server API key remains active; revoke it from the Purveyors account key dashboard if this machine is lost or compromised.');
409
299
  });
410
- /**
411
- * Build and return the `auth` command subtree.
412
- */
413
300
  export function buildAuthCommand() {
414
301
  const auth = new Command('auth').description('Manage authentication with purveyors.io');
415
- const login = auth
302
+ auth
416
303
  .command('login')
417
304
  .description('Log in to purveyors.io')
418
- .option('--headless', 'Headless login (prints URL, you paste back the callback)')
419
- .action(async (opts) => {
420
- if (opts.headless) {
421
- return headlessLoginAction();
422
- }
423
- // Default: browser OAuth flow
424
- return loginAction();
425
- });
426
- login.addHelpText('after', `
305
+ .option('--headless', 'Print the approval URL without opening a browser')
306
+ .action(async (opts) => login(Boolean(opts.headless)))
307
+ .addHelpText('after', `
427
308
  Examples:
428
- ${chalk.dim('# Browser login (interactive, opens Google OAuth in default browser)')}
309
+ ${chalk.dim('# Browser login (opens the Purveyors approval page)')}
429
310
  purvey auth login
430
311
 
431
- ${chalk.dim('# Headless login (agents, CI, servers — no browser required)')}
312
+ ${chalk.dim('# Headless login (agents, SSH sessions, and remote servers)')}
432
313
  purvey auth login --headless
433
- ${chalk.dim('# → prints a Google OAuth URL')}
434
- ${chalk.dim('# → open URL, sign in with Google')}
435
- ${chalk.dim('# → copy the full callback URL from the browser')}
436
- ${chalk.dim('# → paste it back at the prompt')}
314
+ ${chalk.dim('# → open the printed URL in any browser and approve access')}
315
+ ${chalk.dim('# → the CLI completes automatically; nothing is pasted back')}
437
316
 
438
317
  Notes:
439
318
  Credentials are stored at ~/.config/purvey/credentials.json (mode 0600).
440
- OAuth is used once to mint a scoped Parchment API key; session tokens are not retained.
441
- Re-login replaces the prior CLI key for this machine.
319
+ The CLI stores only the scoped Parchment API key returned after approval.
320
+ Re-login atomically replaces the prior CLI key for this machine.
442
321
  `);
443
322
  auth
444
323
  .command('status')