@pyxmate/memory 1.27.0 → 1.27.1
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/chunk-4DXEQC6X.mjs +16 -0
- package/dist/chunk-7IGU3HJO.mjs +70 -0
- package/dist/chunk-7TATCSSN.mjs +650 -0
- package/dist/chunk-QR5K7ETI.mjs +793 -0
- package/dist/chunk-S3CX323Z.mjs +34 -0
- package/dist/chunk-THLUBOGD.mjs +263 -0
- package/dist/chunk-UESIQ47D.mjs +165 -0
- package/dist/chunk-YBPODKB5.mjs +132 -0
- package/dist/cli/pyx-mem.mjs +63 -4226
- package/dist/doctor-AERNUHEZ.mjs +143 -0
- package/dist/insights-PD7Y6QDX.mjs +139 -0
- package/dist/login-4ALCAN4M.mjs +179 -0
- package/dist/login-browser-DO2O2Q2G.mjs +407 -0
- package/dist/login-recovery-AOO76PYX.mjs +97 -0
- package/dist/logout-4CO53LNA.mjs +13 -0
- package/dist/mcp-64KVRH7F.mjs +944 -0
- package/dist/mcp-install-KBB45OB2.mjs +36 -0
- package/dist/overview-COBXHLP3.mjs +88 -0
- package/dist/scaffold-2FSCWF22.mjs +186 -0
- package/dist/status-UHJZLEPL.mjs +74 -0
- package/dist/taxonomy-EKDFDFXW.mjs +103 -0
- package/package.json +1 -1
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EXIT
|
|
3
|
+
} from "./chunk-4DXEQC6X.mjs";
|
|
4
|
+
|
|
5
|
+
// src/cli/keychain/errors.ts
|
|
6
|
+
var PLATFORM_GUIDANCE = {
|
|
7
|
+
darwin: {
|
|
8
|
+
code: "macos-keychain-denied",
|
|
9
|
+
message: "macOS Keychain is locked or access was denied.",
|
|
10
|
+
guidance: "Unlock Keychain Access, approve the pyx-mem prompt, then run: pyx-mem login"
|
|
11
|
+
},
|
|
12
|
+
linuxMissing: {
|
|
13
|
+
code: "linux-secret-service-missing",
|
|
14
|
+
message: "Linux Secret Service is unavailable.",
|
|
15
|
+
guidance: "Install and start a Secret Service provider such as gnome-keyring or KWallet, then run: pyx-mem login"
|
|
16
|
+
},
|
|
17
|
+
linuxDaemon: {
|
|
18
|
+
code: "linux-secret-service-not-running",
|
|
19
|
+
message: "Linux Secret Service is installed but no keyring daemon is reachable.",
|
|
20
|
+
guidance: "Start a DBus session and unlock gnome-keyring or KWallet, then run: pyx-mem login"
|
|
21
|
+
},
|
|
22
|
+
win32: {
|
|
23
|
+
code: "windows-credential-manager-unavailable",
|
|
24
|
+
message: "Windows Credential Manager is unavailable for this user session.",
|
|
25
|
+
guidance: "Enable Credential Manager and run pyx-mem from a normal Windows user session, then run: pyx-mem login"
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
var PROVIDER_IMPORT = {
|
|
29
|
+
code: "provider-import-failed",
|
|
30
|
+
message: "pyx-mem credential provider failed to load (@napi-rs/keyring).",
|
|
31
|
+
guidance: "Reinstall @pyxmate/memory; optional native keyring binaries may be missing for this platform. Re-run with `npm install --include=optional` if a package manager omitted them."
|
|
32
|
+
};
|
|
33
|
+
var RECORD_INVALID = {
|
|
34
|
+
code: "credential-record-invalid",
|
|
35
|
+
message: "Stored pyx-memory credentials are unreadable (schema mismatch or JSON corruption).",
|
|
36
|
+
guidance: "Run: pyx-mem logout && pyx-mem login"
|
|
37
|
+
};
|
|
38
|
+
var KeychainError = class extends Error {
|
|
39
|
+
code;
|
|
40
|
+
guidance;
|
|
41
|
+
exit;
|
|
42
|
+
constructor(args) {
|
|
43
|
+
super(args.message);
|
|
44
|
+
this.name = "KeychainError";
|
|
45
|
+
this.code = args.code;
|
|
46
|
+
this.guidance = args.guidance;
|
|
47
|
+
this.exit = args.exit;
|
|
48
|
+
if (args.cause !== void 0) this.cause = args.cause;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
function platformAccessError(cause) {
|
|
52
|
+
const platform = process.platform;
|
|
53
|
+
const profile = platform === "darwin" ? PLATFORM_GUIDANCE.darwin : platform === "win32" ? PLATFORM_GUIDANCE.win32 : isHeadlessLinuxDaemon(cause) ? PLATFORM_GUIDANCE.linuxDaemon : PLATFORM_GUIDANCE.linuxMissing;
|
|
54
|
+
return new KeychainError({ ...profile, exit: EXIT.CRED_UNAVAILABLE, cause });
|
|
55
|
+
}
|
|
56
|
+
function providerImportError(cause) {
|
|
57
|
+
return new KeychainError({ ...PROVIDER_IMPORT, exit: EXIT.PROVIDER_BROKEN, cause });
|
|
58
|
+
}
|
|
59
|
+
function recordInvalidError(cause) {
|
|
60
|
+
return new KeychainError({ ...RECORD_INVALID, exit: EXIT.NOT_LOGGED_IN, cause });
|
|
61
|
+
}
|
|
62
|
+
function isHeadlessLinuxDaemon(cause) {
|
|
63
|
+
if (process.platform !== "linux") return false;
|
|
64
|
+
const msg = causeMessage(cause).toLowerCase();
|
|
65
|
+
return msg.includes("dbus") || msg.includes("session bus") || msg.includes("no such interface");
|
|
66
|
+
}
|
|
67
|
+
function causeMessage(cause) {
|
|
68
|
+
if (cause instanceof Error) return cause.message;
|
|
69
|
+
if (typeof cause === "string") return cause;
|
|
70
|
+
return "";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// src/cli/lifecycle/api.ts
|
|
74
|
+
var REQUEST_TIMEOUT_MS = 15e3;
|
|
75
|
+
var MAX_RESPONSE_BYTES = 64 * 1024;
|
|
76
|
+
var MAX_ENDPOINT_CHARS = 256;
|
|
77
|
+
var REQUEST_ID_RE = /^[A-Za-z0-9_-]{43}$/;
|
|
78
|
+
var PROJECT_KEY_RE = /^pyx_proj_[A-Za-z0-9_-]{16,160}$/;
|
|
79
|
+
var ERROR_MESSAGES = {
|
|
80
|
+
busy: { message: "Another pyx-mem sign-in or sign-out is in progress.", retryable: true },
|
|
81
|
+
cleanup_pending: {
|
|
82
|
+
message: "Cleanup of an earlier sign-in or sign-out is still pending. Run: pyx-mem login-recovery",
|
|
83
|
+
retryable: true
|
|
84
|
+
},
|
|
85
|
+
invalid_endpoint: {
|
|
86
|
+
message: "Endpoint must be an https URL (http only for loopback) without credentials, query or fragment.",
|
|
87
|
+
retryable: false
|
|
88
|
+
},
|
|
89
|
+
untrusted_authorization_url: {
|
|
90
|
+
message: "The server returned a browser sign-in address that is not trusted for this endpoint.",
|
|
91
|
+
retryable: false
|
|
92
|
+
},
|
|
93
|
+
browser_login_unsupported: {
|
|
94
|
+
message: "This endpoint does not support browser sign-in. Use: pyx-mem login --api-key",
|
|
95
|
+
retryable: false
|
|
96
|
+
},
|
|
97
|
+
network_error: { message: "Could not reach the pyx-memory server.", retryable: true },
|
|
98
|
+
rate_limited: { message: "Too many sign-in attempts. Wait a minute and retry.", retryable: true },
|
|
99
|
+
expired: { message: "The sign-in request expired before it was completed.", retryable: true },
|
|
100
|
+
project_not_eligible: {
|
|
101
|
+
message: "That project can no longer be connected from this account.",
|
|
102
|
+
retryable: true
|
|
103
|
+
},
|
|
104
|
+
credential_changed: {
|
|
105
|
+
message: "The stored pyx-memory credential was changed by another program. Nothing was overwritten.",
|
|
106
|
+
retryable: true
|
|
107
|
+
},
|
|
108
|
+
lock_lost: {
|
|
109
|
+
message: "The sign-in was interrupted. Run: pyx-mem login-recovery",
|
|
110
|
+
retryable: true
|
|
111
|
+
},
|
|
112
|
+
credential_store_unavailable: {
|
|
113
|
+
message: "The OS credential store is unavailable.",
|
|
114
|
+
retryable: false
|
|
115
|
+
},
|
|
116
|
+
journal_invalid: {
|
|
117
|
+
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).',
|
|
118
|
+
retryable: false
|
|
119
|
+
},
|
|
120
|
+
login_failed: { message: "Browser sign-in failed.", retryable: true }
|
|
121
|
+
};
|
|
122
|
+
var SERVER_CODES = {
|
|
123
|
+
rate_limited: "rate_limited",
|
|
124
|
+
cli_login_expired_or_unknown: "expired",
|
|
125
|
+
cli_login_project_not_eligible: "project_not_eligible",
|
|
126
|
+
cli_login_cancelled: "cancelled",
|
|
127
|
+
cli_login_denied: "cancelled"
|
|
128
|
+
};
|
|
129
|
+
var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "[::1]"]);
|
|
130
|
+
var BROWSER_ORIGINS = {
|
|
131
|
+
"https://memory.api.pyxmate.com": "https://memory.pyxmate.com"
|
|
132
|
+
};
|
|
133
|
+
function parseSafeUrl(input) {
|
|
134
|
+
let url;
|
|
135
|
+
try {
|
|
136
|
+
url = new URL(input);
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
const secure = url.protocol === "https:" || url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname);
|
|
141
|
+
if (!secure || url.username !== "" || url.password !== "" || url.hash !== "") return null;
|
|
142
|
+
return url;
|
|
143
|
+
}
|
|
144
|
+
function parseEndpoint(input) {
|
|
145
|
+
const url = parseSafeUrl(input);
|
|
146
|
+
if (url === null || url.search !== "" || input.includes("?") || input.includes("#")) return null;
|
|
147
|
+
const normalized = url.href.replace(/\/+$/, "");
|
|
148
|
+
return normalized.length <= MAX_ENDPOINT_CHARS ? normalized : null;
|
|
149
|
+
}
|
|
150
|
+
function boundLabel(value, maxChars) {
|
|
151
|
+
let label = value.replace(/\p{Cc}/gu, "").slice(0, maxChars);
|
|
152
|
+
while (JSON.stringify(label).length - 2 > maxChars) label = label.slice(0, -1);
|
|
153
|
+
return label;
|
|
154
|
+
}
|
|
155
|
+
function isTrustedAuthorizationUrl(authorizationUrl, endpoint, requestId) {
|
|
156
|
+
const url = parseSafeUrl(authorizationUrl);
|
|
157
|
+
if (url === null) return false;
|
|
158
|
+
const api = new URL(endpoint);
|
|
159
|
+
const trustedOrigin = LOOPBACK_HOSTS.has(api.hostname) ? LOOPBACK_HOSTS.has(url.hostname) : url.origin === (BROWSER_ORIGINS[api.origin] ?? api.origin);
|
|
160
|
+
const params = [...url.searchParams];
|
|
161
|
+
return trustedOrigin && url.pathname === "/cli/authorize" && params.length === 1 && params[0]?.[0] === "request" && params[0][1] === requestId;
|
|
162
|
+
}
|
|
163
|
+
async function readBounded(res) {
|
|
164
|
+
const reader = res.body?.getReader();
|
|
165
|
+
if (!reader) return void 0;
|
|
166
|
+
const chunks = [];
|
|
167
|
+
let size = 0;
|
|
168
|
+
for (; ; ) {
|
|
169
|
+
const { done, value } = await reader.read();
|
|
170
|
+
if (done) break;
|
|
171
|
+
size += value.byteLength;
|
|
172
|
+
if (size > MAX_RESPONSE_BYTES) {
|
|
173
|
+
await reader.cancel();
|
|
174
|
+
return void 0;
|
|
175
|
+
}
|
|
176
|
+
chunks.push(value);
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
180
|
+
} catch {
|
|
181
|
+
return void 0;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function field(json, key) {
|
|
185
|
+
return typeof json === "object" && json !== null ? json[key] : void 0;
|
|
186
|
+
}
|
|
187
|
+
function failure(status, json) {
|
|
188
|
+
const serverCode = field(field(json, "error"), "code");
|
|
189
|
+
const mapped = typeof serverCode === "string" ? SERVER_CODES[serverCode] : void 0;
|
|
190
|
+
return { ok: false, code: mapped ?? (status === 429 ? "rate_limited" : "login_failed") };
|
|
191
|
+
}
|
|
192
|
+
function createCliLoginApi(fetchImpl = fetch) {
|
|
193
|
+
async function post(url, body, bearer, signal) {
|
|
194
|
+
const ctl = new AbortController();
|
|
195
|
+
const abort = () => ctl.abort();
|
|
196
|
+
const timer = setTimeout(abort, REQUEST_TIMEOUT_MS);
|
|
197
|
+
signal?.addEventListener("abort", abort);
|
|
198
|
+
try {
|
|
199
|
+
if (signal?.aborted) return null;
|
|
200
|
+
const res = await fetchImpl(url, {
|
|
201
|
+
method: "POST",
|
|
202
|
+
headers: {
|
|
203
|
+
Accept: "application/json",
|
|
204
|
+
"Content-Type": "application/json",
|
|
205
|
+
...bearer === void 0 ? {} : { Authorization: `Bearer ${bearer}` }
|
|
206
|
+
},
|
|
207
|
+
body: JSON.stringify(body),
|
|
208
|
+
// A redirect would forward the proof or the key to another origin.
|
|
209
|
+
redirect: "error",
|
|
210
|
+
signal: ctl.signal
|
|
211
|
+
});
|
|
212
|
+
return { status: res.status, json: await readBounded(res) };
|
|
213
|
+
} catch {
|
|
214
|
+
return null;
|
|
215
|
+
} finally {
|
|
216
|
+
clearTimeout(timer);
|
|
217
|
+
signal?.removeEventListener("abort", abort);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const base = (endpoint) => `${endpoint}/auth/cli-login`;
|
|
221
|
+
return {
|
|
222
|
+
async start(endpoint, body, signal) {
|
|
223
|
+
const res = await post(`${base(endpoint)}/start`, body, void 0, signal);
|
|
224
|
+
if (res === null) return { ok: false, code: "network_error" };
|
|
225
|
+
if (res.status === 404) return { ok: false, code: "browser_login_unsupported" };
|
|
226
|
+
if (res.status !== 201) return failure(res.status, res.json);
|
|
227
|
+
const requestId = field(res.json, "requestId");
|
|
228
|
+
const authorizationUrl = field(res.json, "authorizationUrl");
|
|
229
|
+
const expiresAt = new Date(String(field(res.json, "expiresAt")));
|
|
230
|
+
if (typeof requestId !== "string" || !REQUEST_ID_RE.test(requestId) || typeof authorizationUrl !== "string" || Number.isNaN(expiresAt.getTime())) {
|
|
231
|
+
return { ok: false, code: "login_failed" };
|
|
232
|
+
}
|
|
233
|
+
if (!isTrustedAuthorizationUrl(authorizationUrl, endpoint, requestId)) {
|
|
234
|
+
return { ok: false, code: "untrusted_authorization_url" };
|
|
235
|
+
}
|
|
236
|
+
return { ok: true, requestId, authorizationUrl, expiresAt };
|
|
237
|
+
},
|
|
238
|
+
async redeem(login, code, signal) {
|
|
239
|
+
const res = await post(
|
|
240
|
+
`${base(login.endpoint)}/${login.requestId}/redeem`,
|
|
241
|
+
{ code, codeVerifier: login.codeVerifier },
|
|
242
|
+
void 0,
|
|
243
|
+
signal
|
|
244
|
+
);
|
|
245
|
+
if (res === null) return { ok: false, code: "network_error" };
|
|
246
|
+
if (res.status !== 200) return failure(res.status, res.json);
|
|
247
|
+
const credential = field(res.json, "credential");
|
|
248
|
+
const apiKey = field(credential, "apiKey");
|
|
249
|
+
const connection = field(credential, "connection");
|
|
250
|
+
const accountLabel = field(connection, "accountLabel");
|
|
251
|
+
const projectId = field(connection, "projectId");
|
|
252
|
+
const projectName = field(connection, "projectName");
|
|
253
|
+
if (typeof apiKey !== "string" || !PROJECT_KEY_RE.test(apiKey) || typeof accountLabel !== "string" || typeof projectId !== "string" || typeof projectName !== "string") {
|
|
254
|
+
return { ok: false, code: "login_failed" };
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
ok: true,
|
|
258
|
+
apiKey,
|
|
259
|
+
// Display metadata only; bounded so the shared record fits every OS store.
|
|
260
|
+
connection: {
|
|
261
|
+
accountLabel: boundLabel(accountLabel, 254),
|
|
262
|
+
projectId: boundLabel(projectId, 64),
|
|
263
|
+
projectName: boundLabel(projectName, 128)
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
},
|
|
267
|
+
async confirm(login, signal) {
|
|
268
|
+
const res = await post(
|
|
269
|
+
`${base(login.endpoint)}/${login.requestId}/confirm`,
|
|
270
|
+
{ codeVerifier: login.codeVerifier },
|
|
271
|
+
login.apiKey,
|
|
272
|
+
signal
|
|
273
|
+
);
|
|
274
|
+
if (res === null) return { ok: false, code: "network_error" };
|
|
275
|
+
if (res.status !== 200 || field(res.json, "status") !== "confirmed") {
|
|
276
|
+
return failure(res.status, res.json);
|
|
277
|
+
}
|
|
278
|
+
return { ok: true };
|
|
279
|
+
},
|
|
280
|
+
async cancel(login) {
|
|
281
|
+
const res = await post(`${base(login.endpoint)}/${login.requestId}/cancel`, {
|
|
282
|
+
codeVerifier: login.codeVerifier
|
|
283
|
+
});
|
|
284
|
+
if (res === null) return false;
|
|
285
|
+
if (res.status === 200) return field(res.json, "status") === "cancelled";
|
|
286
|
+
return res.status === 404 && field(field(res.json, "error"), "code") === "cli_login_expired_or_unknown";
|
|
287
|
+
},
|
|
288
|
+
async revoke(key) {
|
|
289
|
+
const res = await post(`${base(key.endpoint)}/revoke`, {}, key.apiKey);
|
|
290
|
+
return res !== null && res.status === 200 && field(res.json, "status") === "revoked";
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// src/cli/lifecycle/journal.ts
|
|
296
|
+
import { createHash } from "crypto";
|
|
297
|
+
var MAX_STORE_CHARS = 1200;
|
|
298
|
+
var MAX_REVOKE_ENTRIES = 8;
|
|
299
|
+
var TOKEN_RE = /^[A-Za-z0-9_-]{43}$/;
|
|
300
|
+
var FINGERPRINT_RE = /^[0-9a-f]{16}$/;
|
|
301
|
+
var PLACEHOLDER_KEY = `pyx_proj_${"x".repeat(160)}`;
|
|
302
|
+
var SERVER_CONVERGENCE_SECONDS = 30;
|
|
303
|
+
var JournalInvalidError = class extends Error {
|
|
304
|
+
};
|
|
305
|
+
var JournalFullError = class extends Error {
|
|
306
|
+
};
|
|
307
|
+
function credentialFingerprint(endpoint, apiKey) {
|
|
308
|
+
return createHash("sha256").update(`${endpoint}
|
|
309
|
+
${apiKey}`).digest("hex").slice(0, 16);
|
|
310
|
+
}
|
|
311
|
+
function isEmpty(journal) {
|
|
312
|
+
return journal.login === null && journal.logout === null && journal.revoke.length === 0;
|
|
313
|
+
}
|
|
314
|
+
function emptyJournal() {
|
|
315
|
+
return { schemaVersion: 1, login: null, logout: null, revoke: [] };
|
|
316
|
+
}
|
|
317
|
+
function convergenceSeconds(journal, now = Date.now()) {
|
|
318
|
+
if (journal.revokedAt === void 0) return null;
|
|
319
|
+
const elapsed = Math.max(now - journal.revokedAt, 0);
|
|
320
|
+
const left = Math.ceil(SERVER_CONVERGENCE_SECONDS - elapsed / 1e3);
|
|
321
|
+
return left > 0 ? left : null;
|
|
322
|
+
}
|
|
323
|
+
function isExactObject(value, required, optional = []) {
|
|
324
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
325
|
+
const keys = Object.keys(value);
|
|
326
|
+
return required.every((key) => keys.includes(key)) && keys.every((key) => required.includes(key) || optional.includes(key));
|
|
327
|
+
}
|
|
328
|
+
function isKeyRef(value) {
|
|
329
|
+
if (!isExactObject(value, ["endpoint", "apiKey"])) return false;
|
|
330
|
+
const v = value;
|
|
331
|
+
return typeof v.endpoint === "string" && parseEndpoint(v.endpoint) === v.endpoint && typeof v.apiKey === "string" && PROJECT_KEY_RE.test(v.apiKey);
|
|
332
|
+
}
|
|
333
|
+
function isLoginRecord(value) {
|
|
334
|
+
if (!isExactObject(
|
|
335
|
+
value,
|
|
336
|
+
["phase", "endpoint", "requestId", "codeVerifier", "expiresAt"],
|
|
337
|
+
["apiKey", "previous"]
|
|
338
|
+
)) {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
const v = value;
|
|
342
|
+
const held = v.phase === "issued" || v.phase === "committing";
|
|
343
|
+
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));
|
|
344
|
+
}
|
|
345
|
+
function isLogoutRecord(value) {
|
|
346
|
+
if (!isExactObject(value, ["fingerprint", "revoke"])) return false;
|
|
347
|
+
const v = value;
|
|
348
|
+
return typeof v.fingerprint === "string" && FINGERPRINT_RE.test(v.fingerprint) && (v.revoke === null || isKeyRef(v.revoke));
|
|
349
|
+
}
|
|
350
|
+
function isJournal(value) {
|
|
351
|
+
if (!isExactObject(value, ["schemaVersion", "login", "logout", "revoke"], ["revokedAt"])) {
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
const v = value;
|
|
355
|
+
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);
|
|
356
|
+
}
|
|
357
|
+
function hasRoomForLogin(journal, endpoint, previous) {
|
|
358
|
+
const largest = {
|
|
359
|
+
...journal,
|
|
360
|
+
login: {
|
|
361
|
+
phase: "committing",
|
|
362
|
+
endpoint,
|
|
363
|
+
requestId: "x".repeat(43),
|
|
364
|
+
codeVerifier: "x".repeat(43),
|
|
365
|
+
expiresAt: Number.MAX_SAFE_INTEGER,
|
|
366
|
+
apiKey: PLACEHOLDER_KEY,
|
|
367
|
+
...previous ? { previous } : {}
|
|
368
|
+
},
|
|
369
|
+
revokedAt: Number.MAX_SAFE_INTEGER
|
|
370
|
+
};
|
|
371
|
+
return JSON.stringify(largest).length <= MAX_STORE_CHARS;
|
|
372
|
+
}
|
|
373
|
+
async function loadJournal(provider) {
|
|
374
|
+
const raw = await provider.readJournal();
|
|
375
|
+
if (raw === null) return emptyJournal();
|
|
376
|
+
if (raw.length > MAX_STORE_CHARS) throw new JournalInvalidError();
|
|
377
|
+
let parsed;
|
|
378
|
+
try {
|
|
379
|
+
parsed = JSON.parse(raw);
|
|
380
|
+
} catch {
|
|
381
|
+
throw new JournalInvalidError();
|
|
382
|
+
}
|
|
383
|
+
if (!isJournal(parsed)) throw new JournalInvalidError();
|
|
384
|
+
return parsed;
|
|
385
|
+
}
|
|
386
|
+
async function saveJournal(provider, journal) {
|
|
387
|
+
if (isEmpty(journal) && convergenceSeconds(journal) === null) return provider.clearJournal();
|
|
388
|
+
const raw = JSON.stringify(journal);
|
|
389
|
+
if (raw.length > MAX_STORE_CHARS) throw new JournalFullError();
|
|
390
|
+
if (!isJournal(JSON.parse(raw))) throw new Error("refusing to write an invalid journal");
|
|
391
|
+
await provider.writeJournal(raw);
|
|
392
|
+
}
|
|
393
|
+
async function supersedeLogout(provider, endpoint, apiKey) {
|
|
394
|
+
let journal;
|
|
395
|
+
try {
|
|
396
|
+
journal = await loadJournal(provider);
|
|
397
|
+
} catch (err) {
|
|
398
|
+
if (err instanceof JournalInvalidError) return;
|
|
399
|
+
throw err;
|
|
400
|
+
}
|
|
401
|
+
if (journal.logout?.fingerprint !== credentialFingerprint(endpoint, apiKey)) return;
|
|
402
|
+
journal.logout = null;
|
|
403
|
+
await saveJournal(provider, journal);
|
|
404
|
+
}
|
|
405
|
+
function guardKeychain(provider) {
|
|
406
|
+
return {
|
|
407
|
+
write: (credentials) => provider.write(credentials),
|
|
408
|
+
delete: () => provider.delete(),
|
|
409
|
+
check: () => provider.check(),
|
|
410
|
+
readJournal: () => provider.readJournal(),
|
|
411
|
+
writeJournal: (raw) => provider.writeJournal(raw),
|
|
412
|
+
clearJournal: () => provider.clearJournal(),
|
|
413
|
+
acquireLock: (onLost) => provider.acquireLock(onLost),
|
|
414
|
+
isLocked: () => provider.isLocked(),
|
|
415
|
+
async read() {
|
|
416
|
+
const credentials = await provider.read();
|
|
417
|
+
if (credentials === null) return null;
|
|
418
|
+
let blocked;
|
|
419
|
+
try {
|
|
420
|
+
const { logout } = await loadJournal(provider);
|
|
421
|
+
blocked = logout?.fingerprint === credentialFingerprint(credentials.endpoint, credentials.apiKey);
|
|
422
|
+
} catch (err) {
|
|
423
|
+
if (!(err instanceof JournalInvalidError)) throw err;
|
|
424
|
+
blocked = true;
|
|
425
|
+
}
|
|
426
|
+
if (blocked) {
|
|
427
|
+
throw new KeychainError({
|
|
428
|
+
code: "logout-incomplete",
|
|
429
|
+
message: "A pyx-memory sign-out did not finish on this computer.",
|
|
430
|
+
guidance: "Run: pyx-mem login-recovery",
|
|
431
|
+
exit: EXIT.NOT_LOGGED_IN
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
return credentials;
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// src/cli/lifecycle/lock.ts
|
|
440
|
+
import { mkdir } from "fs/promises";
|
|
441
|
+
import { dirname } from "path";
|
|
442
|
+
import lockfile from "proper-lockfile";
|
|
443
|
+
var LOCK_OPTIONS = { realpath: false, stale: 15e3 };
|
|
444
|
+
function createFileLock(file) {
|
|
445
|
+
return {
|
|
446
|
+
async acquire(onLost) {
|
|
447
|
+
await mkdir(dirname(file), { recursive: true, mode: 448 });
|
|
448
|
+
let release;
|
|
449
|
+
try {
|
|
450
|
+
release = await lockfile.lock(file, { ...LOCK_OPTIONS, onCompromised: () => onLost() });
|
|
451
|
+
} catch (err) {
|
|
452
|
+
if (err.code === "ELOCKED") return null;
|
|
453
|
+
throw err;
|
|
454
|
+
}
|
|
455
|
+
return async () => {
|
|
456
|
+
try {
|
|
457
|
+
await release();
|
|
458
|
+
} catch (err) {
|
|
459
|
+
const code = err.code;
|
|
460
|
+
if (code !== "ERELEASED" && code !== "ENOTACQUIRED") throw err;
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
},
|
|
464
|
+
isLocked: () => lockfile.check(file, LOCK_OPTIONS)
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
var BUSY = /* @__PURE__ */ Symbol("lifecycle-lock-busy");
|
|
468
|
+
var LockLostError = class extends Error {
|
|
469
|
+
};
|
|
470
|
+
async function releaseLifecycleLock(release) {
|
|
471
|
+
try {
|
|
472
|
+
await release();
|
|
473
|
+
} catch (err) {
|
|
474
|
+
const code = err?.code;
|
|
475
|
+
const reason = typeof code === "string" && /^[A-Z_]{1,32}$/.test(code) ? code : "unknown";
|
|
476
|
+
process.stderr.write(
|
|
477
|
+
`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.
|
|
478
|
+
`
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
async function withLifecycleLock(provider, fn) {
|
|
483
|
+
let lost = false;
|
|
484
|
+
const release = await provider.acquireLock(() => {
|
|
485
|
+
lost = true;
|
|
486
|
+
});
|
|
487
|
+
if (release === null) return BUSY;
|
|
488
|
+
try {
|
|
489
|
+
return await fn(() => lost);
|
|
490
|
+
} finally {
|
|
491
|
+
await releaseLifecycleLock(release);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// src/cli/keychain/napi-provider.ts
|
|
496
|
+
import { homedir } from "os";
|
|
497
|
+
import { join } from "path";
|
|
498
|
+
|
|
499
|
+
// src/cli/keychain/types.ts
|
|
500
|
+
var KEYCHAIN_SERVICE = "pyx-memory";
|
|
501
|
+
var KEYCHAIN_ACCOUNT = "default";
|
|
502
|
+
var CREDENTIAL_SCHEMA_VERSION = 1;
|
|
503
|
+
var JOURNAL_ACCOUNT = "lifecycle-journal";
|
|
504
|
+
|
|
505
|
+
// src/cli/keychain/napi-provider.ts
|
|
506
|
+
var cached = /* @__PURE__ */ new Map();
|
|
507
|
+
async function loadEntry(account = KEYCHAIN_ACCOUNT) {
|
|
508
|
+
const hit = cached.get(account);
|
|
509
|
+
if (hit) return hit;
|
|
510
|
+
try {
|
|
511
|
+
const mod = await import("@napi-rs/keyring");
|
|
512
|
+
const entry = new mod.Entry(KEYCHAIN_SERVICE, account);
|
|
513
|
+
cached.set(account, entry);
|
|
514
|
+
return entry;
|
|
515
|
+
} catch (err) {
|
|
516
|
+
throw providerImportError(err);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
function parseRecord(raw) {
|
|
520
|
+
let parsed;
|
|
521
|
+
try {
|
|
522
|
+
parsed = JSON.parse(raw);
|
|
523
|
+
} catch (err) {
|
|
524
|
+
throw recordInvalidError(err);
|
|
525
|
+
}
|
|
526
|
+
if (!isValidRecord(parsed)) throw recordInvalidError();
|
|
527
|
+
return parsed;
|
|
528
|
+
}
|
|
529
|
+
function isValidRecord(value) {
|
|
530
|
+
if (typeof value !== "object" || value === null) return false;
|
|
531
|
+
const v = value;
|
|
532
|
+
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));
|
|
533
|
+
}
|
|
534
|
+
function isConnection(value) {
|
|
535
|
+
if (typeof value !== "object" || value === null) return false;
|
|
536
|
+
const c = value;
|
|
537
|
+
return typeof c.accountLabel === "string" && typeof c.projectId === "string" && typeof c.projectName === "string";
|
|
538
|
+
}
|
|
539
|
+
var NapiKeychainProvider = class {
|
|
540
|
+
// The lock file holds no secrets; credentials and the journal stay in the OS store.
|
|
541
|
+
lock = createFileLock(join(homedir(), ".pyx-memory", "lifecycle"));
|
|
542
|
+
async read() {
|
|
543
|
+
const entry = await loadEntry();
|
|
544
|
+
let raw;
|
|
545
|
+
try {
|
|
546
|
+
raw = entry.getPassword();
|
|
547
|
+
} catch (err) {
|
|
548
|
+
throw platformAccessError(err);
|
|
549
|
+
}
|
|
550
|
+
if (raw === null) return null;
|
|
551
|
+
return parseRecord(raw);
|
|
552
|
+
}
|
|
553
|
+
async write(credentials) {
|
|
554
|
+
const entry = await loadEntry();
|
|
555
|
+
const payload = JSON.stringify({
|
|
556
|
+
schemaVersion: CREDENTIAL_SCHEMA_VERSION,
|
|
557
|
+
endpoint: credentials.endpoint,
|
|
558
|
+
apiKey: credentials.apiKey,
|
|
559
|
+
...credentials.provenance === "browser-login" ? { connection: credentials.connection, provenance: credentials.provenance } : {}
|
|
560
|
+
});
|
|
561
|
+
try {
|
|
562
|
+
entry.setPassword(payload);
|
|
563
|
+
} catch (err) {
|
|
564
|
+
throw platformAccessError(err);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
async delete() {
|
|
568
|
+
const entry = await loadEntry();
|
|
569
|
+
try {
|
|
570
|
+
entry.deletePassword();
|
|
571
|
+
} catch (err) {
|
|
572
|
+
throw platformAccessError(err);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
async readJournal() {
|
|
576
|
+
const entry = await loadEntry(JOURNAL_ACCOUNT);
|
|
577
|
+
try {
|
|
578
|
+
return entry.getPassword();
|
|
579
|
+
} catch (err) {
|
|
580
|
+
throw platformAccessError(err);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
async writeJournal(raw) {
|
|
584
|
+
const entry = await loadEntry(JOURNAL_ACCOUNT);
|
|
585
|
+
try {
|
|
586
|
+
entry.setPassword(raw);
|
|
587
|
+
} catch (err) {
|
|
588
|
+
throw platformAccessError(err);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
async clearJournal() {
|
|
592
|
+
const entry = await loadEntry(JOURNAL_ACCOUNT);
|
|
593
|
+
try {
|
|
594
|
+
entry.deletePassword();
|
|
595
|
+
} catch (err) {
|
|
596
|
+
throw platformAccessError(err);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
acquireLock(onLost) {
|
|
600
|
+
return this.lock.acquire(onLost);
|
|
601
|
+
}
|
|
602
|
+
isLocked() {
|
|
603
|
+
return this.lock.isLocked();
|
|
604
|
+
}
|
|
605
|
+
async check() {
|
|
606
|
+
try {
|
|
607
|
+
const entry = await loadEntry();
|
|
608
|
+
entry.getPassword();
|
|
609
|
+
return { ok: true, message: "OS credential store reachable" };
|
|
610
|
+
} catch (err) {
|
|
611
|
+
if (err instanceof Error && "code" in err && "guidance" in err) {
|
|
612
|
+
const ke = err;
|
|
613
|
+
return { ok: false, code: ke.code, message: ke.message, guidance: ke.guidance };
|
|
614
|
+
}
|
|
615
|
+
return { ok: false, message: err instanceof Error ? err.message : String(err) };
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
// src/cli/keychain/index.ts
|
|
621
|
+
function getDefaultKeychain() {
|
|
622
|
+
return guardKeychain(new NapiKeychainProvider());
|
|
623
|
+
}
|
|
624
|
+
function getLifecycleKeychain() {
|
|
625
|
+
return new NapiKeychainProvider();
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
export {
|
|
629
|
+
KeychainError,
|
|
630
|
+
ERROR_MESSAGES,
|
|
631
|
+
parseEndpoint,
|
|
632
|
+
createCliLoginApi,
|
|
633
|
+
JournalInvalidError,
|
|
634
|
+
credentialFingerprint,
|
|
635
|
+
isEmpty,
|
|
636
|
+
emptyJournal,
|
|
637
|
+
convergenceSeconds,
|
|
638
|
+
isKeyRef,
|
|
639
|
+
hasRoomForLogin,
|
|
640
|
+
loadJournal,
|
|
641
|
+
saveJournal,
|
|
642
|
+
supersedeLogout,
|
|
643
|
+
guardKeychain,
|
|
644
|
+
BUSY,
|
|
645
|
+
LockLostError,
|
|
646
|
+
releaseLifecycleLock,
|
|
647
|
+
withLifecycleLock,
|
|
648
|
+
getDefaultKeychain,
|
|
649
|
+
getLifecycleKeychain
|
|
650
|
+
};
|