@luckypiggy/zhihu-ai-cli 0.1.0-test.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.
@@ -0,0 +1,435 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { stdin as input, stderr as output } from "node:process";
3
+ import { homedir } from "node:os";
4
+ import { isAbsolute, join, resolve } from "node:path";
5
+ import { lstatSync } from "node:fs";
6
+ import { ConfigError, DEFAULT_PROFILE, captureProfileInstallSnapshot, normalizeProfileName, guardProfileConnectionChange, readProfileRaw, ProfileConnectionGuardError, restoreProfileInstallSnapshot, validateProfile, writeProfileInstallConfig, } from "./config.js";
7
+ import { ProfileLock, ProfileLockError, withProfileLock } from "./profile-lock.js";
8
+ import { IntegrationHttpClient, HttpError, validateIntegrationInfo, } from "./http.js";
9
+ import { AuthService } from "./auth-service.js";
10
+ import { SecureCredentialStore } from "./credentials.js";
11
+ import { installCodexSkill, resolveCodexSkillTarget, CodexSkillError, CODEX_SKILL_NAME, } from "./codex-skill.js";
12
+ import { CliAuthError, mapFailure } from "./token-manager.js";
13
+ const MAX_CA_BUNDLE_BYTES = 4 * 1024 * 1024;
14
+ const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
15
+ export class InstallError extends Error {
16
+ code;
17
+ retryable;
18
+ exitCode;
19
+ constructor(code, message, options = {}) {
20
+ super(message);
21
+ this.name = "InstallError";
22
+ this.code = code;
23
+ this.retryable = options.retryable ?? false;
24
+ this.exitCode = options.exitCode ?? 2;
25
+ }
26
+ }
27
+ function fail(code, message, exitCode = 2) {
28
+ throw new InstallError(code, message, { exitCode });
29
+ }
30
+ function normalizeLanguage(value) {
31
+ if (value === undefined)
32
+ return DEFAULT_PROFILE.language;
33
+ if (value === "zh-CN" || value === "zh")
34
+ return "zh";
35
+ if (value === "en")
36
+ return "en";
37
+ fail("INVALID_ARGUMENT", "--language must be zh-CN or en");
38
+ }
39
+ function normalizeTimeout(value) {
40
+ if (value === undefined)
41
+ return DEFAULT_PROFILE.timeout_seconds;
42
+ if (!/^\d+$/.test(value))
43
+ fail("INVALID_ARGUMENT", "--timeout must be an integer between 1 and 300 seconds");
44
+ const timeout = Number(value);
45
+ if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 300) {
46
+ fail("INVALID_ARGUMENT", "--timeout must be an integer between 1 and 300 seconds");
47
+ }
48
+ return timeout;
49
+ }
50
+ function normalizeAuthMode(value) {
51
+ if (value === undefined || value === "")
52
+ return "browser";
53
+ if (value !== "browser") {
54
+ fail("INVALID_ARGUMENT", "ZHIHU_AUTH_MODE/--auth-mode must be browser");
55
+ }
56
+ return "browser";
57
+ }
58
+ /**
59
+ * Environment configuration is deliberately limited to non-sensitive values.
60
+ * Empty (or whitespace-only) values are treated as unset so an interactive
61
+ * prompt/default can still be used. Do not add token/username/password
62
+ * fallbacks here: browser OAuth is the only supported authentication path.
63
+ */
64
+ function nonEmptyEnvironmentValue(environment, name) {
65
+ const value = environment[name];
66
+ if (typeof value !== "string" || value.trim() === "")
67
+ return undefined;
68
+ return value.trim();
69
+ }
70
+ /** Resolve the default target without consulting an existing Profile. */
71
+ function defaultCodexSkillTarget(environment) {
72
+ const home = resolve(environment.HOME?.trim() || homedir());
73
+ const codexHome = resolve(environment.CODEX_HOME?.trim() || join(home, ".codex"));
74
+ return join(codexHome, "skills", CODEX_SKILL_NAME);
75
+ }
76
+ function validateText(value, label, maxLength) {
77
+ if (value.length === 0 || value.length > maxLength || CONTROL_PATTERN.test(value)) {
78
+ fail("INVALID_ARGUMENT", `${label} is invalid`);
79
+ }
80
+ return value;
81
+ }
82
+ function validateCaFile(value) {
83
+ if (value === undefined || value === "")
84
+ return undefined;
85
+ validateText(value, "--ca-file", 4096);
86
+ if (!isAbsolute(value))
87
+ fail("CONFIG_INVALID", "--ca-file must be an absolute regular file");
88
+ try {
89
+ const stat = lstatSync(value);
90
+ if (stat.isSymbolicLink() || !stat.isFile() || stat.size <= 0 || stat.size > MAX_CA_BUNDLE_BYTES) {
91
+ fail("CONFIG_INVALID", "--ca-file must be a non-empty regular file no larger than 4 MiB");
92
+ }
93
+ }
94
+ catch (error) {
95
+ if (error instanceof InstallError)
96
+ throw error;
97
+ fail("CONFIG_INVALID", "--ca-file must be a readable regular file");
98
+ }
99
+ return value;
100
+ }
101
+ function interactiveEnabled(inputValue, inputStream, outputStream) {
102
+ if (inputValue.json)
103
+ return false;
104
+ if (inputValue.interactive !== undefined)
105
+ return inputValue.interactive;
106
+ return Boolean(inputStream.isTTY
107
+ && outputStream.isTTY);
108
+ }
109
+ async function promptValue(rl, label, defaultValue, required = false, outputStream = output) {
110
+ for (let attempt = 0; attempt < 3; attempt += 1) {
111
+ let answer;
112
+ try {
113
+ answer = (await rl.question(`${label}${defaultValue === undefined ? "" : ` [${defaultValue}]`}: `)).trim();
114
+ }
115
+ catch {
116
+ fail("USER_CANCELLED", "Installation was cancelled");
117
+ }
118
+ if (!answer && defaultValue !== undefined)
119
+ answer = defaultValue;
120
+ if (!answer && required) {
121
+ outputStream.write("A value is required. Please try again.\n");
122
+ continue;
123
+ }
124
+ if (answer.length <= 4096 && !CONTROL_PATTERN.test(answer))
125
+ return answer;
126
+ outputStream.write("Input is too long or contains control characters. Please try again.\n");
127
+ }
128
+ fail("INVALID_ARGUMENT", "Too many invalid interactive inputs");
129
+ }
130
+ async function confirmOverwrite(rl, outputStream) {
131
+ const answer = await promptValue(rl, "Overwrite the existing profile? (y/N)", "N", false, outputStream);
132
+ return /^(?:y|yes)$/i.test(answer);
133
+ }
134
+ function existingProfile(profile, environment) {
135
+ try {
136
+ return captureProfileInstallSnapshot(profile, environment).profile.exists;
137
+ }
138
+ catch (error) {
139
+ if (error instanceof ConfigError)
140
+ throw error;
141
+ fail("CONFIG_INVALID", "Existing profile cannot be inspected");
142
+ }
143
+ }
144
+ function stableServiceInfo(info) {
145
+ return {
146
+ status: info.status,
147
+ protocol_version: info.protocol_version,
148
+ minimum_cli_version: info.minimum_cli_version,
149
+ capabilities: [...(info.capabilities ?? [])],
150
+ };
151
+ }
152
+ async function rollbackProfile(snapshot, profile, environment, options = {}) {
153
+ if (!snapshot)
154
+ return;
155
+ try {
156
+ const restore = async () => {
157
+ await withProfileLock(profile, async () => restoreProfileInstallSnapshot(snapshot), { environment });
158
+ };
159
+ // The install path holds install-root from the initial snapshot through
160
+ // success/rollback. Standalone recovery still acquires it in the same
161
+ // fixed order.
162
+ if (options.rootLockHeld)
163
+ await restore();
164
+ else
165
+ await withProfileLock("install-root", restore, { environment });
166
+ }
167
+ catch {
168
+ throw new InstallError("CONFIG_INVALID", "Installation failed and local configuration could not be restored", { exitCode: 6 });
169
+ }
170
+ }
171
+ export async function installWizard(inputValue) {
172
+ const environment = inputValue.environment ?? process.env;
173
+ const inputStream = inputValue.input ?? input;
174
+ const outputStream = inputValue.output ?? output;
175
+ const interactive = interactiveEnabled(inputValue, inputStream, outputStream);
176
+ const rl = interactive ? createInterface({ input: inputStream, output: outputStream }) : undefined;
177
+ let profileName = inputValue.profile;
178
+ let baseUrl = inputValue.baseUrl;
179
+ let authMode = inputValue.authMode;
180
+ let timeout = inputValue.timeout;
181
+ let language = inputValue.language;
182
+ try {
183
+ // Stage 6B1 deliberately installs/ confirms the bundled Skill before it
184
+ // reads customer configuration. This makes `npx ... install` useful as a
185
+ // one-line Codex bootstrap and ensures Skill failures cannot trigger
186
+ // network access, prompts, or Profile writes.
187
+ let codex;
188
+ if (!inputValue.skipCodex) {
189
+ try {
190
+ // Do not pass `profile`: a Skill target is independent of a Profile and
191
+ // must be installable before the customer has configured an origin.
192
+ const skillTarget = inputValue.codexSkillDir ?? defaultCodexSkillTarget(environment);
193
+ resolveCodexSkillTarget({ codexSkillDir: skillTarget, environment });
194
+ const codexOptions = {
195
+ codexSkillDir: skillTarget,
196
+ force: inputValue.force,
197
+ environment,
198
+ };
199
+ codex = inputValue.codexInstaller
200
+ ? await inputValue.codexInstaller(codexOptions)
201
+ : installCodexSkill(codexOptions);
202
+ }
203
+ catch (error) {
204
+ if (error instanceof CodexSkillError) {
205
+ throw new InstallError(error.code, error.message, { exitCode: error.exitCode });
206
+ }
207
+ throw error;
208
+ }
209
+ if (interactive && !inputValue.json) {
210
+ outputStream.write("Skill installed, configure environment. Run zhihu install again to retry.\n");
211
+ }
212
+ }
213
+ // CLI/input values win over non-empty environment values; empty values are
214
+ // intentionally ignored so users can fall back to a prompt/default.
215
+ if (profileName === undefined)
216
+ profileName = nonEmptyEnvironmentValue(environment, "ZHIHU_PROFILE");
217
+ if (baseUrl === undefined)
218
+ baseUrl = nonEmptyEnvironmentValue(environment, "ZHIHU_BASE_URL");
219
+ if (authMode === undefined)
220
+ authMode = nonEmptyEnvironmentValue(environment, "ZHIHU_AUTH_MODE");
221
+ if (inputValue.caFile === undefined) {
222
+ const environmentCa = nonEmptyEnvironmentValue(environment, "ZHIHU_CA_BUNDLE");
223
+ if (environmentCa !== undefined)
224
+ inputValue = { ...inputValue, caFile: environmentCa };
225
+ }
226
+ if (timeout === undefined)
227
+ timeout = nonEmptyEnvironmentValue(environment, "ZHIHU_TIMEOUT");
228
+ if (profileName === undefined && interactive)
229
+ profileName = await promptValue(rl, "Profile name (ZHIHU_PROFILE)", "production", false, outputStream);
230
+ if (profileName === undefined)
231
+ profileName = "production";
232
+ if (baseUrl === undefined && interactive)
233
+ baseUrl = await promptValue(rl, "Zhihu Pilot base URL (ZHIHU_BASE_URL)", undefined, true, outputStream);
234
+ if (baseUrl === undefined)
235
+ fail("INVALID_ARGUMENT", "--base-url or ZHIHU_BASE_URL is required when input is not interactive");
236
+ if (authMode === undefined && interactive)
237
+ authMode = await promptValue(rl, "Authentication mode (ZHIHU_AUTH_MODE)", "browser", false, outputStream);
238
+ if (inputValue.caFile === undefined && interactive) {
239
+ const caAnswer = await promptValue(rl, "CA bundle path (ZHIHU_CA_BUNDLE; optional)", "", false, outputStream);
240
+ inputValue = { ...inputValue, caFile: caAnswer };
241
+ }
242
+ if (timeout === undefined && interactive)
243
+ timeout = await promptValue(rl, "Request timeout in seconds (ZHIHU_TIMEOUT)", "30", false, outputStream);
244
+ // Existing language flag/prompt remains compatible; it is intentionally
245
+ // after the Stage 6B1 environment set so the documented env order stays
246
+ // profile → base URL → auth mode → CA → timeout.
247
+ language = language === undefined && interactive
248
+ ? await promptValue(rl, "Language (zh-CN/en)", "en", false, outputStream)
249
+ : language;
250
+ profileName = normalizeProfileName(profileName);
251
+ baseUrl = validateText(baseUrl, "--base-url", 2048);
252
+ normalizeAuthMode(authMode);
253
+ const caFile = validateCaFile(inputValue.caFile);
254
+ if (!interactive && !inputValue.skipLogin) {
255
+ fail("INVALID_ARGUMENT", "--skip-login is required when input is not interactive or --json is used");
256
+ }
257
+ let profile;
258
+ try {
259
+ profile = validateProfile({
260
+ ...DEFAULT_PROFILE,
261
+ base_url: baseUrl,
262
+ timeout_seconds: normalizeTimeout(timeout),
263
+ language: normalizeLanguage(language),
264
+ ...(caFile ? { ca_bundle: caFile } : {}),
265
+ ...(inputValue.codexSkillDir !== undefined ? { codex_skill_dir: inputValue.codexSkillDir } : {}),
266
+ });
267
+ }
268
+ catch (error) {
269
+ if (error instanceof ConfigError)
270
+ fail(error.code, error.message);
271
+ throw error;
272
+ }
273
+ // Constructing the client applies the HTTPS/loopback and URL-userinfo
274
+ // policy before any persistent write. The strict info call is the only
275
+ // server-side preflight and does not carry credentials.
276
+ let client;
277
+ try {
278
+ const validatedClient = new IntegrationHttpClient(profile);
279
+ client = inputValue.clientFactory
280
+ ? inputValue.clientFactory(profile)
281
+ : validatedClient;
282
+ }
283
+ catch (error) {
284
+ if (error instanceof HttpError) {
285
+ const mapped = mapFailure(error);
286
+ throw new InstallError(mapped.code, mapped.message, { retryable: mapped.retryable, exitCode: mapped.exitCode });
287
+ }
288
+ throw error;
289
+ }
290
+ let info;
291
+ try {
292
+ // Keep the strict validator at the install boundary even when a test or
293
+ // host adapter supplies an IntegrationHttpClient-compatible object.
294
+ info = validateIntegrationInfo(await client.info(), true);
295
+ }
296
+ catch (error) {
297
+ if (error instanceof HttpError) {
298
+ const mapped = mapFailure(error);
299
+ throw new InstallError(mapped.code, mapped.message, { retryable: mapped.retryable, exitCode: mapped.exitCode });
300
+ }
301
+ throw error;
302
+ }
303
+ let snapshot;
304
+ let loggedIn = false;
305
+ const credentials = new SecureCredentialStore(profileName);
306
+ const credentialProbeFactory = inputValue.credentialStoreFactory
307
+ ?? ((name) => new SecureCredentialStore(name));
308
+ let replacingExisting = inputValue.force === true;
309
+ let releaseRootLock;
310
+ try {
311
+ // Hold the root lock until the complete install succeeds or rollback
312
+ // finishes. Otherwise a concurrent install can update current_profile
313
+ // after our snapshot and a later rollback would overwrite its result.
314
+ const rootLock = new ProfileLock("install-root", { environment });
315
+ releaseRootLock = await rootLock.acquire();
316
+ if (existingProfile(profileName, environment) && !inputValue.force) {
317
+ if (!interactive)
318
+ fail("CONFIG_EXISTS", `Profile ${profileName} already exists; use --force to replace it`);
319
+ if (!(await confirmOverwrite(rl, outputStream))) {
320
+ fail("USER_CANCELLED", "Existing profile was not changed");
321
+ }
322
+ replacingExisting = true;
323
+ }
324
+ // Read and validate the existing profile, check origin changes, and
325
+ // write the config pair under the target lock. The root lock above is
326
+ // held for the whole transaction, so the guard cannot race another
327
+ // profile manager or observe a half-written pair.
328
+ snapshot = await withProfileLock(profileName, async () => {
329
+ const previousRaw = readProfileRaw(profileName, environment);
330
+ if (previousRaw !== undefined && replacingExisting) {
331
+ let previous;
332
+ try {
333
+ previous = validateProfile(previousRaw);
334
+ }
335
+ catch (error) {
336
+ if (error instanceof ConfigError)
337
+ throw error;
338
+ throw new ConfigError("Existing profile cannot be validated");
339
+ }
340
+ await guardProfileConnectionChange(profileName, previous, profile, credentialProbeFactory);
341
+ }
342
+ return writeProfileInstallConfig(profileName, profile, environment);
343
+ }, { environment });
344
+ if (!inputValue.skipLogin) {
345
+ const auth = new AuthService(client, credentials, profileName, {
346
+ onBrowserFailure: (authorizationUrl) => outputStream.write(`Browser launch failed. Open this URL manually: ${authorizationUrl}\n`),
347
+ });
348
+ await auth.login();
349
+ loggedIn = true;
350
+ }
351
+ let doctor;
352
+ if (inputValue.doctor) {
353
+ try {
354
+ doctor = await inputValue.doctor(environment, profileName, profile);
355
+ }
356
+ catch {
357
+ doctor = { status: "unavailable" };
358
+ }
359
+ }
360
+ return {
361
+ action: "install",
362
+ profile: profileName,
363
+ base_url: profile.base_url,
364
+ status: "installed",
365
+ service: stableServiceInfo(info),
366
+ login: { status: loggedIn ? "logged_in" : "skipped" },
367
+ codex: inputValue.skipCodex
368
+ ? { status: "skipped" }
369
+ : { status: codex?.status === "updated" ? "updated" : codex?.status === "unchanged" ? "unchanged" : "installed", target: codex?.target },
370
+ ...(doctor === undefined ? {} : { doctor }),
371
+ };
372
+ }
373
+ catch (error) {
374
+ let cleanupFailure;
375
+ if (loggedIn) {
376
+ try {
377
+ await new AuthService(client, credentials, profileName).logout();
378
+ }
379
+ catch {
380
+ cleanupFailure = "the remote login session could not be revoked";
381
+ }
382
+ try {
383
+ await credentials.delete();
384
+ }
385
+ catch {
386
+ cleanupFailure = cleanupFailure
387
+ ? `${cleanupFailure} and local credentials could not be removed`
388
+ : "local credentials could not be removed";
389
+ }
390
+ }
391
+ let rollbackFailure;
392
+ try {
393
+ await rollbackProfile(snapshot, profileName, environment, { rootLockHeld: true });
394
+ }
395
+ catch (rollbackError) {
396
+ rollbackFailure = rollbackError instanceof InstallError
397
+ ? rollbackError
398
+ : new InstallError("CONFIG_INVALID", "Installation failed and local configuration could not be restored", { exitCode: 6 });
399
+ }
400
+ if (rollbackFailure)
401
+ throw rollbackFailure;
402
+ if (cleanupFailure) {
403
+ throw new InstallError("AUTH_RELOGIN_REQUIRED", `Installation failed; ${cleanupFailure}. Run zhihu auth logout or login again before retrying.`, { exitCode: 3 });
404
+ }
405
+ if (error instanceof InstallError)
406
+ throw error;
407
+ if (error instanceof ProfileConnectionGuardError) {
408
+ throw new InstallError(error.code, error.message, { exitCode: error.exitCode });
409
+ }
410
+ if (error instanceof ConfigError)
411
+ fail(error.code, error.message);
412
+ if (error instanceof CodexSkillError) {
413
+ throw new InstallError(error.code, error.message, { exitCode: error.exitCode });
414
+ }
415
+ if (error instanceof CliAuthError) {
416
+ throw new InstallError(error.code, error.message, { retryable: error.retryable, exitCode: error.exitCode });
417
+ }
418
+ if (error instanceof ProfileLockError) {
419
+ const mapped = mapFailure(error);
420
+ throw new InstallError(mapped.code, mapped.message, { retryable: mapped.retryable, exitCode: mapped.exitCode });
421
+ }
422
+ if (error instanceof HttpError) {
423
+ const mapped = mapFailure(error);
424
+ throw new InstallError(mapped.code, mapped.message, { retryable: mapped.retryable, exitCode: mapped.exitCode });
425
+ }
426
+ throw new InstallError("PROTOCOL_ERROR", "Installation failed; no local changes were kept", { exitCode: 8 });
427
+ }
428
+ finally {
429
+ releaseRootLock?.();
430
+ }
431
+ }
432
+ finally {
433
+ rl?.close();
434
+ }
435
+ }
package/dist/logger.js ADDED
@@ -0,0 +1,28 @@
1
+ import { redactString, redactValue } from "./redaction.js";
2
+ function formatPart(part) {
3
+ if (typeof part === "string")
4
+ return redactString(part);
5
+ try {
6
+ return JSON.stringify(redactValue(part));
7
+ }
8
+ catch {
9
+ return "[unserializable]";
10
+ }
11
+ }
12
+ /**
13
+ * Create a stderr-only logger. It is disabled by default so JSON stdout stays
14
+ * a single document; every enabled message is redacted before being emitted.
15
+ */
16
+ export function createLogger(enabled = false, sink = (line) => process.stderr.write(`${line}\n`)) {
17
+ const write = (level, parts) => {
18
+ if (!enabled)
19
+ return;
20
+ sink(`[${level}] ${parts.map(formatPart).join(" ")}`);
21
+ };
22
+ return {
23
+ debug: (...parts) => write("debug", parts),
24
+ info: (...parts) => write("info", parts),
25
+ warn: (...parts) => write("warn", parts),
26
+ error: (...parts) => write("error", parts),
27
+ };
28
+ }
package/dist/output.js ADDED
@@ -0,0 +1,48 @@
1
+ import { OUTPUT_SCHEMA_VERSION, } from "./contracts.js";
2
+ import { redactErrorMessage, redactValue } from "./redaction.js";
3
+ function jsonLine(value) {
4
+ process.stdout.write(`${JSON.stringify(value)}\n`);
5
+ }
6
+ function humanValue(value) {
7
+ if (typeof value === "string")
8
+ return value;
9
+ return JSON.stringify(value, null, 2);
10
+ }
11
+ export function emitSuccess(context, data) {
12
+ const envelope = {
13
+ ok: true,
14
+ data: redactValue(data),
15
+ meta: {
16
+ schema_version: OUTPUT_SCHEMA_VERSION,
17
+ command: context.command,
18
+ generated_at: new Date().toISOString(),
19
+ },
20
+ };
21
+ if (context.json) {
22
+ jsonLine(envelope);
23
+ return;
24
+ }
25
+ process.stdout.write(`${humanValue(envelope.data)}\n`);
26
+ }
27
+ export function emitError(context, error) {
28
+ const safeError = {
29
+ code: error.code,
30
+ message: redactErrorMessage(error.message),
31
+ retryable: error.retryable,
32
+ };
33
+ const envelope = {
34
+ ok: false,
35
+ error: safeError,
36
+ meta: {
37
+ schema_version: OUTPUT_SCHEMA_VERSION,
38
+ command: context.command,
39
+ },
40
+ };
41
+ if (context.json) {
42
+ jsonLine(envelope);
43
+ }
44
+ else {
45
+ process.stderr.write(`${safeError.code}: ${safeError.message}\n`);
46
+ }
47
+ return error.exit_code;
48
+ }
package/dist/pkce.js ADDED
@@ -0,0 +1,15 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ const VERIFIER_PATTERN = /^[A-Za-z0-9._~-]{43,128}$/;
3
+ function base64Url(value) {
4
+ return value.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
5
+ }
6
+ /** Generate RFC 7636 S256 material without retaining it outside the caller. */
7
+ export function createPkcePair() {
8
+ const verifier = base64Url(randomBytes(32));
9
+ const challenge = base64Url(createHash("sha256").update(verifier, "ascii").digest());
10
+ return Object.freeze({ verifier, challenge, method: "S256" });
11
+ }
12
+ export function isValidCodeVerifier(value) {
13
+ return typeof value === "string" && VERIFIER_PATTERN.test(value);
14
+ }
15
+ export const generatePkce = createPkcePair;
@@ -0,0 +1,126 @@
1
+ import { closeSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { randomBytes } from "node:crypto";
4
+ import { configRoot, normalizeProfileName } from "./config.js";
5
+ export class ProfileLockError extends Error {
6
+ code = "AUTH_LOCK_TIMEOUT";
7
+ constructor(message = "Another process is refreshing this profile") {
8
+ super(message);
9
+ this.name = "ProfileLockError";
10
+ }
11
+ }
12
+ function sleep(milliseconds) {
13
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
14
+ }
15
+ export function profileLockPath(profile, environment = process.env) {
16
+ const name = normalizeProfileName(profile);
17
+ return join(configRoot(environment), "locks", `${name}.lock`);
18
+ }
19
+ export class ProfileLock {
20
+ path;
21
+ options;
22
+ nonce = randomBytes(12).toString("hex");
23
+ held = false;
24
+ constructor(profile, options = {}) {
25
+ this.path = profileLockPath(profile, options.environment);
26
+ this.options = {
27
+ timeoutMs: options.timeoutMs ?? 15000,
28
+ staleMs: options.staleMs ?? 120000,
29
+ retryMs: options.retryMs ?? 40,
30
+ };
31
+ if (!Number.isFinite(this.options.timeoutMs) || this.options.timeoutMs < 1)
32
+ throw new ProfileLockError("Profile lock timeout is invalid");
33
+ if (!Number.isFinite(this.options.staleMs) || this.options.staleMs < 1)
34
+ throw new ProfileLockError("Profile lock stale timeout is invalid");
35
+ }
36
+ async acquire() {
37
+ const started = Date.now();
38
+ mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
39
+ while (Date.now() - started <= this.options.timeoutMs) {
40
+ try {
41
+ const descriptor = openSync(this.path, "wx", 0o600);
42
+ try {
43
+ writeFileSync(descriptor, JSON.stringify({ pid: process.pid, created_at: Date.now(), nonce: this.nonce }));
44
+ }
45
+ finally {
46
+ closeSync(descriptor);
47
+ }
48
+ this.held = true;
49
+ return () => this.release();
50
+ }
51
+ catch (error) {
52
+ if (!(error && typeof error === "object" && "code" in error && error.code === "EEXIST")) {
53
+ throw new ProfileLockError("Unable to create profile refresh lock");
54
+ }
55
+ if (this.removeIfStale())
56
+ continue;
57
+ await sleep(this.options.retryMs);
58
+ }
59
+ }
60
+ throw new ProfileLockError();
61
+ }
62
+ release() {
63
+ if (!this.held)
64
+ return;
65
+ this.held = false;
66
+ try {
67
+ const raw = JSON.parse(readFileSync(this.path, "utf8"));
68
+ if (raw.nonce !== this.nonce)
69
+ return;
70
+ }
71
+ catch {
72
+ // A crashed/recovered lock is already no longer owned by this instance.
73
+ return;
74
+ }
75
+ try {
76
+ unlinkSync(this.path);
77
+ }
78
+ catch {
79
+ // Release is best effort; a missing lock is already released.
80
+ }
81
+ }
82
+ removeIfStale() {
83
+ try {
84
+ const metadata = statSync(this.path);
85
+ const age = Date.now() - metadata.mtimeMs;
86
+ if (age < this.options.staleMs)
87
+ return false;
88
+ let pid;
89
+ try {
90
+ const raw = JSON.parse(readFileSync(this.path, "utf8"));
91
+ if (typeof raw.pid === "number" && Number.isInteger(raw.pid) && raw.pid > 0)
92
+ pid = raw.pid;
93
+ }
94
+ catch {
95
+ // Invalid metadata can be recovered only after the stale threshold.
96
+ }
97
+ if (pid !== undefined) {
98
+ try {
99
+ process.kill(pid, 0);
100
+ return false;
101
+ }
102
+ catch (error) {
103
+ if (error && typeof error === "object" && "code" in error && error.code === "EPERM")
104
+ return false;
105
+ if (!(error && typeof error === "object" && "code" in error && error.code === "ESRCH"))
106
+ return false;
107
+ }
108
+ }
109
+ unlinkSync(this.path);
110
+ return true;
111
+ }
112
+ catch {
113
+ return false;
114
+ }
115
+ }
116
+ }
117
+ export async function withProfileLock(profile, operation, options = {}) {
118
+ const lock = new ProfileLock(profile, options);
119
+ const release = await lock.acquire();
120
+ try {
121
+ return await operation();
122
+ }
123
+ finally {
124
+ release();
125
+ }
126
+ }