@onenomad/engram-mcp 1.0.0 → 2.0.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.
@@ -1,68 +1,107 @@
1
- /**
2
- * Device-code login + logout against pyre-web.
3
- *
4
- * Flow:
5
- * 1. POST /api/auth/device-code → user_code, device_code, verification_url, expires_in, interval.
6
- * 2. Print the URL + code, best-effort open the browser.
7
- * 3. Poll /api/auth/device-code/poll until approved / denied / expired / timeout.
8
- * 4. On approval, write ~/.pyre/credentials.json. The api_url written
9
- * to disk is the server-returned canonical URL from the poll
10
- * response, NOT the one the user typed at login time. Server is
11
- * the source of truth -- it may normalise / redirect / hand back
12
- * a different storage endpoint than the login endpoint.
13
- *
14
- * No hardcoded URLs. The CLI requires the user to supply the server
15
- * URL at login (positional arg, --server flag, or PYRE_API_URL env).
16
- * Shipping prod is "users point at prod when they log in."
17
- *
18
- * Everything except the final success/failure line goes to stderr. The
19
- * URL + code block goes to stdout so a caller piping our output to a
20
- * file gets only the actionable bits.
21
- */
22
- import { credentialsPath } from './credentials.js';
23
- export interface LoginOptions {
24
- /**
25
- * pyre-web base URL. Required. Caller (the CLI) is responsible for
26
- * resolving this from positional arg / --server flag / PYRE_API_URL
27
- * env var and refusing to call runLogin() without one. runLogin
28
- * itself does NOT look at process.env — keeps the function
29
- * testable and the policy ("server URL required") visible at the
30
- * caller.
31
- */
32
- apiUrl: string;
33
- /** Override hostname (tests). */
34
- deviceName?: string;
35
- /** Override the writes-to-disk target (tests). */
36
- credentialsFile?: string;
37
- /** Override the "open in browser" hook (tests). */
38
- openBrowser?: (url: string) => void;
39
- /** Override fetch (tests). */
40
- fetchImpl?: typeof fetch;
41
- /** Override sleep (tests). */
42
- sleep?: (ms: number) => Promise<void>;
43
- /** Override Date.now (tests). */
44
- now?: () => number;
45
- }
46
- /**
47
- * Resolve a user-supplied server URL from CLI arg / flag / env, in
48
- * that precedence. Returns null when none of the three sources gave
49
- * us a URL caller is expected to print the spec'd error message
50
- * and exit 1.
51
- */
52
- export declare function resolveServerUrl(opts: {
53
- positional?: string;
54
- flag?: string;
55
- }): string | null;
56
- /**
57
- * Run the login flow end-to-end. Returns 0 on success, non-zero on
58
- * failure. Prints user-visible messages to stdout/stderr as documented
59
- * in the deliverable spec.
60
- */
61
- export declare function runLogin(opts: LoginOptions): Promise<number>;
62
- /**
63
- * Idempotent logout — exits 0 whether or not the file existed.
64
- */
65
- export declare function runLogout(opts?: {
66
- credentialsFile?: string;
67
- }): number;
68
- export { credentialsPath };
1
+ /**
2
+ * Device-code login + logout against pyre-web.
3
+ *
4
+ * Flow:
5
+ * 1. POST /api/auth/device-code → user_code, device_code, verification_url, expires_in, interval.
6
+ * 2. Print the URL + code, best-effort open the browser.
7
+ * 3. Poll /api/auth/device-code/poll until approved / denied / expired / timeout.
8
+ * 4. On approval, write ~/.pyre/credentials.json. The api_url written
9
+ * to disk is the server-returned canonical URL from the poll
10
+ * response, NOT the one the user typed at login time. Server is
11
+ * the source of truth -- it may normalise / redirect / hand back
12
+ * a different storage endpoint than the login endpoint.
13
+ *
14
+ * No hardcoded URLs. The CLI requires the user to supply the server
15
+ * URL at login (positional arg, --server flag, or PYRE_API_URL env).
16
+ * Shipping prod is "users point at prod when they log in."
17
+ *
18
+ * Everything except the final success/failure line goes to stderr. The
19
+ * URL + code block goes to stdout so a caller piping our output to a
20
+ * file gets only the actionable bits.
21
+ */
22
+ import { credentialsPath, type Credentials } from './credentials.js';
23
+ export interface DeviceCodeStart {
24
+ user_code: string;
25
+ device_code: string;
26
+ verification_url: string;
27
+ expires_in: number;
28
+ interval: number;
29
+ }
30
+ export type DeviceCodePoll = {
31
+ status: 'pending';
32
+ } | {
33
+ status: 'approved';
34
+ api_url: string;
35
+ api_key: string;
36
+ label: string;
37
+ scopes: string[];
38
+ } | {
39
+ status: 'denied';
40
+ } | {
41
+ status: 'expired';
42
+ };
43
+ export interface LoginOptions {
44
+ /**
45
+ * pyre-web base URL. Required. Caller (the CLI) is responsible for
46
+ * resolving this from positional arg / --server flag / PYRE_API_URL
47
+ * env var and refusing to call runLogin() without one. runLogin
48
+ * itself does NOT look at process.env keeps the function
49
+ * testable and the policy ("server URL required") visible at the
50
+ * caller.
51
+ */
52
+ apiUrl: string;
53
+ /** Override hostname (tests). */
54
+ deviceName?: string;
55
+ /** Override the writes-to-disk target (tests). */
56
+ credentialsFile?: string;
57
+ /** Override the "open in browser" hook (tests). */
58
+ openBrowser?: (url: string) => void;
59
+ /** Override fetch (tests). */
60
+ fetchImpl?: typeof fetch;
61
+ /** Override sleep (tests). */
62
+ sleep?: (ms: number) => Promise<void>;
63
+ /** Override Date.now (tests). */
64
+ now?: () => number;
65
+ }
66
+ /**
67
+ * Resolve a user-supplied server URL from CLI arg / flag / env, in
68
+ * that precedence. Returns null when none of the three sources gave
69
+ * us a URL — caller is expected to print the spec'd error message
70
+ * and exit 1.
71
+ */
72
+ export declare function resolveServerUrl(opts: {
73
+ positional?: string;
74
+ flag?: string;
75
+ }): string | null;
76
+ /**
77
+ * Start a device-code pairing. Retries up to 3 times with 1s/2s/4s
78
+ * backoff on transient network failures before giving up.
79
+ */
80
+ export declare function startDeviceCode(fetchImpl: typeof fetch, apiUrl: string, deviceName: string, sleep: (ms: number) => Promise<void>): Promise<DeviceCodeStart>;
81
+ /**
82
+ * Single poll of /api/auth/device-code/poll. Normalises HTTP 410 to
83
+ * the `expired` status the rest of the codebase already handles.
84
+ * Other non-2xx responses are surfaced as thrown errors so callers
85
+ * (CLI's loop, MCP tool) can decide whether to retry or give up.
86
+ */
87
+ export declare function pollDeviceCode(fetchImpl: typeof fetch, apiUrl: string, deviceCode: string): Promise<DeviceCodePoll>;
88
+ /**
89
+ * Build a Credentials object from an approved poll response. Centralises
90
+ * the shape used by both the CLI and the MCP tool path.
91
+ */
92
+ export declare function credentialsFromApproval(approved: Extract<DeviceCodePoll, {
93
+ status: 'approved';
94
+ }>): Credentials;
95
+ /**
96
+ * Run the login flow end-to-end. Returns 0 on success, non-zero on
97
+ * failure. Prints user-visible messages to stdout/stderr as documented
98
+ * in the deliverable spec.
99
+ */
100
+ export declare function runLogin(opts: LoginOptions): Promise<number>;
101
+ /**
102
+ * Idempotent logout — exits 0 whether or not the file existed.
103
+ */
104
+ export declare function runLogout(opts?: {
105
+ credentialsFile?: string;
106
+ }): number;
107
+ export { credentialsPath };
@@ -1,217 +1,228 @@
1
- /**
2
- * Device-code login + logout against pyre-web.
3
- *
4
- * Flow:
5
- * 1. POST /api/auth/device-code → user_code, device_code, verification_url, expires_in, interval.
6
- * 2. Print the URL + code, best-effort open the browser.
7
- * 3. Poll /api/auth/device-code/poll until approved / denied / expired / timeout.
8
- * 4. On approval, write ~/.pyre/credentials.json. The api_url written
9
- * to disk is the server-returned canonical URL from the poll
10
- * response, NOT the one the user typed at login time. Server is
11
- * the source of truth -- it may normalise / redirect / hand back
12
- * a different storage endpoint than the login endpoint.
13
- *
14
- * No hardcoded URLs. The CLI requires the user to supply the server
15
- * URL at login (positional arg, --server flag, or PYRE_API_URL env).
16
- * Shipping prod is "users point at prod when they log in."
17
- *
18
- * Everything except the final success/failure line goes to stderr. The
19
- * URL + code block goes to stdout so a caller piping our output to a
20
- * file gets only the actionable bits.
21
- */
22
- import { hostname, platform } from 'node:os';
23
- import { spawn } from 'node:child_process';
24
- import { writeCredentials, deleteCredentials, credentialsPath } from './credentials.js';
25
- const PACKAGE_NAME = 'engram-memory';
26
- /**
27
- * Resolve a user-supplied server URL from CLI arg / flag / env, in
28
- * that precedence. Returns null when none of the three sources gave
29
- * us a URL — caller is expected to print the spec'd error message
30
- * and exit 1.
31
- */
32
- export function resolveServerUrl(opts) {
33
- const trim = (s) => {
34
- const t = s?.trim();
35
- if (!t)
36
- return null;
37
- return t.replace(/\/+$/, '');
38
- };
39
- return trim(opts.positional) ?? trim(opts.flag) ?? trim(process.env.PYRE_API_URL);
40
- }
41
- function sleepDefault(ms) {
42
- return new Promise(resolve => setTimeout(resolve, ms));
43
- }
44
- function openInBrowser(url) {
45
- try {
46
- const p = platform();
47
- if (p === 'darwin') {
48
- spawn('open', [url], { stdio: 'ignore', detached: true }).unref();
49
- }
50
- else if (p === 'win32') {
51
- // The empty title arg matters — `start <url>` treats the URL as the title.
52
- spawn('cmd', ['/c', 'start', '', url], { stdio: 'ignore', detached: true }).unref();
53
- }
54
- else {
55
- spawn('xdg-open', [url], { stdio: 'ignore', detached: true }).unref();
56
- }
57
- }
58
- catch {
59
- // Best-effort — the printed URL is always the fallback.
60
- }
61
- }
62
- async function postJson(fetchImpl, url, body) {
63
- const res = await fetchImpl(url, {
64
- method: 'POST',
65
- headers: { 'content-type': 'application/json', 'accept': 'application/json' },
66
- body: JSON.stringify(body),
67
- });
68
- const text = await res.text();
69
- let json = null;
70
- if (text.length > 0) {
71
- try {
72
- json = JSON.parse(text);
73
- }
74
- catch {
75
- // Fall through — non-JSON response is itself a failure to surface.
76
- throw new Error(`non-JSON response from ${url} (status ${res.status}): ${text.slice(0, 200)}`);
77
- }
78
- }
79
- return { status: res.status, json: json };
80
- }
81
- /**
82
- * Start a device-code pairing. Retries up to 3 times with 1s/2s/4s
83
- * backoff on transient network failures before giving up.
84
- */
85
- async function startDeviceCode(fetchImpl, apiUrl, deviceName, sleep) {
86
- const url = `${apiUrl}/api/auth/device-code`;
87
- const backoffs = [1000, 2000, 4000];
88
- let lastErr;
89
- for (let attempt = 0; attempt < backoffs.length; attempt++) {
90
- try {
91
- const { status, json } = await postJson(fetchImpl, url, { device_name: deviceName, package_name: PACKAGE_NAME });
92
- if (status >= 200 && status < 300) {
93
- if (!json.user_code || !json.device_code || !json.verification_url) {
94
- throw new Error(`malformed device-code response: ${JSON.stringify(json)}`);
95
- }
96
- return json;
97
- }
98
- throw new Error(`server returned HTTP ${status}${json?.error ? `: ${json.error}` : ''}`);
99
- }
100
- catch (err) {
101
- lastErr = err;
102
- if (attempt < backoffs.length - 1) {
103
- await sleep(backoffs[attempt]);
104
- }
105
- }
106
- }
107
- throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
108
- }
109
- /**
110
- * Run the login flow end-to-end. Returns 0 on success, non-zero on
111
- * failure. Prints user-visible messages to stdout/stderr as documented
112
- * in the deliverable spec.
113
- */
114
- export async function runLogin(opts) {
115
- const apiUrl = opts.apiUrl.trim().replace(/\/+$/, '');
116
- const deviceName = opts.deviceName ?? hostname();
117
- const fetchImpl = opts.fetchImpl ?? fetch;
118
- const sleep = opts.sleep ?? sleepDefault;
119
- const now = opts.now ?? Date.now;
120
- const open = opts.openBrowser ?? openInBrowser;
121
- let start;
122
- try {
123
- start = await startDeviceCode(fetchImpl, apiUrl, deviceName, sleep);
124
- }
125
- catch (err) {
126
- process.stderr.write(`Could not reach ${apiUrl}: ${err.message}.\n`);
127
- return 1;
128
- }
129
- // The URL + code block — stdout, in one piece so it's easy to grab.
130
- process.stdout.write(`Open this URL in your browser to authorize:\n` +
131
- `\n` +
132
- ` ${start.verification_url}\n` +
133
- `\n` +
134
- `Enter this code when prompted: ${start.user_code}\n` +
135
- `(waiting for approval — Ctrl+C to cancel)\n`);
136
- open(start.verification_url);
137
- const intervalMs = Math.max(1, start.interval) * 1000;
138
- const expiresAt = now() + start.expires_in * 1000;
139
- const pollUrl = `${apiUrl}/api/auth/device-code/poll`;
140
- while (now() < expiresAt) {
141
- await sleep(intervalMs);
142
- if (now() >= expiresAt)
143
- break;
144
- let pollRes;
145
- try {
146
- pollRes = await postJson(fetchImpl, pollUrl, { device_code: start.device_code });
147
- }
148
- catch (err) {
149
- // Transient log to stderr and keep polling until expires_in
150
- // wins.
151
- process.stderr.write(`engram: poll error (will retry): ${err.message}\n`);
152
- continue;
153
- }
154
- // 410 carries `{ status: "expired" }` per the spec.
155
- if (pollRes.status === 410) {
156
- process.stderr.write(`Pairing code expired. Run \`engram-mcp login\` again.\n`);
157
- return 1;
158
- }
159
- if (pollRes.status < 200 || pollRes.status >= 300) {
160
- process.stderr.write(`engram: poll returned HTTP ${pollRes.status} (will retry)\n`);
161
- continue;
162
- }
163
- const body = pollRes.json;
164
- switch (body.status) {
165
- case 'pending':
166
- continue;
167
- case 'denied':
168
- process.stderr.write(`Authorization denied.\n`);
169
- return 1;
170
- case 'expired':
171
- process.stderr.write(`Pairing code expired. Run \`engram-mcp login\` again.\n`);
172
- return 1;
173
- case 'approved': {
174
- const creds = {
175
- api_url: body.api_url,
176
- api_key: body.api_key,
177
- label: body.label,
178
- scopes: body.scopes,
179
- issued_at: new Date().toISOString(),
180
- };
181
- try {
182
- writeCredentials(creds, opts.credentialsFile);
183
- }
184
- catch (err) {
185
- process.stderr.write(`engram: could not write credentials: ${err.message}\n`);
186
- return 1;
187
- }
188
- const where = opts.credentialsFile ?? '~/.pyre/credentials.json';
189
- process.stdout.write(`Logged in. Credentials saved to ${where}.\n`);
190
- return 0;
191
- }
192
- default:
193
- // Unknown status — keep polling. Defensive only; the type
194
- // union above is exhaustive against the documented API.
195
- continue;
196
- }
197
- }
198
- process.stderr.write(`Login timed out.\n`);
199
- return 1;
200
- }
201
- /**
202
- * Idempotent logout — exits 0 whether or not the file existed.
203
- */
204
- export function runLogout(opts = {}) {
205
- const removed = deleteCredentials(opts.credentialsFile);
206
- if (removed) {
207
- process.stdout.write(`Logged out.\n`);
208
- }
209
- else {
210
- process.stdout.write(`Already logged out.\n`);
211
- }
212
- return 0;
213
- }
214
- // Re-export so callers (cli.ts) can resolve the documented path string
215
- // for the success message.
216
- export { credentialsPath };
1
+ /**
2
+ * Device-code login + logout against pyre-web.
3
+ *
4
+ * Flow:
5
+ * 1. POST /api/auth/device-code → user_code, device_code, verification_url, expires_in, interval.
6
+ * 2. Print the URL + code, best-effort open the browser.
7
+ * 3. Poll /api/auth/device-code/poll until approved / denied / expired / timeout.
8
+ * 4. On approval, write ~/.pyre/credentials.json. The api_url written
9
+ * to disk is the server-returned canonical URL from the poll
10
+ * response, NOT the one the user typed at login time. Server is
11
+ * the source of truth -- it may normalise / redirect / hand back
12
+ * a different storage endpoint than the login endpoint.
13
+ *
14
+ * No hardcoded URLs. The CLI requires the user to supply the server
15
+ * URL at login (positional arg, --server flag, or PYRE_API_URL env).
16
+ * Shipping prod is "users point at prod when they log in."
17
+ *
18
+ * Everything except the final success/failure line goes to stderr. The
19
+ * URL + code block goes to stdout so a caller piping our output to a
20
+ * file gets only the actionable bits.
21
+ */
22
+ import { hostname, platform } from 'node:os';
23
+ import { spawn } from 'node:child_process';
24
+ import { writeCredentials, deleteCredentials, credentialsPath } from './credentials.js';
25
+ const PACKAGE_NAME = 'engram-memory';
26
+ /**
27
+ * Resolve a user-supplied server URL from CLI arg / flag / env, in
28
+ * that precedence. Returns null when none of the three sources gave
29
+ * us a URL — caller is expected to print the spec'd error message
30
+ * and exit 1.
31
+ */
32
+ export function resolveServerUrl(opts) {
33
+ const trim = (s) => {
34
+ const t = s?.trim();
35
+ if (!t)
36
+ return null;
37
+ return t.replace(/\/+$/, '');
38
+ };
39
+ return trim(opts.positional) ?? trim(opts.flag) ?? trim(process.env.PYRE_API_URL);
40
+ }
41
+ function sleepDefault(ms) {
42
+ return new Promise(resolve => setTimeout(resolve, ms));
43
+ }
44
+ function openInBrowser(url) {
45
+ try {
46
+ const p = platform();
47
+ if (p === 'darwin') {
48
+ spawn('open', [url], { stdio: 'ignore', detached: true }).unref();
49
+ }
50
+ else if (p === 'win32') {
51
+ // The empty title arg matters — `start <url>` treats the URL as the title.
52
+ spawn('cmd', ['/c', 'start', '', url], { stdio: 'ignore', detached: true }).unref();
53
+ }
54
+ else {
55
+ spawn('xdg-open', [url], { stdio: 'ignore', detached: true }).unref();
56
+ }
57
+ }
58
+ catch {
59
+ // Best-effort — the printed URL is always the fallback.
60
+ }
61
+ }
62
+ async function postJson(fetchImpl, url, body) {
63
+ const res = await fetchImpl(url, {
64
+ method: 'POST',
65
+ headers: { 'content-type': 'application/json', 'accept': 'application/json' },
66
+ body: JSON.stringify(body),
67
+ });
68
+ const text = await res.text();
69
+ let json = null;
70
+ if (text.length > 0) {
71
+ try {
72
+ json = JSON.parse(text);
73
+ }
74
+ catch {
75
+ // Fall through — non-JSON response is itself a failure to surface.
76
+ throw new Error(`non-JSON response from ${url} (status ${res.status}): ${text.slice(0, 200)}`);
77
+ }
78
+ }
79
+ return { status: res.status, json: json };
80
+ }
81
+ /**
82
+ * Start a device-code pairing. Retries up to 3 times with 1s/2s/4s
83
+ * backoff on transient network failures before giving up.
84
+ */
85
+ export async function startDeviceCode(fetchImpl, apiUrl, deviceName, sleep) {
86
+ const url = `${apiUrl}/api/auth/device-code`;
87
+ const backoffs = [1000, 2000, 4000];
88
+ let lastErr;
89
+ for (let attempt = 0; attempt < backoffs.length; attempt++) {
90
+ try {
91
+ const { status, json } = await postJson(fetchImpl, url, { device_name: deviceName, package_name: PACKAGE_NAME });
92
+ if (status >= 200 && status < 300) {
93
+ if (!json.user_code || !json.device_code || !json.verification_url) {
94
+ throw new Error(`malformed device-code response: ${JSON.stringify(json)}`);
95
+ }
96
+ return json;
97
+ }
98
+ throw new Error(`server returned HTTP ${status}${json?.error ? `: ${json.error}` : ''}`);
99
+ }
100
+ catch (err) {
101
+ lastErr = err;
102
+ if (attempt < backoffs.length - 1) {
103
+ await sleep(backoffs[attempt]);
104
+ }
105
+ }
106
+ }
107
+ throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
108
+ }
109
+ /**
110
+ * Single poll of /api/auth/device-code/poll. Normalises HTTP 410 to
111
+ * the `expired` status the rest of the codebase already handles.
112
+ * Other non-2xx responses are surfaced as thrown errors so callers
113
+ * (CLI's loop, MCP tool) can decide whether to retry or give up.
114
+ */
115
+ export async function pollDeviceCode(fetchImpl, apiUrl, deviceCode) {
116
+ const pollUrl = `${apiUrl}/api/auth/device-code/poll`;
117
+ const res = await postJson(fetchImpl, pollUrl, { device_code: deviceCode });
118
+ if (res.status === 410)
119
+ return { status: 'expired' };
120
+ if (res.status < 200 || res.status >= 300) {
121
+ throw new Error(`poll returned HTTP ${res.status}`);
122
+ }
123
+ return res.json;
124
+ }
125
+ /**
126
+ * Build a Credentials object from an approved poll response. Centralises
127
+ * the shape used by both the CLI and the MCP tool path.
128
+ */
129
+ export function credentialsFromApproval(approved) {
130
+ return {
131
+ api_url: approved.api_url,
132
+ api_key: approved.api_key,
133
+ label: approved.label,
134
+ scopes: approved.scopes,
135
+ issued_at: new Date().toISOString(),
136
+ };
137
+ }
138
+ /**
139
+ * Run the login flow end-to-end. Returns 0 on success, non-zero on
140
+ * failure. Prints user-visible messages to stdout/stderr as documented
141
+ * in the deliverable spec.
142
+ */
143
+ export async function runLogin(opts) {
144
+ const apiUrl = opts.apiUrl.trim().replace(/\/+$/, '');
145
+ const deviceName = opts.deviceName ?? hostname();
146
+ const fetchImpl = opts.fetchImpl ?? fetch;
147
+ const sleep = opts.sleep ?? sleepDefault;
148
+ const now = opts.now ?? Date.now;
149
+ const open = opts.openBrowser ?? openInBrowser;
150
+ let start;
151
+ try {
152
+ start = await startDeviceCode(fetchImpl, apiUrl, deviceName, sleep);
153
+ }
154
+ catch (err) {
155
+ process.stderr.write(`Could not reach ${apiUrl}: ${err.message}.\n`);
156
+ return 1;
157
+ }
158
+ // The URL + code block — stdout, in one piece so it's easy to grab.
159
+ process.stdout.write(`Open this URL in your browser to authorize:\n` +
160
+ `\n` +
161
+ ` ${start.verification_url}\n` +
162
+ `\n` +
163
+ `Enter this code when prompted: ${start.user_code}\n` +
164
+ `(waiting for approval — Ctrl+C to cancel)\n`);
165
+ open(start.verification_url);
166
+ const intervalMs = Math.max(1, start.interval) * 1000;
167
+ const expiresAt = now() + start.expires_in * 1000;
168
+ while (now() < expiresAt) {
169
+ await sleep(intervalMs);
170
+ if (now() >= expiresAt)
171
+ break;
172
+ let body;
173
+ try {
174
+ body = await pollDeviceCode(fetchImpl, apiUrl, start.device_code);
175
+ }
176
+ catch (err) {
177
+ // Transient — log and keep polling until expires_in wins.
178
+ process.stderr.write(`engram: poll error (will retry): ${err.message}\n`);
179
+ continue;
180
+ }
181
+ switch (body.status) {
182
+ case 'pending':
183
+ continue;
184
+ case 'denied':
185
+ process.stderr.write(`Authorization denied.\n`);
186
+ return 1;
187
+ case 'expired':
188
+ process.stderr.write(`Pairing code expired. Run \`engram-mcp login\` again.\n`);
189
+ return 1;
190
+ case 'approved': {
191
+ const creds = credentialsFromApproval(body);
192
+ try {
193
+ writeCredentials(creds, opts.credentialsFile);
194
+ }
195
+ catch (err) {
196
+ process.stderr.write(`engram: could not write credentials: ${err.message}\n`);
197
+ return 1;
198
+ }
199
+ const where = opts.credentialsFile ?? '~/.pyre/credentials.json';
200
+ process.stdout.write(`Logged in. Credentials saved to ${where}.\n`);
201
+ return 0;
202
+ }
203
+ default:
204
+ // Unknown status keep polling. Defensive only; the type
205
+ // union above is exhaustive against the documented API.
206
+ continue;
207
+ }
208
+ }
209
+ process.stderr.write(`Login timed out.\n`);
210
+ return 1;
211
+ }
212
+ /**
213
+ * Idempotent logout — exits 0 whether or not the file existed.
214
+ */
215
+ export function runLogout(opts = {}) {
216
+ const removed = deleteCredentials(opts.credentialsFile);
217
+ if (removed) {
218
+ process.stdout.write(`Logged out.\n`);
219
+ }
220
+ else {
221
+ process.stdout.write(`Already logged out.\n`);
222
+ }
223
+ return 0;
224
+ }
225
+ // Re-export so callers (cli.ts) can resolve the documented path string
226
+ // for the success message.
227
+ export { credentialsPath };
217
228
  //# sourceMappingURL=login.js.map