@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/README.md +32 -20
- package/assets/codex-skills/zhihu-aiops/SKILL.md +32 -16
- package/assets/codex-skills/zhihu-aiops/references/commands.md +28 -14
- package/assets/codex-skills/zhihu-aiops/references/errors.md +1 -1
- package/assets/codex-skills/zhihu-aiops/references/safety.md +6 -4
- package/dist/auth-service.js +71 -83
- package/dist/cli.js +226 -22
- package/dist/codex-skill.js +3 -3
- package/dist/config.js +14 -3
- package/dist/contracts.js +3 -1
- package/dist/credentials.js +152 -29
- package/dist/http.js +413 -67
- package/dist/install.js +310 -59
- package/dist/profile-lock.js +32 -3
- package/dist/release.js +24 -0
- package/dist/terminal-prompt.js +191 -0
- package/dist/token-manager.js +36 -15
- package/package.json +4 -2
- package/dist/browser.js +0 -49
- package/dist/pkce.js +0 -15
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.
|
|
62
|
-
*
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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 (
|
|
237
|
-
|
|
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 (
|
|
243
|
-
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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("
|
|
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 =
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
|
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
|
}
|
package/dist/profile-lock.js
CHANGED
|
@@ -9,8 +9,32 @@ export class ProfileLockError extends Error {
|
|
|
9
9
|
this.name = "ProfileLockError";
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
|
-
|
|
13
|
-
|
|
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();
|
package/dist/release.js
ADDED
|
@@ -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`;
|