@luckypiggy/zhihu-ai-cli 0.1.0-test.1 → 0.2.1-test.4

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/install.js CHANGED
@@ -4,14 +4,132 @@ import { homedir } from "node:os";
4
4
  import { isAbsolute, join, resolve } from "node:path";
5
5
  import { lstatSync } from "node:fs";
6
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";
7
+ import { ProfileLock, ProfileLockCancelledError, ProfileLockError, withProfileLock } from "./profile-lock.js";
8
8
  import { IntegrationHttpClient, HttpError, validateIntegrationInfo, } from "./http.js";
9
9
  import { AuthService } from "./auth-service.js";
10
10
  import { SecureCredentialStore } from "./credentials.js";
11
11
  import { installCodexSkill, resolveCodexSkillTarget, CodexSkillError, CODEX_SKILL_NAME, } from "./codex-skill.js";
12
12
  import { CliAuthError, mapFailure } from "./token-manager.js";
13
+ import { CLI_RUNNER } from "./release.js";
14
+ import { promptTerminalCredentials, TerminalPromptCancelledError, TerminalPromptInteractiveRequiredError } from "./terminal-prompt.js";
13
15
  const MAX_CA_BUNDLE_BYTES = 4 * 1024 * 1024;
14
16
  const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
17
+ const MESSAGES = {
18
+ en: {
19
+ languagePrompt: "Language / 语言 (zh-CN/en)",
20
+ profilePrompt: "Profile name (ZHIHU_PROFILE)",
21
+ baseUrlPrompt: "Zhihu Pilot base URL (ZHIHU_BASE_URL)",
22
+ advancedPrompt: "Configure advanced settings? (y/N)",
23
+ usernamePrompt: "Zhihu username",
24
+ tenantPrompt: "Tenant id (optional)",
25
+ captchaPrompt: "Captcha verification (optional)",
26
+ passwordPrompt: "Zhihu password: ",
27
+ caPrompt: "CA bundle path (optional)",
28
+ timeoutPrompt: "Request timeout in seconds",
29
+ overwritePrompt: "Overwrite the existing profile? (y/N)",
30
+ required: "A value is required. Please try again.",
31
+ invalidInput: "Input is too long or contains control characters. Please try again.",
32
+ skillInstalled: "Codex Skill installed. You can rerun the same install command if setup needs to be retried.",
33
+ skillUpdated: "Codex Skill updated. You can rerun the same install command if setup needs to be retried.",
34
+ skillUnchanged: "Codex Skill is already installed. You can rerun the same install command safely.",
35
+ cancelled: "Installation cancelled; you can rerun the same install command.",
36
+ },
37
+ zh: {
38
+ languagePrompt: "语言 / Language(zh-CN/en)",
39
+ profilePrompt: "Profile 名称(ZHIHU_PROFILE)",
40
+ baseUrlPrompt: "智护 Pilot 地址(ZHIHU_BASE_URL)",
41
+ advancedPrompt: "进入高级设置?(y/N)",
42
+ usernamePrompt: "智护用户名",
43
+ tenantPrompt: "租户 ID(可留空)",
44
+ captchaPrompt: "验证码(可留空)",
45
+ passwordPrompt: "智护密码:",
46
+ caPrompt: "CA bundle 路径(可留空)",
47
+ timeoutPrompt: "请求超时秒数",
48
+ overwritePrompt: "覆盖已有 Profile?(y/N)",
49
+ required: "此项不能为空,请重试。",
50
+ invalidInput: "输入过长或包含控制字符,请重试。",
51
+ skillInstalled: "Codex Skill 已安装;如需重试配置,可再次运行相同的安装命令。",
52
+ skillUpdated: "Codex Skill 已更新;如需重试配置,可再次运行相同的安装命令。",
53
+ skillUnchanged: "Codex Skill 已安装,可安全地再次运行相同的安装命令。",
54
+ cancelled: "安装已取消,可重新运行相同安装命令。",
55
+ },
56
+ };
57
+ function wizardMessages(language) {
58
+ return MESSAGES[language ?? "en"];
59
+ }
60
+ function languageAnswer(value) {
61
+ const normalized = value.trim().toLowerCase();
62
+ if (["zh", "zh-cn", "1", "中文", "中文(简体)", "中文(简体)"].includes(normalized))
63
+ return "zh";
64
+ if (["en", "english", "2", "英文"].includes(normalized))
65
+ return "en";
66
+ return undefined;
67
+ }
68
+ function yesAnswer(value) {
69
+ return /^(?:y|yes|是|好|1)$/i.test(value.trim());
70
+ }
71
+ export function localizedInstallMessage(code, language, message) {
72
+ const zh = language === "zh";
73
+ switch (code) {
74
+ case "SERVICE_CONFIGURATION_REQUIRED":
75
+ return zh
76
+ ? "智护服务尚未配置 CLI OAuth。请联系智护 Pilot 管理员完成服务端 CLI OAuth 配置后,再次运行相同的安装命令。"
77
+ : "The Pilot service is not configured for CLI OAuth. Ask a Pilot administrator to finish the server-side CLI OAuth configuration, then rerun the same install command.";
78
+ case "VERSION_INCOMPATIBLE":
79
+ return zh
80
+ ? "当前智护服务与 CLI 版本不兼容。请联系管理员确认服务版本,或使用匹配的 CLI 后重试。"
81
+ : "The configured Pilot service is not compatible with this CLI. Ask an administrator to confirm the service version, then retry with a matching CLI.";
82
+ case "TERMINAL_LOGIN_REQUIRED":
83
+ return zh
84
+ ? "当前智护仍提供旧版浏览器 OAuth。请升级或启用终端登录后,再次运行安装命令。"
85
+ : "This Pilot still exposes legacy browser OAuth. Upgrade or enable terminal login, then rerun the install command.";
86
+ case "INTERACTIVE_REQUIRED":
87
+ return zh
88
+ ? "登录需要交互式终端输入;如只配置 Skill,请使用 --skip-login。"
89
+ : "Login requires an interactive terminal. Use --skip-login for configuration-only setup.";
90
+ case "AUTH_LOGIN_FAILED":
91
+ return zh
92
+ ? "智护登录失败,请核对用户名和密码后重试。"
93
+ : "Zhihu login was rejected. Check the username and password and try again.";
94
+ case "AUTH_ACCOUNT_LOCKED":
95
+ return zh
96
+ ? "智护账号暂时被锁定,请稍后重试或联系管理员。"
97
+ : "Zhihu login is temporarily locked. Try again later or contact an administrator.";
98
+ case "AUTH_CAPTCHA_REQUIRED":
99
+ return zh
100
+ ? "智护登录需要验证码,请完成验证后重试。"
101
+ : "Zhihu login requires captcha verification. Complete the challenge and try again.";
102
+ case "NETWORK_ERROR":
103
+ return zh
104
+ ? "无法连接智护服务,请检查地址和网络后再次运行相同的安装命令。"
105
+ : "The Pilot service could not be reached. Check the address and network, then rerun the same install command.";
106
+ case "TIMEOUT":
107
+ return zh
108
+ ? "连接智护服务超时,请检查地址和网络后再次运行相同的安装命令。"
109
+ : "The Pilot service timed out. Check the address and network, then rerun the same install command.";
110
+ case "USER_CANCELLED":
111
+ return wizardMessages(language).cancelled;
112
+ case "CONFIG_EXISTS":
113
+ return zh
114
+ ? `${message};如需替换,请确认覆盖或使用 --force,然后再次运行相同的安装命令。`
115
+ : `${message}. Confirm the overwrite or use --force, then rerun the same install command.`;
116
+ case "AUTH_ALREADY_LOGGED_IN":
117
+ case "CREDENTIAL_STORE_UNAVAILABLE":
118
+ return zh
119
+ ? `${message}。请先执行 ${CLI_RUNNER} auth logout,或使用新的 Profile,再重试。`
120
+ : `${message}. Run ${CLI_RUNNER} auth logout first, or use a new Profile, then retry.`;
121
+ default:
122
+ return message;
123
+ }
124
+ }
125
+ function localizeInstallError(error, language) {
126
+ if (!language || !(error instanceof InstallError))
127
+ return error;
128
+ const message = localizedInstallMessage(error.code, language, error.message);
129
+ if (message === error.message)
130
+ return error;
131
+ return new InstallError(error.code, message, { retryable: error.retryable, exitCode: error.exitCode });
132
+ }
15
133
  export class InstallError extends Error {
16
134
  code;
17
135
  retryable;
@@ -27,6 +145,10 @@ export class InstallError extends Error {
27
145
  function fail(code, message, exitCode = 2) {
28
146
  throw new InstallError(code, message, { exitCode });
29
147
  }
148
+ function throwIfAborted(signal) {
149
+ if (signal?.aborted)
150
+ fail("USER_CANCELLED", "Installation cancelled / 安装已取消");
151
+ }
30
152
  function normalizeLanguage(value) {
31
153
  if (value === undefined)
32
154
  return DEFAULT_PROFILE.language;
@@ -47,19 +169,11 @@ function normalizeTimeout(value) {
47
169
  }
48
170
  return timeout;
49
171
  }
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
172
  /**
59
173
  * Environment configuration is deliberately limited to non-sensitive values.
60
174
  * 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.
175
+ * prompt/default can still be used. Passwords are intentionally excluded;
176
+ * there is no ZHIHU_PASSWORD fallback and no .env loading.
63
177
  */
64
178
  function nonEmptyEnvironmentValue(environment, name) {
65
179
  const value = environment[name];
@@ -106,30 +220,72 @@ function interactiveEnabled(inputValue, inputStream, outputStream) {
106
220
  return Boolean(inputStream.isTTY
107
221
  && outputStream.isTTY);
108
222
  }
109
- async function promptValue(rl, label, defaultValue, required = false, outputStream = output) {
223
+ async function promptValue(rl, label, defaultValue, required = false, outputStream = output, messages = MESSAGES.en, signal) {
110
224
  for (let attempt = 0; attempt < 3; attempt += 1) {
111
225
  let answer;
112
226
  try {
113
- answer = (await rl.question(`${label}${defaultValue === undefined ? "" : ` [${defaultValue}]`}: `)).trim();
227
+ const prompt = `${label}${defaultValue === undefined ? "" : ` [${defaultValue}]`}: `;
228
+ const inputStream = rl.input;
229
+ let removeCancellationListeners = () => { };
230
+ const cancellation = inputStream
231
+ ? new Promise((_resolve, reject) => {
232
+ let cancelled = false;
233
+ const cancel = () => {
234
+ if (cancelled)
235
+ return;
236
+ cancelled = true;
237
+ reject(new Error("interactive input closed"));
238
+ };
239
+ inputStream.once?.("end", cancel);
240
+ inputStream.once?.("close", cancel);
241
+ signal?.addEventListener("abort", cancel, { once: true });
242
+ if (signal?.aborted)
243
+ cancel();
244
+ removeCancellationListeners = () => {
245
+ inputStream.removeListener?.("end", cancel);
246
+ inputStream.removeListener?.("close", cancel);
247
+ signal?.removeEventListener("abort", cancel);
248
+ };
249
+ })
250
+ : new Promise((_resolve, reject) => {
251
+ const cancel = () => reject(new Error("installation cancelled"));
252
+ signal?.addEventListener("abort", cancel, { once: true });
253
+ if (signal?.aborted)
254
+ cancel();
255
+ removeCancellationListeners = () => signal?.removeEventListener("abort", cancel);
256
+ });
257
+ try {
258
+ answer = (await Promise.race([rl.question(prompt), cancellation])).trim();
259
+ }
260
+ finally {
261
+ removeCancellationListeners();
262
+ }
114
263
  }
115
264
  catch {
116
- fail("USER_CANCELLED", "Installation was cancelled");
265
+ fail("USER_CANCELLED", "Installation cancelled / 安装已取消");
117
266
  }
118
267
  if (!answer && defaultValue !== undefined)
119
268
  answer = defaultValue;
120
269
  if (!answer && required) {
121
- outputStream.write("A value is required. Please try again.\n");
270
+ outputStream.write(`${messages.required}\n`);
122
271
  continue;
123
272
  }
124
273
  if (answer.length <= 4096 && !CONTROL_PATTERN.test(answer))
125
274
  return answer;
126
- outputStream.write("Input is too long or contains control characters. Please try again.\n");
275
+ outputStream.write(`${messages.invalidInput}\n`);
127
276
  }
128
- fail("INVALID_ARGUMENT", "Too many invalid interactive inputs");
277
+ fail("INVALID_ARGUMENT", messages === MESSAGES.zh ? "交互输入无效次数过多。" : "Too many invalid interactive inputs");
129
278
  }
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);
279
+ async function confirmOverwrite(rl, outputStream, messages, signal, inputStream = input) {
280
+ const temporary = rl ?? createInterface({ input: inputStream, output: outputStream });
281
+ try {
282
+ const answer = await promptValue(temporary, messages.overwritePrompt, "N", false, outputStream, messages, signal);
283
+ return yesAnswer(answer);
284
+ }
285
+ finally {
286
+ if (!rl)
287
+ temporary.close();
288
+ }
133
289
  }
134
290
  function existingProfile(profile, environment) {
135
291
  try {
@@ -170,16 +326,30 @@ async function rollbackProfile(snapshot, profile, environment, options = {}) {
170
326
  }
171
327
  export async function installWizard(inputValue) {
172
328
  const environment = inputValue.environment ?? process.env;
329
+ const externalSignal = inputValue.signal;
330
+ const cancellationController = new AbortController();
331
+ const onExternalAbort = () => cancellationController.abort();
332
+ if (externalSignal?.aborted)
333
+ cancellationController.abort();
334
+ else
335
+ externalSignal?.addEventListener("abort", onExternalAbort, { once: true });
336
+ const signal = cancellationController.signal;
173
337
  const inputStream = inputValue.input ?? input;
174
338
  const outputStream = inputValue.output ?? output;
175
339
  const interactive = interactiveEnabled(inputValue, inputStream, outputStream);
176
- const rl = interactive ? createInterface({ input: inputStream, output: outputStream }) : undefined;
340
+ let rl = interactive ? createInterface({ input: inputStream, output: outputStream }) : undefined;
341
+ const onReadlineSigint = () => cancellationController.abort();
342
+ rl?.on("SIGINT", onReadlineSigint);
177
343
  let profileName = inputValue.profile;
178
344
  let baseUrl = inputValue.baseUrl;
179
- let authMode = inputValue.authMode;
180
345
  let timeout = inputValue.timeout;
181
346
  let language = inputValue.language;
347
+ let username = nonEmptyEnvironmentValue(environment, "ZHIHU_USERNAME");
348
+ let tenantId = nonEmptyEnvironmentValue(environment, "ZHIHU_TENANT_ID");
349
+ let loginCredentials;
350
+ let selectedLanguage;
182
351
  try {
352
+ throwIfAborted(signal);
183
353
  // Stage 6B1 deliberately installs/ confirms the bundled Skill before it
184
354
  // reads customer configuration. This makes `npx ... install` useful as a
185
355
  // one-line Codex bootstrap and ensures Skill failures cannot trigger
@@ -199,6 +369,7 @@ export async function installWizard(inputValue) {
199
369
  codex = inputValue.codexInstaller
200
370
  ? await inputValue.codexInstaller(codexOptions)
201
371
  : installCodexSkill(codexOptions);
372
+ throwIfAborted(signal);
202
373
  }
203
374
  catch (error) {
204
375
  if (error instanceof CodexSkillError) {
@@ -206,9 +377,6 @@ export async function installWizard(inputValue) {
206
377
  }
207
378
  throw error;
208
379
  }
209
- if (interactive && !inputValue.json) {
210
- outputStream.write("Skill installed, configure environment. Run zhihu install again to retry.\n");
211
- }
212
380
  }
213
381
  // CLI/input values win over non-empty environment values; empty values are
214
382
  // intentionally ignored so users can fall back to a prompt/default.
@@ -216,8 +384,6 @@ export async function installWizard(inputValue) {
216
384
  profileName = nonEmptyEnvironmentValue(environment, "ZHIHU_PROFILE");
217
385
  if (baseUrl === undefined)
218
386
  baseUrl = nonEmptyEnvironmentValue(environment, "ZHIHU_BASE_URL");
219
- if (authMode === undefined)
220
- authMode = nonEmptyEnvironmentValue(environment, "ZHIHU_AUTH_MODE");
221
387
  if (inputValue.caFile === undefined) {
222
388
  const environmentCa = nonEmptyEnvironmentValue(environment, "ZHIHU_CA_BUNDLE");
223
389
  if (environmentCa !== undefined)
@@ -225,34 +391,56 @@ export async function installWizard(inputValue) {
225
391
  }
226
392
  if (timeout === undefined)
227
393
  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);
394
+ // Select the language before the remaining prompts so every subsequent
395
+ // prompt, action hint and error uses the same locale. A bilingual label
396
+ // keeps the first question understandable without assuming a locale.
397
+ if (language === undefined && interactive) {
398
+ language = await promptValue(rl, MESSAGES.en.languagePrompt, "en", true, outputStream, MESSAGES.en, signal);
399
+ }
400
+ selectedLanguage = languageAnswer(language ?? "en") ?? normalizeLanguage(language);
401
+ language = selectedLanguage;
402
+ const messages = wizardMessages(selectedLanguage);
403
+ if (interactive && !inputValue.json && codex) {
404
+ const skillMessage = codex.status === "unchanged"
405
+ ? messages.skillUnchanged
406
+ : codex.status === "updated" ? messages.skillUpdated : messages.skillInstalled;
407
+ outputStream.write(`${skillMessage}\n`);
408
+ }
409
+ if (baseUrl === undefined && interactive) {
410
+ baseUrl = await promptValue(rl, messages.baseUrlPrompt, undefined, true, outputStream, messages, signal);
411
+ }
234
412
  if (baseUrl === undefined)
235
413
  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 };
414
+ if (profileName === undefined && interactive) {
415
+ profileName = await promptValue(rl, messages.profilePrompt, "production", false, outputStream, messages, signal);
241
416
  }
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;
417
+ if (profileName === undefined)
418
+ profileName = "production";
419
+ // CA and timeout are the only optional advanced settings. Authentication
420
+ // is always the fixed terminal username/password flow.
421
+ const advancedNeeded = inputValue.caFile === undefined || timeout === undefined;
422
+ if (interactive && advancedNeeded) {
423
+ const advanced = await promptValue(rl, messages.advancedPrompt, "N", false, outputStream, messages, signal);
424
+ if (yesAnswer(advanced)) {
425
+ if (inputValue.caFile === undefined) {
426
+ const caAnswer = await promptValue(rl, messages.caPrompt, "", false, outputStream, messages, signal);
427
+ inputValue = { ...inputValue, caFile: caAnswer };
428
+ }
429
+ if (timeout === undefined) {
430
+ timeout = await promptValue(rl, messages.timeoutPrompt, "30", false, outputStream, messages, signal);
431
+ }
432
+ }
433
+ }
434
+ if (timeout === undefined)
435
+ timeout = "30";
436
+ if (inputValue.caFile === undefined)
437
+ inputValue = { ...inputValue, caFile: "" };
438
+ throwIfAborted(signal);
250
439
  profileName = normalizeProfileName(profileName);
251
440
  baseUrl = validateText(baseUrl, "--base-url", 2048);
252
- normalizeAuthMode(authMode);
253
441
  const caFile = validateCaFile(inputValue.caFile);
254
442
  if (!interactive && !inputValue.skipLogin) {
255
- fail("INVALID_ARGUMENT", "--skip-login is required when input is not interactive or --json is used");
443
+ fail("INTERACTIVE_REQUIRED", "Interactive terminal input is required for login; rerun with --skip-login for configuration-only setup");
256
444
  }
257
445
  let profile;
258
446
  try {
@@ -263,6 +451,8 @@ export async function installWizard(inputValue) {
263
451
  language: normalizeLanguage(language),
264
452
  ...(caFile ? { ca_bundle: caFile } : {}),
265
453
  ...(inputValue.codexSkillDir !== undefined ? { codex_skill_dir: inputValue.codexSkillDir } : {}),
454
+ ...(username ? { username } : {}),
455
+ ...(tenantId ? { tenant_id: tenantId } : {}),
266
456
  });
267
457
  }
268
458
  catch (error) {
@@ -291,7 +481,8 @@ export async function installWizard(inputValue) {
291
481
  try {
292
482
  // Keep the strict validator at the install boundary even when a test or
293
483
  // host adapter supplies an IntegrationHttpClient-compatible object.
294
- info = validateIntegrationInfo(await client.info(), true);
484
+ info = validateIntegrationInfo(await client.info({ signal }), true);
485
+ throwIfAborted(signal);
295
486
  }
296
487
  catch (error) {
297
488
  if (error instanceof HttpError) {
@@ -300,9 +491,47 @@ export async function installWizard(inputValue) {
300
491
  }
301
492
  throw error;
302
493
  }
494
+ if (!inputValue.skipLogin) {
495
+ // The service has been preflighted before credentials are requested.
496
+ // Close the setup readline before creating the password prompt so a TTY
497
+ // can enter raw mode without an echoing second reader.
498
+ rl?.close();
499
+ rl = undefined;
500
+ try {
501
+ loginCredentials = await promptTerminalCredentials({
502
+ input: inputStream,
503
+ output: outputStream,
504
+ usernameLabel: messages.usernamePrompt,
505
+ tenantLabel: messages.tenantPrompt,
506
+ captchaLabel: messages.captchaPrompt,
507
+ passwordLabel: messages.passwordPrompt,
508
+ defaultUsername: username,
509
+ defaultTenantId: tenantId,
510
+ signal,
511
+ secretPrompt: inputValue.secretPrompt,
512
+ valuePrompt: inputValue.valuePrompt,
513
+ });
514
+ }
515
+ catch (error) {
516
+ if (error instanceof TerminalPromptCancelledError)
517
+ fail("USER_CANCELLED", "Installation cancelled / 安装已取消");
518
+ if (error instanceof TerminalPromptInteractiveRequiredError)
519
+ fail("INTERACTIVE_REQUIRED", "Interactive terminal input is required for password login");
520
+ fail("INVALID_ARGUMENT", "Username, tenant id, or password is invalid");
521
+ }
522
+ username = loginCredentials.username;
523
+ tenantId = loginCredentials.tenant_id;
524
+ // Persist only the non-sensitive hints entered by the user. The
525
+ // password remains one-shot input and is never part of ProfileConfig.
526
+ profile = validateProfile({
527
+ ...profile,
528
+ ...(username ? { username } : {}),
529
+ ...(tenantId ? { tenant_id: tenantId } : {}),
530
+ });
531
+ }
303
532
  let snapshot;
304
533
  let loggedIn = false;
305
- const credentials = new SecureCredentialStore(profileName);
534
+ const credentials = (inputValue.authCredentialStoreFactory?.(profileName) ?? new SecureCredentialStore(profileName));
306
535
  const credentialProbeFactory = inputValue.credentialStoreFactory
307
536
  ?? ((name) => new SecureCredentialStore(name));
308
537
  let replacingExisting = inputValue.force === true;
@@ -311,12 +540,13 @@ export async function installWizard(inputValue) {
311
540
  // Hold the root lock until the complete install succeeds or rollback
312
541
  // finishes. Otherwise a concurrent install can update current_profile
313
542
  // after our snapshot and a later rollback would overwrite its result.
314
- const rootLock = new ProfileLock("install-root", { environment });
543
+ const rootLock = new ProfileLock("install-root", { environment, signal });
315
544
  releaseRootLock = await rootLock.acquire();
545
+ throwIfAborted(signal);
316
546
  if (existingProfile(profileName, environment) && !inputValue.force) {
317
547
  if (!interactive)
318
548
  fail("CONFIG_EXISTS", `Profile ${profileName} already exists; use --force to replace it`);
319
- if (!(await confirmOverwrite(rl, outputStream))) {
549
+ if (!(await confirmOverwrite(rl, outputStream, wizardMessages(selectedLanguage), signal, inputStream))) {
320
550
  fail("USER_CANCELLED", "Existing profile was not changed");
321
551
  }
322
552
  replacingExisting = true;
@@ -340,27 +570,37 @@ export async function installWizard(inputValue) {
340
570
  await guardProfileConnectionChange(profileName, previous, profile, credentialProbeFactory);
341
571
  }
342
572
  return writeProfileInstallConfig(profileName, profile, environment);
343
- }, { environment });
573
+ }, { environment, signal });
574
+ throwIfAborted(signal);
344
575
  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();
576
+ const auth = inputValue.authServiceFactory
577
+ ? inputValue.authServiceFactory(client, credentials, profileName, environment)
578
+ : new AuthService(client, credentials, profileName, { environment });
579
+ if (!loginCredentials)
580
+ fail("INTERACTIVE_REQUIRED", "Interactive terminal input is required for login");
581
+ await auth.login(loginCredentials, { signal });
349
582
  loggedIn = true;
583
+ // Mark the remote session as owned before checking cancellation. If an
584
+ // abort lands in this small window, the rollback path must still
585
+ // revoke the issued session and delete its local credential.
586
+ throwIfAborted(signal);
350
587
  }
351
588
  let doctor;
352
589
  if (inputValue.doctor) {
590
+ throwIfAborted(signal);
353
591
  try {
354
592
  doctor = await inputValue.doctor(environment, profileName, profile);
355
593
  }
356
594
  catch {
357
595
  doctor = { status: "unavailable" };
358
596
  }
597
+ throwIfAborted(signal);
359
598
  }
360
599
  return {
361
600
  action: "install",
362
601
  profile: profileName,
363
602
  base_url: profile.base_url,
603
+ language: profile.language,
364
604
  status: "installed",
365
605
  service: stableServiceInfo(info),
366
606
  login: { status: loggedIn ? "logged_in" : "skipped" },
@@ -374,7 +614,7 @@ export async function installWizard(inputValue) {
374
614
  let cleanupFailure;
375
615
  if (loggedIn) {
376
616
  try {
377
- await new AuthService(client, credentials, profileName).logout();
617
+ await new AuthService(client, credentials, profileName, { environment }).logout();
378
618
  }
379
619
  catch {
380
620
  cleanupFailure = "the remote login session could not be revoked";
@@ -400,7 +640,7 @@ export async function installWizard(inputValue) {
400
640
  if (rollbackFailure)
401
641
  throw rollbackFailure;
402
642
  if (cleanupFailure) {
403
- throw new InstallError("AUTH_RELOGIN_REQUIRED", `Installation failed; ${cleanupFailure}. Run zhihu auth logout or login again before retrying.`, { exitCode: 3 });
643
+ throw new InstallError("AUTH_RELOGIN_REQUIRED", `Installation failed; ${cleanupFailure}. Run ${CLI_RUNNER} auth logout or login again before retrying.`, { exitCode: 3 });
404
644
  }
405
645
  if (error instanceof InstallError)
406
646
  throw error;
@@ -412,6 +652,9 @@ export async function installWizard(inputValue) {
412
652
  if (error instanceof CodexSkillError) {
413
653
  throw new InstallError(error.code, error.message, { exitCode: error.exitCode });
414
654
  }
655
+ if (error instanceof ProfileLockCancelledError || signal?.aborted) {
656
+ throw new InstallError("USER_CANCELLED", "Installation cancelled / 安装已取消", { exitCode: 2 });
657
+ }
415
658
  if (error instanceof CliAuthError) {
416
659
  throw new InstallError(error.code, error.message, { retryable: error.retryable, exitCode: error.exitCode });
417
660
  }
@@ -429,7 +672,15 @@ export async function installWizard(inputValue) {
429
672
  releaseRootLock?.();
430
673
  }
431
674
  }
675
+ catch (error) {
676
+ const localized = localizeInstallError(error, selectedLanguage);
677
+ if (localized !== error)
678
+ throw localized;
679
+ throw error;
680
+ }
432
681
  finally {
682
+ rl?.removeListener("SIGINT", onReadlineSigint);
433
683
  rl?.close();
684
+ externalSignal?.removeEventListener("abort", onExternalAbort);
434
685
  }
435
686
  }
@@ -9,8 +9,32 @@ export class ProfileLockError extends Error {
9
9
  this.name = "ProfileLockError";
10
10
  }
11
11
  }
12
- function sleep(milliseconds) {
13
- return new Promise((resolve) => setTimeout(resolve, milliseconds));
12
+ export class ProfileLockCancelledError extends Error {
13
+ code = "USER_CANCELLED";
14
+ constructor() {
15
+ super("The profile lock acquisition was cancelled");
16
+ this.name = "ProfileLockCancelledError";
17
+ }
18
+ }
19
+ function sleep(milliseconds, signal) {
20
+ return new Promise((resolve, reject) => {
21
+ if (signal?.aborted) {
22
+ reject(new ProfileLockCancelledError());
23
+ return;
24
+ }
25
+ let timer;
26
+ const onAbort = () => {
27
+ if (timer !== undefined)
28
+ clearTimeout(timer);
29
+ signal?.removeEventListener("abort", onAbort);
30
+ reject(new ProfileLockCancelledError());
31
+ };
32
+ timer = setTimeout(() => {
33
+ signal?.removeEventListener("abort", onAbort);
34
+ resolve();
35
+ }, milliseconds);
36
+ signal?.addEventListener("abort", onAbort, { once: true });
37
+ });
14
38
  }
15
39
  export function profileLockPath(profile, environment = process.env) {
16
40
  const name = normalizeProfileName(profile);
@@ -27,6 +51,7 @@ export class ProfileLock {
27
51
  timeoutMs: options.timeoutMs ?? 15000,
28
52
  staleMs: options.staleMs ?? 120000,
29
53
  retryMs: options.retryMs ?? 40,
54
+ signal: options.signal,
30
55
  };
31
56
  if (!Number.isFinite(this.options.timeoutMs) || this.options.timeoutMs < 1)
32
57
  throw new ProfileLockError("Profile lock timeout is invalid");
@@ -35,8 +60,12 @@ export class ProfileLock {
35
60
  }
36
61
  async acquire() {
37
62
  const started = Date.now();
63
+ if (this.options.signal?.aborted)
64
+ throw new ProfileLockCancelledError();
38
65
  mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
39
66
  while (Date.now() - started <= this.options.timeoutMs) {
67
+ if (this.options.signal?.aborted)
68
+ throw new ProfileLockCancelledError();
40
69
  try {
41
70
  const descriptor = openSync(this.path, "wx", 0o600);
42
71
  try {
@@ -54,7 +83,7 @@ export class ProfileLock {
54
83
  }
55
84
  if (this.removeIfStale())
56
85
  continue;
57
- await sleep(this.options.retryMs);
86
+ await sleep(this.options.retryMs, this.options.signal);
58
87
  }
59
88
  }
60
89
  throw new ProfileLockError();
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Release identity shared by the CLI, Codex Skill marker and public test
3
+ * package builder. Keep these values together so a staged public package
4
+ * cannot accidentally advertise a different runner or marker version.
5
+ */
6
+ export const CLI_PACKAGE_NAME = "@luckypiggy/zhihu-ai-cli";
7
+ export const CLI_VERSION = "0.2.1-test.4";
8
+ /** Runner used by the canonical package and offline deliveries. */
9
+ export const CLI_RUNNER = "npx --yes @luckypiggy/zhihu-ai-cli@0.2.1-test.4";
10
+ export const CLI_DOCTOR_COMMAND = "npx --yes @luckypiggy/zhihu-ai-cli@0.2.1-test.4 doctor";
11
+ /** Build a user-facing command from the runner for the current package. */
12
+ export function cliCommand(...arguments_) {
13
+ return [CLI_RUNNER, ...arguments_].join(" ");
14
+ }
15
+ /**
16
+ * The public smoke-test package is intentionally version-pinned. A floating
17
+ * dist-tag would make an installed Skill non-reproducible and could silently
18
+ * change the command that Codex executes later.
19
+ */
20
+ export const PUBLIC_TEST_PACKAGE_NAME = "@luckypiggy/zhihu-ai-cli";
21
+ export const PUBLIC_TEST_PACKAGE_VERSION = "0.2.1-test.4";
22
+ export const PUBLIC_TEST_DIST_TAG = "test";
23
+ export const PUBLIC_TEST_RUNNER = `npx --yes ${PUBLIC_TEST_PACKAGE_NAME}@${PUBLIC_TEST_PACKAGE_VERSION}`;
24
+ export const PUBLIC_TEST_DOCTOR_COMMAND = `${PUBLIC_TEST_RUNNER} doctor`;