@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.
- package/README.md +92 -0
- package/assets/codex-skills/zhihu-aiops/SKILL.md +39 -0
- package/assets/codex-skills/zhihu-aiops/agents/openai.yaml +4 -0
- package/assets/codex-skills/zhihu-aiops/references/commands.md +33 -0
- package/assets/codex-skills/zhihu-aiops/references/errors.md +14 -0
- package/assets/codex-skills/zhihu-aiops/references/safety.md +9 -0
- package/bin/zhihu.js +5 -0
- package/dist/auth-service.js +204 -0
- package/dist/browser.js +49 -0
- package/dist/cli.js +1092 -0
- package/dist/codex-skill.js +573 -0
- package/dist/config.js +350 -0
- package/dist/contracts.js +15 -0
- package/dist/credentials.js +198 -0
- package/dist/http.js +1875 -0
- package/dist/install.js +435 -0
- package/dist/logger.js +28 -0
- package/dist/output.js +48 -0
- package/dist/pkce.js +15 -0
- package/dist/profile-lock.js +126 -0
- package/dist/redaction.js +42 -0
- package/dist/token-manager.js +320 -0
- package/package.json +50 -0
package/dist/config.js
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { chmodSync, lstatSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
4
|
+
import { CONFIG_SCHEMA_VERSION } from "./contracts.js";
|
|
5
|
+
export const DEFAULT_PROFILE_NAME = "default";
|
|
6
|
+
export const DEFAULT_PROFILE = {
|
|
7
|
+
base_url: "",
|
|
8
|
+
timeout_seconds: 30,
|
|
9
|
+
language: "en",
|
|
10
|
+
};
|
|
11
|
+
export class ConfigError extends Error {
|
|
12
|
+
code;
|
|
13
|
+
constructor(message, code = "CONFIG_INVALID") {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "ConfigError";
|
|
16
|
+
this.code = code;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Stable, non-secret failure raised when changing an authenticated profile origin. */
|
|
20
|
+
export class ProfileConnectionGuardError extends Error {
|
|
21
|
+
code;
|
|
22
|
+
exitCode = 3;
|
|
23
|
+
constructor(code, message) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "ProfileConnectionGuardError";
|
|
26
|
+
this.code = code;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function homeDirectory(environment) {
|
|
30
|
+
return environment.HOME?.trim() || homedir();
|
|
31
|
+
}
|
|
32
|
+
export function configRoot(environment = process.env) {
|
|
33
|
+
const xdg = environment.XDG_CONFIG_HOME?.trim();
|
|
34
|
+
return join(xdg || join(homeDirectory(environment), ".config"), "zhihu-ai");
|
|
35
|
+
}
|
|
36
|
+
export function rootConfigPath(environment = process.env) {
|
|
37
|
+
return join(configRoot(environment), "config.json");
|
|
38
|
+
}
|
|
39
|
+
export function profilePath(profileName, environment = process.env) {
|
|
40
|
+
return join(configRoot(environment), "profiles", `${normalizeProfileName(profileName)}.json`);
|
|
41
|
+
}
|
|
42
|
+
export function normalizeProfileName(value) {
|
|
43
|
+
const name = value.trim();
|
|
44
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name)) {
|
|
45
|
+
throw new ConfigError("Profile name must use letters, numbers, dot, underscore, or hyphen");
|
|
46
|
+
}
|
|
47
|
+
return name;
|
|
48
|
+
}
|
|
49
|
+
function readJsonFile(path) {
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
throw new ConfigError(`Unable to read configuration file: ${path}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function objectOrEmpty(value, label) {
|
|
61
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
62
|
+
throw new ConfigError(`${label} must be a JSON object`);
|
|
63
|
+
}
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
export function readRootConfig(environment = process.env) {
|
|
67
|
+
const raw = readJsonFile(rootConfigPath(environment));
|
|
68
|
+
if (raw === undefined) {
|
|
69
|
+
return { schema_version: CONFIG_SCHEMA_VERSION, current_profile: DEFAULT_PROFILE_NAME };
|
|
70
|
+
}
|
|
71
|
+
const object = objectOrEmpty(raw, "Root configuration");
|
|
72
|
+
const schemaVersion = object.schema_version;
|
|
73
|
+
const currentProfile = object.current_profile;
|
|
74
|
+
if (schemaVersion !== CONFIG_SCHEMA_VERSION || typeof currentProfile !== "string") {
|
|
75
|
+
throw new ConfigError("Root configuration schema is not supported");
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
schema_version: schemaVersion,
|
|
79
|
+
current_profile: normalizeProfileName(currentProfile),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export function readProfileRaw(profileName, environment = process.env) {
|
|
83
|
+
return readJsonFile(profilePath(profileName, environment));
|
|
84
|
+
}
|
|
85
|
+
function optionalString(object, key) {
|
|
86
|
+
if (object[key] === undefined)
|
|
87
|
+
return undefined;
|
|
88
|
+
if (typeof object[key] !== "string" || object[key].length > 4096) {
|
|
89
|
+
throw new ConfigError(`Profile field ${key} must be a string`);
|
|
90
|
+
}
|
|
91
|
+
return object[key];
|
|
92
|
+
}
|
|
93
|
+
function validateBaseUrl(value) {
|
|
94
|
+
if (value === undefined || value === "")
|
|
95
|
+
return "";
|
|
96
|
+
if (typeof value !== "string" || value.length > 2048) {
|
|
97
|
+
throw new ConfigError("Profile base_url must be an HTTP(S) URL");
|
|
98
|
+
}
|
|
99
|
+
let parsed;
|
|
100
|
+
try {
|
|
101
|
+
parsed = new URL(value);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
throw new ConfigError("Profile base_url must be an HTTP(S) URL");
|
|
105
|
+
}
|
|
106
|
+
if (!/^https?:$/.test(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash) {
|
|
107
|
+
throw new ConfigError("Profile base_url must not contain credentials, query, or fragment");
|
|
108
|
+
}
|
|
109
|
+
return parsed.toString().replace(/\/$/, "");
|
|
110
|
+
}
|
|
111
|
+
export function validateProfile(value) {
|
|
112
|
+
const object = objectOrEmpty(value ?? {}, "Profile configuration");
|
|
113
|
+
const allowed = new Set(["base_url", "timeout_seconds", "language", "ca_bundle", "codex_skill_dir"]);
|
|
114
|
+
for (const key of Object.keys(object)) {
|
|
115
|
+
if (!allowed.has(key))
|
|
116
|
+
throw new ConfigError(`Profile field ${key} is not supported`);
|
|
117
|
+
}
|
|
118
|
+
const timeout = object.timeout_seconds ?? DEFAULT_PROFILE.timeout_seconds;
|
|
119
|
+
if (typeof timeout !== "number" || !Number.isInteger(timeout) || timeout < 1 || timeout > 300) {
|
|
120
|
+
throw new ConfigError("Profile timeout_seconds must be an integer between 1 and 300");
|
|
121
|
+
}
|
|
122
|
+
const language = object.language ?? DEFAULT_PROFILE.language;
|
|
123
|
+
if (language !== "zh" && language !== "en")
|
|
124
|
+
throw new ConfigError("Profile language must be zh or en");
|
|
125
|
+
const caBundle = optionalString(object, "ca_bundle");
|
|
126
|
+
const codexSkillDir = optionalString(object, "codex_skill_dir");
|
|
127
|
+
return {
|
|
128
|
+
base_url: validateBaseUrl(object.base_url),
|
|
129
|
+
timeout_seconds: timeout,
|
|
130
|
+
language,
|
|
131
|
+
...(caBundle ? { ca_bundle: caBundle } : {}),
|
|
132
|
+
...(codexSkillDir ? { codex_skill_dir: codexSkillDir } : {}),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Connection identity is deliberately limited to the service origin and the
|
|
137
|
+
* trust bundle. Timeout, language, and local Skill settings can be changed
|
|
138
|
+
* in-place without invalidating an existing login.
|
|
139
|
+
*/
|
|
140
|
+
export function profileConnectionIdentityChanged(previous, next) {
|
|
141
|
+
return previous.base_url !== next.base_url
|
|
142
|
+
|| (previous.ca_bundle ?? undefined) !== (next.ca_bundle ?? undefined);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Refuse to silently move a profile with an active credential to a new
|
|
146
|
+
* service origin. Callers hold the global and target profile locks while
|
|
147
|
+
* invoking this guard. The probe is injected so tests never touch a real
|
|
148
|
+
* keychain/secret service.
|
|
149
|
+
*/
|
|
150
|
+
export async function guardProfileConnectionChange(profileName, previous, next, credentialStoreFactory) {
|
|
151
|
+
if (!profileConnectionIdentityChanged(previous, next))
|
|
152
|
+
return;
|
|
153
|
+
let store;
|
|
154
|
+
try {
|
|
155
|
+
store = credentialStoreFactory(profileName);
|
|
156
|
+
if (!(await store.available())) {
|
|
157
|
+
throw new Error("credential store unavailable");
|
|
158
|
+
}
|
|
159
|
+
const credential = await store.get();
|
|
160
|
+
if (credential) {
|
|
161
|
+
throw new ProfileConnectionGuardError("AUTH_ALREADY_LOGGED_IN", `Profile ${profileName} has an active login on another Pilot origin; run zhihu auth logout --profile ${profileName} or use a new profile`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
if (error instanceof ProfileConnectionGuardError)
|
|
166
|
+
throw error;
|
|
167
|
+
throw new ProfileConnectionGuardError("CREDENTIAL_STORE_UNAVAILABLE", `Unable to verify existing credentials for profile ${profileName}; run zhihu auth logout --profile ${profileName} before changing the Pilot origin`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
export function writeJsonAtomic(path, value) {
|
|
171
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
172
|
+
const temporaryPath = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
173
|
+
try {
|
|
174
|
+
writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
175
|
+
chmodSync(temporaryPath, 0o600);
|
|
176
|
+
renameSync(temporaryPath, path);
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
// A failed write or rename must not leave a stale temporary file that
|
|
180
|
+
// could be mistaken for a valid configuration on the next invocation.
|
|
181
|
+
try {
|
|
182
|
+
unlinkSync(temporaryPath);
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
if (!(error && typeof error === "object" && "code" in error && error.code === "ENOENT")) {
|
|
186
|
+
// The primary write error is more actionable; callers still restore
|
|
187
|
+
// the pair of configuration files and report any rollback failure.
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
function snapshotFile(path) {
|
|
193
|
+
try {
|
|
194
|
+
const stat = lstatSync(path);
|
|
195
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
196
|
+
throw new ConfigError("Configuration file must be a regular file");
|
|
197
|
+
}
|
|
198
|
+
return { path, exists: true, content: readFileSync(path, "utf8"), mode: stat.mode & 0o777 };
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
if (error instanceof ConfigError)
|
|
202
|
+
throw error;
|
|
203
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
|
204
|
+
return { path, exists: false };
|
|
205
|
+
}
|
|
206
|
+
throw new ConfigError("Unable to inspect configuration file");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** Capture and restore both files touched by a profile install. */
|
|
210
|
+
export function captureProfileInstallSnapshot(profileName, environment = process.env) {
|
|
211
|
+
const name = normalizeProfileName(profileName);
|
|
212
|
+
return {
|
|
213
|
+
profile: snapshotFile(profilePath(name, environment)),
|
|
214
|
+
root: snapshotFile(rootConfigPath(environment)),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
function restoreSnapshot(snapshot) {
|
|
218
|
+
if (snapshot.exists) {
|
|
219
|
+
try {
|
|
220
|
+
const current = lstatSync(snapshot.path);
|
|
221
|
+
if (current.isSymbolicLink() || !current.isFile())
|
|
222
|
+
throw new ConfigError("Configuration rollback target is unsafe");
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
if (error instanceof ConfigError)
|
|
226
|
+
throw error;
|
|
227
|
+
if (!(error && typeof error === "object" && "code" in error && error.code === "ENOENT")) {
|
|
228
|
+
throw new ConfigError("Unable to restore configuration");
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const temporaryPath = `${snapshot.path}.rollback-${process.pid}-${Date.now()}`;
|
|
232
|
+
try {
|
|
233
|
+
writeFileSync(temporaryPath, snapshot.content ?? "", { mode: snapshot.mode ?? 0o600 });
|
|
234
|
+
chmodSync(temporaryPath, snapshot.mode ?? 0o600);
|
|
235
|
+
renameSync(temporaryPath, snapshot.path);
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
try {
|
|
239
|
+
unlinkSync(temporaryPath);
|
|
240
|
+
}
|
|
241
|
+
catch { /* best effort cleanup */ }
|
|
242
|
+
throw new ConfigError("Unable to restore configuration");
|
|
243
|
+
}
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
// A missing snapshot means an install created this file. Unlink only the
|
|
248
|
+
// exact regular file; never recurse or follow a symlink during rollback.
|
|
249
|
+
const stat = lstatSync(snapshot.path);
|
|
250
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
251
|
+
throw new ConfigError("Configuration rollback target is unsafe");
|
|
252
|
+
unlinkSync(snapshot.path);
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
if (error instanceof ConfigError)
|
|
256
|
+
throw error;
|
|
257
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT")
|
|
258
|
+
return;
|
|
259
|
+
throw new ConfigError("Unable to restore configuration");
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
export function restoreProfileInstallSnapshot(snapshot) {
|
|
263
|
+
let failure = false;
|
|
264
|
+
try {
|
|
265
|
+
restoreSnapshot(snapshot.profile);
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
failure = true;
|
|
269
|
+
}
|
|
270
|
+
try {
|
|
271
|
+
restoreSnapshot(snapshot.root);
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
failure = true;
|
|
275
|
+
}
|
|
276
|
+
if (failure)
|
|
277
|
+
throw new ConfigError("Unable to restore configuration");
|
|
278
|
+
}
|
|
279
|
+
export function writeProfileInstallConfig(profileName, profile, environment = process.env) {
|
|
280
|
+
const name = normalizeProfileName(profileName);
|
|
281
|
+
const snapshot = captureProfileInstallSnapshot(name, environment);
|
|
282
|
+
try {
|
|
283
|
+
writeJsonAtomic(profilePath(name, environment), validateProfile(profile));
|
|
284
|
+
writeJsonAtomic(rootConfigPath(environment), {
|
|
285
|
+
schema_version: CONFIG_SCHEMA_VERSION,
|
|
286
|
+
current_profile: name,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
try {
|
|
291
|
+
restoreProfileInstallSnapshot(snapshot);
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
throw new ConfigError("Configuration write failed and local configuration could not be restored");
|
|
295
|
+
}
|
|
296
|
+
throw error;
|
|
297
|
+
}
|
|
298
|
+
return snapshot;
|
|
299
|
+
}
|
|
300
|
+
export function initializeProfile(profileName, partial, environment = process.env, force = false) {
|
|
301
|
+
const name = normalizeProfileName(profileName);
|
|
302
|
+
const destination = profilePath(name, environment);
|
|
303
|
+
if (!force && readProfileRaw(name, environment) !== undefined) {
|
|
304
|
+
throw new ConfigError(`Profile ${name} already exists`, "CONFIG_EXISTS");
|
|
305
|
+
}
|
|
306
|
+
const profile = validateProfile({ ...DEFAULT_PROFILE, ...partial });
|
|
307
|
+
const snapshot = captureProfileInstallSnapshot(name, environment);
|
|
308
|
+
try {
|
|
309
|
+
writeJsonAtomic(destination, profile);
|
|
310
|
+
writeJsonAtomic(rootConfigPath(environment), {
|
|
311
|
+
schema_version: CONFIG_SCHEMA_VERSION,
|
|
312
|
+
current_profile: name,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
try {
|
|
317
|
+
restoreProfileInstallSnapshot(snapshot);
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
throw new ConfigError("Configuration write failed and local configuration could not be restored");
|
|
321
|
+
}
|
|
322
|
+
throw error;
|
|
323
|
+
}
|
|
324
|
+
return profile;
|
|
325
|
+
}
|
|
326
|
+
export function readConfigSnapshot(environment = process.env, requestedProfile) {
|
|
327
|
+
const root = readRootConfig(environment);
|
|
328
|
+
const selectedProfile = normalizeProfileName(requestedProfile || root.current_profile);
|
|
329
|
+
const profile = readProfileRaw(selectedProfile, environment) ?? DEFAULT_PROFILE;
|
|
330
|
+
return {
|
|
331
|
+
root_path: rootConfigPath(environment),
|
|
332
|
+
profile_path: profilePath(selectedProfile, environment),
|
|
333
|
+
selected_profile: selectedProfile,
|
|
334
|
+
root,
|
|
335
|
+
profile,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
export function checkProfile(environment = process.env, requestedProfile) {
|
|
339
|
+
const snapshot = readConfigSnapshot(environment, requestedProfile);
|
|
340
|
+
try {
|
|
341
|
+
return { snapshot, profile: validateProfile(snapshot.profile) };
|
|
342
|
+
}
|
|
343
|
+
catch (error) {
|
|
344
|
+
return {
|
|
345
|
+
snapshot,
|
|
346
|
+
profile: undefined,
|
|
347
|
+
error: error instanceof ConfigError ? error.message : "Invalid profile configuration",
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const CLI_VERSION = "0.1.0-test.1";
|
|
2
|
+
export const OUTPUT_SCHEMA_VERSION = "1.0";
|
|
3
|
+
export const CONFIG_SCHEMA_VERSION = "1.0";
|
|
4
|
+
export const MIN_NODE_MAJOR = 22;
|
|
5
|
+
export const P1_CAPABILITY_SCOPES = [
|
|
6
|
+
"profile:read",
|
|
7
|
+
"capabilities:read",
|
|
8
|
+
"employees:read",
|
|
9
|
+
"employees:chat",
|
|
10
|
+
"assets:read",
|
|
11
|
+
"metrics:read",
|
|
12
|
+
"alarms:read",
|
|
13
|
+
"incidents:analyze",
|
|
14
|
+
"inspections:run-readonly",
|
|
15
|
+
];
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
const MAX_CREDENTIAL_LENGTH = 4096;
|
|
3
|
+
export class CredentialStoreError extends Error {
|
|
4
|
+
code;
|
|
5
|
+
constructor(code, message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "CredentialStoreError";
|
|
8
|
+
this.code = code;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function validSecret(value) {
|
|
12
|
+
return typeof value === "string" && value.length > 0 && value.length <= MAX_CREDENTIAL_LENGTH && !/[\u0000\r\n]/.test(value);
|
|
13
|
+
}
|
|
14
|
+
function safeProfile(profile) {
|
|
15
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(profile)) {
|
|
16
|
+
throw new CredentialStoreError("CREDENTIAL_STORE_ERROR", "Profile name is invalid");
|
|
17
|
+
}
|
|
18
|
+
return profile;
|
|
19
|
+
}
|
|
20
|
+
/** Spawn a fixed executable without a shell and without putting secret input in argv/env. */
|
|
21
|
+
export const processCommandRunner = (command, args, options = {}) => new Promise((resolve, reject) => {
|
|
22
|
+
let child;
|
|
23
|
+
try {
|
|
24
|
+
const allowedEnvironmentKeys = new Set([
|
|
25
|
+
"HOME",
|
|
26
|
+
"PATH",
|
|
27
|
+
"LANG",
|
|
28
|
+
"LC_ALL",
|
|
29
|
+
"LC_CTYPE",
|
|
30
|
+
"DBUS_SESSION_BUS_ADDRESS",
|
|
31
|
+
"XDG_RUNTIME_DIR",
|
|
32
|
+
"XDG_CONFIG_HOME",
|
|
33
|
+
"DISPLAY",
|
|
34
|
+
]);
|
|
35
|
+
const environment = Object.fromEntries(Object.entries(process.env).filter(([key]) => allowedEnvironmentKeys.has(key) || key.startsWith("LC_")));
|
|
36
|
+
child = spawn(command, [...args], {
|
|
37
|
+
shell: false,
|
|
38
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
39
|
+
env: environment,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
reject(new CredentialStoreError("CREDENTIAL_STORE_UNAVAILABLE", "System credential store is unavailable"));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
let stdout = "";
|
|
47
|
+
let stderr = "";
|
|
48
|
+
let timer;
|
|
49
|
+
const finish = (result) => {
|
|
50
|
+
if (timer)
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
resolve(result);
|
|
53
|
+
};
|
|
54
|
+
child.stdout.on("data", (chunk) => {
|
|
55
|
+
stdout += chunk.toString();
|
|
56
|
+
if (stdout.length > 16384)
|
|
57
|
+
child.kill();
|
|
58
|
+
});
|
|
59
|
+
child.stderr.on("data", (chunk) => {
|
|
60
|
+
stderr += chunk.toString();
|
|
61
|
+
if (stderr.length > 16384)
|
|
62
|
+
child.kill();
|
|
63
|
+
});
|
|
64
|
+
child.once("error", () => {
|
|
65
|
+
if (timer)
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
reject(new CredentialStoreError("CREDENTIAL_STORE_UNAVAILABLE", "System credential store is unavailable"));
|
|
68
|
+
});
|
|
69
|
+
child.once("close", (code) => finish({ code: code ?? 1, stdout, stderr }));
|
|
70
|
+
if (options.timeoutMs) {
|
|
71
|
+
timer = setTimeout(() => {
|
|
72
|
+
child.kill();
|
|
73
|
+
reject(new CredentialStoreError("CREDENTIAL_STORE_UNAVAILABLE", "System credential store timed out"));
|
|
74
|
+
}, options.timeoutMs);
|
|
75
|
+
}
|
|
76
|
+
if (options.input !== undefined)
|
|
77
|
+
child.stdin.write(options.input);
|
|
78
|
+
child.stdin.end();
|
|
79
|
+
});
|
|
80
|
+
function missingResult(result) {
|
|
81
|
+
return result.code !== 0 && !result.stdout.trim() && !result.stderr.trim();
|
|
82
|
+
}
|
|
83
|
+
/** OS-backed secret store. There is deliberately no plaintext-file fallback. */
|
|
84
|
+
export class SecureCredentialStore {
|
|
85
|
+
service;
|
|
86
|
+
account;
|
|
87
|
+
platform;
|
|
88
|
+
runner;
|
|
89
|
+
constructor(profile, options = {}) {
|
|
90
|
+
const safe = safeProfile(profile);
|
|
91
|
+
this.service = "zhihu-pilot-cli";
|
|
92
|
+
this.account = safe;
|
|
93
|
+
this.platform = options.platform ?? process.platform;
|
|
94
|
+
this.runner = options.runner ?? processCommandRunner;
|
|
95
|
+
}
|
|
96
|
+
command() {
|
|
97
|
+
if (this.platform === "darwin") {
|
|
98
|
+
return {
|
|
99
|
+
executable: "/usr/bin/security",
|
|
100
|
+
readArgs: ["find-generic-password", "-s", this.service, "-a", this.account, "-w"],
|
|
101
|
+
// -w intentionally remains the final argument; the secret is sent via stdin.
|
|
102
|
+
writeArgs: ["add-generic-password", "-U", "-s", this.service, "-a", this.account, "-w"],
|
|
103
|
+
deleteArgs: ["delete-generic-password", "-s", this.service, "-a", this.account],
|
|
104
|
+
checkArgs: ["list-keychains"],
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
if (this.platform === "linux") {
|
|
108
|
+
return {
|
|
109
|
+
executable: "/usr/bin/secret-tool",
|
|
110
|
+
readArgs: ["lookup", "service", this.service, "account", this.account],
|
|
111
|
+
writeArgs: ["store", "--label", "Zhihu Pilot CLI", "service", this.service, "account", this.account],
|
|
112
|
+
deleteArgs: ["clear", "service", this.service, "account", this.account],
|
|
113
|
+
checkArgs: ["--version"],
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
throw new CredentialStoreError("CREDENTIAL_STORE_UNAVAILABLE", "This operating system has no supported credential store");
|
|
117
|
+
}
|
|
118
|
+
async available() {
|
|
119
|
+
try {
|
|
120
|
+
const command = this.command();
|
|
121
|
+
const result = await this.runner(command.executable, command.checkArgs, { timeoutMs: 5000 });
|
|
122
|
+
if (result.code !== 0)
|
|
123
|
+
throw new CredentialStoreError("CREDENTIAL_STORE_UNAVAILABLE", "System credential store is unavailable");
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
if (error instanceof CredentialStoreError)
|
|
128
|
+
throw error;
|
|
129
|
+
throw new CredentialStoreError("CREDENTIAL_STORE_UNAVAILABLE", "System credential store is unavailable");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async get() {
|
|
133
|
+
try {
|
|
134
|
+
const command = this.command();
|
|
135
|
+
const result = await this.runner(command.executable, command.readArgs, { timeoutMs: 5000 });
|
|
136
|
+
if (result.code !== 0) {
|
|
137
|
+
const diagnostic = result.stderr.toLowerCase();
|
|
138
|
+
if (missingResult(result) || diagnostic.includes("could not be found") || diagnostic.includes("no such secret") || diagnostic.includes("not found"))
|
|
139
|
+
return null;
|
|
140
|
+
throw new CredentialStoreError("CREDENTIAL_STORE_ERROR", "Unable to read system credential");
|
|
141
|
+
}
|
|
142
|
+
const credential = result.stdout.trim();
|
|
143
|
+
if (!credential)
|
|
144
|
+
return null;
|
|
145
|
+
if (!validSecret(credential))
|
|
146
|
+
throw new CredentialStoreError("CREDENTIAL_STORE_ERROR", "Stored credential is invalid");
|
|
147
|
+
return credential;
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
if (error instanceof CredentialStoreError)
|
|
151
|
+
throw error;
|
|
152
|
+
throw new CredentialStoreError("CREDENTIAL_STORE_ERROR", "Unable to read system credential");
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async set(credential) {
|
|
156
|
+
if (!validSecret(credential))
|
|
157
|
+
throw new CredentialStoreError("CREDENTIAL_STORE_ERROR", "Credential is invalid");
|
|
158
|
+
try {
|
|
159
|
+
const command = this.command();
|
|
160
|
+
const result = await this.runner(command.executable, command.writeArgs, {
|
|
161
|
+
input: `${credential}\n`,
|
|
162
|
+
timeoutMs: 5000,
|
|
163
|
+
});
|
|
164
|
+
if (result.code !== 0)
|
|
165
|
+
throw new CredentialStoreError("CREDENTIAL_STORE_ERROR", "Unable to write system credential");
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
if (error instanceof CredentialStoreError)
|
|
169
|
+
throw error;
|
|
170
|
+
throw new CredentialStoreError("CREDENTIAL_STORE_ERROR", "Unable to write system credential");
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async delete() {
|
|
174
|
+
try {
|
|
175
|
+
const command = this.command();
|
|
176
|
+
const result = await this.runner(command.executable, command.deleteArgs, { timeoutMs: 5000 });
|
|
177
|
+
const diagnostic = result.stderr.toLowerCase();
|
|
178
|
+
if (result.code !== 0 && !missingResult(result) && !diagnostic.includes("could not be found") && !diagnostic.includes("no such secret") && !diagnostic.includes("not found")) {
|
|
179
|
+
throw new CredentialStoreError("CREDENTIAL_STORE_ERROR", "Unable to delete system credential");
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
if (error instanceof CredentialStoreError)
|
|
184
|
+
throw error;
|
|
185
|
+
throw new CredentialStoreError("CREDENTIAL_STORE_ERROR", "Unable to delete system credential");
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
async read() {
|
|
189
|
+
return this.get();
|
|
190
|
+
}
|
|
191
|
+
async write(credential) {
|
|
192
|
+
return this.set(credential);
|
|
193
|
+
}
|
|
194
|
+
async remove() {
|
|
195
|
+
return this.delete();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
export { SecureCredentialStore as OsCredentialStore };
|