@pyxmate/memory 1.27.0 → 1.27.2

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.
@@ -0,0 +1,407 @@
1
+ import {
2
+ settle
3
+ } from "./chunk-7IGU3HJO.mjs";
4
+ import {
5
+ ERROR_MESSAGES,
6
+ JournalInvalidError,
7
+ KeychainError,
8
+ createCliLoginApi,
9
+ credentialFingerprint,
10
+ getLifecycleKeychain,
11
+ hasRoomForLogin,
12
+ isEmpty,
13
+ isKeyRef,
14
+ loadJournal,
15
+ parseEndpoint,
16
+ releaseLifecycleLock,
17
+ saveJournal
18
+ } from "./chunk-7TATCSSN.mjs";
19
+ import {
20
+ EXIT
21
+ } from "./chunk-4DXEQC6X.mjs";
22
+
23
+ // src/cli/commands/login-browser.ts
24
+ import { spawn } from "child_process";
25
+ import { createHash as createHash2, randomBytes } from "crypto";
26
+ import { hostname } from "os";
27
+ import { createInterface } from "readline";
28
+
29
+ // src/cli/lifecycle/callback-server.ts
30
+ import { createHash, timingSafeEqual } from "crypto";
31
+ import { createServer } from "http";
32
+ var CALLBACK_PATH = "/pyx-memory/callback";
33
+ var CODE_RE = /^[A-Za-z0-9_-]{43}$/;
34
+ var STYLES = `
35
+ :root { color-scheme: dark; font-family: "Avenir Next", "Trebuchet MS", sans-serif; }
36
+ * { box-sizing: border-box; }
37
+ body {
38
+ margin: 0; min-height: 100vh; min-height: 100svh; padding: 32px 20px;
39
+ display: flex; color: #f7f8fb; background: #0b0b0e;
40
+ background-image: radial-gradient(ellipse at 50% 55%, #0071e31c, transparent 65%);
41
+ }
42
+ main { width: 100%; max-width: 496px; margin: auto; }
43
+ .brand { margin: 0 0 20px 8px; font-size: 15px; font-weight: 600; letter-spacing: -.02em; }
44
+ .brand span { margin-left: 8px; color: #a9adb8; font-weight: 400; }
45
+ .card {
46
+ padding: clamp(24px, 6vw, 40px); border: 1px solid #30333c; border-radius: 28px;
47
+ background: #15161c; box-shadow: 0 24px 80px #0006;
48
+ }
49
+ .symbol {
50
+ display: grid; place-items: center; width: 64px; height: 64px; margin-bottom: 32px;
51
+ border: 1px solid #2c4059; border-radius: 20px; color: #6ab4ff; background: #12263a;
52
+ }
53
+ .continue .symbol { animation: arrive 360ms cubic-bezier(.22, 1, .36, 1); }
54
+ .cancelled .symbol { color: #a9adb8; background: #20222b; border-color: #383b47; }
55
+ .invalid .symbol { color: #f4bd7a; background: #302719; border-color: #59462d; }
56
+ h1 {
57
+ margin: 0 0 20px; font-size: clamp(28px, 6vw, 40px); font-weight: 600;
58
+ line-height: 1.12; letter-spacing: -.035em; overflow-wrap: break-word;
59
+ }
60
+ p { margin: 0; color: #a9adb8; font-size: 16px; line-height: 1.65; }
61
+ .note { margin-top: 32px; padding-top: 24px; border-top: 1px solid #30333c; font-size: 14px; }
62
+ @keyframes arrive { from { opacity: .65; transform: translateY(4px); } }
63
+ @media (prefers-reduced-motion: reduce) { .continue .symbol { animation: none; } }
64
+ `;
65
+ var PAGE_CSP = `default-src 'none'; style-src 'sha256-${createHash("sha256").update(STYLES).digest("base64")}'; base-uri 'none'; form-action 'none'`;
66
+ var page = (state, title, text, icon) => `<!doctype html>
67
+ <html lang="en">
68
+ <head>
69
+ <meta charset="utf-8">
70
+ <meta name="viewport" content="width=device-width, initial-scale=1">
71
+ <title>${title} \xB7 Pyx Memory</title>
72
+ <style>${STYLES}</style>
73
+ </head>
74
+ <body>
75
+ <main class="${state}">
76
+ <div class="brand">pyx<span>Memory</span></div>
77
+ <section class="card" aria-labelledby="title">
78
+ <div class="symbol" aria-hidden="true"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">${icon}</svg></div>
79
+ <h1 id="title">${title}</h1>
80
+ <p>${text}</p>
81
+ <p class="note">You can close this tab.</p>
82
+ </section>
83
+ </main>
84
+ </body>
85
+ </html>`;
86
+ var ACCEPTED_PAGE = page(
87
+ "continue",
88
+ "Continue in your app",
89
+ "Your approval was received. Return to the app that started this sign-in to finish connecting.",
90
+ '<path d="m9 4-5 5 5 5M4 9h10a6 6 0 0 1 0 12h-2"/>'
91
+ );
92
+ var DENIED_PAGE = page(
93
+ "cancelled",
94
+ "Sign-in cancelled",
95
+ "Return to the app that started this sign-in. You can try again there whenever you\u2019re ready.",
96
+ '<path d="m7 7 10 10M17 7 7 17"/>'
97
+ );
98
+ var REJECTED_PAGE = page(
99
+ "invalid",
100
+ "This sign-in link isn\u2019t valid",
101
+ "Return to the app that started this sign-in to continue or try again.",
102
+ '<path d="M12 6v7m0 4v.01"/><circle cx="12" cy="12" r="10"/>'
103
+ );
104
+ function sameSecret(a, b) {
105
+ const left = Buffer.from(a);
106
+ const right = Buffer.from(b);
107
+ return left.length === right.length && timingSafeEqual(left, right);
108
+ }
109
+ function single(params, key) {
110
+ const values = params.getAll(key);
111
+ return values.length === 1 ? values[0] : null;
112
+ }
113
+ async function startCallbackServer() {
114
+ let expected = null;
115
+ let settle2 = () => {
116
+ };
117
+ const result = new Promise((resolve) => {
118
+ settle2 = resolve;
119
+ });
120
+ function accept(method, host, rawUrl) {
121
+ if (method !== "GET" || expected === null || host !== `127.0.0.1:${port}`) return null;
122
+ const url = new URL(rawUrl, `http://127.0.0.1:${port}`);
123
+ if (url.pathname !== CALLBACK_PATH) return null;
124
+ const params = url.searchParams;
125
+ const keys = new Set(params.keys());
126
+ const code = single(params, "code");
127
+ const error = single(params, "error");
128
+ if (keys.size !== 3 || single(params, "request") !== expected.requestId || !sameSecret(single(params, "state") ?? "", expected.state)) {
129
+ return null;
130
+ }
131
+ if (code !== null && CODE_RE.test(code)) return { code };
132
+ if (error === "access_denied") return { denied: true };
133
+ return null;
134
+ }
135
+ const server = createServer({ maxHeaderSize: 8192 }, (req, res) => {
136
+ const accepted = accept(req.method, req.headers.host, req.url ?? "");
137
+ if (accepted !== null) {
138
+ expected = null;
139
+ settle2(accepted);
140
+ }
141
+ res.writeHead(accepted === null ? 400 : 200, {
142
+ "Content-Type": "text/html; charset=utf-8",
143
+ "Cache-Control": "no-store",
144
+ "Content-Security-Policy": PAGE_CSP,
145
+ "Referrer-Policy": "no-referrer",
146
+ "X-Content-Type-Options": "nosniff",
147
+ Connection: "close"
148
+ });
149
+ res.end(accepted === null ? REJECTED_PAGE : "denied" in accepted ? DENIED_PAGE : ACCEPTED_PAGE);
150
+ });
151
+ server.requestTimeout = 5e3;
152
+ server.headersTimeout = 5e3;
153
+ await new Promise((resolve, reject) => {
154
+ server.once("error", reject);
155
+ server.listen(0, "127.0.0.1", resolve);
156
+ });
157
+ const port = server.address().port;
158
+ return {
159
+ port,
160
+ expect(requestId, state) {
161
+ expected = { requestId, state };
162
+ },
163
+ result,
164
+ close() {
165
+ server.close();
166
+ server.closeAllConnections();
167
+ }
168
+ };
169
+ }
170
+
171
+ // src/cli/commands/login-browser.ts
172
+ var DEFAULT_ENDPOINT = "https://memory.api.pyxmate.com";
173
+ var MAX_STDIN_LINE = 1024;
174
+ var Stop = class extends Error {
175
+ constructor(outcome) {
176
+ super(outcome);
177
+ this.outcome = outcome;
178
+ }
179
+ outcome;
180
+ };
181
+ function stopFor(failure) {
182
+ return new Stop(failure.code === "cancelled" ? "requested" : failure.code);
183
+ }
184
+ function printHuman(e) {
185
+ if (e.event === "awaiting_browser") {
186
+ process.stdout.write(`Sign in with your browser:
187
+ ${e.authorizationUrl}
188
+ `);
189
+ } else if (e.event === "connecting") {
190
+ process.stdout.write("Finishing sign-in\u2026\n");
191
+ } else if (e.event === "connected") {
192
+ process.stdout.write(
193
+ `Connected to ${e.connection.projectName} as ${e.connection.accountLabel}. Run: pyx-mem doctor
194
+ `
195
+ );
196
+ } else if (e.event === "cancelled") {
197
+ process.stdout.write("Sign-in cancelled. Stored credentials were not changed.\n");
198
+ } else {
199
+ process.stderr.write(`Error: ${e.message}
200
+ `);
201
+ }
202
+ if ("cleanupPending" in e && e.cleanupPending && e.event !== "error") {
203
+ process.stderr.write("Server-side cleanup is pending. Run: pyx-mem login-recovery\n");
204
+ }
205
+ }
206
+ function openInBrowser(url) {
207
+ const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : ["xdg-open", [url]];
208
+ const child = spawn(command, args, { stdio: "ignore", detached: true });
209
+ child.on("error", () => {
210
+ process.stderr.write(
211
+ "Could not open a browser automatically. Open the sign-in URL yourself.\n"
212
+ );
213
+ });
214
+ child.unref();
215
+ }
216
+ function exitFor(outcome) {
217
+ if (outcome === "requested" || outcome === "denied") return EXIT.CANCELLED;
218
+ if (outcome === "busy") return EXIT.BUSY;
219
+ if (outcome === "cleanup_pending") return EXIT.CLEANUP_PENDING;
220
+ if (outcome === "invalid_endpoint") return EXIT.USAGE;
221
+ return EXIT.DOCTOR_FAIL;
222
+ }
223
+ async function snapshot(keychain) {
224
+ try {
225
+ const credentials = await keychain.read();
226
+ return {
227
+ token: credentials === null ? "none" : credentialFingerprint(credentials.endpoint, credentials.apiKey),
228
+ credentials
229
+ };
230
+ } catch (err) {
231
+ if (err instanceof KeychainError && err.code === "credential-record-invalid") {
232
+ return { token: "invalid", credentials: null };
233
+ }
234
+ throw err;
235
+ }
236
+ }
237
+ async function browserLoginCommand(opts) {
238
+ const keychain = opts.keychain ?? getLifecycleKeychain();
239
+ const api = createCliLoginApi(opts.fetchImpl);
240
+ const emit = opts.json ? (e) => process.stdout.write(`${JSON.stringify(e)}
241
+ `) : printHuman;
242
+ const stop = new AbortController();
243
+ let lockLost = false;
244
+ const cancel = () => stop.abort();
245
+ const stopped = new Promise((_, reject) => {
246
+ stop.signal.addEventListener("abort", () => reject(new Stop("requested")));
247
+ });
248
+ stopped.catch(() => {
249
+ });
250
+ const check = () => {
251
+ if (lockLost) throw new Stop("lock_lost");
252
+ if (stop.signal.aborted) throw new Stop("requested");
253
+ };
254
+ process.once("SIGINT", cancel);
255
+ process.once("SIGTERM", cancel);
256
+ const lines = opts.json ? createInterface({ input: opts.input ?? process.stdin }) : null;
257
+ lines?.on("line", (line) => {
258
+ if (line.length > MAX_STDIN_LINE) return;
259
+ try {
260
+ if (JSON.parse(line)?.action === "cancel") cancel();
261
+ } catch {
262
+ }
263
+ });
264
+ if (opts.noOpen) lines?.on("close", cancel);
265
+ let release = null;
266
+ let listener = null;
267
+ let journal = null;
268
+ let connected = null;
269
+ let outcome = null;
270
+ let storeError = null;
271
+ try {
272
+ release = await keychain.acquireLock(() => {
273
+ lockLost = true;
274
+ cancel();
275
+ });
276
+ if (release === null) throw new Stop("busy");
277
+ journal = await settle(keychain, api, await loadJournal(keychain), () => lockLost);
278
+ if (journal.login !== null) throw new Stop("cleanup_pending");
279
+ const base = await snapshot(keychain);
280
+ const endpoint = parseEndpoint(opts.endpoint ?? base.credentials?.endpoint ?? DEFAULT_ENDPOINT);
281
+ if (endpoint === null) throw new Stop("invalid_endpoint");
282
+ const replaced = base.credentials ? { endpoint: base.credentials.endpoint, apiKey: base.credentials.apiKey } : null;
283
+ const previous = replaced !== null && base.credentials?.provenance === "browser-login" && isKeyRef(replaced) ? replaced : void 0;
284
+ if (!hasRoomForLogin(journal, endpoint, previous)) throw new Stop("cleanup_pending");
285
+ listener = await startCallbackServer();
286
+ const codeVerifier = randomBytes(32).toString("base64url");
287
+ const state = randomBytes(32).toString("base64url");
288
+ const started = await api.start(
289
+ endpoint,
290
+ {
291
+ codeChallenge: createHash2("sha256").update(codeVerifier).digest("base64url"),
292
+ callbackPort: listener.port,
293
+ state,
294
+ deviceLabel: hostname().replace(/\p{Cc}/gu, "").trim().slice(0, 64) || "this computer"
295
+ },
296
+ stop.signal
297
+ );
298
+ check();
299
+ if (!started.ok) throw stopFor(started);
300
+ const login = {
301
+ phase: "started",
302
+ endpoint,
303
+ requestId: started.requestId,
304
+ codeVerifier,
305
+ expiresAt: started.expiresAt.getTime()
306
+ };
307
+ journal.login = login;
308
+ await saveJournal(keychain, journal);
309
+ check();
310
+ listener.expect(started.requestId, state);
311
+ emit({
312
+ event: "awaiting_browser",
313
+ authorizationUrl: started.authorizationUrl,
314
+ expiresAt: started.expiresAt.toISOString()
315
+ });
316
+ if (!opts.noOpen) (opts.openBrowser ?? openInBrowser)(started.authorizationUrl);
317
+ let expiry;
318
+ const expired = new Promise((_, reject) => {
319
+ const ms = Math.min(Math.max(started.expiresAt.getTime() - Date.now(), 0), 6e5);
320
+ expiry = setTimeout(() => reject(new Stop("expired")), ms);
321
+ });
322
+ const callback = await Promise.race([listener.result, stopped, expired]).finally(
323
+ () => clearTimeout(expiry)
324
+ );
325
+ check();
326
+ if ("denied" in callback) throw new Stop("denied");
327
+ emit({ event: "connecting" });
328
+ const redeemed = await api.redeem(login, callback.code, stop.signal);
329
+ check();
330
+ if (!redeemed.ok) throw stopFor(redeemed);
331
+ const issued = { ...login, phase: "issued", apiKey: redeemed.apiKey };
332
+ journal.login = issued;
333
+ await saveJournal(keychain, journal);
334
+ check();
335
+ const confirmed = await api.confirm(issued, stop.signal);
336
+ check();
337
+ if (!confirmed.ok) throw stopFor(confirmed);
338
+ const current = (await loadJournal(keychain)).login;
339
+ if (current?.requestId !== issued.requestId || current.phase !== "issued") {
340
+ throw new Stop("lock_lost");
341
+ }
342
+ journal.login = { ...issued, phase: "committing", ...previous ? { previous } : {} };
343
+ await saveJournal(keychain, journal);
344
+ check();
345
+ if ((await snapshot(keychain)).token !== base.token) throw new Stop("credential_changed");
346
+ check();
347
+ const fingerprint = credentialFingerprint(endpoint, redeemed.apiKey);
348
+ try {
349
+ await keychain.write({
350
+ schemaVersion: 1,
351
+ endpoint,
352
+ apiKey: redeemed.apiKey,
353
+ connection: redeemed.connection,
354
+ provenance: "browser-login"
355
+ });
356
+ } catch (err) {
357
+ if ((await snapshot(keychain)).token !== fingerprint) throw err;
358
+ }
359
+ connected = { connection: redeemed.connection, fingerprint };
360
+ } catch (err) {
361
+ if (err instanceof Stop) outcome = err.outcome;
362
+ else if (err instanceof JournalInvalidError) outcome = "journal_invalid";
363
+ else if (err instanceof KeychainError) {
364
+ storeError = err;
365
+ outcome = "credential_store_unavailable";
366
+ } else outcome = "login_failed";
367
+ }
368
+ listener?.close();
369
+ lines?.close();
370
+ process.off("SIGINT", cancel);
371
+ process.off("SIGTERM", cancel);
372
+ let cleanupPending = outcome !== "busy";
373
+ if (journal !== null && !lockLost) {
374
+ try {
375
+ cleanupPending = !isEmpty(await settle(keychain, api, journal, () => lockLost));
376
+ } catch {
377
+ cleanupPending = true;
378
+ }
379
+ }
380
+ if (release !== null) await releaseLifecycleLock(release);
381
+ if (connected !== null) {
382
+ emit({
383
+ event: "connected",
384
+ connection: connected.connection,
385
+ credentialFingerprint: connected.fingerprint,
386
+ cleanupPending
387
+ });
388
+ return EXIT.OK;
389
+ }
390
+ if (outcome === "requested" || outcome === "denied") {
391
+ emit({ event: "cancelled", reason: outcome, cleanupPending });
392
+ return EXIT.CANCELLED;
393
+ }
394
+ const code = outcome ?? "login_failed";
395
+ emit({
396
+ event: "error",
397
+ code,
398
+ message: storeError ? `${storeError.message} ${storeError.guidance}` : ERROR_MESSAGES[code].message,
399
+ retryable: ERROR_MESSAGES[code].retryable,
400
+ cleanupPending
401
+ });
402
+ return storeError?.exit ?? exitFor(code);
403
+ }
404
+ export {
405
+ DEFAULT_ENDPOINT,
406
+ browserLoginCommand
407
+ };
@@ -0,0 +1,97 @@
1
+ import {
2
+ cleanupPendingAfterFailure,
3
+ lifecycleFailure
4
+ } from "./chunk-YBPODKB5.mjs";
5
+ import {
6
+ settle
7
+ } from "./chunk-7IGU3HJO.mjs";
8
+ import {
9
+ BUSY,
10
+ JournalInvalidError,
11
+ KeychainError,
12
+ LockLostError,
13
+ convergenceSeconds,
14
+ createCliLoginApi,
15
+ credentialFingerprint,
16
+ emptyJournal,
17
+ getLifecycleKeychain,
18
+ isEmpty,
19
+ isKeyRef,
20
+ loadJournal,
21
+ saveJournal,
22
+ withLifecycleLock
23
+ } from "./chunk-7TATCSSN.mjs";
24
+ import {
25
+ EXIT
26
+ } from "./chunk-4DXEQC6X.mjs";
27
+
28
+ // src/cli/commands/login-recovery.ts
29
+ async function signOutJournal(keychain) {
30
+ const journal = emptyJournal();
31
+ const shared = await keychain.read().catch((err) => {
32
+ if (err instanceof KeychainError && err.code === "credential-record-invalid") return null;
33
+ throw err;
34
+ });
35
+ if (shared !== null) {
36
+ const key = { endpoint: shared.endpoint, apiKey: shared.apiKey };
37
+ journal.logout = {
38
+ fingerprint: credentialFingerprint(shared.endpoint, shared.apiKey),
39
+ revoke: shared.provenance === "browser-login" && isKeyRef(key) ? key : null
40
+ };
41
+ }
42
+ await saveJournal(keychain, journal);
43
+ return journal;
44
+ }
45
+ async function loginRecoveryCommand(opts = {}) {
46
+ const keychain = opts.keychain ?? getLifecycleKeychain();
47
+ const fail = (failure, exit) => {
48
+ if (opts.json) process.stdout.write(`${JSON.stringify(failure)}
49
+ `);
50
+ else process.stderr.write(`Error: ${failure.message}
51
+ `);
52
+ return exit;
53
+ };
54
+ try {
55
+ const journal = await withLifecycleLock(keychain, async (lockLost) => {
56
+ const loaded = await loadJournal(keychain).catch((err) => {
57
+ if (err instanceof JournalInvalidError && opts.discardUnreadableJournal) {
58
+ return signOutJournal(keychain);
59
+ }
60
+ throw err;
61
+ });
62
+ const settled = await settle(keychain, createCliLoginApi(opts.fetchImpl), loaded, lockLost);
63
+ if (lockLost()) throw new LockLostError();
64
+ return settled;
65
+ });
66
+ if (journal === BUSY) return fail(lifecycleFailure("busy", true), EXIT.BUSY);
67
+ if (!isEmpty(journal)) {
68
+ return fail(lifecycleFailure("cleanup_pending", true), EXIT.CLEANUP_PENDING);
69
+ }
70
+ const convergence = convergenceSeconds(journal);
71
+ const report = {
72
+ ok: true,
73
+ cleanupPending: false,
74
+ serverRevocation: convergence !== null ? "revoked" : "not_attempted",
75
+ serverConvergenceSeconds: convergence
76
+ };
77
+ if (opts.json) process.stdout.write(`${JSON.stringify(report)}
78
+ `);
79
+ else process.stdout.write("Nothing is pending.\n");
80
+ return EXIT.OK;
81
+ } catch (err) {
82
+ if (err instanceof JournalInvalidError) {
83
+ return fail(lifecycleFailure("journal_invalid", true), EXIT.DOCTOR_FAIL);
84
+ }
85
+ const pending = await cleanupPendingAfterFailure(keychain);
86
+ if (err instanceof KeychainError) {
87
+ return fail(lifecycleFailure("credential_store_unavailable", pending, err), err.exit);
88
+ }
89
+ if (err instanceof LockLostError) {
90
+ return fail(lifecycleFailure("lock_lost", pending), EXIT.DOCTOR_FAIL);
91
+ }
92
+ return fail(lifecycleFailure("cleanup_pending", true), EXIT.CLEANUP_PENDING);
93
+ }
94
+ }
95
+ export {
96
+ loginRecoveryCommand
97
+ };
@@ -0,0 +1,13 @@
1
+ import {
2
+ cleanupPendingAfterFailure,
3
+ lifecycleFailure,
4
+ logoutCommand
5
+ } from "./chunk-YBPODKB5.mjs";
6
+ import "./chunk-7IGU3HJO.mjs";
7
+ import "./chunk-7TATCSSN.mjs";
8
+ import "./chunk-4DXEQC6X.mjs";
9
+ export {
10
+ cleanupPendingAfterFailure,
11
+ lifecycleFailure,
12
+ logoutCommand
13
+ };