@pyxmate/memory 1.20.2 → 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.
- package/dist/cli/pyx-mem.mjs +1298 -89
- package/package.json +3 -1
package/dist/cli/pyx-mem.mjs
CHANGED
|
@@ -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 =
|
|
100
|
-
async function loadEntry() {
|
|
101
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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 {
|
|
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(
|
|
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
|
|
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 ??
|
|
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
|
-
|
|
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
|
|
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
|
|
896
|
-
|
|
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
|
-
|
|
901
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
const
|
|
1018
|
-
|
|
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
|
-
|
|
1021
|
-
|
|
1022
|
-
})
|
|
1023
|
-
|
|
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.
|
|
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
|
-
|
|
1093
|
-
|
|
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(
|
|
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 =
|
|
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
|
|
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 =
|
|
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 ??
|
|
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 =
|
|
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 ??
|
|
1494
|
-
const filePath = scope === "project" ?
|
|
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 ??
|
|
1526
|
-
const yamlPath =
|
|
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 ??
|
|
1558
|
-
const filePath =
|
|
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 ??
|
|
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" ?
|
|
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 ??
|
|
1593
|
-
const filePath = scope === "user" ?
|
|
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 ??
|
|
1602
|
-
const filePath = scope === "user" ?
|
|
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 ??
|
|
2858
|
+
const home = opts._homeDir ?? homedir2();
|
|
1715
2859
|
const result = writeMemoryRulesFile({
|
|
1716
|
-
filePath:
|
|
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 ??
|
|
2866
|
+
const home = opts._homeDir ?? homedir2();
|
|
1723
2867
|
for (const segs of Object.values(RULES_FILE_TARGETS)) {
|
|
1724
|
-
const filePath =
|
|
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
|
|
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:
|
|
2002
|
-
{ path:
|
|
2003
|
-
{ path:
|
|
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:
|
|
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
|
-
|
|
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
|
|
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 :
|
|
2060
|
-
|
|
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(
|
|
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
|
|
2176
|
-
taxonomy --json
|
|
2177
|
-
|
|
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
|
|
2318
|
-
|
|
2319
|
-
|
|
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":
|