@pyxmate/memory 1.21.0 → 1.22.3

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 (2) hide show
  1. package/dist/cli/pyx-mem.mjs +1298 -89
  2. package/package.json +3 -1
@@ -19,9 +19,15 @@ var EXIT = {
19
19
  USAGE: 2,
20
20
  NOT_LOGGED_IN: 3,
21
21
  CRED_UNAVAILABLE: 4,
22
- PROVIDER_BROKEN: 5
22
+ PROVIDER_BROKEN: 5,
23
+ BUSY: 6,
24
+ CLEANUP_PENDING: 7,
25
+ CANCELLED: 8
23
26
  };
24
27
 
28
+ // src/cli/lifecycle/journal.ts
29
+ import { createHash } from "crypto";
30
+
25
31
  // src/cli/keychain/errors.ts
26
32
  var PLATFORM_GUIDANCE = {
27
33
  darwin: {
@@ -90,19 +96,447 @@ function causeMessage(cause) {
90
96
  return "";
91
97
  }
92
98
 
99
+ // src/cli/lifecycle/api.ts
100
+ var REQUEST_TIMEOUT_MS = 15e3;
101
+ var MAX_RESPONSE_BYTES = 64 * 1024;
102
+ var MAX_ENDPOINT_CHARS = 256;
103
+ var REQUEST_ID_RE = /^[A-Za-z0-9_-]{43}$/;
104
+ var PROJECT_KEY_RE = /^pyx_proj_[A-Za-z0-9_-]{16,160}$/;
105
+ var ERROR_MESSAGES = {
106
+ busy: { message: "Another pyx-mem sign-in or sign-out is in progress.", retryable: true },
107
+ cleanup_pending: {
108
+ message: "Cleanup of an earlier sign-in or sign-out is still pending. Run: pyx-mem login-recovery",
109
+ retryable: true
110
+ },
111
+ invalid_endpoint: {
112
+ message: "Endpoint must be an https URL (http only for loopback) without credentials, query or fragment.",
113
+ retryable: false
114
+ },
115
+ untrusted_authorization_url: {
116
+ message: "The server returned a browser sign-in address that is not trusted for this endpoint.",
117
+ retryable: false
118
+ },
119
+ browser_login_unsupported: {
120
+ message: "This endpoint does not support browser sign-in. Use: pyx-mem login --api-key",
121
+ retryable: false
122
+ },
123
+ network_error: { message: "Could not reach the pyx-memory server.", retryable: true },
124
+ rate_limited: { message: "Too many sign-in attempts. Wait a minute and retry.", retryable: true },
125
+ expired: { message: "The sign-in request expired before it was completed.", retryable: true },
126
+ project_not_eligible: {
127
+ message: "That project can no longer be connected from this account.",
128
+ retryable: true
129
+ },
130
+ credential_changed: {
131
+ message: "The stored pyx-memory credential was changed by another program. Nothing was overwritten.",
132
+ retryable: true
133
+ },
134
+ lock_lost: {
135
+ message: "The sign-in was interrupted. Run: pyx-mem login-recovery",
136
+ retryable: true
137
+ },
138
+ credential_store_unavailable: {
139
+ message: "The OS credential store is unavailable.",
140
+ retryable: false
141
+ },
142
+ journal_invalid: {
143
+ message: 'The pending-cleanup record in the OS credential store is unreadable. Nothing was deleted, and pyx-memory stays blocked on this computer. To reset: revoke unexpected "Pyx CLI" keys in the dashboard, then run: pyx-mem login-recovery --discard-unreadable-journal (this signs this computer out).',
144
+ retryable: false
145
+ },
146
+ login_failed: { message: "Browser sign-in failed.", retryable: true }
147
+ };
148
+ var SERVER_CODES = {
149
+ rate_limited: "rate_limited",
150
+ cli_login_expired_or_unknown: "expired",
151
+ cli_login_project_not_eligible: "project_not_eligible",
152
+ cli_login_cancelled: "cancelled",
153
+ cli_login_denied: "cancelled"
154
+ };
155
+ var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "[::1]"]);
156
+ var BROWSER_ORIGINS = {
157
+ "https://memory.api.pyxmate.com": "https://memory.pyxmate.com"
158
+ };
159
+ function parseSafeUrl(input) {
160
+ let url;
161
+ try {
162
+ url = new URL(input);
163
+ } catch {
164
+ return null;
165
+ }
166
+ const secure = url.protocol === "https:" || url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname);
167
+ if (!secure || url.username !== "" || url.password !== "" || url.hash !== "") return null;
168
+ return url;
169
+ }
170
+ function parseEndpoint(input) {
171
+ const url = parseSafeUrl(input);
172
+ if (url === null || url.search !== "" || input.includes("?") || input.includes("#")) return null;
173
+ const normalized = url.href.replace(/\/+$/, "");
174
+ return normalized.length <= MAX_ENDPOINT_CHARS ? normalized : null;
175
+ }
176
+ function boundLabel(value, maxChars) {
177
+ let label = value.replace(/\p{Cc}/gu, "").slice(0, maxChars);
178
+ while (JSON.stringify(label).length - 2 > maxChars) label = label.slice(0, -1);
179
+ return label;
180
+ }
181
+ function isTrustedAuthorizationUrl(authorizationUrl, endpoint, requestId) {
182
+ const url = parseSafeUrl(authorizationUrl);
183
+ if (url === null) return false;
184
+ const api = new URL(endpoint);
185
+ const trustedOrigin = LOOPBACK_HOSTS.has(api.hostname) ? LOOPBACK_HOSTS.has(url.hostname) : url.origin === (BROWSER_ORIGINS[api.origin] ?? api.origin);
186
+ const params = [...url.searchParams];
187
+ return trustedOrigin && url.pathname === "/cli/authorize" && params.length === 1 && params[0]?.[0] === "request" && params[0][1] === requestId;
188
+ }
189
+ async function readBounded(res) {
190
+ const reader = res.body?.getReader();
191
+ if (!reader) return void 0;
192
+ const chunks = [];
193
+ let size = 0;
194
+ for (; ; ) {
195
+ const { done, value } = await reader.read();
196
+ if (done) break;
197
+ size += value.byteLength;
198
+ if (size > MAX_RESPONSE_BYTES) {
199
+ await reader.cancel();
200
+ return void 0;
201
+ }
202
+ chunks.push(value);
203
+ }
204
+ try {
205
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
206
+ } catch {
207
+ return void 0;
208
+ }
209
+ }
210
+ function field(json, key) {
211
+ return typeof json === "object" && json !== null ? json[key] : void 0;
212
+ }
213
+ function failure(status, json) {
214
+ const serverCode = field(field(json, "error"), "code");
215
+ const mapped = typeof serverCode === "string" ? SERVER_CODES[serverCode] : void 0;
216
+ return { ok: false, code: mapped ?? (status === 429 ? "rate_limited" : "login_failed") };
217
+ }
218
+ function createCliLoginApi(fetchImpl = fetch) {
219
+ async function post(url, body, bearer, signal) {
220
+ const ctl = new AbortController();
221
+ const abort = () => ctl.abort();
222
+ const timer = setTimeout(abort, REQUEST_TIMEOUT_MS);
223
+ signal?.addEventListener("abort", abort);
224
+ try {
225
+ if (signal?.aborted) return null;
226
+ const res = await fetchImpl(url, {
227
+ method: "POST",
228
+ headers: {
229
+ Accept: "application/json",
230
+ "Content-Type": "application/json",
231
+ ...bearer === void 0 ? {} : { Authorization: `Bearer ${bearer}` }
232
+ },
233
+ body: JSON.stringify(body),
234
+ // A redirect would forward the proof or the key to another origin.
235
+ redirect: "error",
236
+ signal: ctl.signal
237
+ });
238
+ return { status: res.status, json: await readBounded(res) };
239
+ } catch {
240
+ return null;
241
+ } finally {
242
+ clearTimeout(timer);
243
+ signal?.removeEventListener("abort", abort);
244
+ }
245
+ }
246
+ const base = (endpoint) => `${endpoint}/auth/cli-login`;
247
+ return {
248
+ async start(endpoint, body, signal) {
249
+ const res = await post(`${base(endpoint)}/start`, body, void 0, signal);
250
+ if (res === null) return { ok: false, code: "network_error" };
251
+ if (res.status === 404) return { ok: false, code: "browser_login_unsupported" };
252
+ if (res.status !== 201) return failure(res.status, res.json);
253
+ const requestId = field(res.json, "requestId");
254
+ const authorizationUrl = field(res.json, "authorizationUrl");
255
+ const expiresAt = new Date(String(field(res.json, "expiresAt")));
256
+ if (typeof requestId !== "string" || !REQUEST_ID_RE.test(requestId) || typeof authorizationUrl !== "string" || Number.isNaN(expiresAt.getTime())) {
257
+ return { ok: false, code: "login_failed" };
258
+ }
259
+ if (!isTrustedAuthorizationUrl(authorizationUrl, endpoint, requestId)) {
260
+ return { ok: false, code: "untrusted_authorization_url" };
261
+ }
262
+ return { ok: true, requestId, authorizationUrl, expiresAt };
263
+ },
264
+ async redeem(login, code, signal) {
265
+ const res = await post(
266
+ `${base(login.endpoint)}/${login.requestId}/redeem`,
267
+ { code, codeVerifier: login.codeVerifier },
268
+ void 0,
269
+ signal
270
+ );
271
+ if (res === null) return { ok: false, code: "network_error" };
272
+ if (res.status !== 200) return failure(res.status, res.json);
273
+ const credential = field(res.json, "credential");
274
+ const apiKey = field(credential, "apiKey");
275
+ const connection = field(credential, "connection");
276
+ const accountLabel = field(connection, "accountLabel");
277
+ const projectId = field(connection, "projectId");
278
+ const projectName = field(connection, "projectName");
279
+ if (typeof apiKey !== "string" || !PROJECT_KEY_RE.test(apiKey) || typeof accountLabel !== "string" || typeof projectId !== "string" || typeof projectName !== "string") {
280
+ return { ok: false, code: "login_failed" };
281
+ }
282
+ return {
283
+ ok: true,
284
+ apiKey,
285
+ // Display metadata only; bounded so the shared record fits every OS store.
286
+ connection: {
287
+ accountLabel: boundLabel(accountLabel, 254),
288
+ projectId: boundLabel(projectId, 64),
289
+ projectName: boundLabel(projectName, 128)
290
+ }
291
+ };
292
+ },
293
+ async confirm(login, signal) {
294
+ const res = await post(
295
+ `${base(login.endpoint)}/${login.requestId}/confirm`,
296
+ { codeVerifier: login.codeVerifier },
297
+ login.apiKey,
298
+ signal
299
+ );
300
+ if (res === null) return { ok: false, code: "network_error" };
301
+ if (res.status !== 200 || field(res.json, "status") !== "confirmed") {
302
+ return failure(res.status, res.json);
303
+ }
304
+ return { ok: true };
305
+ },
306
+ async cancel(login) {
307
+ const res = await post(`${base(login.endpoint)}/${login.requestId}/cancel`, {
308
+ codeVerifier: login.codeVerifier
309
+ });
310
+ if (res === null) return false;
311
+ if (res.status === 200) return field(res.json, "status") === "cancelled";
312
+ return res.status === 404 && field(field(res.json, "error"), "code") === "cli_login_expired_or_unknown";
313
+ },
314
+ async revoke(key) {
315
+ const res = await post(`${base(key.endpoint)}/revoke`, {}, key.apiKey);
316
+ return res !== null && res.status === 200 && field(res.json, "status") === "revoked";
317
+ }
318
+ };
319
+ }
320
+
321
+ // src/cli/lifecycle/journal.ts
322
+ var MAX_STORE_CHARS = 1200;
323
+ var MAX_REVOKE_ENTRIES = 8;
324
+ var TOKEN_RE = /^[A-Za-z0-9_-]{43}$/;
325
+ var FINGERPRINT_RE = /^[0-9a-f]{16}$/;
326
+ var PLACEHOLDER_KEY = `pyx_proj_${"x".repeat(160)}`;
327
+ var SERVER_CONVERGENCE_SECONDS = 30;
328
+ var JournalInvalidError = class extends Error {
329
+ };
330
+ var JournalFullError = class extends Error {
331
+ };
332
+ function credentialFingerprint(endpoint, apiKey) {
333
+ return createHash("sha256").update(`${endpoint}
334
+ ${apiKey}`).digest("hex").slice(0, 16);
335
+ }
336
+ function isEmpty(journal) {
337
+ return journal.login === null && journal.logout === null && journal.revoke.length === 0;
338
+ }
339
+ function emptyJournal() {
340
+ return { schemaVersion: 1, login: null, logout: null, revoke: [] };
341
+ }
342
+ function convergenceSeconds(journal, now = Date.now()) {
343
+ if (journal.revokedAt === void 0) return null;
344
+ const elapsed = Math.max(now - journal.revokedAt, 0);
345
+ const left = Math.ceil(SERVER_CONVERGENCE_SECONDS - elapsed / 1e3);
346
+ return left > 0 ? left : null;
347
+ }
348
+ function isExactObject(value, required, optional = []) {
349
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
350
+ const keys = Object.keys(value);
351
+ return required.every((key) => keys.includes(key)) && keys.every((key) => required.includes(key) || optional.includes(key));
352
+ }
353
+ function isKeyRef(value) {
354
+ if (!isExactObject(value, ["endpoint", "apiKey"])) return false;
355
+ const v = value;
356
+ return typeof v.endpoint === "string" && parseEndpoint(v.endpoint) === v.endpoint && typeof v.apiKey === "string" && PROJECT_KEY_RE.test(v.apiKey);
357
+ }
358
+ function isLoginRecord(value) {
359
+ if (!isExactObject(
360
+ value,
361
+ ["phase", "endpoint", "requestId", "codeVerifier", "expiresAt"],
362
+ ["apiKey", "previous"]
363
+ )) {
364
+ return false;
365
+ }
366
+ const v = value;
367
+ const held = v.phase === "issued" || v.phase === "committing";
368
+ return (held || v.phase === "started") && typeof v.requestId === "string" && TOKEN_RE.test(v.requestId) && typeof v.codeVerifier === "string" && TOKEN_RE.test(v.codeVerifier) && Number.isSafeInteger(v.expiresAt) && (held ? isKeyRef({ endpoint: v.endpoint, apiKey: v.apiKey }) : v.apiKey === void 0 && isKeyRef({ endpoint: v.endpoint, apiKey: PLACEHOLDER_KEY })) && (v.previous === void 0 || v.phase === "committing" && isKeyRef(v.previous));
369
+ }
370
+ function isLogoutRecord(value) {
371
+ if (!isExactObject(value, ["fingerprint", "revoke"])) return false;
372
+ const v = value;
373
+ return typeof v.fingerprint === "string" && FINGERPRINT_RE.test(v.fingerprint) && (v.revoke === null || isKeyRef(v.revoke));
374
+ }
375
+ function isJournal(value) {
376
+ if (!isExactObject(value, ["schemaVersion", "login", "logout", "revoke"], ["revokedAt"])) {
377
+ return false;
378
+ }
379
+ const v = value;
380
+ return v.schemaVersion === 1 && (v.login === null || isLoginRecord(v.login)) && (v.logout === null || isLogoutRecord(v.logout)) && Array.isArray(v.revoke) && v.revoke.length <= MAX_REVOKE_ENTRIES && v.revoke.every(isKeyRef) && (v.revokedAt === void 0 || Number.isSafeInteger(v.revokedAt) && v.revokedAt >= 0);
381
+ }
382
+ function hasRoomForLogin(journal, endpoint, previous) {
383
+ const largest = {
384
+ ...journal,
385
+ login: {
386
+ phase: "committing",
387
+ endpoint,
388
+ requestId: "x".repeat(43),
389
+ codeVerifier: "x".repeat(43),
390
+ expiresAt: Number.MAX_SAFE_INTEGER,
391
+ apiKey: PLACEHOLDER_KEY,
392
+ ...previous ? { previous } : {}
393
+ },
394
+ revokedAt: Number.MAX_SAFE_INTEGER
395
+ };
396
+ return JSON.stringify(largest).length <= MAX_STORE_CHARS;
397
+ }
398
+ async function loadJournal(provider) {
399
+ const raw = await provider.readJournal();
400
+ if (raw === null) return emptyJournal();
401
+ if (raw.length > MAX_STORE_CHARS) throw new JournalInvalidError();
402
+ let parsed;
403
+ try {
404
+ parsed = JSON.parse(raw);
405
+ } catch {
406
+ throw new JournalInvalidError();
407
+ }
408
+ if (!isJournal(parsed)) throw new JournalInvalidError();
409
+ return parsed;
410
+ }
411
+ async function saveJournal(provider, journal) {
412
+ if (isEmpty(journal) && convergenceSeconds(journal) === null) return provider.clearJournal();
413
+ const raw = JSON.stringify(journal);
414
+ if (raw.length > MAX_STORE_CHARS) throw new JournalFullError();
415
+ if (!isJournal(JSON.parse(raw))) throw new Error("refusing to write an invalid journal");
416
+ await provider.writeJournal(raw);
417
+ }
418
+ async function supersedeLogout(provider, endpoint, apiKey) {
419
+ let journal;
420
+ try {
421
+ journal = await loadJournal(provider);
422
+ } catch (err) {
423
+ if (err instanceof JournalInvalidError) return;
424
+ throw err;
425
+ }
426
+ if (journal.logout?.fingerprint !== credentialFingerprint(endpoint, apiKey)) return;
427
+ journal.logout = null;
428
+ await saveJournal(provider, journal);
429
+ }
430
+ function guardKeychain(provider) {
431
+ return {
432
+ write: (credentials) => provider.write(credentials),
433
+ delete: () => provider.delete(),
434
+ check: () => provider.check(),
435
+ readJournal: () => provider.readJournal(),
436
+ writeJournal: (raw) => provider.writeJournal(raw),
437
+ clearJournal: () => provider.clearJournal(),
438
+ acquireLock: (onLost) => provider.acquireLock(onLost),
439
+ isLocked: () => provider.isLocked(),
440
+ async read() {
441
+ const credentials = await provider.read();
442
+ if (credentials === null) return null;
443
+ let blocked;
444
+ try {
445
+ const { logout } = await loadJournal(provider);
446
+ blocked = logout?.fingerprint === credentialFingerprint(credentials.endpoint, credentials.apiKey);
447
+ } catch (err) {
448
+ if (!(err instanceof JournalInvalidError)) throw err;
449
+ blocked = true;
450
+ }
451
+ if (blocked) {
452
+ throw new KeychainError({
453
+ code: "logout-incomplete",
454
+ message: "A pyx-memory sign-out did not finish on this computer.",
455
+ guidance: "Run: pyx-mem login-recovery",
456
+ exit: EXIT.NOT_LOGGED_IN
457
+ });
458
+ }
459
+ return credentials;
460
+ }
461
+ };
462
+ }
463
+
464
+ // src/cli/keychain/napi-provider.ts
465
+ import { homedir } from "os";
466
+ import { join } from "path";
467
+
468
+ // src/cli/lifecycle/lock.ts
469
+ import { mkdir } from "fs/promises";
470
+ import { dirname } from "path";
471
+ import lockfile from "proper-lockfile";
472
+ var LOCK_OPTIONS = { realpath: false, stale: 15e3 };
473
+ function createFileLock(file) {
474
+ return {
475
+ async acquire(onLost) {
476
+ await mkdir(dirname(file), { recursive: true, mode: 448 });
477
+ let release;
478
+ try {
479
+ release = await lockfile.lock(file, { ...LOCK_OPTIONS, onCompromised: () => onLost() });
480
+ } catch (err) {
481
+ if (err.code === "ELOCKED") return null;
482
+ throw err;
483
+ }
484
+ return async () => {
485
+ try {
486
+ await release();
487
+ } catch (err) {
488
+ const code = err.code;
489
+ if (code !== "ERELEASED" && code !== "ENOTACQUIRED") throw err;
490
+ }
491
+ };
492
+ },
493
+ isLocked: () => lockfile.check(file, LOCK_OPTIONS)
494
+ };
495
+ }
496
+ var BUSY = /* @__PURE__ */ Symbol("lifecycle-lock-busy");
497
+ var LockLostError = class extends Error {
498
+ };
499
+ async function releaseLifecycleLock(release) {
500
+ try {
501
+ await release();
502
+ } catch (err) {
503
+ const code = err?.code;
504
+ const reason = typeof code === "string" && /^[A-Z_]{1,32}$/.test(code) ? code : "unknown";
505
+ process.stderr.write(
506
+ `Warning: the pyx-mem lifecycle lock could not be released (${reason}). The reported result stands; other pyx-mem sign-in or sign-out commands may answer busy for up to ${LOCK_OPTIONS.stale / 1e3} seconds.
507
+ `
508
+ );
509
+ }
510
+ }
511
+ async function withLifecycleLock(provider, fn) {
512
+ let lost = false;
513
+ const release = await provider.acquireLock(() => {
514
+ lost = true;
515
+ });
516
+ if (release === null) return BUSY;
517
+ try {
518
+ return await fn(() => lost);
519
+ } finally {
520
+ await releaseLifecycleLock(release);
521
+ }
522
+ }
523
+
93
524
  // src/cli/keychain/types.ts
94
525
  var KEYCHAIN_SERVICE = "pyx-memory";
95
526
  var KEYCHAIN_ACCOUNT = "default";
96
527
  var CREDENTIAL_SCHEMA_VERSION = 1;
528
+ var JOURNAL_ACCOUNT = "lifecycle-journal";
97
529
 
98
530
  // src/cli/keychain/napi-provider.ts
99
- var cached = null;
100
- async function loadEntry() {
101
- if (cached) return cached;
531
+ var cached = /* @__PURE__ */ new Map();
532
+ async function loadEntry(account = KEYCHAIN_ACCOUNT) {
533
+ const hit = cached.get(account);
534
+ if (hit) return hit;
102
535
  try {
103
536
  const mod = await import("@napi-rs/keyring");
104
- cached = new mod.Entry(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT);
105
- return cached;
537
+ const entry = new mod.Entry(KEYCHAIN_SERVICE, account);
538
+ cached.set(account, entry);
539
+ return entry;
106
540
  } catch (err) {
107
541
  throw providerImportError(err);
108
542
  }
@@ -120,9 +554,16 @@ function parseRecord(raw) {
120
554
  function isValidRecord(value) {
121
555
  if (typeof value !== "object" || value === null) return false;
122
556
  const v = value;
123
- return v.schemaVersion === CREDENTIAL_SCHEMA_VERSION && typeof v.endpoint === "string" && v.endpoint.length > 0 && typeof v.apiKey === "string" && v.apiKey.length > 0;
557
+ return v.schemaVersion === CREDENTIAL_SCHEMA_VERSION && typeof v.endpoint === "string" && v.endpoint.length > 0 && typeof v.apiKey === "string" && v.apiKey.length > 0 && (v.provenance === void 0 || v.provenance === "browser-login" && isConnection(v.connection));
558
+ }
559
+ function isConnection(value) {
560
+ if (typeof value !== "object" || value === null) return false;
561
+ const c = value;
562
+ return typeof c.accountLabel === "string" && typeof c.projectId === "string" && typeof c.projectName === "string";
124
563
  }
125
564
  var NapiKeychainProvider = class {
565
+ // The lock file holds no secrets; credentials and the journal stay in the OS store.
566
+ lock = createFileLock(join(homedir(), ".pyx-memory", "lifecycle"));
126
567
  async read() {
127
568
  const entry = await loadEntry();
128
569
  let raw;
@@ -139,7 +580,8 @@ var NapiKeychainProvider = class {
139
580
  const payload = JSON.stringify({
140
581
  schemaVersion: CREDENTIAL_SCHEMA_VERSION,
141
582
  endpoint: credentials.endpoint,
142
- apiKey: credentials.apiKey
583
+ apiKey: credentials.apiKey,
584
+ ...credentials.provenance === "browser-login" ? { connection: credentials.connection, provenance: credentials.provenance } : {}
143
585
  });
144
586
  try {
145
587
  entry.setPassword(payload);
@@ -155,6 +597,36 @@ var NapiKeychainProvider = class {
155
597
  throw platformAccessError(err);
156
598
  }
157
599
  }
600
+ async readJournal() {
601
+ const entry = await loadEntry(JOURNAL_ACCOUNT);
602
+ try {
603
+ return entry.getPassword();
604
+ } catch (err) {
605
+ throw platformAccessError(err);
606
+ }
607
+ }
608
+ async writeJournal(raw) {
609
+ const entry = await loadEntry(JOURNAL_ACCOUNT);
610
+ try {
611
+ entry.setPassword(raw);
612
+ } catch (err) {
613
+ throw platformAccessError(err);
614
+ }
615
+ }
616
+ async clearJournal() {
617
+ const entry = await loadEntry(JOURNAL_ACCOUNT);
618
+ try {
619
+ entry.deletePassword();
620
+ } catch (err) {
621
+ throw platformAccessError(err);
622
+ }
623
+ }
624
+ acquireLock(onLost) {
625
+ return this.lock.acquire(onLost);
626
+ }
627
+ isLocked() {
628
+ return this.lock.isLocked();
629
+ }
158
630
  async check() {
159
631
  try {
160
632
  const entry = await loadEntry();
@@ -172,6 +644,9 @@ var NapiKeychainProvider = class {
172
644
 
173
645
  // src/cli/keychain/index.ts
174
646
  function getDefaultKeychain() {
647
+ return guardKeychain(new NapiKeychainProvider());
648
+ }
649
+ function getLifecycleKeychain() {
175
650
  return new NapiKeychainProvider();
176
651
  }
177
652
 
@@ -511,6 +986,7 @@ async function readSurface(opts) {
511
986
  state: "error",
512
987
  endpoint: null,
513
988
  keySource: null,
989
+ credentialFingerprint: null,
514
990
  problem: error instanceof KeychainError ? [error.message, error.guidance].filter(Boolean).join(" ") : "The OS credential store could not be read."
515
991
  };
516
992
  }
@@ -521,9 +997,11 @@ async function readSurface(opts) {
521
997
  state: "signed_out",
522
998
  endpoint: null,
523
999
  keySource: null,
1000
+ credentialFingerprint: null,
524
1001
  problem: null
525
1002
  };
526
1003
  }
1004
+ const fingerprint = credentialFingerprint(credentials.endpoint, credentials.apiKey);
527
1005
  if (!isHttpUrl(credentials.endpoint)) {
528
1006
  return {
529
1007
  ok: false,
@@ -531,6 +1009,7 @@ async function readSurface(opts) {
531
1009
  state: "error",
532
1010
  endpoint: null,
533
1011
  keySource: "keychain",
1012
+ credentialFingerprint: fingerprint,
534
1013
  problem: "The stored pyx-memory endpoint is invalid. Run: pyx-mem login"
535
1014
  };
536
1015
  }
@@ -544,6 +1023,7 @@ async function readSurface(opts) {
544
1023
  opts.surface,
545
1024
  response.status,
546
1025
  credentials.endpoint,
1026
+ fingerprint,
547
1027
  opts.minimumServiceVersion,
548
1028
  http
549
1029
  );
@@ -556,12 +1036,18 @@ async function readSurface(opts) {
556
1036
  state: "error",
557
1037
  endpoint: credentials.endpoint,
558
1038
  keySource: "keychain",
1039
+ credentialFingerprint: fingerprint,
559
1040
  problem: `pyx-memory returned an invalid ${opts.surface} response. Run: pyx-mem doctor`
560
1041
  };
561
1042
  }
562
- return { ok: true, endpoint: credentials.endpoint, data: parsed.data };
1043
+ return {
1044
+ ok: true,
1045
+ endpoint: credentials.endpoint,
1046
+ credentialFingerprint: fingerprint,
1047
+ data: parsed.data
1048
+ };
563
1049
  }
564
- async function failedSurfaceRequest(surface, status, endpoint, minimumServiceVersion, http) {
1050
+ async function failedSurfaceRequest(surface, status, endpoint, credentialFingerprint2, minimumServiceVersion, http) {
565
1051
  if (status === 401) {
566
1052
  return {
567
1053
  ok: false,
@@ -569,6 +1055,7 @@ async function failedSurfaceRequest(surface, status, endpoint, minimumServiceVer
569
1055
  state: "signed_out",
570
1056
  endpoint,
571
1057
  keySource: "keychain",
1058
+ credentialFingerprint: credentialFingerprint2,
572
1059
  problem: requestFailure(surface, status)
573
1060
  };
574
1061
  }
@@ -580,6 +1067,7 @@ async function failedSurfaceRequest(surface, status, endpoint, minimumServiceVer
580
1067
  state: serviceOutdated ? "service_outdated" : "error",
581
1068
  endpoint,
582
1069
  keySource: "keychain",
1070
+ credentialFingerprint: credentialFingerprint2,
583
1071
  problem: serviceOutdated ? `pyx-memory ${surface} requires service ${minimumServiceVersion} or newer; the connected service reports ${serviceVersion}. Update the hosted pyx-memory service or dashboard integration, then retry.` : requestFailure(surface, status)
584
1072
  };
585
1073
  }
@@ -699,8 +1187,14 @@ async function insightsCommand(opts = {}) {
699
1187
  keychain: opts.keychain,
700
1188
  fetchImpl: opts.fetchImpl
701
1189
  });
1190
+ const withIdentity = opts.withIdentity === true;
702
1191
  if (!result.ok) {
703
- emit(report(result.state, result.endpoint, result.keySource, result.problem));
1192
+ emit(
1193
+ report(result.state, result.endpoint, result.keySource, result.problem, {
1194
+ withIdentity,
1195
+ credentialFingerprint: result.credentialFingerprint
1196
+ })
1197
+ );
704
1198
  return result.exit;
705
1199
  }
706
1200
  emit({
@@ -708,17 +1202,22 @@ async function insightsCommand(opts = {}) {
708
1202
  state: "ready",
709
1203
  endpoint: result.endpoint,
710
1204
  keySource: "keychain",
1205
+ ...identityField({ withIdentity, credentialFingerprint: result.credentialFingerprint }),
711
1206
  ...result.data,
712
1207
  problem: null
713
1208
  });
714
1209
  return EXIT.OK;
715
1210
  }
716
- function report(state, endpoint, keySource, problem) {
1211
+ function identityField(identity) {
1212
+ return identity.withIdentity ? { credentialFingerprint: identity.credentialFingerprint } : {};
1213
+ }
1214
+ function report(state, endpoint, keySource, problem, identity) {
717
1215
  return {
718
1216
  schemaVersion: INSIGHTS_SCHEMA_VERSION,
719
1217
  state,
720
1218
  endpoint,
721
1219
  keySource,
1220
+ ...identityField(identity),
722
1221
  generatedAt: null,
723
1222
  scope: "connected_memory",
724
1223
  coverage: null,
@@ -812,7 +1311,7 @@ async function readSingleLineFromStdin() {
812
1311
  // src/cli/commands/login.ts
813
1312
  var DEFAULT_ENDPOINT = "https://memory.api.pyxmate.com";
814
1313
  async function loginCommand(opts = {}) {
815
- const provider = opts.keychain ?? getDefaultKeychain();
1314
+ const provider = opts.keychain ?? getLifecycleKeychain();
816
1315
  const fetcher = opts.fetchImpl ?? fetch;
817
1316
  const endpoint = normalizeEndpoint(
818
1317
  opts.endpoint ?? await readExistingEndpoint(provider) ?? DEFAULT_ENDPOINT
@@ -844,7 +1343,16 @@ No credentials were written.
844
1343
  return EXIT.NOT_LOGGED_IN;
845
1344
  }
846
1345
  try {
847
- await provider.write({ schemaVersion: 1, endpoint, apiKey });
1346
+ const written = await withLifecycleLock(provider, async () => {
1347
+ await supersedeLogout(provider, endpoint, apiKey);
1348
+ await provider.write({ schemaVersion: 1, endpoint, apiKey });
1349
+ });
1350
+ if (written === BUSY) {
1351
+ process.stderr.write(
1352
+ "Error: another pyx-mem sign-in or sign-out is in progress. Retry in a moment.\nNo credentials were written.\n"
1353
+ );
1354
+ return EXIT.BUSY;
1355
+ }
848
1356
  } catch (err) {
849
1357
  if (err instanceof KeychainError) {
850
1358
  process.stderr.write(
@@ -888,21 +1396,617 @@ function isHttpUrl2(value) {
888
1396
  }
889
1397
  }
890
1398
 
1399
+ // src/cli/commands/login-browser.ts
1400
+ import { spawn } from "child_process";
1401
+ import { createHash as createHash3, randomBytes } from "crypto";
1402
+ import { hostname } from "os";
1403
+ import { createInterface } from "readline";
1404
+
1405
+ // src/cli/lifecycle/callback-server.ts
1406
+ import { createHash as createHash2, timingSafeEqual } from "crypto";
1407
+ import { createServer } from "http";
1408
+ var CALLBACK_PATH = "/pyx-memory/callback";
1409
+ var CODE_RE = /^[A-Za-z0-9_-]{43}$/;
1410
+ var STYLES = `
1411
+ :root { color-scheme: dark; font-family: "Avenir Next", "Trebuchet MS", sans-serif; }
1412
+ * { box-sizing: border-box; }
1413
+ body {
1414
+ margin: 0; min-height: 100vh; min-height: 100svh; padding: 32px 20px;
1415
+ display: flex; color: #f7f8fb; background: #0b0b0e;
1416
+ background-image: radial-gradient(ellipse at 50% 55%, #0071e31c, transparent 65%);
1417
+ }
1418
+ main { width: 100%; max-width: 496px; margin: auto; }
1419
+ .brand { margin: 0 0 20px 8px; font-size: 15px; font-weight: 600; letter-spacing: -.02em; }
1420
+ .brand span { margin-left: 8px; color: #a9adb8; font-weight: 400; }
1421
+ .card {
1422
+ padding: clamp(24px, 6vw, 40px); border: 1px solid #30333c; border-radius: 28px;
1423
+ background: #15161c; box-shadow: 0 24px 80px #0006;
1424
+ }
1425
+ .symbol {
1426
+ display: grid; place-items: center; width: 64px; height: 64px; margin-bottom: 32px;
1427
+ border: 1px solid #2c4059; border-radius: 20px; color: #6ab4ff; background: #12263a;
1428
+ }
1429
+ .continue .symbol { animation: arrive 360ms cubic-bezier(.22, 1, .36, 1); }
1430
+ .cancelled .symbol { color: #a9adb8; background: #20222b; border-color: #383b47; }
1431
+ .invalid .symbol { color: #f4bd7a; background: #302719; border-color: #59462d; }
1432
+ h1 {
1433
+ margin: 0 0 20px; font-size: clamp(28px, 6vw, 40px); font-weight: 600;
1434
+ line-height: 1.12; letter-spacing: -.035em; overflow-wrap: break-word;
1435
+ }
1436
+ p { margin: 0; color: #a9adb8; font-size: 16px; line-height: 1.65; }
1437
+ .note { margin-top: 32px; padding-top: 24px; border-top: 1px solid #30333c; font-size: 14px; }
1438
+ @keyframes arrive { from { opacity: .65; transform: translateY(4px); } }
1439
+ @media (prefers-reduced-motion: reduce) { .continue .symbol { animation: none; } }
1440
+ `;
1441
+ var PAGE_CSP = `default-src 'none'; style-src 'sha256-${createHash2("sha256").update(STYLES).digest("base64")}'; base-uri 'none'; form-action 'none'`;
1442
+ var page = (state, title, text, icon) => `<!doctype html>
1443
+ <html lang="en">
1444
+ <head>
1445
+ <meta charset="utf-8">
1446
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1447
+ <title>${title} \xB7 Pyx Memory</title>
1448
+ <style>${STYLES}</style>
1449
+ </head>
1450
+ <body>
1451
+ <main class="${state}">
1452
+ <div class="brand">pyx<span>Memory</span></div>
1453
+ <section class="card" aria-labelledby="title">
1454
+ <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>
1455
+ <h1 id="title">${title}</h1>
1456
+ <p>${text}</p>
1457
+ <p class="note">You can close this tab.</p>
1458
+ </section>
1459
+ </main>
1460
+ </body>
1461
+ </html>`;
1462
+ var ACCEPTED_PAGE = page(
1463
+ "continue",
1464
+ "Continue in your app",
1465
+ "Your approval was received. Return to the app that started this sign-in to finish connecting.",
1466
+ '<path d="m9 4-5 5 5 5M4 9h10a6 6 0 0 1 0 12h-2"/>'
1467
+ );
1468
+ var DENIED_PAGE = page(
1469
+ "cancelled",
1470
+ "Sign-in cancelled",
1471
+ "Return to the app that started this sign-in. You can try again there whenever you\u2019re ready.",
1472
+ '<path d="m7 7 10 10M17 7 7 17"/>'
1473
+ );
1474
+ var REJECTED_PAGE = page(
1475
+ "invalid",
1476
+ "This sign-in link isn\u2019t valid",
1477
+ "Return to the app that started this sign-in to continue or try again.",
1478
+ '<path d="M12 6v7m0 4v.01"/><circle cx="12" cy="12" r="10"/>'
1479
+ );
1480
+ function sameSecret(a, b) {
1481
+ const left = Buffer.from(a);
1482
+ const right = Buffer.from(b);
1483
+ return left.length === right.length && timingSafeEqual(left, right);
1484
+ }
1485
+ function single(params, key) {
1486
+ const values = params.getAll(key);
1487
+ return values.length === 1 ? values[0] : null;
1488
+ }
1489
+ async function startCallbackServer() {
1490
+ let expected = null;
1491
+ let settle2 = () => {
1492
+ };
1493
+ const result = new Promise((resolve3) => {
1494
+ settle2 = resolve3;
1495
+ });
1496
+ function accept(method, host, rawUrl) {
1497
+ if (method !== "GET" || expected === null || host !== `127.0.0.1:${port}`) return null;
1498
+ const url = new URL(rawUrl, `http://127.0.0.1:${port}`);
1499
+ if (url.pathname !== CALLBACK_PATH) return null;
1500
+ const params = url.searchParams;
1501
+ const keys = new Set(params.keys());
1502
+ const code = single(params, "code");
1503
+ const error = single(params, "error");
1504
+ if (keys.size !== 3 || single(params, "request") !== expected.requestId || !sameSecret(single(params, "state") ?? "", expected.state)) {
1505
+ return null;
1506
+ }
1507
+ if (code !== null && CODE_RE.test(code)) return { code };
1508
+ if (error === "access_denied") return { denied: true };
1509
+ return null;
1510
+ }
1511
+ const server = createServer({ maxHeaderSize: 8192 }, (req, res) => {
1512
+ const accepted = accept(req.method, req.headers.host, req.url ?? "");
1513
+ if (accepted !== null) {
1514
+ expected = null;
1515
+ settle2(accepted);
1516
+ }
1517
+ res.writeHead(accepted === null ? 400 : 200, {
1518
+ "Content-Type": "text/html; charset=utf-8",
1519
+ "Cache-Control": "no-store",
1520
+ "Content-Security-Policy": PAGE_CSP,
1521
+ "Referrer-Policy": "no-referrer",
1522
+ "X-Content-Type-Options": "nosniff",
1523
+ Connection: "close"
1524
+ });
1525
+ res.end(accepted === null ? REJECTED_PAGE : "denied" in accepted ? DENIED_PAGE : ACCEPTED_PAGE);
1526
+ });
1527
+ server.requestTimeout = 5e3;
1528
+ server.headersTimeout = 5e3;
1529
+ await new Promise((resolve3, reject) => {
1530
+ server.once("error", reject);
1531
+ server.listen(0, "127.0.0.1", resolve3);
1532
+ });
1533
+ const port = server.address().port;
1534
+ return {
1535
+ port,
1536
+ expect(requestId, state) {
1537
+ expected = { requestId, state };
1538
+ },
1539
+ result,
1540
+ close() {
1541
+ server.close();
1542
+ server.closeAllConnections();
1543
+ }
1544
+ };
1545
+ }
1546
+
1547
+ // src/cli/lifecycle/settle.ts
1548
+ var STARTED_EXPIRY_MARGIN_MS = 15 * 6e4;
1549
+ async function settle(keychain, api, journal, lockLost) {
1550
+ const holdsLock = () => {
1551
+ if (lockLost()) throw new LockLostError();
1552
+ };
1553
+ const save = async () => {
1554
+ holdsLock();
1555
+ await saveJournal(keychain, journal);
1556
+ };
1557
+ const readShared = () => keychain.read().catch((err) => {
1558
+ if (err instanceof KeychainError && err.code === "credential-record-invalid") return null;
1559
+ throw err;
1560
+ });
1561
+ const queueRevoke = (key) => {
1562
+ const queued = journal.revoke.some(
1563
+ (pending) => pending.endpoint === key.endpoint && pending.apiKey === key.apiKey
1564
+ );
1565
+ if (!queued) journal.revoke.push(key);
1566
+ };
1567
+ const login = journal.login;
1568
+ if (login?.phase === "started") {
1569
+ holdsLock();
1570
+ if (Date.now() > login.expiresAt + STARTED_EXPIRY_MARGIN_MS || await api.cancel(login)) {
1571
+ journal.login = null;
1572
+ await save();
1573
+ }
1574
+ } else if (login?.apiKey !== void 0) {
1575
+ const own = { endpoint: login.endpoint, apiKey: login.apiKey };
1576
+ const shared = login.phase === "committing" ? await readShared() : null;
1577
+ const committed = shared !== null && credentialFingerprint(shared.endpoint, shared.apiKey) === credentialFingerprint(own.endpoint, own.apiKey);
1578
+ journal.login = null;
1579
+ if (!committed) queueRevoke(own);
1580
+ if (login.previous !== void 0 && shared?.apiKey !== login.previous.apiKey) {
1581
+ queueRevoke(login.previous);
1582
+ }
1583
+ await save();
1584
+ }
1585
+ if (journal.logout !== null) {
1586
+ const { fingerprint, revoke } = journal.logout;
1587
+ const shared = await readShared();
1588
+ if (shared !== null && credentialFingerprint(shared.endpoint, shared.apiKey) === fingerprint) {
1589
+ holdsLock();
1590
+ await keychain.delete();
1591
+ }
1592
+ journal.logout = null;
1593
+ if (revoke !== null) queueRevoke(revoke);
1594
+ await save();
1595
+ }
1596
+ for (const key of [...journal.revoke]) {
1597
+ holdsLock();
1598
+ if (await api.revoke(key)) {
1599
+ journal.revoke = journal.revoke.filter((pending) => pending !== key);
1600
+ journal.revokedAt = Date.now();
1601
+ await save();
1602
+ }
1603
+ }
1604
+ return journal;
1605
+ }
1606
+
1607
+ // src/cli/commands/login-browser.ts
1608
+ var DEFAULT_ENDPOINT2 = "https://memory.api.pyxmate.com";
1609
+ var MAX_STDIN_LINE = 1024;
1610
+ var Stop = class extends Error {
1611
+ constructor(outcome) {
1612
+ super(outcome);
1613
+ this.outcome = outcome;
1614
+ }
1615
+ outcome;
1616
+ };
1617
+ function stopFor(failure2) {
1618
+ return new Stop(failure2.code === "cancelled" ? "requested" : failure2.code);
1619
+ }
1620
+ function printHuman(e) {
1621
+ if (e.event === "awaiting_browser") {
1622
+ process.stdout.write(`Sign in with your browser:
1623
+ ${e.authorizationUrl}
1624
+ `);
1625
+ } else if (e.event === "connecting") {
1626
+ process.stdout.write("Finishing sign-in\u2026\n");
1627
+ } else if (e.event === "connected") {
1628
+ process.stdout.write(
1629
+ `Connected to ${e.connection.projectName} as ${e.connection.accountLabel}. Run: pyx-mem doctor
1630
+ `
1631
+ );
1632
+ } else if (e.event === "cancelled") {
1633
+ process.stdout.write("Sign-in cancelled. Stored credentials were not changed.\n");
1634
+ } else {
1635
+ process.stderr.write(`Error: ${e.message}
1636
+ `);
1637
+ }
1638
+ if ("cleanupPending" in e && e.cleanupPending && e.event !== "error") {
1639
+ process.stderr.write("Server-side cleanup is pending. Run: pyx-mem login-recovery\n");
1640
+ }
1641
+ }
1642
+ function openInBrowser(url) {
1643
+ const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : ["xdg-open", [url]];
1644
+ const child = spawn(command, args, { stdio: "ignore", detached: true });
1645
+ child.on("error", () => {
1646
+ process.stderr.write(
1647
+ "Could not open a browser automatically. Open the sign-in URL yourself.\n"
1648
+ );
1649
+ });
1650
+ child.unref();
1651
+ }
1652
+ function exitFor(outcome) {
1653
+ if (outcome === "requested" || outcome === "denied") return EXIT.CANCELLED;
1654
+ if (outcome === "busy") return EXIT.BUSY;
1655
+ if (outcome === "cleanup_pending") return EXIT.CLEANUP_PENDING;
1656
+ if (outcome === "invalid_endpoint") return EXIT.USAGE;
1657
+ return EXIT.DOCTOR_FAIL;
1658
+ }
1659
+ async function snapshot(keychain) {
1660
+ try {
1661
+ const credentials = await keychain.read();
1662
+ return {
1663
+ token: credentials === null ? "none" : credentialFingerprint(credentials.endpoint, credentials.apiKey),
1664
+ credentials
1665
+ };
1666
+ } catch (err) {
1667
+ if (err instanceof KeychainError && err.code === "credential-record-invalid") {
1668
+ return { token: "invalid", credentials: null };
1669
+ }
1670
+ throw err;
1671
+ }
1672
+ }
1673
+ async function browserLoginCommand(opts) {
1674
+ const keychain = opts.keychain ?? getLifecycleKeychain();
1675
+ const api = createCliLoginApi(opts.fetchImpl);
1676
+ const emit4 = opts.json ? (e) => process.stdout.write(`${JSON.stringify(e)}
1677
+ `) : printHuman;
1678
+ const stop = new AbortController();
1679
+ let lockLost = false;
1680
+ const cancel = () => stop.abort();
1681
+ const stopped = new Promise((_, reject) => {
1682
+ stop.signal.addEventListener("abort", () => reject(new Stop("requested")));
1683
+ });
1684
+ stopped.catch(() => {
1685
+ });
1686
+ const check = () => {
1687
+ if (lockLost) throw new Stop("lock_lost");
1688
+ if (stop.signal.aborted) throw new Stop("requested");
1689
+ };
1690
+ process.once("SIGINT", cancel);
1691
+ process.once("SIGTERM", cancel);
1692
+ const lines = opts.json ? createInterface({ input: opts.input ?? process.stdin }) : null;
1693
+ lines?.on("line", (line) => {
1694
+ if (line.length > MAX_STDIN_LINE) return;
1695
+ try {
1696
+ if (JSON.parse(line)?.action === "cancel") cancel();
1697
+ } catch {
1698
+ }
1699
+ });
1700
+ if (opts.noOpen) lines?.on("close", cancel);
1701
+ let release = null;
1702
+ let listener = null;
1703
+ let journal = null;
1704
+ let connected = null;
1705
+ let outcome = null;
1706
+ let storeError = null;
1707
+ try {
1708
+ release = await keychain.acquireLock(() => {
1709
+ lockLost = true;
1710
+ cancel();
1711
+ });
1712
+ if (release === null) throw new Stop("busy");
1713
+ journal = await settle(keychain, api, await loadJournal(keychain), () => lockLost);
1714
+ if (journal.login !== null) throw new Stop("cleanup_pending");
1715
+ const base = await snapshot(keychain);
1716
+ const endpoint = parseEndpoint(opts.endpoint ?? base.credentials?.endpoint ?? DEFAULT_ENDPOINT2);
1717
+ if (endpoint === null) throw new Stop("invalid_endpoint");
1718
+ const replaced = base.credentials ? { endpoint: base.credentials.endpoint, apiKey: base.credentials.apiKey } : null;
1719
+ const previous = replaced !== null && base.credentials?.provenance === "browser-login" && isKeyRef(replaced) ? replaced : void 0;
1720
+ if (!hasRoomForLogin(journal, endpoint, previous)) throw new Stop("cleanup_pending");
1721
+ listener = await startCallbackServer();
1722
+ const codeVerifier = randomBytes(32).toString("base64url");
1723
+ const state = randomBytes(32).toString("base64url");
1724
+ const started = await api.start(
1725
+ endpoint,
1726
+ {
1727
+ codeChallenge: createHash3("sha256").update(codeVerifier).digest("base64url"),
1728
+ callbackPort: listener.port,
1729
+ state,
1730
+ deviceLabel: hostname().replace(/\p{Cc}/gu, "").trim().slice(0, 64) || "this computer"
1731
+ },
1732
+ stop.signal
1733
+ );
1734
+ check();
1735
+ if (!started.ok) throw stopFor(started);
1736
+ const login = {
1737
+ phase: "started",
1738
+ endpoint,
1739
+ requestId: started.requestId,
1740
+ codeVerifier,
1741
+ expiresAt: started.expiresAt.getTime()
1742
+ };
1743
+ journal.login = login;
1744
+ await saveJournal(keychain, journal);
1745
+ check();
1746
+ listener.expect(started.requestId, state);
1747
+ emit4({
1748
+ event: "awaiting_browser",
1749
+ authorizationUrl: started.authorizationUrl,
1750
+ expiresAt: started.expiresAt.toISOString()
1751
+ });
1752
+ if (!opts.noOpen) (opts.openBrowser ?? openInBrowser)(started.authorizationUrl);
1753
+ let expiry;
1754
+ const expired = new Promise((_, reject) => {
1755
+ const ms = Math.min(Math.max(started.expiresAt.getTime() - Date.now(), 0), 6e5);
1756
+ expiry = setTimeout(() => reject(new Stop("expired")), ms);
1757
+ });
1758
+ const callback = await Promise.race([listener.result, stopped, expired]).finally(
1759
+ () => clearTimeout(expiry)
1760
+ );
1761
+ check();
1762
+ if ("denied" in callback) throw new Stop("denied");
1763
+ emit4({ event: "connecting" });
1764
+ const redeemed = await api.redeem(login, callback.code, stop.signal);
1765
+ check();
1766
+ if (!redeemed.ok) throw stopFor(redeemed);
1767
+ const issued = { ...login, phase: "issued", apiKey: redeemed.apiKey };
1768
+ journal.login = issued;
1769
+ await saveJournal(keychain, journal);
1770
+ check();
1771
+ const confirmed = await api.confirm(issued, stop.signal);
1772
+ check();
1773
+ if (!confirmed.ok) throw stopFor(confirmed);
1774
+ const current = (await loadJournal(keychain)).login;
1775
+ if (current?.requestId !== issued.requestId || current.phase !== "issued") {
1776
+ throw new Stop("lock_lost");
1777
+ }
1778
+ journal.login = { ...issued, phase: "committing", ...previous ? { previous } : {} };
1779
+ await saveJournal(keychain, journal);
1780
+ check();
1781
+ if ((await snapshot(keychain)).token !== base.token) throw new Stop("credential_changed");
1782
+ check();
1783
+ const fingerprint = credentialFingerprint(endpoint, redeemed.apiKey);
1784
+ try {
1785
+ await keychain.write({
1786
+ schemaVersion: 1,
1787
+ endpoint,
1788
+ apiKey: redeemed.apiKey,
1789
+ connection: redeemed.connection,
1790
+ provenance: "browser-login"
1791
+ });
1792
+ } catch (err) {
1793
+ if ((await snapshot(keychain)).token !== fingerprint) throw err;
1794
+ }
1795
+ connected = { connection: redeemed.connection, fingerprint };
1796
+ } catch (err) {
1797
+ if (err instanceof Stop) outcome = err.outcome;
1798
+ else if (err instanceof JournalInvalidError) outcome = "journal_invalid";
1799
+ else if (err instanceof KeychainError) {
1800
+ storeError = err;
1801
+ outcome = "credential_store_unavailable";
1802
+ } else outcome = "login_failed";
1803
+ }
1804
+ listener?.close();
1805
+ lines?.close();
1806
+ process.off("SIGINT", cancel);
1807
+ process.off("SIGTERM", cancel);
1808
+ let cleanupPending = outcome !== "busy";
1809
+ if (journal !== null && !lockLost) {
1810
+ try {
1811
+ cleanupPending = !isEmpty(await settle(keychain, api, journal, () => lockLost));
1812
+ } catch {
1813
+ cleanupPending = true;
1814
+ }
1815
+ }
1816
+ if (release !== null) await releaseLifecycleLock(release);
1817
+ if (connected !== null) {
1818
+ emit4({
1819
+ event: "connected",
1820
+ connection: connected.connection,
1821
+ credentialFingerprint: connected.fingerprint,
1822
+ cleanupPending
1823
+ });
1824
+ return EXIT.OK;
1825
+ }
1826
+ if (outcome === "requested" || outcome === "denied") {
1827
+ emit4({ event: "cancelled", reason: outcome, cleanupPending });
1828
+ return EXIT.CANCELLED;
1829
+ }
1830
+ const code = outcome ?? "login_failed";
1831
+ emit4({
1832
+ event: "error",
1833
+ code,
1834
+ message: storeError ? `${storeError.message} ${storeError.guidance}` : ERROR_MESSAGES[code].message,
1835
+ retryable: ERROR_MESSAGES[code].retryable,
1836
+ cleanupPending
1837
+ });
1838
+ return storeError?.exit ?? exitFor(code);
1839
+ }
1840
+
891
1841
  // src/cli/commands/logout.ts
1842
+ function lifecycleFailure(code, cleanupPending, err) {
1843
+ return {
1844
+ ok: false,
1845
+ code,
1846
+ // Keychain guidance is static text; any other error's text is never shown.
1847
+ message: err instanceof KeychainError ? `${err.message} ${err.guidance}` : ERROR_MESSAGES[code].message,
1848
+ retryable: ERROR_MESSAGES[code].retryable,
1849
+ cleanupPending
1850
+ };
1851
+ }
1852
+ async function cleanupPendingAfterFailure(keychain) {
1853
+ try {
1854
+ return !isEmpty(await loadJournal(keychain));
1855
+ } catch {
1856
+ return true;
1857
+ }
1858
+ }
892
1859
  async function logoutCommand(opts = {}) {
893
- const provider = opts.keychain ?? getDefaultKeychain();
1860
+ const keychain = opts.keychain ?? getLifecycleKeychain();
1861
+ const api = createCliLoginApi(opts.fetchImpl);
1862
+ const fail = (failure2, exit) => {
1863
+ if (opts.json) process.stdout.write(`${JSON.stringify(failure2)}
1864
+ `);
1865
+ else process.stderr.write(`Error: ${failure2.message}
1866
+ `);
1867
+ return exit;
1868
+ };
894
1869
  try {
895
- await provider.delete();
896
- process.stdout.write("pyx-memory credentials deleted from OS credential store.\n");
897
- return EXIT.OK;
1870
+ const result = await withLifecycleLock(keychain, (lockLost) => logout(lockLost));
1871
+ return result === BUSY ? fail(lifecycleFailure("busy", false), EXIT.BUSY) : result;
898
1872
  } catch (err) {
1873
+ const pending = await cleanupPendingAfterFailure(keychain);
899
1874
  if (err instanceof KeychainError) {
900
- process.stderr.write(`Error: ${err.message}
901
- ${err.guidance}
1875
+ return fail(lifecycleFailure("credential_store_unavailable", pending, err), err.exit);
1876
+ }
1877
+ if (err instanceof LockLostError) {
1878
+ return fail(lifecycleFailure("lock_lost", pending), EXIT.DOCTOR_FAIL);
1879
+ }
1880
+ if (err instanceof JournalInvalidError) {
1881
+ return fail(lifecycleFailure("journal_invalid", true), EXIT.DOCTOR_FAIL);
1882
+ }
1883
+ return fail(lifecycleFailure("cleanup_pending", true), EXIT.CLEANUP_PENDING);
1884
+ }
1885
+ async function logout(lockLost) {
1886
+ const journal = await loadJournal(keychain);
1887
+ const selected = await keychain.read().catch((err) => {
1888
+ if (err instanceof KeychainError && err.code === "credential-record-invalid") {
1889
+ return "unreadable";
1890
+ }
1891
+ throw err;
1892
+ });
1893
+ const managed = selected !== null && selected !== "unreadable" && selected.provenance === "browser-login" && isKeyRef({ endpoint: selected.endpoint, apiKey: selected.apiKey }) ? { endpoint: selected.endpoint, apiKey: selected.apiKey } : null;
1894
+ if (selected === null || selected === "unreadable") {
1895
+ await keychain.delete();
1896
+ } else {
1897
+ journal.logout = {
1898
+ fingerprint: credentialFingerprint(selected.endpoint, selected.apiKey),
1899
+ revoke: managed
1900
+ };
1901
+ await saveJournal(keychain, journal);
1902
+ }
1903
+ const cleanupPending = !isEmpty(await settle(keychain, api, journal, lockLost));
1904
+ if (await keychain.read() !== null) {
1905
+ return fail(lifecycleFailure("credential_changed", cleanupPending), EXIT.DOCTOR_FAIL);
1906
+ }
1907
+ if (lockLost()) throw new LockLostError();
1908
+ const convergence = convergenceSeconds(journal);
1909
+ const revocationPending = journal.revoke.length > 0;
1910
+ const serverRevocation = revocationPending ? "pending" : convergence !== null ? "revoked" : "not_attempted";
1911
+ const legacy = selected !== null && managed === null;
1912
+ const report4 = {
1913
+ ok: true,
1914
+ credentialKind: selected === null ? null : managed !== null ? "browser" : "legacy",
1915
+ localDeleted: selected !== null,
1916
+ serverRevocation,
1917
+ cleanupPending,
1918
+ reconnectRequired: selected !== null || serverRevocation !== "not_attempted",
1919
+ // Proxies older than the per-request credential check hold the key in
1920
+ // memory. A key that is still valid on the server keeps them working.
1921
+ pendingLegacySessions: legacy || revocationPending,
1922
+ serverConvergenceSeconds: convergence
1923
+ };
1924
+ if (opts.json) {
1925
+ process.stdout.write(`${JSON.stringify(report4)}
902
1926
  `);
903
- return err.exit;
1927
+ } else {
1928
+ process.stdout.write(
1929
+ selected === null ? "No pyx-memory credentials were stored.\n" : "pyx-memory credentials deleted from OS credential store.\n"
1930
+ );
1931
+ if (cleanupPending) {
1932
+ process.stderr.write(
1933
+ "The server-side key could not be revoked yet. Run: pyx-mem login-recovery\n"
1934
+ );
1935
+ } else if (report4.pendingLegacySessions) {
1936
+ process.stdout.write(
1937
+ "The API key itself was not revoked (other computers may use it). Restart running agents; revoke the key in the dashboard to end it everywhere.\n"
1938
+ );
1939
+ }
904
1940
  }
1941
+ return cleanupPending ? EXIT.CLEANUP_PENDING : EXIT.OK;
1942
+ }
1943
+ }
1944
+
1945
+ // src/cli/commands/login-recovery.ts
1946
+ async function signOutJournal(keychain) {
1947
+ const journal = emptyJournal();
1948
+ const shared = await keychain.read().catch((err) => {
1949
+ if (err instanceof KeychainError && err.code === "credential-record-invalid") return null;
905
1950
  throw err;
1951
+ });
1952
+ if (shared !== null) {
1953
+ const key = { endpoint: shared.endpoint, apiKey: shared.apiKey };
1954
+ journal.logout = {
1955
+ fingerprint: credentialFingerprint(shared.endpoint, shared.apiKey),
1956
+ revoke: shared.provenance === "browser-login" && isKeyRef(key) ? key : null
1957
+ };
1958
+ }
1959
+ await saveJournal(keychain, journal);
1960
+ return journal;
1961
+ }
1962
+ async function loginRecoveryCommand(opts = {}) {
1963
+ const keychain = opts.keychain ?? getLifecycleKeychain();
1964
+ const fail = (failure2, exit) => {
1965
+ if (opts.json) process.stdout.write(`${JSON.stringify(failure2)}
1966
+ `);
1967
+ else process.stderr.write(`Error: ${failure2.message}
1968
+ `);
1969
+ return exit;
1970
+ };
1971
+ try {
1972
+ const journal = await withLifecycleLock(keychain, async (lockLost) => {
1973
+ const loaded = await loadJournal(keychain).catch((err) => {
1974
+ if (err instanceof JournalInvalidError && opts.discardUnreadableJournal) {
1975
+ return signOutJournal(keychain);
1976
+ }
1977
+ throw err;
1978
+ });
1979
+ const settled = await settle(keychain, createCliLoginApi(opts.fetchImpl), loaded, lockLost);
1980
+ if (lockLost()) throw new LockLostError();
1981
+ return settled;
1982
+ });
1983
+ if (journal === BUSY) return fail(lifecycleFailure("busy", true), EXIT.BUSY);
1984
+ if (!isEmpty(journal)) {
1985
+ return fail(lifecycleFailure("cleanup_pending", true), EXIT.CLEANUP_PENDING);
1986
+ }
1987
+ const convergence = convergenceSeconds(journal);
1988
+ const report4 = {
1989
+ ok: true,
1990
+ cleanupPending: false,
1991
+ serverRevocation: convergence !== null ? "revoked" : "not_attempted",
1992
+ serverConvergenceSeconds: convergence
1993
+ };
1994
+ if (opts.json) process.stdout.write(`${JSON.stringify(report4)}
1995
+ `);
1996
+ else process.stdout.write("Nothing is pending.\n");
1997
+ return EXIT.OK;
1998
+ } catch (err) {
1999
+ if (err instanceof JournalInvalidError) {
2000
+ return fail(lifecycleFailure("journal_invalid", true), EXIT.DOCTOR_FAIL);
2001
+ }
2002
+ const pending = await cleanupPendingAfterFailure(keychain);
2003
+ if (err instanceof KeychainError) {
2004
+ return fail(lifecycleFailure("credential_store_unavailable", pending, err), err.exit);
2005
+ }
2006
+ if (err instanceof LockLostError) {
2007
+ return fail(lifecycleFailure("lock_lost", pending), EXIT.DOCTOR_FAIL);
2008
+ }
2009
+ return fail(lifecycleFailure("cleanup_pending", true), EXIT.CLEANUP_PENDING);
906
2010
  }
907
2011
  }
908
2012
 
@@ -925,7 +2029,7 @@ function createReadCredentials(providerFactory) {
925
2029
  }
926
2030
 
927
2031
  // src/mcp/proxy-server.ts
928
- import { createHash, randomUUID as randomUUID3 } from "crypto";
2032
+ import { createHash as createHash4, randomUUID as randomUUID3 } from "crypto";
929
2033
  import { readFile, stat } from "fs/promises";
930
2034
  import { basename, isAbsolute, resolve } from "path";
931
2035
  import { Client as McpClient } from "@modelcontextprotocol/sdk/client/index.js";
@@ -954,13 +2058,39 @@ function createMcpIdempotentFetch(fetchImpl = fetch, sessionNonce = randomUUID3(
954
2058
  if (typeof body !== "string") {
955
2059
  throw new Error("remote MCP POST body must be text for stable request idempotency");
956
2060
  }
957
- const key = createHash("sha256").update("pyx-mcp-idempotency-v1\0").update(sessionNonce).update("\0").update(body).digest("hex");
2061
+ const key = createHash4("sha256").update("pyx-mcp-idempotency-v1\0").update(sessionNonce).update("\0").update(body).digest("hex");
958
2062
  headers.set("Idempotency-Key", `mcp-${key}`);
959
2063
  }
960
2064
  return fetchImpl(input, { ...init, method, headers, body });
961
2065
  };
962
2066
  return Object.assign(wrapped, { preconnect: fetch.preconnect });
963
2067
  }
2068
+ function resultText(result) {
2069
+ return result.content.map((c) => c.type === "text" ? c.text : "").join(" ").trim();
2070
+ }
2071
+ function createCredentialGuard(readCredentials, snapshot2) {
2072
+ return async () => {
2073
+ const read = await readCredentials();
2074
+ if (!read.ok) {
2075
+ throw new Error(
2076
+ `${resultText(read.result) || "pyx-memory credentials are unavailable. Run: pyx-mem login"}
2077
+ This attempt was not sent. Then reconnect the pyx-memory MCP server.`
2078
+ );
2079
+ }
2080
+ if (read.credentials.endpoint !== snapshot2.endpoint || read.credentials.apiKey !== snapshot2.apiKey) {
2081
+ throw new Error(
2082
+ "pyx-memory credentials changed since this MCP session started (different endpoint or account). This attempt was not sent. Reconnect the pyx-memory MCP server to use the current sign-in."
2083
+ );
2084
+ }
2085
+ };
2086
+ }
2087
+ function createGuardedFetch(guard, fetchImpl = fetch) {
2088
+ const wrapped = async (input, init) => {
2089
+ await guard();
2090
+ return fetchImpl(input, init);
2091
+ };
2092
+ return Object.assign(wrapped, { preconnect: fetch.preconnect });
2093
+ }
964
2094
  function extractLocalUpload(meta) {
965
2095
  if (meta === null || typeof meta !== "object") return void 0;
966
2096
  const descriptor = meta[LOCAL_UPLOAD_META_KEY];
@@ -1003,24 +2133,31 @@ async function readCappedLocalFile(rawPath, descriptor) {
1003
2133
  };
1004
2134
  }
1005
2135
  }
1006
- function createLocalFileUploader(credentials, fetchImpl = fetch) {
2136
+ function createLocalFileUploader(credentials, readCredentials, fetchImpl = fetch) {
2137
+ const guard = createCredentialGuard(readCredentials, credentials);
1007
2138
  return async (descriptor, args) => {
1008
- const file = await readCappedLocalFile(args[descriptor.pathArg], descriptor);
1009
- if (!file.ok) return file.result;
1010
- const http = createHttpClient(credentials, fetchImpl);
1011
- const res = await http.requestMultipart({
1012
- path: descriptor.endpoint,
1013
- formData: () => {
1014
- const form = new FormData();
1015
- form.set(descriptor.fileField, new File([new Uint8Array(file.bytes)], file.name));
1016
- for (const key of descriptor.optionalArgs ?? []) {
1017
- const value = args[key];
1018
- if (typeof value === "string" && value.length > 0) form.set(key, value);
2139
+ try {
2140
+ await guard();
2141
+ const file = await readCappedLocalFile(args[descriptor.pathArg], descriptor);
2142
+ if (!file.ok) return file.result;
2143
+ const http = createHttpClient(credentials, fetchImpl);
2144
+ const res = await http.requestMultipart({
2145
+ path: descriptor.endpoint,
2146
+ formData: async () => {
2147
+ await guard();
2148
+ const form = new FormData();
2149
+ form.set(descriptor.fileField, new File([new Uint8Array(file.bytes)], file.name));
2150
+ for (const key of descriptor.optionalArgs ?? []) {
2151
+ const value = args[key];
2152
+ if (typeof value === "string" && value.length > 0) form.set(key, value);
2153
+ }
2154
+ return form;
1019
2155
  }
1020
- return form;
1021
- }
1022
- });
1023
- return res.ok ? mcpJson(res.data) : res.result;
2156
+ });
2157
+ return res.ok ? mcpJson(res.data) : res.result;
2158
+ } catch (err) {
2159
+ return mcpText(err instanceof Error ? err.message : String(err), true);
2160
+ }
1024
2161
  };
1025
2162
  }
1026
2163
  function createProxyServer(client, version, uploadLocalFile) {
@@ -1086,18 +2223,21 @@ function createProxyServer(client, version, uploadLocalFile) {
1086
2223
  return server;
1087
2224
  }
1088
2225
  async function runMcpProxyServer(opts) {
1089
- const version = opts.version ?? (true ? "1.21.0" : "0.0.0-dev");
2226
+ const version = opts.version ?? (true ? "1.22.3" : "0.0.0-dev");
1090
2227
  const read = await opts.readCredentials();
1091
2228
  if (!read.ok) {
1092
- const text = read.result.content.map((c) => c.type === "text" ? c.text : "").join(" ").trim();
1093
- throw new Error(text || "pyx-memory credentials are unavailable. Run: pyx-mem login");
2229
+ throw new Error(
2230
+ resultText(read.result) || "pyx-memory credentials are unavailable. Run: pyx-mem login"
2231
+ );
1094
2232
  }
1095
2233
  const { endpoint, apiKey } = read.credentials;
1096
2234
  const url = new URL(`${endpoint.replace(/\/+$/, "")}${REMOTE_MCP_PATH}`);
1097
2235
  const client = new McpClient({ name: "pyx-mem-proxy", version }, { capabilities: {} });
1098
2236
  const clientTransport = new StreamableHTTPClientTransport(url, {
1099
2237
  requestInit: { headers: { Authorization: `Bearer ${apiKey}` } },
1100
- fetch: createMcpIdempotentFetch()
2238
+ fetch: createMcpIdempotentFetch(
2239
+ createGuardedFetch(createCredentialGuard(opts.readCredentials, read.credentials))
2240
+ )
1101
2241
  });
1102
2242
  try {
1103
2243
  await client.connect(clientTransport);
@@ -1108,7 +2248,11 @@ async function runMcpProxyServer(opts) {
1108
2248
  );
1109
2249
  }
1110
2250
  try {
1111
- const server = createProxyServer(client, version, createLocalFileUploader(read.credentials));
2251
+ const server = createProxyServer(
2252
+ client,
2253
+ version,
2254
+ createLocalFileUploader(read.credentials, opts.readCredentials)
2255
+ );
1112
2256
  const transport = new StdioServerTransport();
1113
2257
  const closed = new Promise((resolve3) => {
1114
2258
  transport.onclose = () => resolve3();
@@ -1124,12 +2268,12 @@ async function runMcpProxyServer(opts) {
1124
2268
  // src/cli/commands/mcp-install.ts
1125
2269
  import { spawnSync as nodeSpawnSync } from "child_process";
1126
2270
  import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
1127
- import { homedir } from "os";
1128
- import { join } from "path";
2271
+ import { homedir as homedir2 } from "os";
2272
+ import { join as join2 } from "path";
1129
2273
 
1130
2274
  // src/cli/commands/mcp-install-merge.ts
1131
2275
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
1132
- import { dirname } from "path";
2276
+ import { dirname as dirname2 } from "path";
1133
2277
  function mergeWriteJsonMcpEntry(opts) {
1134
2278
  const { filePath, serverName, entry } = opts;
1135
2279
  let existingDoc = {};
@@ -1191,7 +2335,7 @@ Remove or rename the existing entry, then re-run.
1191
2335
  const nextDoc = { ...existingDoc, mcpServers: servers };
1192
2336
  const payload = `${JSON.stringify(nextDoc, null, 2)}
1193
2337
  `;
1194
- const parent = dirname(filePath);
2338
+ const parent = dirname2(filePath);
1195
2339
  if (!existsSync(parent)) {
1196
2340
  mkdirSync(parent, { recursive: true });
1197
2341
  }
@@ -1244,7 +2388,7 @@ function indent(text) {
1244
2388
 
1245
2389
  // src/cli/commands/memory-rules-merge.ts
1246
2390
  import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync as renameSync2, statSync, writeFileSync as writeFileSync2 } from "fs";
1247
- import { dirname as dirname2 } from "path";
2391
+ import { dirname as dirname3 } from "path";
1248
2392
  var RULES_BEGIN_MARKER = "<!-- pyx-memory:begin (managed by `pyx-mem mcp install`; edit outside these markers) -->";
1249
2393
  var RULES_END_MARKER = "<!-- pyx-memory:end -->";
1250
2394
  var BEGIN_MARKER_PREFIX = "<!-- pyx-memory:begin";
@@ -1375,7 +2519,7 @@ function writeMemoryRulesFile(opts) {
1375
2519
  `
1376
2520
  };
1377
2521
  }
1378
- const parent = dirname2(filePath);
2522
+ const parent = dirname3(filePath);
1379
2523
  if (!existsSync2(parent)) mkdirSync2(parent, { recursive: true });
1380
2524
  const mode = fileExists ? statSync(filePath).mode & 511 : 420;
1381
2525
  const tmpPath = `${filePath}.tmp`;
@@ -1446,7 +2590,7 @@ function mcpInstallCodexCommand(opts = {}) {
1446
2590
  if (!validateScope(scope)) return EXIT.USAGE;
1447
2591
  const remote = opts.remote ?? false;
1448
2592
  const spawnSync = opts._spawnSync ?? nodeSpawnSync;
1449
- const home = opts._homeDir ?? homedir();
2593
+ const home = opts._homeDir ?? homedir2();
1450
2594
  const probe = spawnSync("codex", ["--version"], { stdio: "pipe" });
1451
2595
  if (probe.error || probe.status !== 0) {
1452
2596
  printCodexManualInstructions(home, remote);
@@ -1468,7 +2612,7 @@ Restart Codex CLI to make the tools available. No API key was written to config.
1468
2612
  return EXIT.OK;
1469
2613
  }
1470
2614
  function printCodexManualInstructions(home, remote) {
1471
- const configPath = join(home, ".codex", "config.toml");
2615
+ const configPath = join2(home, ".codex", "config.toml");
1472
2616
  process.stdout.write(
1473
2617
  [
1474
2618
  "The `codex` CLI is not on PATH, or the install command failed.",
@@ -1490,8 +2634,8 @@ function printCodexManualInstructions(home, remote) {
1490
2634
  function mcpInstallCursorCommand(opts = {}) {
1491
2635
  const scope = opts.scope ?? "user";
1492
2636
  if (!validateScope(scope)) return EXIT.USAGE;
1493
- const home = opts._homeDir ?? homedir();
1494
- const filePath = scope === "project" ? join(process.cwd(), ".cursor", "mcp.json") : join(home, ".cursor", "mcp.json");
2637
+ const home = opts._homeDir ?? homedir2();
2638
+ const filePath = scope === "project" ? join2(process.cwd(), ".cursor", "mcp.json") : join2(home, ".cursor", "mcp.json");
1495
2639
  return writeJsonAndReport(filePath, "Cursor", mcpEntry(opts.remote ?? false));
1496
2640
  }
1497
2641
  function mcpInstallClineCommand(opts = {}) {
@@ -1522,8 +2666,8 @@ function mcpInstallContinueCommand(opts = {}) {
1522
2666
  const scope = opts.scope ?? "user";
1523
2667
  if (!validateScope(scope)) return EXIT.USAGE;
1524
2668
  const remote = opts.remote ?? false;
1525
- const home = opts._homeDir ?? homedir();
1526
- const yamlPath = join(home, ".continue", "mcpServers", `${SERVER_NAME}.yaml`);
2669
+ const home = opts._homeDir ?? homedir2();
2670
+ const yamlPath = join2(home, ".continue", "mcpServers", `${SERVER_NAME}.yaml`);
1527
2671
  process.stdout.write(
1528
2672
  [
1529
2673
  "Continue uses YAML config. The recommended way to add an MCP server is a",
@@ -1554,8 +2698,8 @@ function mcpInstallContinueCommand(opts = {}) {
1554
2698
  function mcpInstallWindsurfCommand(opts = {}) {
1555
2699
  const scope = opts.scope ?? "user";
1556
2700
  if (!validateScope(scope)) return EXIT.USAGE;
1557
- const home = opts._homeDir ?? homedir();
1558
- const filePath = join(home, ".codeium", "windsurf", "mcp_config.json");
2701
+ const home = opts._homeDir ?? homedir2();
2702
+ const filePath = join2(home, ".codeium", "windsurf", "mcp_config.json");
1559
2703
  return writeJsonAndReport(filePath, "Windsurf", mcpEntry(opts.remote ?? false));
1560
2704
  }
1561
2705
  function mcpInstallGeminiCliCommand(opts = {}) {
@@ -1563,7 +2707,7 @@ function mcpInstallGeminiCliCommand(opts = {}) {
1563
2707
  if (!validateScope(scope)) return EXIT.USAGE;
1564
2708
  const remote = opts.remote ?? false;
1565
2709
  const spawnSync = opts._spawnSync ?? nodeSpawnSync;
1566
- const home = opts._homeDir ?? homedir();
2710
+ const home = opts._homeDir ?? homedir2();
1567
2711
  const probe = spawnSync("gemini", ["--version"], { stdio: "pipe" });
1568
2712
  if (probe.error || probe.status !== 0) {
1569
2713
  return writeJsonAndReport(geminiConfigPath(home, scope), "Gemini CLI", mcpEntry(remote));
@@ -1584,13 +2728,13 @@ Restart Gemini CLI to make the tools available. No API key was written to settin
1584
2728
  return EXIT.OK;
1585
2729
  }
1586
2730
  function geminiConfigPath(home, scope) {
1587
- return scope === "project" ? join(process.cwd(), ".gemini", "settings.json") : join(home, ".gemini", "settings.json");
2731
+ return scope === "project" ? join2(process.cwd(), ".gemini", "settings.json") : join2(home, ".gemini", "settings.json");
1588
2732
  }
1589
2733
  function mcpInstallPiCommand(opts = {}) {
1590
2734
  const scope = opts.scope ?? "user";
1591
2735
  if (!validateScope(scope)) return EXIT.USAGE;
1592
- const home = opts._homeDir ?? homedir();
1593
- const filePath = scope === "user" ? join(home, ".pi", "agent", "mcp.json") : join(process.cwd(), ".pi", "mcp.json");
2736
+ const home = opts._homeDir ?? homedir2();
2737
+ const filePath = scope === "user" ? join2(home, ".pi", "agent", "mcp.json") : join2(process.cwd(), ".pi", "mcp.json");
1594
2738
  return writeJsonAndReport(filePath, "Pi", mcpEntry(opts.remote ?? false), {
1595
2739
  prereq: "pi has no native MCP support \u2014 install the third-party adapter once:\n pi install npm:pi-mcp-adapter"
1596
2740
  });
@@ -1598,8 +2742,8 @@ function mcpInstallPiCommand(opts = {}) {
1598
2742
  function mcpInstallOhMyPiCommand(opts = {}) {
1599
2743
  const scope = opts.scope ?? "user";
1600
2744
  if (!validateScope(scope)) return EXIT.USAGE;
1601
- const home = opts._homeDir ?? homedir();
1602
- const filePath = scope === "user" ? join(home, ".omp", "agent", "mcp.json") : join(process.cwd(), ".omp", "mcp.json");
2745
+ const home = opts._homeDir ?? homedir2();
2746
+ const filePath = scope === "user" ? join2(home, ".omp", "agent", "mcp.json") : join2(process.cwd(), ".omp", "mcp.json");
1603
2747
  return writeJsonAndReport(filePath, "oh-my-pi", mcpEntry(opts.remote ?? false));
1604
2748
  }
1605
2749
  function mcpInstallOpenClawCommand(opts = {}) {
@@ -1711,17 +2855,17 @@ function installMemoryRules(opts) {
1711
2855
  );
1712
2856
  return;
1713
2857
  }
1714
- const home = opts._homeDir ?? homedir();
2858
+ const home = opts._homeDir ?? homedir2();
1715
2859
  const result = writeMemoryRulesFile({
1716
- filePath: join(home, ...segs),
2860
+ filePath: join2(home, ...segs),
1717
2861
  section: PERSISTENT_MEMORY_SECTION
1718
2862
  });
1719
2863
  process.stdout.write(result.message);
1720
2864
  }
1721
2865
  function reconcileMemoryRules(opts = {}) {
1722
- const home = opts._homeDir ?? homedir();
2866
+ const home = opts._homeDir ?? homedir2();
1723
2867
  for (const segs of Object.values(RULES_FILE_TARGETS)) {
1724
- const filePath = join(home, ...segs);
2868
+ const filePath = join2(home, ...segs);
1725
2869
  try {
1726
2870
  if (!existsSync3(filePath)) continue;
1727
2871
  if (!hasPyxMemoryRules(readFileSync3(filePath, "utf8"))) continue;
@@ -1869,7 +3013,7 @@ function boundedLabel(value, fallback, maxChars) {
1869
3013
 
1870
3014
  // src/cli/commands/scaffold.ts
1871
3015
  import { existsSync as existsSync4, mkdirSync as mkdirSync3, statSync as statSync2, writeFileSync as writeFileSync3 } from "fs";
1872
- import { basename as basename2, join as join2, resolve as resolve2 } from "path";
3016
+ import { basename as basename2, join as join3, resolve as resolve2 } from "path";
1873
3017
  var SERVER_IMAGE = "ghcr.io/pyx-corp/pyx-memory-v1:latest";
1874
3018
  var DOCKER_COMPOSE = `services:
1875
3019
  pyx-memory:
@@ -1998,11 +3142,11 @@ function resolveTarget(args) {
1998
3142
  }
1999
3143
  function buildFiles(targetDir, appName) {
2000
3144
  return [
2001
- { path: join2(targetDir, "docker-compose.yml"), contents: DOCKER_COMPOSE },
2002
- { path: join2(targetDir, ".env.example"), contents: ENV_EXAMPLE },
2003
- { path: join2(targetDir, "memory.ts"), contents: MEMORY_TS },
3145
+ { path: join3(targetDir, "docker-compose.yml"), contents: DOCKER_COMPOSE },
3146
+ { path: join3(targetDir, ".env.example"), contents: ENV_EXAMPLE },
3147
+ { path: join3(targetDir, "memory.ts"), contents: MEMORY_TS },
2004
3148
  {
2005
- path: join2(targetDir, "PYX_MEMORY_DESIGN_GUIDE.md"),
3149
+ path: join3(targetDir, "PYX_MEMORY_DESIGN_GUIDE.md"),
2006
3150
  contents: buildDesignGuide({ appName })
2007
3151
  }
2008
3152
  ];
@@ -2045,9 +3189,19 @@ function scaffoldCommand(args = {}) {
2045
3189
  }
2046
3190
 
2047
3191
  // src/cli/commands/status.ts
2048
- import { createHash as createHash2 } from "crypto";
3192
+ async function readLifecycle(provider) {
3193
+ let serverConvergenceSeconds = null;
3194
+ try {
3195
+ const journal = await loadJournal(provider);
3196
+ serverConvergenceSeconds = convergenceSeconds(journal);
3197
+ if (isEmpty(journal)) return { cleanupPending: false, serverConvergenceSeconds };
3198
+ } catch (err) {
3199
+ if (!(err instanceof JournalInvalidError)) throw err;
3200
+ }
3201
+ return { cleanupPending: !await provider.isLocked(), serverConvergenceSeconds };
3202
+ }
2049
3203
  async function statusCommand(opts = {}) {
2050
- const provider = opts.keychain ?? getDefaultKeychain();
3204
+ const provider = opts.keychain ? guardKeychain(opts.keychain) : getDefaultKeychain();
2051
3205
  let report4;
2052
3206
  try {
2053
3207
  const creds = await provider.read();
@@ -2056,8 +3210,10 @@ async function statusCommand(opts = {}) {
2056
3210
  keyPresent: creds !== null,
2057
3211
  keySource: creds !== null ? "keychain" : null,
2058
3212
  loggedIn: creds !== null,
2059
- credentialFingerprint: creds === null ? null : createHash2("sha256").update(`${creds.endpoint}
2060
- ${creds.apiKey}`).digest("hex").slice(0, 16)
3213
+ credentialFingerprint: creds === null ? null : credentialFingerprint(creds.endpoint, creds.apiKey),
3214
+ lifecycleVersion: 1,
3215
+ connection: creds?.provenance === "browser-login" ? creds.connection ?? null : null,
3216
+ ...await readLifecycle(provider)
2061
3217
  };
2062
3218
  } catch (err) {
2063
3219
  if (err instanceof KeychainError) return emitError(err, opts.json);
@@ -2135,23 +3291,35 @@ async function taxonomyCommand(opts = {}) {
2135
3291
  keychain: opts.keychain,
2136
3292
  fetchImpl: opts.fetchImpl
2137
3293
  });
3294
+ const withIdentity = opts.withIdentity === true;
2138
3295
  if (!result.ok) {
2139
- emit3(report3(result.state, result.endpoint, result.keySource, result.problem));
3296
+ emit3(
3297
+ report3(result.state, result.endpoint, result.keySource, result.problem, {
3298
+ withIdentity,
3299
+ credentialFingerprint: result.credentialFingerprint
3300
+ })
3301
+ );
2140
3302
  return result.exit;
2141
3303
  }
2142
3304
  emit3({
2143
- ...report3("ready", result.endpoint, "keychain", null),
3305
+ ...report3("ready", result.endpoint, "keychain", null, {
3306
+ withIdentity,
3307
+ credentialFingerprint: result.credentialFingerprint
3308
+ }),
2144
3309
  clusters: result.data.clusters,
2145
3310
  totals: result.data.totals
2146
3311
  });
2147
3312
  return EXIT.OK;
2148
3313
  }
2149
- function report3(state, endpoint, keySource, problem) {
3314
+ function report3(state, endpoint, keySource, problem, identity) {
2150
3315
  return {
2151
3316
  schemaVersion: TAXONOMY_SCHEMA_VERSION,
2152
3317
  state,
2153
3318
  endpoint,
2154
3319
  keySource,
3320
+ // Spread, not `undefined`: JSON.stringify would drop an undefined value,
3321
+ // but the key must be absent from the object too so exact-shape checks hold.
3322
+ ...identity.withIdentity ? { credentialFingerprint: identity.credentialFingerprint } : {},
2155
3323
  clusters: [],
2156
3324
  totals: { clusters: 0, unnamed: 0, nodesConsidered: 0 },
2157
3325
  problem
@@ -2170,11 +3338,20 @@ Usage:
2170
3338
 
2171
3339
  Commands:
2172
3340
  login [--endpoint <url>] [--api-key <key>] Store endpoint and API key in OS credential store.
3341
+ login --browser [--json] [--no-open] Sign in with your browser; the approved project key is
3342
+ stored in the same place. --json emits JSONL events
3343
+ for host apps; --no-open leaves opening the URL to them.
2173
3344
  status [--json] Show endpoint, key presence, MCP config status.
2174
3345
  overview --json Read memory count and three recent safe labels.
2175
- insights --json Read body-free memory usage and coverage aggregates.
2176
- taxonomy --json Read knowledge-graph cluster composition (read-only).
2177
- logout Delete stored pyx-memory credentials.
3346
+ insights --json [--with-identity] Read body-free memory usage and coverage aggregates.
3347
+ taxonomy --json [--with-identity] Read knowledge-graph cluster composition (read-only).
3348
+ --with-identity adds credentialFingerprint (the same
3349
+ value as status --json) so a host can tell which
3350
+ account a read was served for. Off by default.
3351
+ logout [--json] Delete stored pyx-memory credentials. A browser sign-in
3352
+ key is also revoked on the server; a pasted key is not.
3353
+ login-recovery [--json] Finish pending sign-in/sign-out cleanup. Never signs in.
3354
+ [--discard-unreadable-journal] Only if told to: replace an unreadable cleanup record; signs this computer out.
2178
3355
  doctor [--json] Diagnose keychain, credentials, backend, MCP startup.
2179
3356
  scaffold [--name <dir>] Generate Docker, env, SDK, and memory design-guide starter files.
2180
3357
  mcp Start the stdio MCP proxy to the hosted server, so
@@ -2302,6 +3479,30 @@ function runMcpCommand(parsed) {
2302
3479
  );
2303
3480
  return EXIT.USAGE;
2304
3481
  }
3482
+ var LOGIN_FLAGS = /* @__PURE__ */ new Set(["endpoint", "api-key", "browser", "json", "no-open"]);
3483
+ function runLogin(parsed) {
3484
+ const { flags } = parsed;
3485
+ const browser = flags.browser === true;
3486
+ const invalid = Object.keys(flags).some((flag) => !LOGIN_FLAGS.has(flag)) || parsed.positional.length > 0 || flags.browser !== void 0 && !browser || (flags.json !== void 0 || flags["no-open"] !== void 0) && !browser || browser && flags["api-key"] !== void 0 || flags.json !== void 0 && flags.json !== true || flags["no-open"] !== void 0 && flags["no-open"] !== true;
3487
+ if (invalid) {
3488
+ process.stderr.write(
3489
+ "Error: usage: pyx-mem login [--endpoint <url>] [--api-key <key>] | pyx-mem login --browser [--json] [--no-open] [--endpoint <url>]\n"
3490
+ );
3491
+ return EXIT.USAGE;
3492
+ }
3493
+ const endpoint = typeof flags.endpoint === "string" ? flags.endpoint : void 0;
3494
+ if (browser) {
3495
+ return browserLoginCommand({
3496
+ endpoint,
3497
+ json: flags.json === true,
3498
+ noOpen: flags["no-open"] === true
3499
+ });
3500
+ }
3501
+ return loginCommand({
3502
+ endpoint,
3503
+ apiKey: typeof flags["api-key"] === "string" ? flags["api-key"] : void 0
3504
+ });
3505
+ }
2305
3506
  async function main() {
2306
3507
  const parsed = parseArgs(process.argv.slice(2));
2307
3508
  if (parsed.flags.help === true || parsed.flags.h === true) {
@@ -2314,10 +3515,18 @@ async function main() {
2314
3515
  process.stdout.write(HELP_TEXT);
2315
3516
  return EXIT.OK;
2316
3517
  case "login":
2317
- return loginCommand({
2318
- endpoint: typeof parsed.flags.endpoint === "string" ? parsed.flags.endpoint : void 0,
2319
- apiKey: typeof parsed.flags["api-key"] === "string" ? parsed.flags["api-key"] : void 0
3518
+ return runLogin(parsed);
3519
+ case "login-recovery":
3520
+ return loginRecoveryCommand({
3521
+ json: parsed.flags.json === true,
3522
+ discardUnreadableJournal: parsed.flags["discard-unreadable-journal"] === true
2320
3523
  });
3524
+ case "lifecycle-capabilities":
3525
+ process.stdout.write(
3526
+ `${JSON.stringify({ schemaVersion: 1, browserLogin: true, sharedCredentialGuard: true })}
3527
+ `
3528
+ );
3529
+ return EXIT.OK;
2321
3530
  case "status":
2322
3531
  return statusCommand({ json: parsed.flags.json === true });
2323
3532
  case "overview":
@@ -2331,15 +3540,15 @@ async function main() {
2331
3540
  process.stderr.write("Error: `pyx-mem insights` requires --json.\n");
2332
3541
  return EXIT.USAGE;
2333
3542
  }
2334
- return insightsCommand();
3543
+ return insightsCommand({ withIdentity: parsed.flags["with-identity"] === true });
2335
3544
  case "taxonomy":
2336
3545
  if (parsed.flags.json !== true) {
2337
3546
  process.stderr.write("Error: `pyx-mem taxonomy` requires --json.\n");
2338
3547
  return EXIT.USAGE;
2339
3548
  }
2340
- return taxonomyCommand();
3549
+ return taxonomyCommand({ withIdentity: parsed.flags["with-identity"] === true });
2341
3550
  case "logout":
2342
- return logoutCommand();
3551
+ return logoutCommand({ json: parsed.flags.json === true });
2343
3552
  case "doctor":
2344
3553
  return doctorCommand({ json: parsed.flags.json === true });
2345
3554
  case "scaffold":