@pyxmate/memory 1.21.0 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  PERSISTENT_MEMORY_SECTION,
4
4
  buildDesignGuide
5
- } from "../chunk-EZERG25I.mjs";
5
+ } from "../chunk-QD7G23CN.mjs";
6
6
  import {
7
7
  MEMORY_PROJECT_LABEL_MAX_CHARS,
8
8
  TAXONOMY_MAX_CATEGORIES,
@@ -10,7 +10,7 @@ import {
10
10
  TAXONOMY_MAX_SAMPLE_TOPICS,
11
11
  TAXONOMY_MAX_TOP_ENTITIES
12
12
  } from "../chunk-KZKGRND2.mjs";
13
- import "../chunk-BUKDWHTY.mjs";
13
+ import "../chunk-C46EE5BH.mjs";
14
14
 
15
15
  // src/cli/exit-codes.ts
16
16
  var EXIT = {
@@ -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.23.0" : "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,12 +2248,18 @@ 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();
1115
2259
  });
2260
+ process.stdin.once("end", () => void transport.close());
1116
2261
  await server.connect(transport);
2262
+ void opts.onConnected?.(client);
1117
2263
  await closed;
1118
2264
  } finally {
1119
2265
  await client.close().catch(() => {
@@ -1124,12 +2270,12 @@ async function runMcpProxyServer(opts) {
1124
2270
  // src/cli/commands/mcp-install.ts
1125
2271
  import { spawnSync as nodeSpawnSync } from "child_process";
1126
2272
  import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
1127
- import { homedir } from "os";
1128
- import { join } from "path";
2273
+ import { homedir as homedir2 } from "os";
2274
+ import { join as join2 } from "path";
1129
2275
 
1130
2276
  // src/cli/commands/mcp-install-merge.ts
1131
2277
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
1132
- import { dirname } from "path";
2278
+ import { dirname as dirname2 } from "path";
1133
2279
  function mergeWriteJsonMcpEntry(opts) {
1134
2280
  const { filePath, serverName, entry } = opts;
1135
2281
  let existingDoc = {};
@@ -1191,7 +2337,7 @@ Remove or rename the existing entry, then re-run.
1191
2337
  const nextDoc = { ...existingDoc, mcpServers: servers };
1192
2338
  const payload = `${JSON.stringify(nextDoc, null, 2)}
1193
2339
  `;
1194
- const parent = dirname(filePath);
2340
+ const parent = dirname2(filePath);
1195
2341
  if (!existsSync(parent)) {
1196
2342
  mkdirSync(parent, { recursive: true });
1197
2343
  }
@@ -1244,7 +2390,7 @@ function indent(text) {
1244
2390
 
1245
2391
  // src/cli/commands/memory-rules-merge.ts
1246
2392
  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";
2393
+ import { dirname as dirname3 } from "path";
1248
2394
  var RULES_BEGIN_MARKER = "<!-- pyx-memory:begin (managed by `pyx-mem mcp install`; edit outside these markers) -->";
1249
2395
  var RULES_END_MARKER = "<!-- pyx-memory:end -->";
1250
2396
  var BEGIN_MARKER_PREFIX = "<!-- pyx-memory:begin";
@@ -1375,7 +2521,7 @@ function writeMemoryRulesFile(opts) {
1375
2521
  `
1376
2522
  };
1377
2523
  }
1378
- const parent = dirname2(filePath);
2524
+ const parent = dirname3(filePath);
1379
2525
  if (!existsSync2(parent)) mkdirSync2(parent, { recursive: true });
1380
2526
  const mode = fileExists ? statSync(filePath).mode & 511 : 420;
1381
2527
  const tmpPath = `${filePath}.tmp`;
@@ -1446,18 +2592,27 @@ function mcpInstallCodexCommand(opts = {}) {
1446
2592
  if (!validateScope(scope)) return EXIT.USAGE;
1447
2593
  const remote = opts.remote ?? false;
1448
2594
  const spawnSync = opts._spawnSync ?? nodeSpawnSync;
1449
- const home = opts._homeDir ?? homedir();
2595
+ const home = opts._homeDir ?? homedir2();
2596
+ const codexHome = (opts._env ?? process.env).CODEX_HOME;
1450
2597
  const probe = spawnSync("codex", ["--version"], { stdio: "pipe" });
1451
2598
  if (probe.error || probe.status !== 0) {
1452
- printCodexManualInstructions(home, remote);
2599
+ printCodexManualInstructions(home, remote, codexHome);
1453
2600
  return EXIT.OK;
1454
2601
  }
1455
- const cmd = ["mcp", "add", SERVER_NAME, "--", "pyx-mem", ...pyxMemArgs(remote)];
2602
+ const cmd = [
2603
+ "mcp",
2604
+ "add",
2605
+ SERVER_NAME,
2606
+ ...codexHome ? ["--env", `CODEX_HOME=${codexHome}`] : [],
2607
+ "--",
2608
+ "pyx-mem",
2609
+ ...pyxMemArgs(remote)
2610
+ ];
1456
2611
  const res = spawnSync("codex", cmd, { stdio: "inherit" });
1457
2612
  if (res.status !== 0) {
1458
2613
  process.stderr.write(`\`codex ${cmd.join(" ")}\` exited with code ${res.status ?? "null"}.
1459
2614
  `);
1460
- printCodexManualInstructions(home, remote);
2615
+ printCodexManualInstructions(home, remote, codexHome);
1461
2616
  return EXIT.USAGE;
1462
2617
  }
1463
2618
  process.stdout.write(
@@ -1467,20 +2622,21 @@ Restart Codex CLI to make the tools available. No API key was written to config.
1467
2622
  );
1468
2623
  return EXIT.OK;
1469
2624
  }
1470
- function printCodexManualInstructions(home, remote) {
1471
- const configPath = join(home, ".codex", "config.toml");
2625
+ function printCodexManualInstructions(home, remote, codexHome) {
2626
+ const configPath = join2(codexHome ?? join2(home, ".codex"), "config.toml");
1472
2627
  process.stdout.write(
1473
2628
  [
1474
2629
  "The `codex` CLI is not on PATH, or the install command failed.",
1475
2630
  "",
1476
2631
  "Run this once Codex CLI is installed:",
1477
- ` codex mcp add ${SERVER_NAME} -- pyx-mem ${pyxMemArgs(remote).join(" ")}`,
2632
+ ` codex mcp add ${SERVER_NAME}${codexHome ? ` --env ${JSON.stringify(`CODEX_HOME=${codexHome}`)}` : ""} -- pyx-mem ${pyxMemArgs(remote).join(" ")}`,
1478
2633
  "",
1479
2634
  `Or add this section manually to ${configPath}:`,
1480
2635
  "",
1481
2636
  `[mcp_servers.${SERVER_NAME}]`,
1482
2637
  `command = "pyx-mem"`,
1483
2638
  `args = ${JSON.stringify(pyxMemArgs(remote))}`,
2639
+ ...codexHome ? [`env = { CODEX_HOME = ${JSON.stringify(codexHome)} }`] : [],
1484
2640
  "",
1485
2641
  "Do NOT add your pyx-memory API key here. Run `pyx-mem login` to store it in the OS credential store.",
1486
2642
  ""
@@ -1490,8 +2646,8 @@ function printCodexManualInstructions(home, remote) {
1490
2646
  function mcpInstallCursorCommand(opts = {}) {
1491
2647
  const scope = opts.scope ?? "user";
1492
2648
  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");
2649
+ const home = opts._homeDir ?? homedir2();
2650
+ const filePath = scope === "project" ? join2(process.cwd(), ".cursor", "mcp.json") : join2(home, ".cursor", "mcp.json");
1495
2651
  return writeJsonAndReport(filePath, "Cursor", mcpEntry(opts.remote ?? false));
1496
2652
  }
1497
2653
  function mcpInstallClineCommand(opts = {}) {
@@ -1522,8 +2678,8 @@ function mcpInstallContinueCommand(opts = {}) {
1522
2678
  const scope = opts.scope ?? "user";
1523
2679
  if (!validateScope(scope)) return EXIT.USAGE;
1524
2680
  const remote = opts.remote ?? false;
1525
- const home = opts._homeDir ?? homedir();
1526
- const yamlPath = join(home, ".continue", "mcpServers", `${SERVER_NAME}.yaml`);
2681
+ const home = opts._homeDir ?? homedir2();
2682
+ const yamlPath = join2(home, ".continue", "mcpServers", `${SERVER_NAME}.yaml`);
1527
2683
  process.stdout.write(
1528
2684
  [
1529
2685
  "Continue uses YAML config. The recommended way to add an MCP server is a",
@@ -1554,8 +2710,8 @@ function mcpInstallContinueCommand(opts = {}) {
1554
2710
  function mcpInstallWindsurfCommand(opts = {}) {
1555
2711
  const scope = opts.scope ?? "user";
1556
2712
  if (!validateScope(scope)) return EXIT.USAGE;
1557
- const home = opts._homeDir ?? homedir();
1558
- const filePath = join(home, ".codeium", "windsurf", "mcp_config.json");
2713
+ const home = opts._homeDir ?? homedir2();
2714
+ const filePath = join2(home, ".codeium", "windsurf", "mcp_config.json");
1559
2715
  return writeJsonAndReport(filePath, "Windsurf", mcpEntry(opts.remote ?? false));
1560
2716
  }
1561
2717
  function mcpInstallGeminiCliCommand(opts = {}) {
@@ -1563,7 +2719,7 @@ function mcpInstallGeminiCliCommand(opts = {}) {
1563
2719
  if (!validateScope(scope)) return EXIT.USAGE;
1564
2720
  const remote = opts.remote ?? false;
1565
2721
  const spawnSync = opts._spawnSync ?? nodeSpawnSync;
1566
- const home = opts._homeDir ?? homedir();
2722
+ const home = opts._homeDir ?? homedir2();
1567
2723
  const probe = spawnSync("gemini", ["--version"], { stdio: "pipe" });
1568
2724
  if (probe.error || probe.status !== 0) {
1569
2725
  return writeJsonAndReport(geminiConfigPath(home, scope), "Gemini CLI", mcpEntry(remote));
@@ -1584,13 +2740,13 @@ Restart Gemini CLI to make the tools available. No API key was written to settin
1584
2740
  return EXIT.OK;
1585
2741
  }
1586
2742
  function geminiConfigPath(home, scope) {
1587
- return scope === "project" ? join(process.cwd(), ".gemini", "settings.json") : join(home, ".gemini", "settings.json");
2743
+ return scope === "project" ? join2(process.cwd(), ".gemini", "settings.json") : join2(home, ".gemini", "settings.json");
1588
2744
  }
1589
2745
  function mcpInstallPiCommand(opts = {}) {
1590
2746
  const scope = opts.scope ?? "user";
1591
2747
  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");
2748
+ const home = opts._homeDir ?? homedir2();
2749
+ const filePath = scope === "user" ? join2(home, ".pi", "agent", "mcp.json") : join2(process.cwd(), ".pi", "mcp.json");
1594
2750
  return writeJsonAndReport(filePath, "Pi", mcpEntry(opts.remote ?? false), {
1595
2751
  prereq: "pi has no native MCP support \u2014 install the third-party adapter once:\n pi install npm:pi-mcp-adapter"
1596
2752
  });
@@ -1598,8 +2754,8 @@ function mcpInstallPiCommand(opts = {}) {
1598
2754
  function mcpInstallOhMyPiCommand(opts = {}) {
1599
2755
  const scope = opts.scope ?? "user";
1600
2756
  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");
2757
+ const home = opts._homeDir ?? homedir2();
2758
+ const filePath = scope === "user" ? join2(home, ".omp", "agent", "mcp.json") : join2(process.cwd(), ".omp", "mcp.json");
1603
2759
  return writeJsonAndReport(filePath, "oh-my-pi", mcpEntry(opts.remote ?? false));
1604
2760
  }
1605
2761
  function mcpInstallOpenClawCommand(opts = {}) {
@@ -1697,35 +2853,49 @@ function printHermesManualInstructions(remote) {
1697
2853
  );
1698
2854
  }
1699
2855
  var RULES_FILE_TARGETS = {
1700
- "claude-code": [".claude", "CLAUDE.md"],
1701
- codex: [".codex", "AGENTS.md"],
1702
- "gemini-cli": [".gemini", "GEMINI.md"],
1703
- "oh-my-pi": [".omp", "agent", "RULES.md"]
2856
+ "claude-code": { envDir: "CLAUDE_CONFIG_DIR", dir: [".claude"], file: "CLAUDE.md" },
2857
+ codex: { envDir: "CODEX_HOME", dir: [".codex"], file: "AGENTS.md" },
2858
+ "gemini-cli": { dir: [".gemini"], file: "GEMINI.md" },
2859
+ "oh-my-pi": { dir: [".omp", "agent"], file: "RULES.md" }
1704
2860
  };
2861
+ function rulesFilePath(target, opts) {
2862
+ if (!Object.hasOwn(RULES_FILE_TARGETS, target)) return void 0;
2863
+ const spec = RULES_FILE_TARGETS[target];
2864
+ const env = opts._env ?? (opts._homeDir === void 0 ? process.env : {});
2865
+ const configDir = spec.envDir ? env[spec.envDir] : void 0;
2866
+ return configDir ? join2(configDir, spec.file) : join2(opts._homeDir ?? homedir2(), ...spec.dir, spec.file);
2867
+ }
1705
2868
  function installMemoryRules(opts) {
1706
- const segs = RULES_FILE_TARGETS[opts.target];
1707
- if (!segs) return;
2869
+ const filePath = rulesFilePath(opts.target, opts);
2870
+ if (!filePath) return;
1708
2871
  if (opts.noRules) {
1709
2872
  process.stdout.write(
1710
2873
  'Skipped writing the memory rules (--no-rules). The tools are installed, but the agent will reach for memory only when you ask; add the "## Persistent Memory" rules yourself to change that.\n'
1711
2874
  );
1712
2875
  return;
1713
2876
  }
1714
- const home = opts._homeDir ?? homedir();
1715
- const result = writeMemoryRulesFile({
1716
- filePath: join(home, ...segs),
1717
- section: PERSISTENT_MEMORY_SECTION
1718
- });
2877
+ const result = writeMemoryRulesFile({ filePath, section: PERSISTENT_MEMORY_SECTION });
1719
2878
  process.stdout.write(result.message);
1720
2879
  }
2880
+ function installMemoryRulesOnly(target, opts = {}) {
2881
+ if (target === void 0 || !rulesFilePath(target, opts)) {
2882
+ process.stderr.write(
2883
+ `Error: \`pyx-mem mcp rules\` needs a host with a known rules file: ${Object.keys(RULES_FILE_TARGETS).join(", ")}.
2884
+ `
2885
+ );
2886
+ return EXIT.USAGE;
2887
+ }
2888
+ installMemoryRules({ ...opts, target, noRules: false });
2889
+ return EXIT.OK;
2890
+ }
1721
2891
  function reconcileMemoryRules(opts = {}) {
1722
- const home = opts._homeDir ?? homedir();
1723
- for (const segs of Object.values(RULES_FILE_TARGETS)) {
1724
- const filePath = join(home, ...segs);
2892
+ const section = opts.section ?? PERSISTENT_MEMORY_SECTION;
2893
+ for (const target of Object.keys(RULES_FILE_TARGETS)) {
2894
+ const filePath = rulesFilePath(target, opts);
1725
2895
  try {
1726
2896
  if (!existsSync3(filePath)) continue;
1727
2897
  if (!hasPyxMemoryRules(readFileSync3(filePath, "utf8"))) continue;
1728
- const result = writeMemoryRulesFile({ filePath, section: PERSISTENT_MEMORY_SECTION });
2898
+ const result = writeMemoryRulesFile({ filePath, section });
1729
2899
  if (result.action !== "unchanged") {
1730
2900
  process.stderr.write(
1731
2901
  `pyx-memory: refreshed the "## Persistent Memory" rules in ${filePath}
@@ -1736,6 +2906,31 @@ function reconcileMemoryRules(opts = {}) {
1736
2906
  }
1737
2907
  }
1738
2908
  }
2909
+ var MAX_SERVER_RULES_BYTES = 16 * 1024;
2910
+ async function syncMemoryRulesFromServer(client, opts = {}) {
2911
+ try {
2912
+ const { tools } = await client.listTools();
2913
+ if (!tools.some((tool) => tool.name === "get_memory_rules")) {
2914
+ reconcileMemoryRules(opts);
2915
+ return;
2916
+ }
2917
+ const result = await client.callTool({ name: "get_memory_rules", arguments: {} });
2918
+ const [first] = result.content ?? [];
2919
+ const section = first?.type === "text" ? first.text : void 0;
2920
+ if (result.isError || !section || !isRulesSection(section)) {
2921
+ throw new Error('the server returned no valid "## Persistent Memory" section');
2922
+ }
2923
+ reconcileMemoryRules({ ...opts, section });
2924
+ } catch (err) {
2925
+ process.stderr.write(
2926
+ `pyx-memory: left the installed memory rules unchanged \u2014 could not fetch current rules (${err instanceof Error ? err.message : String(err)}).
2927
+ `
2928
+ );
2929
+ }
2930
+ }
2931
+ function isRulesSection(text) {
2932
+ return text.startsWith("## Persistent Memory\n") && Buffer.byteLength(text, "utf8") <= MAX_SERVER_RULES_BYTES && !text.includes("<!-- pyx-memory:") && !/^\s*(```|~~~)/m.test(text);
2933
+ }
1739
2934
  function validateScope(scope) {
1740
2935
  if (VALID_SCOPES.has(scope)) return true;
1741
2936
  process.stderr.write(`Error: invalid --scope \`${scope}\`. Expected: local | user | project.
@@ -1765,14 +2960,12 @@ function writeJsonAndReport(filePath, agentLabel, entry, opts = {}) {
1765
2960
 
1766
2961
  // src/cli/commands/mcp.ts
1767
2962
  async function mcpCommand() {
1768
- reconcileMemoryRules();
1769
2963
  const readCredentials = createReadCredentials(() => getDefaultKeychain());
1770
- const onStdinEnd = new Promise((resolve3) => {
1771
- process.stdin.once("end", resolve3);
1772
- process.stdin.once("close", resolve3);
1773
- });
1774
2964
  try {
1775
- await Promise.race([runMcpProxyServer({ readCredentials }), onStdinEnd]);
2965
+ await runMcpProxyServer({
2966
+ readCredentials,
2967
+ onConnected: (client) => syncMemoryRulesFromServer(client)
2968
+ });
1776
2969
  return EXIT.OK;
1777
2970
  } catch (err) {
1778
2971
  process.stderr.write(`Internal error: ${err instanceof Error ? err.message : String(err)}
@@ -1869,7 +3062,7 @@ function boundedLabel(value, fallback, maxChars) {
1869
3062
 
1870
3063
  // src/cli/commands/scaffold.ts
1871
3064
  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";
3065
+ import { basename as basename2, join as join3, resolve as resolve2 } from "path";
1873
3066
  var SERVER_IMAGE = "ghcr.io/pyx-corp/pyx-memory-v1:latest";
1874
3067
  var DOCKER_COMPOSE = `services:
1875
3068
  pyx-memory:
@@ -1998,11 +3191,11 @@ function resolveTarget(args) {
1998
3191
  }
1999
3192
  function buildFiles(targetDir, appName) {
2000
3193
  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 },
3194
+ { path: join3(targetDir, "docker-compose.yml"), contents: DOCKER_COMPOSE },
3195
+ { path: join3(targetDir, ".env.example"), contents: ENV_EXAMPLE },
3196
+ { path: join3(targetDir, "memory.ts"), contents: MEMORY_TS },
2004
3197
  {
2005
- path: join2(targetDir, "PYX_MEMORY_DESIGN_GUIDE.md"),
3198
+ path: join3(targetDir, "PYX_MEMORY_DESIGN_GUIDE.md"),
2006
3199
  contents: buildDesignGuide({ appName })
2007
3200
  }
2008
3201
  ];
@@ -2045,9 +3238,19 @@ function scaffoldCommand(args = {}) {
2045
3238
  }
2046
3239
 
2047
3240
  // src/cli/commands/status.ts
2048
- import { createHash as createHash2 } from "crypto";
3241
+ async function readLifecycle(provider) {
3242
+ let serverConvergenceSeconds = null;
3243
+ try {
3244
+ const journal = await loadJournal(provider);
3245
+ serverConvergenceSeconds = convergenceSeconds(journal);
3246
+ if (isEmpty(journal)) return { cleanupPending: false, serverConvergenceSeconds };
3247
+ } catch (err) {
3248
+ if (!(err instanceof JournalInvalidError)) throw err;
3249
+ }
3250
+ return { cleanupPending: !await provider.isLocked(), serverConvergenceSeconds };
3251
+ }
2049
3252
  async function statusCommand(opts = {}) {
2050
- const provider = opts.keychain ?? getDefaultKeychain();
3253
+ const provider = opts.keychain ? guardKeychain(opts.keychain) : getDefaultKeychain();
2051
3254
  let report4;
2052
3255
  try {
2053
3256
  const creds = await provider.read();
@@ -2056,8 +3259,10 @@ async function statusCommand(opts = {}) {
2056
3259
  keyPresent: creds !== null,
2057
3260
  keySource: creds !== null ? "keychain" : null,
2058
3261
  loggedIn: creds !== null,
2059
- credentialFingerprint: creds === null ? null : createHash2("sha256").update(`${creds.endpoint}
2060
- ${creds.apiKey}`).digest("hex").slice(0, 16)
3262
+ credentialFingerprint: creds === null ? null : credentialFingerprint(creds.endpoint, creds.apiKey),
3263
+ lifecycleVersion: 1,
3264
+ connection: creds?.provenance === "browser-login" ? creds.connection ?? null : null,
3265
+ ...await readLifecycle(provider)
2061
3266
  };
2062
3267
  } catch (err) {
2063
3268
  if (err instanceof KeychainError) return emitError(err, opts.json);
@@ -2135,23 +3340,35 @@ async function taxonomyCommand(opts = {}) {
2135
3340
  keychain: opts.keychain,
2136
3341
  fetchImpl: opts.fetchImpl
2137
3342
  });
3343
+ const withIdentity = opts.withIdentity === true;
2138
3344
  if (!result.ok) {
2139
- emit3(report3(result.state, result.endpoint, result.keySource, result.problem));
3345
+ emit3(
3346
+ report3(result.state, result.endpoint, result.keySource, result.problem, {
3347
+ withIdentity,
3348
+ credentialFingerprint: result.credentialFingerprint
3349
+ })
3350
+ );
2140
3351
  return result.exit;
2141
3352
  }
2142
3353
  emit3({
2143
- ...report3("ready", result.endpoint, "keychain", null),
3354
+ ...report3("ready", result.endpoint, "keychain", null, {
3355
+ withIdentity,
3356
+ credentialFingerprint: result.credentialFingerprint
3357
+ }),
2144
3358
  clusters: result.data.clusters,
2145
3359
  totals: result.data.totals
2146
3360
  });
2147
3361
  return EXIT.OK;
2148
3362
  }
2149
- function report3(state, endpoint, keySource, problem) {
3363
+ function report3(state, endpoint, keySource, problem, identity) {
2150
3364
  return {
2151
3365
  schemaVersion: TAXONOMY_SCHEMA_VERSION,
2152
3366
  state,
2153
3367
  endpoint,
2154
3368
  keySource,
3369
+ // Spread, not `undefined`: JSON.stringify would drop an undefined value,
3370
+ // but the key must be absent from the object too so exact-shape checks hold.
3371
+ ...identity.withIdentity ? { credentialFingerprint: identity.credentialFingerprint } : {},
2155
3372
  clusters: [],
2156
3373
  totals: { clusters: 0, unnamed: 0, nodesConsidered: 0 },
2157
3374
  problem
@@ -2170,11 +3387,20 @@ Usage:
2170
3387
 
2171
3388
  Commands:
2172
3389
  login [--endpoint <url>] [--api-key <key>] Store endpoint and API key in OS credential store.
3390
+ login --browser [--json] [--no-open] Sign in with your browser; the approved project key is
3391
+ stored in the same place. --json emits JSONL events
3392
+ for host apps; --no-open leaves opening the URL to them.
2173
3393
  status [--json] Show endpoint, key presence, MCP config status.
2174
3394
  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.
3395
+ insights --json [--with-identity] Read body-free memory usage and coverage aggregates.
3396
+ taxonomy --json [--with-identity] Read knowledge-graph cluster composition (read-only).
3397
+ --with-identity adds credentialFingerprint (the same
3398
+ value as status --json) so a host can tell which
3399
+ account a read was served for. Off by default.
3400
+ logout [--json] Delete stored pyx-memory credentials. A browser sign-in
3401
+ key is also revoked on the server; a pasted key is not.
3402
+ login-recovery [--json] Finish pending sign-in/sign-out cleanup. Never signs in.
3403
+ [--discard-unreadable-journal] Only if told to: replace an unreadable cleanup record; signs this computer out.
2178
3404
  doctor [--json] Diagnose keychain, credentials, backend, MCP startup.
2179
3405
  scaffold [--name <dir>] Generate Docker, env, SDK, and memory design-guide starter files.
2180
3406
  mcp Start the stdio MCP proxy to the hosted server, so
@@ -2193,6 +3419,10 @@ Commands:
2193
3419
  your other content is preserved) so memory fires by
2194
3420
  default. Pass --no-rules to skip that write.
2195
3421
  Targets: claude-code, codex, cursor, cline, continue, windsurf, gemini-cli, pi, oh-my-pi, openclaw, hermes.
3422
+ mcp rules <target> Write or refresh only the always-on rules for
3423
+ claude-code, codex, gemini-cli, or oh-my-pi (no MCP
3424
+ registration). Honors CLAUDE_CONFIG_DIR / CODEX_HOME;
3425
+ 'pyx-mem mcp' keeps them current from the server.
2196
3426
 
2197
3427
  Notes:
2198
3428
  - Credentials are stored only in the OS credential store (Keychain / libsecret / Credential Manager).
@@ -2296,12 +3526,43 @@ function runMcpCommand(parsed) {
2296
3526
  parsed.flags["no-rules"] === true
2297
3527
  );
2298
3528
  }
3529
+ if (parsed.subcommand === "rules") {
3530
+ if (Object.keys(parsed.flags).length > 0) {
3531
+ process.stderr.write("Error: `pyx-mem mcp rules <host>` takes no flags.\n");
3532
+ return EXIT.USAGE;
3533
+ }
3534
+ return installMemoryRulesOnly(parsed.positional[0]);
3535
+ }
2299
3536
  process.stderr.write(
2300
3537
  `Error: unknown subcommand \`mcp ${parsed.subcommand}\`. Run \`pyx-mem --help\`.
2301
3538
  `
2302
3539
  );
2303
3540
  return EXIT.USAGE;
2304
3541
  }
3542
+ var LOGIN_FLAGS = /* @__PURE__ */ new Set(["endpoint", "api-key", "browser", "json", "no-open"]);
3543
+ function runLogin(parsed) {
3544
+ const { flags } = parsed;
3545
+ const browser = flags.browser === true;
3546
+ 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;
3547
+ if (invalid) {
3548
+ process.stderr.write(
3549
+ "Error: usage: pyx-mem login [--endpoint <url>] [--api-key <key>] | pyx-mem login --browser [--json] [--no-open] [--endpoint <url>]\n"
3550
+ );
3551
+ return EXIT.USAGE;
3552
+ }
3553
+ const endpoint = typeof flags.endpoint === "string" ? flags.endpoint : void 0;
3554
+ if (browser) {
3555
+ return browserLoginCommand({
3556
+ endpoint,
3557
+ json: flags.json === true,
3558
+ noOpen: flags["no-open"] === true
3559
+ });
3560
+ }
3561
+ return loginCommand({
3562
+ endpoint,
3563
+ apiKey: typeof flags["api-key"] === "string" ? flags["api-key"] : void 0
3564
+ });
3565
+ }
2305
3566
  async function main() {
2306
3567
  const parsed = parseArgs(process.argv.slice(2));
2307
3568
  if (parsed.flags.help === true || parsed.flags.h === true) {
@@ -2314,10 +3575,18 @@ async function main() {
2314
3575
  process.stdout.write(HELP_TEXT);
2315
3576
  return EXIT.OK;
2316
3577
  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
3578
+ return runLogin(parsed);
3579
+ case "login-recovery":
3580
+ return loginRecoveryCommand({
3581
+ json: parsed.flags.json === true,
3582
+ discardUnreadableJournal: parsed.flags["discard-unreadable-journal"] === true
2320
3583
  });
3584
+ case "lifecycle-capabilities":
3585
+ process.stdout.write(
3586
+ `${JSON.stringify({ schemaVersion: 1, browserLogin: true, sharedCredentialGuard: true })}
3587
+ `
3588
+ );
3589
+ return EXIT.OK;
2321
3590
  case "status":
2322
3591
  return statusCommand({ json: parsed.flags.json === true });
2323
3592
  case "overview":
@@ -2331,15 +3600,15 @@ async function main() {
2331
3600
  process.stderr.write("Error: `pyx-mem insights` requires --json.\n");
2332
3601
  return EXIT.USAGE;
2333
3602
  }
2334
- return insightsCommand();
3603
+ return insightsCommand({ withIdentity: parsed.flags["with-identity"] === true });
2335
3604
  case "taxonomy":
2336
3605
  if (parsed.flags.json !== true) {
2337
3606
  process.stderr.write("Error: `pyx-mem taxonomy` requires --json.\n");
2338
3607
  return EXIT.USAGE;
2339
3608
  }
2340
- return taxonomyCommand();
3609
+ return taxonomyCommand({ withIdentity: parsed.flags["with-identity"] === true });
2341
3610
  case "logout":
2342
- return logoutCommand();
3611
+ return logoutCommand({ json: parsed.flags.json === true });
2343
3612
  case "doctor":
2344
3613
  return doctorCommand({ json: parsed.flags.json === true });
2345
3614
  case "scaffold":