@luckypiggy/zhihu-ai-cli 0.2.1-test.4 → 0.2.1-test.5
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 +14 -7
- package/assets/codex-skills/zhihu-aiops/SKILL.md +44 -22
- package/assets/codex-skills/zhihu-aiops/references/commands.md +30 -18
- package/assets/codex-skills/zhihu-aiops/references/errors.md +2 -1
- package/assets/codex-skills/zhihu-aiops/references/safety.md +7 -2
- package/dist/cli.js +70 -5
- package/dist/codex-skill.js +50 -8
- package/dist/credentials.js +30 -10
- package/dist/install.js +11 -4
- package/dist/local-runner.js +1717 -0
- package/dist/profile-lock.js +7 -0
- package/dist/release.js +7 -4
- package/package.json +1 -1
|
@@ -0,0 +1,1717 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persist the running CLI so later Skill calls do not resolve it through npx.
|
|
3
|
+
* The first install may still enter through an exact-version npx command.
|
|
4
|
+
* After that, Codex uses a stable local wrapper that execs the copied package
|
|
5
|
+
* with the Node binary that performed the install.
|
|
6
|
+
*
|
|
7
|
+
* Lock order: persistLocalCliRunner acquires the runner lock and releases it
|
|
8
|
+
* before returning. Skill install calls it before any Profile lock. It refuses
|
|
9
|
+
* to run while a Profile lock is already held.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
12
|
+
import { chmodSync, closeSync, constants, cpSync, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { profileLocksHeld } from "./profile-lock.js";
|
|
17
|
+
import { CLI_RUNNER } from "./release.js";
|
|
18
|
+
const VERSION_PATTERN = /^[0-9A-Za-z][0-9A-Za-z._+-]*$/;
|
|
19
|
+
const PACKAGE_NAME_PATTERN = /^(?:@[A-Za-z0-9._-]+\/)?[A-Za-z0-9._-]+$/;
|
|
20
|
+
const SAFE_COMMAND_PATH = /^\/[A-Za-z0-9._@+:,/=-]+$/;
|
|
21
|
+
const UNSAFE_PATH = /[\u0000-\u001f\u007f`]/;
|
|
22
|
+
const COPY_FILES = ["package.json", "README.md"];
|
|
23
|
+
const COPY_DIRECTORIES = ["bin", "dist", "assets"];
|
|
24
|
+
const MARKER_NAME = ".zhihu-runner.json";
|
|
25
|
+
const MARKER_SCHEMA = "2";
|
|
26
|
+
const ENTRY_RELATIVE = "bin/zhihu.js";
|
|
27
|
+
const WRAPPER_HEADER = "#!/bin/sh\n# zhihu-ai-cli-runner 2\n";
|
|
28
|
+
const WRAPPER_SUFFIX = " \"$@\"\n";
|
|
29
|
+
const METADATA_KEYS = ["digest", "entry", "generation", "node_path", "owner", "package", "schema", "version"];
|
|
30
|
+
const DIGEST_PATTERN = /^[a-f0-9]{64}$/;
|
|
31
|
+
const CONTENT_TAG_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
|
|
32
|
+
const GENERATION_GRACE_MS = 10 * 60 * 1000;
|
|
33
|
+
const RETENTION_NAME = "retention.json";
|
|
34
|
+
export const RUNNER_LOCK_RECOVERY_CONFIRM = "recover-stale-runner-lock";
|
|
35
|
+
const LOCK_FINGERPRINT_PATTERN = /^[a-f0-9]{16}$/;
|
|
36
|
+
const LOCK_CLAIM_NAME = /^\.lock-claim-[a-f0-9]{24}$/;
|
|
37
|
+
const LOCK_TEMP_NAME = /^\.lock-temp-[a-f0-9]{24}$/;
|
|
38
|
+
const LOCK_GATE_NAME = ".lock-gate";
|
|
39
|
+
const LOCK_GATE_KEYS = ["fingerprint", "pid", "purpose", "schema"];
|
|
40
|
+
export class LocalRunnerError extends Error {
|
|
41
|
+
code;
|
|
42
|
+
constructor(code, message) {
|
|
43
|
+
super(message);
|
|
44
|
+
this.name = "LocalRunnerError";
|
|
45
|
+
this.code = code;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function fail(code, message) {
|
|
49
|
+
throw new LocalRunnerError(code, message);
|
|
50
|
+
}
|
|
51
|
+
function isEnoent(error) {
|
|
52
|
+
return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
|
|
53
|
+
}
|
|
54
|
+
function isEexist(error) {
|
|
55
|
+
return Boolean(error && typeof error === "object" && "code" in error && error.code === "EEXIST");
|
|
56
|
+
}
|
|
57
|
+
function homeDirectory(environment) {
|
|
58
|
+
const raw = environment.HOME;
|
|
59
|
+
if (typeof raw === "string" && UNSAFE_PATH.test(raw)) {
|
|
60
|
+
fail("LOCAL_RUNNER_UNSAFE_PATH", "Local CLI runner path cannot be embedded in a command");
|
|
61
|
+
}
|
|
62
|
+
const value = raw?.trim();
|
|
63
|
+
return resolve(value || homedir());
|
|
64
|
+
}
|
|
65
|
+
export function localRunnerRoot(environment) {
|
|
66
|
+
return layoutFor(environment).runnerRoot;
|
|
67
|
+
}
|
|
68
|
+
export function localRunnerCommandPath(environment) {
|
|
69
|
+
return layoutFor(environment).command;
|
|
70
|
+
}
|
|
71
|
+
function layoutFor(environment) {
|
|
72
|
+
const home = homeDirectory(environment);
|
|
73
|
+
const configuredRaw = environment.XDG_DATA_HOME;
|
|
74
|
+
if (typeof configuredRaw === "string" && UNSAFE_PATH.test(configuredRaw)) {
|
|
75
|
+
fail("LOCAL_RUNNER_UNSAFE_PATH", "Local CLI runner path cannot be embedded in a command");
|
|
76
|
+
}
|
|
77
|
+
const configured = configuredRaw?.trim();
|
|
78
|
+
const dataRoot = configured ? resolve(configured) : join(home, ".local", "share");
|
|
79
|
+
const pilotRoot = join(dataRoot, "zhihu-pilot");
|
|
80
|
+
const runnerRoot = join(pilotRoot, "runner");
|
|
81
|
+
return {
|
|
82
|
+
home,
|
|
83
|
+
dataRoot,
|
|
84
|
+
pilotRoot,
|
|
85
|
+
runnerRoot,
|
|
86
|
+
generationRoot: join(runnerRoot, "generations"),
|
|
87
|
+
binDir: join(runnerRoot, "bin"),
|
|
88
|
+
command: join(runnerRoot, "bin", "zhihu"),
|
|
89
|
+
lockPath: join(runnerRoot, ".lock"),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function assertEmbeddableCommandPath(path) {
|
|
93
|
+
if (!isAbsolute(path) || UNSAFE_PATH.test(path)) {
|
|
94
|
+
fail("LOCAL_RUNNER_UNSAFE_PATH", "Local CLI runner path cannot be embedded in a command");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function shellQuote(value) {
|
|
98
|
+
assertEmbeddableCommandPath(value);
|
|
99
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
100
|
+
}
|
|
101
|
+
export function runnerCommandToken(path) {
|
|
102
|
+
assertEmbeddableCommandPath(path);
|
|
103
|
+
if (SAFE_COMMAND_PATH.test(path))
|
|
104
|
+
return path;
|
|
105
|
+
return shellQuote(path);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Replace the packaged runner token with the local command.
|
|
109
|
+
* A short `zhihu` runner is matched only at a command boundary so names such
|
|
110
|
+
* as `zhihu-aiops` stay intact. A multi-word npx runner is replaced exactly.
|
|
111
|
+
*/
|
|
112
|
+
export function retargetPackagedRunner(content, packagedRunner, localCommand) {
|
|
113
|
+
if (UNSAFE_PATH.test(localCommand)) {
|
|
114
|
+
fail("LOCAL_RUNNER_UNSAFE_PATH", "Local CLI runner path cannot be embedded in a command");
|
|
115
|
+
}
|
|
116
|
+
if (packagedRunner === "zhihu") {
|
|
117
|
+
return content.replace(/`zhihu(?=\s|`)/g, `\`${localCommand}`);
|
|
118
|
+
}
|
|
119
|
+
return content.split(packagedRunner).join(localCommand);
|
|
120
|
+
}
|
|
121
|
+
function packageRoot() {
|
|
122
|
+
return resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
123
|
+
}
|
|
124
|
+
function assertNoSymlinks(path) {
|
|
125
|
+
let stat;
|
|
126
|
+
try {
|
|
127
|
+
stat = lstatSync(path);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner source cannot be read");
|
|
131
|
+
}
|
|
132
|
+
if (stat.isSymbolicLink())
|
|
133
|
+
fail("LOCAL_RUNNER_SYMLINK", "Local CLI runner source contains a symbolic link");
|
|
134
|
+
if (!stat.isDirectory())
|
|
135
|
+
return;
|
|
136
|
+
let entries;
|
|
137
|
+
try {
|
|
138
|
+
entries = readdirSync(path, { withFileTypes: true });
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner source cannot be read");
|
|
142
|
+
}
|
|
143
|
+
for (const entry of entries) {
|
|
144
|
+
const child = join(path, entry.name);
|
|
145
|
+
if (entry.isSymbolicLink())
|
|
146
|
+
fail("LOCAL_RUNNER_SYMLINK", "Local CLI runner source contains a symbolic link");
|
|
147
|
+
if (entry.isDirectory())
|
|
148
|
+
assertNoSymlinks(child);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function readPackageIdentity(root) {
|
|
152
|
+
let parsed;
|
|
153
|
+
try {
|
|
154
|
+
parsed = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner package identity cannot be read");
|
|
158
|
+
}
|
|
159
|
+
const name = parsed.name;
|
|
160
|
+
const version = parsed.version;
|
|
161
|
+
if (typeof name !== "string" || !PACKAGE_NAME_PATTERN.test(name)) {
|
|
162
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner package identity cannot be read");
|
|
163
|
+
}
|
|
164
|
+
if (typeof version !== "string" || !VERSION_PATTERN.test(version) || version.includes("..")) {
|
|
165
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner package version is not usable as a directory name");
|
|
166
|
+
}
|
|
167
|
+
return { name, version };
|
|
168
|
+
}
|
|
169
|
+
function metadataText(metadata) {
|
|
170
|
+
return `${JSON.stringify({
|
|
171
|
+
digest: metadata.digest,
|
|
172
|
+
entry: metadata.entry,
|
|
173
|
+
generation: metadata.generation,
|
|
174
|
+
node_path: metadata.node_path,
|
|
175
|
+
owner: metadata.owner,
|
|
176
|
+
package: metadata.package,
|
|
177
|
+
schema: metadata.schema,
|
|
178
|
+
version: metadata.version,
|
|
179
|
+
}, null, 2)}\n`;
|
|
180
|
+
}
|
|
181
|
+
function generationId(version, digest) {
|
|
182
|
+
return `${version}.${digest}`;
|
|
183
|
+
}
|
|
184
|
+
function generationMatches(version, digest, generation) {
|
|
185
|
+
const canonical = generationId(version, digest);
|
|
186
|
+
if (generation === canonical)
|
|
187
|
+
return true;
|
|
188
|
+
if (!generation.startsWith(`${canonical}.`) || generation.includes(".."))
|
|
189
|
+
return false;
|
|
190
|
+
return /^[A-Za-z0-9._-]{1,96}$/.test(generation.slice(canonical.length + 1));
|
|
191
|
+
}
|
|
192
|
+
function parseMetadata(text, generation) {
|
|
193
|
+
let parsed;
|
|
194
|
+
try {
|
|
195
|
+
parsed = JSON.parse(text);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
201
|
+
return undefined;
|
|
202
|
+
const record = parsed;
|
|
203
|
+
if (Object.keys(record).sort().join("\0") !== METADATA_KEYS.join("\0"))
|
|
204
|
+
return undefined;
|
|
205
|
+
if (record.owner !== "zhihu-ai-cli" || record.schema !== MARKER_SCHEMA)
|
|
206
|
+
return undefined;
|
|
207
|
+
if (typeof record.package !== "string" || !PACKAGE_NAME_PATTERN.test(record.package))
|
|
208
|
+
return undefined;
|
|
209
|
+
if (typeof record.version !== "string" || !VERSION_PATTERN.test(record.version) || record.version.includes(".."))
|
|
210
|
+
return undefined;
|
|
211
|
+
if (typeof record.digest !== "string" || !DIGEST_PATTERN.test(record.digest))
|
|
212
|
+
return undefined;
|
|
213
|
+
if (record.generation !== generation || !generationMatches(record.version, record.digest, generation))
|
|
214
|
+
return undefined;
|
|
215
|
+
if (record.entry !== ENTRY_RELATIVE)
|
|
216
|
+
return undefined;
|
|
217
|
+
if (typeof record.node_path !== "string" || !isAbsolute(record.node_path) || UNSAFE_PATH.test(record.node_path))
|
|
218
|
+
return undefined;
|
|
219
|
+
return {
|
|
220
|
+
owner: "zhihu-ai-cli",
|
|
221
|
+
schema: MARKER_SCHEMA,
|
|
222
|
+
package: record.package,
|
|
223
|
+
version: record.version,
|
|
224
|
+
generation,
|
|
225
|
+
digest: record.digest,
|
|
226
|
+
node_path: record.node_path,
|
|
227
|
+
entry: ENTRY_RELATIVE,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
function readMetadata(directory) {
|
|
231
|
+
const markerPath = join(directory, MARKER_NAME);
|
|
232
|
+
let stat;
|
|
233
|
+
try {
|
|
234
|
+
stat = lstatSync(markerPath);
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
240
|
+
return undefined;
|
|
241
|
+
try {
|
|
242
|
+
return parseMetadata(readFileSync(markerPath, "utf8"), basename(directory));
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
function ensureChildDirectory(parent, name) {
|
|
249
|
+
if (name.length === 0 || name === "." || name === ".." || name.includes("/") || name.includes("\\")) {
|
|
250
|
+
fail("LOCAL_RUNNER_UNSAFE_PATH", "Local CLI runner path cannot be embedded in a command");
|
|
251
|
+
}
|
|
252
|
+
const path = join(parent, name);
|
|
253
|
+
const accept = () => {
|
|
254
|
+
const stat = lstatSync(path);
|
|
255
|
+
if (stat.isSymbolicLink())
|
|
256
|
+
fail("LOCAL_RUNNER_SYMLINK", "Local CLI runner directory is a symbolic link");
|
|
257
|
+
if (!stat.isDirectory())
|
|
258
|
+
fail("LOCAL_RUNNER_FOREIGN", "Local CLI runner directory is not a directory");
|
|
259
|
+
return path;
|
|
260
|
+
};
|
|
261
|
+
try {
|
|
262
|
+
return accept();
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
if (error instanceof LocalRunnerError)
|
|
266
|
+
throw error;
|
|
267
|
+
if (!isEnoent(error))
|
|
268
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner directory cannot be created");
|
|
269
|
+
}
|
|
270
|
+
try {
|
|
271
|
+
mkdirSync(path, { mode: 0o755 });
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
if (!isEexist(error))
|
|
275
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner directory cannot be created");
|
|
276
|
+
}
|
|
277
|
+
return accept();
|
|
278
|
+
}
|
|
279
|
+
function ensureDataRoot(layout) {
|
|
280
|
+
let homeStat;
|
|
281
|
+
try {
|
|
282
|
+
homeStat = lstatSync(layout.home);
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner data directory is unavailable");
|
|
286
|
+
}
|
|
287
|
+
if (homeStat.isSymbolicLink() || !homeStat.isDirectory()) {
|
|
288
|
+
fail("LOCAL_RUNNER_SYMLINK", "Local CLI runner directory is a symbolic link");
|
|
289
|
+
}
|
|
290
|
+
const insideHome = layout.dataRoot === layout.home || layout.dataRoot.startsWith(`${layout.home}${sep}`);
|
|
291
|
+
if (!insideHome) {
|
|
292
|
+
let dataStat;
|
|
293
|
+
try {
|
|
294
|
+
dataStat = lstatSync(layout.dataRoot);
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner data directory is unavailable");
|
|
298
|
+
}
|
|
299
|
+
if (dataStat.isSymbolicLink())
|
|
300
|
+
fail("LOCAL_RUNNER_SYMLINK", "Local CLI runner directory is a symbolic link");
|
|
301
|
+
if (!dataStat.isDirectory())
|
|
302
|
+
fail("LOCAL_RUNNER_FOREIGN", "Local CLI runner directory is not a directory");
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const rel = relative(layout.home, layout.dataRoot);
|
|
306
|
+
let current = layout.home;
|
|
307
|
+
if (rel.length === 0)
|
|
308
|
+
return;
|
|
309
|
+
for (const part of rel.split(sep))
|
|
310
|
+
current = ensureChildDirectory(current, part);
|
|
311
|
+
}
|
|
312
|
+
function assertContained(layout, path) {
|
|
313
|
+
let rootReal = "";
|
|
314
|
+
let pathReal = "";
|
|
315
|
+
try {
|
|
316
|
+
rootReal = realpathSync(layout.dataRoot);
|
|
317
|
+
pathReal = realpathSync(path);
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner directory cannot be created");
|
|
321
|
+
}
|
|
322
|
+
if (pathReal !== rootReal && !pathReal.startsWith(`${rootReal}${sep}`)) {
|
|
323
|
+
fail("LOCAL_RUNNER_SYMLINK", "Local CLI runner directory is a symbolic link");
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
function ensureManagedDirectories(layout) {
|
|
327
|
+
ensureDataRoot(layout);
|
|
328
|
+
ensureChildDirectory(layout.dataRoot, "zhihu-pilot");
|
|
329
|
+
ensureChildDirectory(layout.pilotRoot, "runner");
|
|
330
|
+
ensureChildDirectory(layout.runnerRoot, "generations");
|
|
331
|
+
ensureChildDirectory(layout.runnerRoot, "bin");
|
|
332
|
+
}
|
|
333
|
+
const LEGACY_WRAPPER_PREFIX = "#!/bin/sh\n# zhihu-ai-cli-runner 1\nexec ";
|
|
334
|
+
function isCopiedPath(rel) {
|
|
335
|
+
if (COPY_FILES.includes(rel))
|
|
336
|
+
return true;
|
|
337
|
+
return COPY_DIRECTORIES.some((directory) => rel === directory || rel.startsWith(`${directory}/`));
|
|
338
|
+
}
|
|
339
|
+
function collectTree(root, include) {
|
|
340
|
+
const records = [];
|
|
341
|
+
const visit = (directory, prefix) => {
|
|
342
|
+
let entries;
|
|
343
|
+
try {
|
|
344
|
+
entries = readdirSync(directory, { withFileTypes: true });
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
for (const entry of entries) {
|
|
350
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
351
|
+
if (!include(rel))
|
|
352
|
+
continue;
|
|
353
|
+
const full = join(directory, entry.name);
|
|
354
|
+
let stat;
|
|
355
|
+
try {
|
|
356
|
+
stat = lstatSync(full);
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
if (stat.isSymbolicLink())
|
|
362
|
+
return false;
|
|
363
|
+
if (stat.isDirectory()) {
|
|
364
|
+
records.push({ rel, kind: "dir", executable: "", body: Buffer.alloc(0) });
|
|
365
|
+
if (!visit(full, rel))
|
|
366
|
+
return false;
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (!stat.isFile())
|
|
370
|
+
return false;
|
|
371
|
+
try {
|
|
372
|
+
records.push({
|
|
373
|
+
rel,
|
|
374
|
+
kind: "file",
|
|
375
|
+
executable: (stat.mode & 0o111) !== 0 ? "1" : "0",
|
|
376
|
+
body: readFileSync(full),
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return true;
|
|
384
|
+
};
|
|
385
|
+
if (!visit(root, ""))
|
|
386
|
+
return undefined;
|
|
387
|
+
records.sort((left, right) => (left.rel < right.rel ? -1 : left.rel > right.rel ? 1 : 0));
|
|
388
|
+
return records;
|
|
389
|
+
}
|
|
390
|
+
function hashRecords(records) {
|
|
391
|
+
const hash = createHash("sha256");
|
|
392
|
+
for (const record of records) {
|
|
393
|
+
hash.update(record.kind);
|
|
394
|
+
hash.update("\0");
|
|
395
|
+
hash.update(record.rel);
|
|
396
|
+
hash.update("\0");
|
|
397
|
+
if (record.kind === "file") {
|
|
398
|
+
hash.update(record.executable);
|
|
399
|
+
hash.update("\0");
|
|
400
|
+
hash.update(record.body);
|
|
401
|
+
hash.update("\0");
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return hash.digest("hex");
|
|
405
|
+
}
|
|
406
|
+
/** Digest a generation tree. The marker is excluded. Symlinks make the result undefined. */
|
|
407
|
+
export function runnerTreeDigest(root) {
|
|
408
|
+
const records = collectTree(root, (rel) => rel !== MARKER_NAME);
|
|
409
|
+
return records ? hashRecords(records) : undefined;
|
|
410
|
+
}
|
|
411
|
+
function sourceDigest(root) {
|
|
412
|
+
const records = collectTree(root, isCopiedPath);
|
|
413
|
+
if (!records)
|
|
414
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner source cannot be read");
|
|
415
|
+
return hashRecords(records);
|
|
416
|
+
}
|
|
417
|
+
function wrapperScript(generationDir, metadata) {
|
|
418
|
+
const entryPath = join(generationDir, metadata.entry);
|
|
419
|
+
return `${WRAPPER_HEADER}# generation ${metadata.generation}\nexec ${shellQuote(metadata.node_path)} ${shellQuote(entryPath)}${WRAPPER_SUFFIX}`;
|
|
420
|
+
}
|
|
421
|
+
function wrapperGeneration(text) {
|
|
422
|
+
const marker = "# generation ";
|
|
423
|
+
if (!text.startsWith(`${WRAPPER_HEADER}${marker}`) || !text.endsWith(WRAPPER_SUFFIX))
|
|
424
|
+
return undefined;
|
|
425
|
+
const rest = text.slice(WRAPPER_HEADER.length + marker.length);
|
|
426
|
+
const newline = rest.indexOf("\n");
|
|
427
|
+
if (newline <= 0)
|
|
428
|
+
return undefined;
|
|
429
|
+
const generation = rest.slice(0, newline);
|
|
430
|
+
if (!/^[0-9A-Za-z][0-9A-Za-z._+-]*$/.test(generation) || generation.includes(".."))
|
|
431
|
+
return undefined;
|
|
432
|
+
const execLine = rest.slice(newline + 1);
|
|
433
|
+
if (!execLine.startsWith("exec "))
|
|
434
|
+
return undefined;
|
|
435
|
+
return generation;
|
|
436
|
+
}
|
|
437
|
+
function isReplaceableWrapper(text) {
|
|
438
|
+
if (text.startsWith(LEGACY_WRAPPER_PREFIX) && text.endsWith(WRAPPER_SUFFIX))
|
|
439
|
+
return true;
|
|
440
|
+
return wrapperGeneration(text) !== undefined;
|
|
441
|
+
}
|
|
442
|
+
function assertWrapperSafe(script, generationDir, metadata) {
|
|
443
|
+
if (script !== wrapperScript(generationDir, metadata)) {
|
|
444
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner entry could not be verified");
|
|
445
|
+
}
|
|
446
|
+
const entryPath = join(generationDir, metadata.entry);
|
|
447
|
+
const structural = script.replaceAll(shellQuote(metadata.node_path), "").replaceAll(shellQuote(entryPath), "");
|
|
448
|
+
if (/\bnpx\b|\bnpm\b|registry/i.test(structural)) {
|
|
449
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner entry could not be verified");
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
function assertEntryReplaceable(command) {
|
|
453
|
+
let stat;
|
|
454
|
+
try {
|
|
455
|
+
stat = lstatSync(command);
|
|
456
|
+
}
|
|
457
|
+
catch (error) {
|
|
458
|
+
if (isEnoent(error))
|
|
459
|
+
return;
|
|
460
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner entry cannot be replaced");
|
|
461
|
+
}
|
|
462
|
+
if (stat.isSymbolicLink())
|
|
463
|
+
fail("LOCAL_RUNNER_SYMLINK", "Local CLI runner entry is a symbolic link");
|
|
464
|
+
if (!stat.isFile())
|
|
465
|
+
fail("LOCAL_RUNNER_FOREIGN", "Local CLI runner entry is not a regular file");
|
|
466
|
+
let text = "";
|
|
467
|
+
try {
|
|
468
|
+
text = readFileSync(command, "utf8");
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner entry cannot be replaced");
|
|
472
|
+
}
|
|
473
|
+
if (!isReplaceableWrapper(text))
|
|
474
|
+
fail("LOCAL_RUNNER_FOREIGN", "Local CLI runner entry is not managed by this CLI");
|
|
475
|
+
}
|
|
476
|
+
function removeOwnDirectory(directory) {
|
|
477
|
+
try {
|
|
478
|
+
const stat = lstatSync(directory);
|
|
479
|
+
if (stat.isSymbolicLink() || !stat.isDirectory())
|
|
480
|
+
return;
|
|
481
|
+
rmSync(directory, { recursive: true, force: true });
|
|
482
|
+
}
|
|
483
|
+
catch {
|
|
484
|
+
// The directory is already gone.
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
function wrapperSelects(command, generation) {
|
|
488
|
+
try {
|
|
489
|
+
const stat = lstatSync(command);
|
|
490
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
491
|
+
return false;
|
|
492
|
+
return readFileSync(command, "utf8").includes(`\n# generation ${generation}\n`);
|
|
493
|
+
}
|
|
494
|
+
catch {
|
|
495
|
+
return false;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
function wrapperMatches(command, expected) {
|
|
499
|
+
try {
|
|
500
|
+
const stat = lstatSync(command);
|
|
501
|
+
if (stat.isSymbolicLink() || !stat.isFile() || (stat.mode & 0o111) === 0)
|
|
502
|
+
return false;
|
|
503
|
+
return readFileSync(command, "utf8") === expected;
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
return false;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
function writeStableEntry(binDir, generationDir, metadata, beforeRename) {
|
|
510
|
+
const command = join(binDir, "zhihu");
|
|
511
|
+
assertEntryReplaceable(command);
|
|
512
|
+
const script = wrapperScript(generationDir, metadata);
|
|
513
|
+
const temporary = join(binDir, `.zhihu.${randomBytes(8).toString("hex")}.tmp`);
|
|
514
|
+
let created = false;
|
|
515
|
+
try {
|
|
516
|
+
const descriptor = openSync(temporary, "wx", 0o644);
|
|
517
|
+
created = true;
|
|
518
|
+
try {
|
|
519
|
+
writeFileSync(descriptor, script);
|
|
520
|
+
}
|
|
521
|
+
finally {
|
|
522
|
+
closeSync(descriptor);
|
|
523
|
+
}
|
|
524
|
+
chmodSync(temporary, 0o755);
|
|
525
|
+
const temporaryStat = lstatSync(temporary);
|
|
526
|
+
if (temporaryStat.isSymbolicLink() || !temporaryStat.isFile() || (temporaryStat.mode & 0o111) === 0) {
|
|
527
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner entry could not be verified");
|
|
528
|
+
}
|
|
529
|
+
assertWrapperSafe(readFileSync(temporary, "utf8"), generationDir, metadata);
|
|
530
|
+
if (beforeRename)
|
|
531
|
+
beforeRename();
|
|
532
|
+
assertEntryReplaceable(command);
|
|
533
|
+
renameSync(temporary, command);
|
|
534
|
+
created = false;
|
|
535
|
+
}
|
|
536
|
+
catch (error) {
|
|
537
|
+
if (created) {
|
|
538
|
+
try {
|
|
539
|
+
const stat = lstatSync(temporary);
|
|
540
|
+
if (!stat.isSymbolicLink() && stat.isFile())
|
|
541
|
+
unlinkSync(temporary);
|
|
542
|
+
}
|
|
543
|
+
catch {
|
|
544
|
+
// The temporary file is already gone.
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (error instanceof LocalRunnerError)
|
|
548
|
+
throw error;
|
|
549
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner entry cannot be replaced");
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
function safeGenerationName(value) {
|
|
553
|
+
return /^[0-9A-Za-z][0-9A-Za-z._+-]*$/.test(value) && !value.includes("..") && !value.includes("/") && !value.includes("\\");
|
|
554
|
+
}
|
|
555
|
+
function readRetention(layout) {
|
|
556
|
+
const path = join(layout.runnerRoot, RETENTION_NAME);
|
|
557
|
+
try {
|
|
558
|
+
const stat = lstatSync(path);
|
|
559
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
560
|
+
return undefined;
|
|
561
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
562
|
+
if (parsed.schema !== "1")
|
|
563
|
+
return undefined;
|
|
564
|
+
if (typeof parsed.active !== "string" || !safeGenerationName(parsed.active))
|
|
565
|
+
return undefined;
|
|
566
|
+
if (parsed.previous !== "" && (typeof parsed.previous !== "string" || !safeGenerationName(parsed.previous)))
|
|
567
|
+
return undefined;
|
|
568
|
+
if (Object.keys(parsed).sort().join("\0") !== "active\0previous\0schema")
|
|
569
|
+
return undefined;
|
|
570
|
+
return { active: parsed.active, previous: parsed.previous };
|
|
571
|
+
}
|
|
572
|
+
catch {
|
|
573
|
+
return undefined;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
function writeRetention(layout, active, previous) {
|
|
577
|
+
const path = join(layout.runnerRoot, RETENTION_NAME);
|
|
578
|
+
let existing;
|
|
579
|
+
try {
|
|
580
|
+
existing = lstatSync(path);
|
|
581
|
+
}
|
|
582
|
+
catch (error) {
|
|
583
|
+
if (!isEnoent(error))
|
|
584
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner could not be installed");
|
|
585
|
+
existing = undefined;
|
|
586
|
+
}
|
|
587
|
+
if (existing?.isSymbolicLink())
|
|
588
|
+
fail("LOCAL_RUNNER_SYMLINK", "Local CLI runner directory is a symbolic link");
|
|
589
|
+
const temporary = join(layout.runnerRoot, `.retention.${randomBytes(8).toString("hex")}.tmp`);
|
|
590
|
+
const body = `${JSON.stringify({ active, previous, schema: "1" })}\n`;
|
|
591
|
+
const descriptor = openSync(temporary, "wx", 0o644);
|
|
592
|
+
try {
|
|
593
|
+
writeFileSync(descriptor, body);
|
|
594
|
+
}
|
|
595
|
+
finally {
|
|
596
|
+
closeSync(descriptor);
|
|
597
|
+
}
|
|
598
|
+
try {
|
|
599
|
+
renameSync(temporary, path);
|
|
600
|
+
}
|
|
601
|
+
catch (error) {
|
|
602
|
+
try {
|
|
603
|
+
unlinkSync(temporary);
|
|
604
|
+
}
|
|
605
|
+
catch {
|
|
606
|
+
// The temporary file is already gone.
|
|
607
|
+
}
|
|
608
|
+
throw error;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
function collectExpiredGenerations(layout, active, previous, graceMs) {
|
|
612
|
+
let names = [];
|
|
613
|
+
try {
|
|
614
|
+
names = readdirSync(layout.generationRoot);
|
|
615
|
+
}
|
|
616
|
+
catch {
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
const retained = new Set([active, previous].filter((name) => name.length > 0));
|
|
620
|
+
for (const name of names) {
|
|
621
|
+
if (name.startsWith(".") || retained.has(name) || !safeGenerationName(name))
|
|
622
|
+
continue;
|
|
623
|
+
const directory = join(layout.generationRoot, name);
|
|
624
|
+
try {
|
|
625
|
+
const stat = lstatSync(directory);
|
|
626
|
+
if (stat.isSymbolicLink() || !stat.isDirectory())
|
|
627
|
+
continue;
|
|
628
|
+
if (Date.now() - stat.mtimeMs < graceMs)
|
|
629
|
+
continue;
|
|
630
|
+
const metadata = readMetadata(directory);
|
|
631
|
+
if (!metadata || metadata.generation !== name)
|
|
632
|
+
continue;
|
|
633
|
+
if (runnerTreeDigest(directory) !== metadata.digest)
|
|
634
|
+
continue;
|
|
635
|
+
rmSync(directory, { recursive: true, force: true });
|
|
636
|
+
}
|
|
637
|
+
catch {
|
|
638
|
+
// Cleanup must not break the active entry.
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
function sleepSync(milliseconds) {
|
|
643
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
|
|
644
|
+
}
|
|
645
|
+
function lockOwnerAlive(pid) {
|
|
646
|
+
if (pid === undefined)
|
|
647
|
+
return false;
|
|
648
|
+
try {
|
|
649
|
+
process.kill(pid, 0);
|
|
650
|
+
return true;
|
|
651
|
+
}
|
|
652
|
+
catch (error) {
|
|
653
|
+
const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
|
|
654
|
+
return code !== "ESRCH";
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
function isErrno(error, code) {
|
|
658
|
+
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
|
|
659
|
+
}
|
|
660
|
+
function parseLock(text) {
|
|
661
|
+
try {
|
|
662
|
+
const raw = JSON.parse(text);
|
|
663
|
+
const pid = typeof raw.pid === "number" && Number.isInteger(raw.pid) && raw.pid > 0 ? raw.pid : undefined;
|
|
664
|
+
return { nonce: typeof raw.nonce === "string" ? raw.nonce : "", pid };
|
|
665
|
+
}
|
|
666
|
+
catch {
|
|
667
|
+
return { nonce: "", pid: undefined };
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
function readLockBody(path) {
|
|
671
|
+
try {
|
|
672
|
+
return parseLock(readFileSync(path, "utf8"));
|
|
673
|
+
}
|
|
674
|
+
catch {
|
|
675
|
+
return { nonce: "", pid: undefined };
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
function recoveryRunner(environment) {
|
|
679
|
+
try {
|
|
680
|
+
const existing = existingLocalRunnerCommand(environment);
|
|
681
|
+
if (existing)
|
|
682
|
+
return runnerCommandToken(existing);
|
|
683
|
+
}
|
|
684
|
+
catch (error) {
|
|
685
|
+
if (error instanceof LocalRunnerError && error.code !== "LOCAL_RUNNER_IO_ERROR")
|
|
686
|
+
throw error;
|
|
687
|
+
}
|
|
688
|
+
return CLI_RUNNER;
|
|
689
|
+
}
|
|
690
|
+
export function runnerLockStatusCommand(environment = process.env) {
|
|
691
|
+
return `${recoveryRunner(environment)} runner lock status --json`;
|
|
692
|
+
}
|
|
693
|
+
export function runnerLockRecoverCommand(environment = process.env) {
|
|
694
|
+
return `${recoveryRunner(environment)} runner lock recover --confirm ${RUNNER_LOCK_RECOVERY_CONFIRM} --fingerprint <fingerprint> --json`;
|
|
695
|
+
}
|
|
696
|
+
export function localizedRunnerLockMessage(language, environment = process.env) {
|
|
697
|
+
const status = runnerLockStatusCommand(environment);
|
|
698
|
+
const recover = runnerLockRecoverCommand(environment);
|
|
699
|
+
if (language === "zh") {
|
|
700
|
+
return `安装锁超时。请运行 ${status};若状态为 stale,再运行 ${recover} 后重试。不要手工删除锁。`;
|
|
701
|
+
}
|
|
702
|
+
return `The local runner install lock timed out. Run ${status}; if it is stale, run ${recover} and retry. Do not delete the lock by hand.`;
|
|
703
|
+
}
|
|
704
|
+
function runnerLockTimeoutMessage(environment) {
|
|
705
|
+
return `LOCAL_RUNNER_LOCK_TIMEOUT. ${localizedRunnerLockMessage("en", environment)} ${localizedRunnerLockMessage("zh", environment)}`;
|
|
706
|
+
}
|
|
707
|
+
function managedPublishName(path) {
|
|
708
|
+
const name = basename(path);
|
|
709
|
+
return name === ".lock" || name === LOCK_GATE_NAME || LOCK_TEMP_NAME.test(name);
|
|
710
|
+
}
|
|
711
|
+
function removeOwnedDirectoryEntry(path, dev, ino) {
|
|
712
|
+
if (!managedPublishName(path))
|
|
713
|
+
return;
|
|
714
|
+
try {
|
|
715
|
+
const stat = lstatSync(path);
|
|
716
|
+
if (stat.isSymbolicLink() || !stat.isFile() || stat.dev !== dev || stat.ino !== ino || (stat.mode & 0o077) !== 0)
|
|
717
|
+
return;
|
|
718
|
+
unlinkSync(path);
|
|
719
|
+
}
|
|
720
|
+
catch {
|
|
721
|
+
// The entry is already gone or no longer the inode owned by this publish.
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
function privateFileIdentity(path, dev, ino) {
|
|
725
|
+
try {
|
|
726
|
+
const stat = lstatSync(path);
|
|
727
|
+
return !stat.isSymbolicLink() && stat.isFile() && stat.dev === dev && stat.ino === ino && (stat.mode & 0o077) === 0;
|
|
728
|
+
}
|
|
729
|
+
catch {
|
|
730
|
+
return false;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Write a complete private document to a temporary inode, then publish that
|
|
735
|
+
* inode at the stable path with link(2). The stable directory entry is never
|
|
736
|
+
* created empty and an existing stable entry is never replaced.
|
|
737
|
+
*/
|
|
738
|
+
function publishPrivateDocument(stablePath, document, failure, hooks = {}) {
|
|
739
|
+
if (hooks.beforeCreate)
|
|
740
|
+
hooks.beforeCreate();
|
|
741
|
+
const tempPath = join(dirname(stablePath), `.lock-temp-${randomBytes(12).toString("hex")}`);
|
|
742
|
+
const descriptor = openSync(tempPath, "wx", 0o600);
|
|
743
|
+
let identity;
|
|
744
|
+
try {
|
|
745
|
+
const created = fstatSync(descriptor);
|
|
746
|
+
identity = { dev: created.dev, ino: created.ino };
|
|
747
|
+
if (!created.isFile() || (created.mode & 0o077) !== 0)
|
|
748
|
+
fail(failure.code, failure.message);
|
|
749
|
+
if (hooks.afterCreate)
|
|
750
|
+
hooks.afterCreate();
|
|
751
|
+
writeFileSync(descriptor, document);
|
|
752
|
+
if (hooks.afterWrite)
|
|
753
|
+
hooks.afterWrite();
|
|
754
|
+
fsyncSync(descriptor);
|
|
755
|
+
}
|
|
756
|
+
catch (error) {
|
|
757
|
+
try {
|
|
758
|
+
closeSync(descriptor);
|
|
759
|
+
}
|
|
760
|
+
catch {
|
|
761
|
+
// The temporary descriptor is already closed.
|
|
762
|
+
}
|
|
763
|
+
if (identity)
|
|
764
|
+
removeOwnedDirectoryEntry(tempPath, identity.dev, identity.ino);
|
|
765
|
+
throw error;
|
|
766
|
+
}
|
|
767
|
+
try {
|
|
768
|
+
closeSync(descriptor);
|
|
769
|
+
}
|
|
770
|
+
catch {
|
|
771
|
+
// The temporary descriptor is already closed.
|
|
772
|
+
}
|
|
773
|
+
if (!identity || !privateFileIdentity(tempPath, identity.dev, identity.ino))
|
|
774
|
+
fail(failure.code, failure.message);
|
|
775
|
+
try {
|
|
776
|
+
linkSync(tempPath, stablePath);
|
|
777
|
+
}
|
|
778
|
+
catch (error) {
|
|
779
|
+
removeOwnedDirectoryEntry(tempPath, identity.dev, identity.ino);
|
|
780
|
+
throw error;
|
|
781
|
+
}
|
|
782
|
+
let stable;
|
|
783
|
+
try {
|
|
784
|
+
stable = lstatSync(stablePath);
|
|
785
|
+
}
|
|
786
|
+
catch (error) {
|
|
787
|
+
removeOwnedDirectoryEntry(tempPath, identity.dev, identity.ino);
|
|
788
|
+
throw error;
|
|
789
|
+
}
|
|
790
|
+
if (stable.isSymbolicLink() || !stable.isFile() || stable.dev !== identity.dev || stable.ino !== identity.ino) {
|
|
791
|
+
removeOwnedDirectoryEntry(tempPath, identity.dev, identity.ino);
|
|
792
|
+
fail(failure.code, failure.message);
|
|
793
|
+
}
|
|
794
|
+
try {
|
|
795
|
+
if (hooks.afterPublish)
|
|
796
|
+
hooks.afterPublish();
|
|
797
|
+
}
|
|
798
|
+
catch (error) {
|
|
799
|
+
removeOwnedDirectoryEntry(stablePath, identity.dev, identity.ino);
|
|
800
|
+
removeOwnedDirectoryEntry(tempPath, identity.dev, identity.ino);
|
|
801
|
+
throw error;
|
|
802
|
+
}
|
|
803
|
+
removeOwnedDirectoryEntry(tempPath, identity.dev, identity.ino);
|
|
804
|
+
return identity;
|
|
805
|
+
}
|
|
806
|
+
function acquireRunnerLock(lockPath, environment, options) {
|
|
807
|
+
const timeoutMs = options.lockTimeoutMs ?? 15000;
|
|
808
|
+
const staleMs = options.staleMs ?? 120000;
|
|
809
|
+
const retryMs = options.retryMs ?? 40;
|
|
810
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 1 || !Number.isFinite(staleMs) || staleMs < 1) {
|
|
811
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner could not be installed");
|
|
812
|
+
}
|
|
813
|
+
const runnerRoot = dirname(lockPath);
|
|
814
|
+
const nonce = randomBytes(12).toString("hex");
|
|
815
|
+
const started = Date.now();
|
|
816
|
+
let ownedDev = 0;
|
|
817
|
+
let ownedIno = 0;
|
|
818
|
+
const releaseOwned = () => {
|
|
819
|
+
removeOwnedLockEntries(runnerRoot, lockPath, ownedDev, ownedIno, nonce);
|
|
820
|
+
if (!ownsLockEntry(runnerRoot, lockPath, ownedDev, ownedIno, nonce))
|
|
821
|
+
removeDeadRecoveryGate(runnerRoot);
|
|
822
|
+
};
|
|
823
|
+
const rejectOwnLock = () => {
|
|
824
|
+
removeOwnedLockEntries(runnerRoot, lockPath, ownedDev, ownedIno, nonce);
|
|
825
|
+
if (options.afterOwnLockRejected)
|
|
826
|
+
options.afterOwnLockRejected();
|
|
827
|
+
};
|
|
828
|
+
while (Date.now() - started <= timeoutMs) {
|
|
829
|
+
const block = inspectRecoveryBlock(runnerRoot);
|
|
830
|
+
if (block === "symlink")
|
|
831
|
+
fail("LOCAL_RUNNER_SYMLINK", "Local CLI runner lock is a symbolic link");
|
|
832
|
+
if (block === "foreign")
|
|
833
|
+
fail("LOCAL_RUNNER_FOREIGN", "Local CLI runner lock is not a regular file");
|
|
834
|
+
if (block === "busy") {
|
|
835
|
+
if (options.onLockBusy)
|
|
836
|
+
options.onLockBusy();
|
|
837
|
+
sleepSync(retryMs);
|
|
838
|
+
continue;
|
|
839
|
+
}
|
|
840
|
+
if (options.afterPreLockCheck)
|
|
841
|
+
options.afterPreLockCheck();
|
|
842
|
+
try {
|
|
843
|
+
const existing = lstatSync(lockPath);
|
|
844
|
+
if (existing.isSymbolicLink())
|
|
845
|
+
fail("LOCAL_RUNNER_SYMLINK", "Local CLI runner lock is a symbolic link");
|
|
846
|
+
}
|
|
847
|
+
catch (error) {
|
|
848
|
+
if (error instanceof LocalRunnerError)
|
|
849
|
+
throw error;
|
|
850
|
+
if (!isEnoent(error))
|
|
851
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner could not be installed");
|
|
852
|
+
}
|
|
853
|
+
try {
|
|
854
|
+
const published = publishPrivateDocument(lockPath, JSON.stringify({ pid: process.pid, created_at: Date.now(), nonce }), { code: "LOCAL_RUNNER_IO_ERROR", message: "Local CLI runner could not be installed" }, {
|
|
855
|
+
afterCreate: options.afterLockTempCreate,
|
|
856
|
+
afterWrite: options.afterLockTempWrite,
|
|
857
|
+
afterPublish: options.afterLockPublish,
|
|
858
|
+
});
|
|
859
|
+
ownedDev = published.dev;
|
|
860
|
+
ownedIno = published.ino;
|
|
861
|
+
const again = inspectRecoveryBlock(runnerRoot);
|
|
862
|
+
if (again !== "clear") {
|
|
863
|
+
rejectOwnLock();
|
|
864
|
+
if (again === "symlink")
|
|
865
|
+
fail("LOCAL_RUNNER_SYMLINK", "Local CLI runner lock is a symbolic link");
|
|
866
|
+
if (again === "foreign")
|
|
867
|
+
fail("LOCAL_RUNNER_FOREIGN", "Local CLI runner lock is not a regular file");
|
|
868
|
+
if (options.onLockBusy)
|
|
869
|
+
options.onLockBusy();
|
|
870
|
+
sleepSync(retryMs);
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
return releaseOwned;
|
|
874
|
+
}
|
|
875
|
+
catch (error) {
|
|
876
|
+
if (error instanceof LocalRunnerError)
|
|
877
|
+
throw error;
|
|
878
|
+
if (!isEexist(error))
|
|
879
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner could not be installed");
|
|
880
|
+
// A lock is never renamed or removed here. Stale locks wait until timeout.
|
|
881
|
+
if (options.onLockBusy)
|
|
882
|
+
options.onLockBusy();
|
|
883
|
+
sleepSync(retryMs);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
fail("LOCAL_RUNNER_LOCK_TIMEOUT", runnerLockTimeoutMessage(environment));
|
|
887
|
+
}
|
|
888
|
+
function lockFingerprint(dev, ino, nonce) {
|
|
889
|
+
return createHash("sha256").update(`${dev}:${ino}:${nonce}`).digest("hex").slice(0, 16);
|
|
890
|
+
}
|
|
891
|
+
function lockDetail(state, environment) {
|
|
892
|
+
const status = runnerLockStatusCommand(environment);
|
|
893
|
+
const recover = runnerLockRecoverCommand(environment);
|
|
894
|
+
switch (state) {
|
|
895
|
+
case "held":
|
|
896
|
+
return `A local runner install may be in progress. Run ${status} and retry after it finishes. 可能有安装正在进行。请运行 ${status},结束后再重试。`;
|
|
897
|
+
case "stale":
|
|
898
|
+
return `A previous install left a stale lock. Run ${recover}, then retry install. Do not delete the lock by hand. 请使用 status 返回的 fingerprint 运行 ${recover} 后重试安装。不要手工删除锁。`;
|
|
899
|
+
case "symlink":
|
|
900
|
+
return "The runner lock is a symbolic link and recovery will not remove it. Do not delete it by hand. 锁是符号链接,恢复命令不会删除它。不要手工删除。";
|
|
901
|
+
case "invalid":
|
|
902
|
+
return "The runner lock is not a recoverable regular file. Recovery will not remove it. Do not delete it by hand. 锁不是可恢复的普通文件,恢复命令不会删除它。不要手工删除。";
|
|
903
|
+
default: {
|
|
904
|
+
const unreachable = state;
|
|
905
|
+
return unreachable;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
function lockClaimNames(runnerRoot) {
|
|
910
|
+
try {
|
|
911
|
+
return readdirSync(runnerRoot).filter((name) => LOCK_CLAIM_NAME.test(name)).sort();
|
|
912
|
+
}
|
|
913
|
+
catch {
|
|
914
|
+
return [];
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
function describeLock(stat, body, staleMs, environment) {
|
|
918
|
+
if (stat.isSymbolicLink())
|
|
919
|
+
return { state: "symlink", detail: lockDetail("symlink", environment) };
|
|
920
|
+
const age = Math.max(0, Date.now() - stat.mtimeMs);
|
|
921
|
+
if (!stat.isFile())
|
|
922
|
+
return { state: "invalid", age_ms: age, detail: lockDetail("invalid", environment) };
|
|
923
|
+
const fingerprint = body.nonce ? lockFingerprint(stat.dev, stat.ino, body.nonce) : undefined;
|
|
924
|
+
const common = {
|
|
925
|
+
age_ms: age,
|
|
926
|
+
...(body.pid !== undefined ? { pid: body.pid } : {}),
|
|
927
|
+
...(fingerprint ? { fingerprint } : {}),
|
|
928
|
+
};
|
|
929
|
+
if (!body.nonce)
|
|
930
|
+
return { state: "invalid", ...common, detail: lockDetail("invalid", environment) };
|
|
931
|
+
if (lockOwnerAlive(body.pid) || age < staleMs)
|
|
932
|
+
return { state: "held", ...common, detail: lockDetail("held", environment) };
|
|
933
|
+
return { state: "stale", ...common, detail: lockDetail("stale", environment) };
|
|
934
|
+
}
|
|
935
|
+
/** Read the install lock without creating, renaming, or removing it. */
|
|
936
|
+
export function inspectLocalRunnerLock(environment = process.env, options = {}) {
|
|
937
|
+
const staleMs = options.staleMs ?? 120000;
|
|
938
|
+
if (!Number.isFinite(staleMs) || staleMs < 1)
|
|
939
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner lock recovery could not be completed");
|
|
940
|
+
const layout = layoutFor(environment);
|
|
941
|
+
let path = layout.lockPath;
|
|
942
|
+
let stat;
|
|
943
|
+
try {
|
|
944
|
+
stat = lstatSync(path);
|
|
945
|
+
}
|
|
946
|
+
catch {
|
|
947
|
+
const claims = lockClaimNames(layout.runnerRoot);
|
|
948
|
+
if (claims.length === 0)
|
|
949
|
+
return describeRecoveryGate(layout.runnerRoot, environment);
|
|
950
|
+
path = join(layout.runnerRoot, claims[0]);
|
|
951
|
+
try {
|
|
952
|
+
stat = lstatSync(path);
|
|
953
|
+
}
|
|
954
|
+
catch {
|
|
955
|
+
return { state: "invalid", detail: lockDetail("invalid", environment) };
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
const body = stat.isSymbolicLink() || !stat.isFile()
|
|
959
|
+
? { nonce: "", pid: undefined }
|
|
960
|
+
: readLockBody(path);
|
|
961
|
+
return describeLock(stat, body, staleMs, environment);
|
|
962
|
+
}
|
|
963
|
+
function openLockNoFollow(path) {
|
|
964
|
+
try {
|
|
965
|
+
return openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
966
|
+
}
|
|
967
|
+
catch (error) {
|
|
968
|
+
if (isErrno(error, "ELOOP"))
|
|
969
|
+
fail("LOCAL_RUNNER_SYMLINK", "Local CLI runner lock is a symbolic link");
|
|
970
|
+
if (isErrno(error, "EISDIR"))
|
|
971
|
+
fail("LOCAL_RUNNER_FOREIGN", "Local CLI runner lock is not a regular file");
|
|
972
|
+
if (isEnoent(error))
|
|
973
|
+
fail("LOCAL_RUNNER_LOCK_TIMEOUT", "Local CLI runner lock is still in use");
|
|
974
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner lock recovery could not be completed");
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
function readLockFromDescriptor(descriptor) {
|
|
978
|
+
const stat = fstatSync(descriptor);
|
|
979
|
+
if (!stat.isFile())
|
|
980
|
+
fail("LOCAL_RUNNER_FOREIGN", "Local CLI runner lock is not a regular file");
|
|
981
|
+
const length = Math.min(Math.max(stat.size, 0), 4096);
|
|
982
|
+
if (length < 1)
|
|
983
|
+
return { nonce: "", pid: undefined };
|
|
984
|
+
const buffer = Buffer.alloc(length);
|
|
985
|
+
try {
|
|
986
|
+
readSync(descriptor, buffer, 0, length, 0);
|
|
987
|
+
}
|
|
988
|
+
catch {
|
|
989
|
+
return { nonce: "", pid: undefined };
|
|
990
|
+
}
|
|
991
|
+
return parseLock(buffer.toString("utf8"));
|
|
992
|
+
}
|
|
993
|
+
function acceptOpenLock(path, fingerprint, staleMs) {
|
|
994
|
+
let observed;
|
|
995
|
+
try {
|
|
996
|
+
observed = lstatSync(path);
|
|
997
|
+
}
|
|
998
|
+
catch (error) {
|
|
999
|
+
if (isEnoent(error))
|
|
1000
|
+
fail("LOCAL_RUNNER_LOCK_TIMEOUT", "Local CLI runner lock is still in use");
|
|
1001
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner lock recovery could not be completed");
|
|
1002
|
+
}
|
|
1003
|
+
if (observed.isSymbolicLink())
|
|
1004
|
+
fail("LOCAL_RUNNER_SYMLINK", "Local CLI runner lock is a symbolic link");
|
|
1005
|
+
if (!observed.isFile())
|
|
1006
|
+
fail("LOCAL_RUNNER_FOREIGN", "Local CLI runner lock is not a regular file");
|
|
1007
|
+
const descriptor = openLockNoFollow(path);
|
|
1008
|
+
try {
|
|
1009
|
+
const current = fstatSync(descriptor);
|
|
1010
|
+
const body = readLockFromDescriptor(descriptor);
|
|
1011
|
+
const age = Date.now() - current.mtimeMs;
|
|
1012
|
+
const actual = body.nonce ? lockFingerprint(current.dev, current.ino, body.nonce) : "";
|
|
1013
|
+
if (!body.nonce || actual !== fingerprint || age < staleMs || lockOwnerAlive(body.pid) || current.dev !== observed.dev || current.ino !== observed.ino) {
|
|
1014
|
+
fail("LOCAL_RUNNER_LOCK_TIMEOUT", "Local CLI runner lock is still in use");
|
|
1015
|
+
}
|
|
1016
|
+
return { descriptor, dev: current.dev, ino: current.ino };
|
|
1017
|
+
}
|
|
1018
|
+
catch (error) {
|
|
1019
|
+
closeSync(descriptor);
|
|
1020
|
+
throw error;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
function lockEntryOwned(path, dev, ino, nonce) {
|
|
1024
|
+
try {
|
|
1025
|
+
const stat = lstatSync(path);
|
|
1026
|
+
if (stat.isSymbolicLink() || !stat.isFile() || stat.dev !== dev || stat.ino !== ino)
|
|
1027
|
+
return false;
|
|
1028
|
+
return readLockBody(path).nonce === nonce;
|
|
1029
|
+
}
|
|
1030
|
+
catch {
|
|
1031
|
+
return false;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
function ownedLockPaths(runnerRoot, lockPath) {
|
|
1035
|
+
return [lockPath, ...lockClaimNames(runnerRoot).map((name) => join(runnerRoot, name))];
|
|
1036
|
+
}
|
|
1037
|
+
function removeOwnedLockEntries(runnerRoot, lockPath, dev, ino, nonce) {
|
|
1038
|
+
for (const path of ownedLockPaths(runnerRoot, lockPath)) {
|
|
1039
|
+
if (!lockEntryOwned(path, dev, ino, nonce))
|
|
1040
|
+
continue;
|
|
1041
|
+
try {
|
|
1042
|
+
unlinkSync(path);
|
|
1043
|
+
}
|
|
1044
|
+
catch {
|
|
1045
|
+
// A replaced entry is no longer owned by this process.
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
function ownsLockEntry(runnerRoot, lockPath, dev, ino, nonce) {
|
|
1050
|
+
return ownedLockPaths(runnerRoot, lockPath).some((path) => lockEntryOwned(path, dev, ino, nonce));
|
|
1051
|
+
}
|
|
1052
|
+
function recoveryGatePath(runnerRoot) {
|
|
1053
|
+
return join(runnerRoot, LOCK_GATE_NAME);
|
|
1054
|
+
}
|
|
1055
|
+
function recoveryGateDocument(fingerprint, pid) {
|
|
1056
|
+
return JSON.stringify({ fingerprint, pid, purpose: "recover", schema: "1" });
|
|
1057
|
+
}
|
|
1058
|
+
function parseRecoveryGate(text) {
|
|
1059
|
+
try {
|
|
1060
|
+
const raw = JSON.parse(text);
|
|
1061
|
+
if (Object.keys(raw).sort().join(",") !== LOCK_GATE_KEYS.join(","))
|
|
1062
|
+
return undefined;
|
|
1063
|
+
if (raw.purpose !== "recover" || raw.schema !== "1")
|
|
1064
|
+
return undefined;
|
|
1065
|
+
if (typeof raw.fingerprint !== "string" || !LOCK_FINGERPRINT_PATTERN.test(raw.fingerprint))
|
|
1066
|
+
return undefined;
|
|
1067
|
+
if (typeof raw.pid !== "number" || !Number.isInteger(raw.pid) || raw.pid <= 0)
|
|
1068
|
+
return undefined;
|
|
1069
|
+
return { fingerprint: raw.fingerprint, pid: raw.pid, purpose: "recover", schema: "1" };
|
|
1070
|
+
}
|
|
1071
|
+
catch {
|
|
1072
|
+
return undefined;
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
function readRecoveryGate(path) {
|
|
1076
|
+
let descriptor;
|
|
1077
|
+
try {
|
|
1078
|
+
descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
1079
|
+
}
|
|
1080
|
+
catch (error) {
|
|
1081
|
+
if (isEnoent(error))
|
|
1082
|
+
return { kind: "missing" };
|
|
1083
|
+
if (isErrno(error, "ELOOP"))
|
|
1084
|
+
return { kind: "symlink" };
|
|
1085
|
+
if (isErrno(error, "EISDIR"))
|
|
1086
|
+
return { kind: "foreign" };
|
|
1087
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner lock recovery could not be completed");
|
|
1088
|
+
}
|
|
1089
|
+
try {
|
|
1090
|
+
const stat = fstatSync(descriptor);
|
|
1091
|
+
if (!stat.isFile())
|
|
1092
|
+
return { kind: "foreign" };
|
|
1093
|
+
if ((stat.mode & 0o077) !== 0)
|
|
1094
|
+
return { kind: "invalid" };
|
|
1095
|
+
const length = Math.min(Math.max(stat.size, 0), 4096);
|
|
1096
|
+
const buffer = Buffer.alloc(length);
|
|
1097
|
+
if (length > 0) {
|
|
1098
|
+
try {
|
|
1099
|
+
readSync(descriptor, buffer, 0, length, 0);
|
|
1100
|
+
}
|
|
1101
|
+
catch {
|
|
1102
|
+
return { kind: "invalid" };
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
const body = parseRecoveryGate(buffer.toString("utf8"));
|
|
1106
|
+
if (!body)
|
|
1107
|
+
return { kind: "invalid" };
|
|
1108
|
+
return { kind: "valid", stat, body };
|
|
1109
|
+
}
|
|
1110
|
+
finally {
|
|
1111
|
+
closeSync(descriptor);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
function inspectRecoveryBlock(runnerRoot) {
|
|
1115
|
+
const gate = readRecoveryGate(recoveryGatePath(runnerRoot));
|
|
1116
|
+
if (gate.kind === "symlink")
|
|
1117
|
+
return "symlink";
|
|
1118
|
+
if (gate.kind === "foreign")
|
|
1119
|
+
return "foreign";
|
|
1120
|
+
if (lockClaimNames(runnerRoot).length > 0)
|
|
1121
|
+
return "busy";
|
|
1122
|
+
if (gate.kind === "missing")
|
|
1123
|
+
return "clear";
|
|
1124
|
+
return "busy";
|
|
1125
|
+
}
|
|
1126
|
+
function describeRecoveryGate(runnerRoot, environment) {
|
|
1127
|
+
const gate = readRecoveryGate(recoveryGatePath(runnerRoot));
|
|
1128
|
+
if (gate.kind === "missing")
|
|
1129
|
+
return { state: "missing" };
|
|
1130
|
+
if (gate.kind === "symlink")
|
|
1131
|
+
return { state: "symlink", detail: lockDetail("symlink", environment) };
|
|
1132
|
+
if (gate.kind !== "valid")
|
|
1133
|
+
return { state: "invalid", detail: lockDetail("invalid", environment) };
|
|
1134
|
+
const state = lockOwnerAlive(gate.body.pid) ? "held" : "stale";
|
|
1135
|
+
return {
|
|
1136
|
+
state,
|
|
1137
|
+
pid: gate.body.pid,
|
|
1138
|
+
age_ms: Math.max(0, Date.now() - gate.stat.mtimeMs),
|
|
1139
|
+
fingerprint: gate.body.fingerprint,
|
|
1140
|
+
detail: lockDetail(state, environment),
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
function dropRecoveryGate(runnerRoot, identity) {
|
|
1144
|
+
const path = recoveryGatePath(runnerRoot);
|
|
1145
|
+
try {
|
|
1146
|
+
const stat = lstatSync(path);
|
|
1147
|
+
if (stat.isSymbolicLink() || !stat.isFile() || stat.dev !== identity.dev || stat.ino !== identity.ino)
|
|
1148
|
+
return;
|
|
1149
|
+
unlinkSync(path);
|
|
1150
|
+
}
|
|
1151
|
+
catch {
|
|
1152
|
+
// The gate is already gone or no longer the inode this recovery held.
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
function removeDeadRecoveryGate(runnerRoot) {
|
|
1156
|
+
const path = recoveryGatePath(runnerRoot);
|
|
1157
|
+
const gate = readRecoveryGate(path);
|
|
1158
|
+
if (gate.kind !== "valid" || lockOwnerAlive(gate.body.pid) || lockClaimNames(runnerRoot).length > 0)
|
|
1159
|
+
return;
|
|
1160
|
+
dropRecoveryGate(runnerRoot, gate.stat);
|
|
1161
|
+
}
|
|
1162
|
+
function cleanupDeadDisplacement(runnerRoot, lockPath) {
|
|
1163
|
+
const gate = readRecoveryGate(recoveryGatePath(runnerRoot));
|
|
1164
|
+
if (gate.kind !== "valid" || lockOwnerAlive(gate.body.pid))
|
|
1165
|
+
return;
|
|
1166
|
+
let lockStat;
|
|
1167
|
+
try {
|
|
1168
|
+
lockStat = lstatSync(lockPath);
|
|
1169
|
+
}
|
|
1170
|
+
catch {
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
if (lockStat.isSymbolicLink() || !lockStat.isFile())
|
|
1174
|
+
return;
|
|
1175
|
+
let names = [];
|
|
1176
|
+
try {
|
|
1177
|
+
names = readdirSync(runnerRoot);
|
|
1178
|
+
}
|
|
1179
|
+
catch {
|
|
1180
|
+
return;
|
|
1181
|
+
}
|
|
1182
|
+
let different = false;
|
|
1183
|
+
for (const name of names) {
|
|
1184
|
+
if (!LOCK_CLAIM_NAME.test(name))
|
|
1185
|
+
continue;
|
|
1186
|
+
const path = join(runnerRoot, name);
|
|
1187
|
+
try {
|
|
1188
|
+
const stat = lstatSync(path);
|
|
1189
|
+
if (stat.isSymbolicLink() || !stat.isFile() || stat.dev !== lockStat.dev || stat.ino !== lockStat.ino) {
|
|
1190
|
+
different = true;
|
|
1191
|
+
continue;
|
|
1192
|
+
}
|
|
1193
|
+
try {
|
|
1194
|
+
unlinkSync(path);
|
|
1195
|
+
}
|
|
1196
|
+
catch (unlinkError) {
|
|
1197
|
+
if (!isEnoent(unlinkError))
|
|
1198
|
+
different = true;
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
catch (error) {
|
|
1202
|
+
if (!isEnoent(error))
|
|
1203
|
+
different = true;
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
if (!different)
|
|
1207
|
+
dropRecoveryGate(runnerRoot, gate.stat);
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1210
|
+
* Publish or adopt the recovery gate. One live holder excludes every other
|
|
1211
|
+
* recovery. After that holder removes the gate, a later recovery may publish
|
|
1212
|
+
* its own gate; exclusion is per holder, not one publish for the lifetime.
|
|
1213
|
+
* The later recovery still fails closed when the target lock is already gone.
|
|
1214
|
+
*/
|
|
1215
|
+
function ensureRecoveryGate(runnerRoot, fingerprint, options = {}) {
|
|
1216
|
+
const path = recoveryGatePath(runnerRoot);
|
|
1217
|
+
try {
|
|
1218
|
+
const published = publishPrivateDocument(path, recoveryGateDocument(fingerprint, process.pid), { code: "LOCAL_RUNNER_IO_ERROR", message: "Local CLI runner lock recovery could not be completed" }, {
|
|
1219
|
+
beforeCreate: options.beforeGateTemp,
|
|
1220
|
+
afterCreate: options.afterGateTempCreate,
|
|
1221
|
+
afterWrite: options.afterGateTempWrite,
|
|
1222
|
+
afterPublish: options.afterGatePublish,
|
|
1223
|
+
});
|
|
1224
|
+
return { dev: published.dev, ino: published.ino, fresh: true };
|
|
1225
|
+
}
|
|
1226
|
+
catch (error) {
|
|
1227
|
+
if (error instanceof LocalRunnerError)
|
|
1228
|
+
throw error;
|
|
1229
|
+
if (!isEexist(error))
|
|
1230
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner lock recovery could not be completed");
|
|
1231
|
+
}
|
|
1232
|
+
const existing = readRecoveryGate(path);
|
|
1233
|
+
if (existing.kind === "symlink")
|
|
1234
|
+
fail("LOCAL_RUNNER_SYMLINK", "Local CLI runner lock is a symbolic link");
|
|
1235
|
+
if (existing.kind === "foreign")
|
|
1236
|
+
fail("LOCAL_RUNNER_FOREIGN", "Local CLI runner lock is not a regular file");
|
|
1237
|
+
if (existing.kind !== "valid" || lockOwnerAlive(existing.body.pid) || existing.body.fingerprint !== fingerprint) {
|
|
1238
|
+
fail("LOCAL_RUNNER_LOCK_TIMEOUT", "Local CLI runner lock is still in use");
|
|
1239
|
+
}
|
|
1240
|
+
return { dev: existing.stat.dev, ino: existing.stat.ino, fresh: false };
|
|
1241
|
+
}
|
|
1242
|
+
function restoreDisplacedLock(claimPath, lockPath) {
|
|
1243
|
+
try {
|
|
1244
|
+
linkSync(claimPath, lockPath);
|
|
1245
|
+
}
|
|
1246
|
+
catch {
|
|
1247
|
+
return false;
|
|
1248
|
+
}
|
|
1249
|
+
return true;
|
|
1250
|
+
}
|
|
1251
|
+
function deleteVerifiedClaimInodes(runnerRoot, dev, ino) {
|
|
1252
|
+
let names = [];
|
|
1253
|
+
try {
|
|
1254
|
+
names = readdirSync(runnerRoot);
|
|
1255
|
+
}
|
|
1256
|
+
catch {
|
|
1257
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner lock recovery could not be completed");
|
|
1258
|
+
}
|
|
1259
|
+
for (const name of names) {
|
|
1260
|
+
if (!LOCK_CLAIM_NAME.test(name))
|
|
1261
|
+
continue;
|
|
1262
|
+
const path = join(runnerRoot, name);
|
|
1263
|
+
try {
|
|
1264
|
+
const stat = lstatSync(path);
|
|
1265
|
+
if (stat.isSymbolicLink() || !stat.isFile() || stat.dev !== dev || stat.ino !== ino)
|
|
1266
|
+
continue;
|
|
1267
|
+
unlinkSync(path);
|
|
1268
|
+
}
|
|
1269
|
+
catch (error) {
|
|
1270
|
+
if (isEnoent(error))
|
|
1271
|
+
continue;
|
|
1272
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner lock recovery could not be completed");
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
function isolateVerifiedLock(source, lockPath, runnerRoot, identity, gate, options) {
|
|
1277
|
+
const claimPath = join(runnerRoot, `.lock-claim-${randomBytes(12).toString("hex")}`);
|
|
1278
|
+
try {
|
|
1279
|
+
renameSync(source, claimPath);
|
|
1280
|
+
}
|
|
1281
|
+
catch (error) {
|
|
1282
|
+
if (isEnoent(error)) {
|
|
1283
|
+
dropRecoveryGate(runnerRoot, gate);
|
|
1284
|
+
if (gate.fresh)
|
|
1285
|
+
fail("LOCAL_RUNNER_LOCK_TIMEOUT", "Local CLI runner lock is still in use");
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner lock recovery could not be completed");
|
|
1289
|
+
}
|
|
1290
|
+
if (options.afterRename)
|
|
1291
|
+
options.afterRename();
|
|
1292
|
+
let claimStat;
|
|
1293
|
+
try {
|
|
1294
|
+
claimStat = lstatSync(claimPath);
|
|
1295
|
+
}
|
|
1296
|
+
catch (error) {
|
|
1297
|
+
if (isEnoent(error)) {
|
|
1298
|
+
dropRecoveryGate(runnerRoot, gate);
|
|
1299
|
+
fail("LOCAL_RUNNER_LOCK_TIMEOUT", "Local CLI runner lock is still in use");
|
|
1300
|
+
}
|
|
1301
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner lock recovery could not be completed");
|
|
1302
|
+
}
|
|
1303
|
+
if (claimStat.isSymbolicLink() || !claimStat.isFile() || claimStat.dev !== identity.dev || claimStat.ino !== identity.ino) {
|
|
1304
|
+
if (!claimStat.isSymbolicLink() && claimStat.isFile() && restoreDisplacedLock(claimPath, lockPath)) {
|
|
1305
|
+
if (options.afterRestore)
|
|
1306
|
+
options.afterRestore();
|
|
1307
|
+
let unlinked = false;
|
|
1308
|
+
try {
|
|
1309
|
+
unlinkSync(claimPath);
|
|
1310
|
+
unlinked = true;
|
|
1311
|
+
}
|
|
1312
|
+
catch (error) {
|
|
1313
|
+
if (isEnoent(error))
|
|
1314
|
+
unlinked = true;
|
|
1315
|
+
}
|
|
1316
|
+
if (unlinked)
|
|
1317
|
+
dropRecoveryGate(runnerRoot, gate);
|
|
1318
|
+
}
|
|
1319
|
+
fail("LOCAL_RUNNER_LOCK_TIMEOUT", "Local CLI runner lock is still in use");
|
|
1320
|
+
}
|
|
1321
|
+
if (options.afterClaim)
|
|
1322
|
+
options.afterClaim();
|
|
1323
|
+
deleteVerifiedClaimInodes(runnerRoot, identity.dev, identity.ino);
|
|
1324
|
+
if (options.afterDelete)
|
|
1325
|
+
options.afterDelete();
|
|
1326
|
+
dropRecoveryGate(runnerRoot, gate);
|
|
1327
|
+
}
|
|
1328
|
+
/**
|
|
1329
|
+
* Remove one stale install lock after the caller repeats the confirmation
|
|
1330
|
+
* phrase and the fingerprint from status. The deleted inode is the opened
|
|
1331
|
+
* file, never a later directory entry at the stable lock path. Install does
|
|
1332
|
+
* not call this.
|
|
1333
|
+
*
|
|
1334
|
+
* The gate guarantees mutual exclusion, not a single publish for the whole
|
|
1335
|
+
* recovery lifetime. A later recovery may publish another gate after the
|
|
1336
|
+
* first holder releases it, then return `LOCAL_RUNNER_LOCK_TIMEOUT` if the
|
|
1337
|
+
* target lock is already gone. Two gate holds, and the recovery critical
|
|
1338
|
+
* sections inside them, must not overlap.
|
|
1339
|
+
*/
|
|
1340
|
+
export function recoverLocalRunnerLock(environment = process.env, options = {}) {
|
|
1341
|
+
if (options.confirm !== RUNNER_LOCK_RECOVERY_CONFIRM) {
|
|
1342
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner lock recovery requires explicit confirmation");
|
|
1343
|
+
}
|
|
1344
|
+
const staleMs = options.staleMs ?? 120000;
|
|
1345
|
+
if (!Number.isFinite(staleMs) || staleMs < 1 || typeof options.fingerprint !== "string" || !LOCK_FINGERPRINT_PATTERN.test(options.fingerprint)) {
|
|
1346
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner lock recovery requires explicit confirmation");
|
|
1347
|
+
}
|
|
1348
|
+
const fingerprint = options.fingerprint;
|
|
1349
|
+
const layout = layoutFor(environment);
|
|
1350
|
+
cleanupDeadDisplacement(layout.runnerRoot, layout.lockPath);
|
|
1351
|
+
let source = layout.lockPath;
|
|
1352
|
+
try {
|
|
1353
|
+
lstatSync(source);
|
|
1354
|
+
}
|
|
1355
|
+
catch (error) {
|
|
1356
|
+
if (!isEnoent(error))
|
|
1357
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner lock recovery could not be completed");
|
|
1358
|
+
const claims = lockClaimNames(layout.runnerRoot);
|
|
1359
|
+
if (claims.length === 0) {
|
|
1360
|
+
concludeAbsentRecovery(layout.runnerRoot, fingerprint);
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
source = join(layout.runnerRoot, claims[0]);
|
|
1364
|
+
}
|
|
1365
|
+
const opened = acceptOpenLock(source, fingerprint, staleMs);
|
|
1366
|
+
try {
|
|
1367
|
+
if (options.beforeRename)
|
|
1368
|
+
options.beforeRename();
|
|
1369
|
+
const gate = ensureRecoveryGate(layout.runnerRoot, fingerprint, options);
|
|
1370
|
+
if (options.afterGate)
|
|
1371
|
+
options.afterGate();
|
|
1372
|
+
isolateVerifiedLock(source, layout.lockPath, layout.runnerRoot, opened, gate, options);
|
|
1373
|
+
}
|
|
1374
|
+
finally {
|
|
1375
|
+
closeSync(opened.descriptor);
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
function concludeAbsentRecovery(runnerRoot, fingerprint) {
|
|
1379
|
+
const gate = readRecoveryGate(recoveryGatePath(runnerRoot));
|
|
1380
|
+
if (gate.kind === "missing" || gate.kind === "invalid") {
|
|
1381
|
+
fail("LOCAL_RUNNER_LOCK_TIMEOUT", "Local CLI runner lock is still in use");
|
|
1382
|
+
}
|
|
1383
|
+
if (gate.kind === "symlink")
|
|
1384
|
+
fail("LOCAL_RUNNER_SYMLINK", "Local CLI runner lock is a symbolic link");
|
|
1385
|
+
if (gate.kind === "foreign")
|
|
1386
|
+
fail("LOCAL_RUNNER_FOREIGN", "Local CLI runner lock is not a regular file");
|
|
1387
|
+
if (lockOwnerAlive(gate.body.pid) || gate.body.fingerprint !== fingerprint) {
|
|
1388
|
+
fail("LOCAL_RUNNER_LOCK_TIMEOUT", "Local CLI runner lock is still in use");
|
|
1389
|
+
}
|
|
1390
|
+
dropRecoveryGate(runnerRoot, gate.stat);
|
|
1391
|
+
}
|
|
1392
|
+
function nodeIsUsable(nodePath) {
|
|
1393
|
+
try {
|
|
1394
|
+
const stat = statSync(nodePath);
|
|
1395
|
+
return stat.isFile() && (stat.mode & 0o111) !== 0;
|
|
1396
|
+
}
|
|
1397
|
+
catch {
|
|
1398
|
+
return false;
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
function entryIsUsable(entryPath) {
|
|
1402
|
+
try {
|
|
1403
|
+
const stat = lstatSync(entryPath);
|
|
1404
|
+
return !stat.isSymbolicLink() && stat.isFile();
|
|
1405
|
+
}
|
|
1406
|
+
catch {
|
|
1407
|
+
return false;
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
function classifyGeneration(directory, digest, nodePath) {
|
|
1411
|
+
let stat;
|
|
1412
|
+
try {
|
|
1413
|
+
stat = lstatSync(directory);
|
|
1414
|
+
}
|
|
1415
|
+
catch (error) {
|
|
1416
|
+
if (isEnoent(error))
|
|
1417
|
+
return "missing";
|
|
1418
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner could not be installed");
|
|
1419
|
+
}
|
|
1420
|
+
if (stat.isSymbolicLink())
|
|
1421
|
+
return "symlink";
|
|
1422
|
+
if (!stat.isDirectory())
|
|
1423
|
+
return "file";
|
|
1424
|
+
const metadata = readMetadata(directory);
|
|
1425
|
+
if (!metadata)
|
|
1426
|
+
return "foreign";
|
|
1427
|
+
if (metadata.digest !== digest || metadata.node_path !== nodePath || !entryIsUsable(join(directory, metadata.entry)))
|
|
1428
|
+
return "damaged";
|
|
1429
|
+
if (runnerTreeDigest(directory) !== metadata.digest)
|
|
1430
|
+
return "damaged";
|
|
1431
|
+
return "verified";
|
|
1432
|
+
}
|
|
1433
|
+
/** Read runner health without creating directories or files. */
|
|
1434
|
+
export function inspectLocalRunner(environment = process.env) {
|
|
1435
|
+
const broken = (detail) => ({
|
|
1436
|
+
command: CLI_RUNNER,
|
|
1437
|
+
health: { state: "broken", detail },
|
|
1438
|
+
});
|
|
1439
|
+
let layout;
|
|
1440
|
+
try {
|
|
1441
|
+
layout = layoutFor(environment);
|
|
1442
|
+
assertEmbeddableCommandPath(layout.command);
|
|
1443
|
+
}
|
|
1444
|
+
catch (error) {
|
|
1445
|
+
if (error instanceof LocalRunnerError && error.code === "LOCAL_RUNNER_UNSAFE_PATH") {
|
|
1446
|
+
return broken("runner path cannot be embedded");
|
|
1447
|
+
}
|
|
1448
|
+
return broken("runner installation is incomplete");
|
|
1449
|
+
}
|
|
1450
|
+
let stat;
|
|
1451
|
+
try {
|
|
1452
|
+
stat = lstatSync(layout.command);
|
|
1453
|
+
}
|
|
1454
|
+
catch (error) {
|
|
1455
|
+
if (isEnoent(error))
|
|
1456
|
+
return { command: CLI_RUNNER, health: { state: "missing" } };
|
|
1457
|
+
return broken("runner installation is incomplete");
|
|
1458
|
+
}
|
|
1459
|
+
if (stat.isSymbolicLink() || !stat.isFile() || (stat.mode & 0o111) === 0) {
|
|
1460
|
+
return broken("runner entry is not an executable file");
|
|
1461
|
+
}
|
|
1462
|
+
let text = "";
|
|
1463
|
+
try {
|
|
1464
|
+
text = readFileSync(layout.command, "utf8");
|
|
1465
|
+
}
|
|
1466
|
+
catch {
|
|
1467
|
+
return broken("runner installation is incomplete");
|
|
1468
|
+
}
|
|
1469
|
+
const generation = wrapperGeneration(text);
|
|
1470
|
+
if (!generation)
|
|
1471
|
+
return broken("runner entry is not managed by this CLI");
|
|
1472
|
+
const generationDir = join(layout.generationRoot, generation);
|
|
1473
|
+
let generationStat;
|
|
1474
|
+
try {
|
|
1475
|
+
generationStat = lstatSync(generationDir);
|
|
1476
|
+
}
|
|
1477
|
+
catch {
|
|
1478
|
+
return broken("runner installation is incomplete");
|
|
1479
|
+
}
|
|
1480
|
+
if (generationStat.isSymbolicLink() || !generationStat.isDirectory()) {
|
|
1481
|
+
return broken("runner installation is incomplete");
|
|
1482
|
+
}
|
|
1483
|
+
const metadata = readMetadata(generationDir);
|
|
1484
|
+
if (!metadata)
|
|
1485
|
+
return broken("runner installation is incomplete");
|
|
1486
|
+
let expected = "";
|
|
1487
|
+
try {
|
|
1488
|
+
expected = wrapperScript(generationDir, metadata);
|
|
1489
|
+
}
|
|
1490
|
+
catch {
|
|
1491
|
+
return broken("runner installation is incomplete");
|
|
1492
|
+
}
|
|
1493
|
+
if (text !== expected)
|
|
1494
|
+
return broken("runner entry is not managed by this CLI");
|
|
1495
|
+
const entryPath = join(generationDir, metadata.entry);
|
|
1496
|
+
if (!entryIsUsable(entryPath))
|
|
1497
|
+
return broken("runner target is unavailable");
|
|
1498
|
+
if (!nodeIsUsable(metadata.node_path))
|
|
1499
|
+
return broken("runner node is unavailable");
|
|
1500
|
+
if (runnerTreeDigest(generationDir) !== metadata.digest)
|
|
1501
|
+
return broken("runner content does not match its generation");
|
|
1502
|
+
return { command: layout.command, health: { state: "ready" } };
|
|
1503
|
+
}
|
|
1504
|
+
/** Read the stable wrapper when it is a ready managed entry. Does not create it. */
|
|
1505
|
+
export function existingLocalRunnerCommand(environment = process.env) {
|
|
1506
|
+
const inspected = inspectLocalRunner(environment);
|
|
1507
|
+
return inspected.health.state === "ready" ? inspected.command : undefined;
|
|
1508
|
+
}
|
|
1509
|
+
function stageGeneration(layout, root, destination, metadata, afterStageCopy) {
|
|
1510
|
+
const stage = join(layout.generationRoot, `.${randomBytes(8).toString("hex")}.stage`);
|
|
1511
|
+
mkdirSync(stage, { mode: 0o755 });
|
|
1512
|
+
try {
|
|
1513
|
+
for (const file of COPY_FILES)
|
|
1514
|
+
cpSync(join(root, file), join(stage, file));
|
|
1515
|
+
for (const directory of COPY_DIRECTORIES) {
|
|
1516
|
+
cpSync(join(root, directory), join(stage, directory), { recursive: true, verbatimSymlinks: true });
|
|
1517
|
+
}
|
|
1518
|
+
if (afterStageCopy)
|
|
1519
|
+
afterStageCopy(stage);
|
|
1520
|
+
if (runnerTreeDigest(stage) !== metadata.digest)
|
|
1521
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner could not be installed");
|
|
1522
|
+
writeFileSync(join(stage, MARKER_NAME), metadataText(metadata), { mode: 0o644 });
|
|
1523
|
+
assertNoSymlinks(stage);
|
|
1524
|
+
const written = parseMetadata(readFileSync(join(stage, MARKER_NAME), "utf8"), metadata.generation);
|
|
1525
|
+
if (!written || written.digest !== metadata.digest || runnerTreeDigest(stage) !== metadata.digest) {
|
|
1526
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner could not be installed");
|
|
1527
|
+
}
|
|
1528
|
+
let existing;
|
|
1529
|
+
try {
|
|
1530
|
+
existing = lstatSync(destination);
|
|
1531
|
+
}
|
|
1532
|
+
catch (error) {
|
|
1533
|
+
if (!isEnoent(error))
|
|
1534
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner could not be installed");
|
|
1535
|
+
existing = undefined;
|
|
1536
|
+
}
|
|
1537
|
+
if (existing) {
|
|
1538
|
+
removeOwnDirectory(stage);
|
|
1539
|
+
if (existing.isSymbolicLink())
|
|
1540
|
+
fail("LOCAL_RUNNER_SYMLINK", "Local CLI runner version directory is a symbolic link");
|
|
1541
|
+
fail("LOCAL_RUNNER_FOREIGN", "Local CLI runner version directory is not managed by this CLI");
|
|
1542
|
+
}
|
|
1543
|
+
renameSync(stage, destination);
|
|
1544
|
+
return destination;
|
|
1545
|
+
}
|
|
1546
|
+
catch (error) {
|
|
1547
|
+
removeOwnDirectory(stage);
|
|
1548
|
+
if (error instanceof LocalRunnerError)
|
|
1549
|
+
throw error;
|
|
1550
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner could not be installed");
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
/** Copy the running package and point the stable wrapper at that copy. */
|
|
1554
|
+
export function persistLocalCliRunner(environment = process.env, options = {}) {
|
|
1555
|
+
if (profileLocksHeld() > 0) {
|
|
1556
|
+
fail("LOCAL_RUNNER_LOCK_ORDER", "Local CLI runner cannot be installed while a profile lock is held");
|
|
1557
|
+
}
|
|
1558
|
+
const contentTag = options.contentTag ?? "";
|
|
1559
|
+
if (contentTag !== "" && !CONTENT_TAG_PATTERN.test(contentTag)) {
|
|
1560
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner could not be installed");
|
|
1561
|
+
}
|
|
1562
|
+
const graceMs = options.generationGraceMs ?? GENERATION_GRACE_MS;
|
|
1563
|
+
if (!Number.isFinite(graceMs) || graceMs < 0)
|
|
1564
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner could not be installed");
|
|
1565
|
+
const layout = layoutFor(environment);
|
|
1566
|
+
assertEmbeddableCommandPath(layout.command);
|
|
1567
|
+
assertEmbeddableCommandPath(process.execPath);
|
|
1568
|
+
const root = packageRoot();
|
|
1569
|
+
const identity = readPackageIdentity(root);
|
|
1570
|
+
for (const file of COPY_FILES)
|
|
1571
|
+
assertNoSymlinks(join(root, file));
|
|
1572
|
+
for (const directory of COPY_DIRECTORIES)
|
|
1573
|
+
assertNoSymlinks(join(root, directory));
|
|
1574
|
+
let entryStat;
|
|
1575
|
+
try {
|
|
1576
|
+
entryStat = lstatSync(join(root, "bin", "zhihu.js"));
|
|
1577
|
+
}
|
|
1578
|
+
catch {
|
|
1579
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner entrypoint is missing");
|
|
1580
|
+
}
|
|
1581
|
+
if (entryStat.isSymbolicLink() || !entryStat.isFile())
|
|
1582
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner entrypoint is missing");
|
|
1583
|
+
const digest = sourceDigest(root);
|
|
1584
|
+
const canonical = generationId(identity.version, digest);
|
|
1585
|
+
const preferredName = contentTag ? `${canonical}.${contentTag}` : canonical;
|
|
1586
|
+
ensureManagedDirectories(layout);
|
|
1587
|
+
const release = acquireRunnerLock(layout.lockPath, environment, options);
|
|
1588
|
+
let createdGeneration = "";
|
|
1589
|
+
let activeGeneration = "";
|
|
1590
|
+
try {
|
|
1591
|
+
if (options.insideLock)
|
|
1592
|
+
options.insideLock();
|
|
1593
|
+
ensureManagedDirectories(layout);
|
|
1594
|
+
assertContained(layout, layout.runnerRoot);
|
|
1595
|
+
assertContained(layout, layout.generationRoot);
|
|
1596
|
+
assertContained(layout, layout.binDir);
|
|
1597
|
+
assertEntryReplaceable(layout.command);
|
|
1598
|
+
const priorText = (() => {
|
|
1599
|
+
try {
|
|
1600
|
+
return readFileSync(layout.command, "utf8");
|
|
1601
|
+
}
|
|
1602
|
+
catch {
|
|
1603
|
+
return "";
|
|
1604
|
+
}
|
|
1605
|
+
})();
|
|
1606
|
+
const priorGeneration = wrapperGeneration(priorText);
|
|
1607
|
+
let metadata = chooseGeneration(layout, identity, digest, preferredName, contentTag !== "");
|
|
1608
|
+
activeGeneration = metadata.generation;
|
|
1609
|
+
const destination = join(layout.generationRoot, metadata.generation);
|
|
1610
|
+
const state = classifyGeneration(destination, digest, process.execPath);
|
|
1611
|
+
if (state === "symlink")
|
|
1612
|
+
fail("LOCAL_RUNNER_SYMLINK", "Local CLI runner version directory is a symbolic link");
|
|
1613
|
+
if (state === "file" || state === "foreign")
|
|
1614
|
+
fail("LOCAL_RUNNER_FOREIGN", "Local CLI runner version directory is not managed by this CLI");
|
|
1615
|
+
if (state === "missing") {
|
|
1616
|
+
createdGeneration = stageGeneration(layout, root, destination, metadata, options.afterStageCopy);
|
|
1617
|
+
assertContained(layout, destination);
|
|
1618
|
+
}
|
|
1619
|
+
const expected = wrapperScript(destination, metadata);
|
|
1620
|
+
if (!wrapperMatches(layout.command, expected)) {
|
|
1621
|
+
writeStableEntry(layout.binDir, destination, metadata, options.beforeEntryRename);
|
|
1622
|
+
createdGeneration = "";
|
|
1623
|
+
}
|
|
1624
|
+
const retainedPrevious = priorGeneration && priorGeneration !== metadata.generation
|
|
1625
|
+
? priorGeneration
|
|
1626
|
+
: readRetention(layout)?.active === metadata.generation ? readRetention(layout)?.previous ?? "" : "";
|
|
1627
|
+
try {
|
|
1628
|
+
if (options.beforeCleanup)
|
|
1629
|
+
options.beforeCleanup();
|
|
1630
|
+
writeRetention(layout, metadata.generation, retainedPrevious);
|
|
1631
|
+
collectExpiredGenerations(layout, metadata.generation, retainedPrevious, graceMs);
|
|
1632
|
+
}
|
|
1633
|
+
catch {
|
|
1634
|
+
// A cleanup failure leaves the new entry in place.
|
|
1635
|
+
}
|
|
1636
|
+
return layout.command;
|
|
1637
|
+
}
|
|
1638
|
+
catch (error) {
|
|
1639
|
+
if (createdGeneration && !wrapperSelects(layout.command, activeGeneration))
|
|
1640
|
+
removeOwnDirectory(createdGeneration);
|
|
1641
|
+
if (error instanceof LocalRunnerError)
|
|
1642
|
+
throw error;
|
|
1643
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner could not be installed");
|
|
1644
|
+
}
|
|
1645
|
+
finally {
|
|
1646
|
+
release();
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
function chooseGeneration(layout, identity, digest, preferredName, exactName) {
|
|
1650
|
+
const build = (generation) => ({
|
|
1651
|
+
owner: "zhihu-ai-cli",
|
|
1652
|
+
schema: MARKER_SCHEMA,
|
|
1653
|
+
package: identity.name,
|
|
1654
|
+
version: identity.version,
|
|
1655
|
+
generation,
|
|
1656
|
+
digest,
|
|
1657
|
+
node_path: process.execPath,
|
|
1658
|
+
entry: ENTRY_RELATIVE,
|
|
1659
|
+
});
|
|
1660
|
+
const preferred = join(layout.generationRoot, preferredName);
|
|
1661
|
+
const preferredState = classifyGeneration(preferred, digest, process.execPath);
|
|
1662
|
+
if (preferredState === "verified")
|
|
1663
|
+
return build(preferredName);
|
|
1664
|
+
if (!exactName) {
|
|
1665
|
+
const selected = findVerifiedGeneration(layout, identity.version, digest);
|
|
1666
|
+
if (selected)
|
|
1667
|
+
return build(selected);
|
|
1668
|
+
}
|
|
1669
|
+
if (preferredState === "missing")
|
|
1670
|
+
return build(preferredName);
|
|
1671
|
+
if (preferredState === "symlink")
|
|
1672
|
+
fail("LOCAL_RUNNER_SYMLINK", "Local CLI runner version directory is a symbolic link");
|
|
1673
|
+
if (preferredState === "file" || preferredState === "foreign") {
|
|
1674
|
+
fail("LOCAL_RUNNER_FOREIGN", "Local CLI runner version directory is not managed by this CLI");
|
|
1675
|
+
}
|
|
1676
|
+
const repair = unusedGenerationName(layout.generationRoot, preferredName);
|
|
1677
|
+
return build(repair);
|
|
1678
|
+
}
|
|
1679
|
+
function findVerifiedGeneration(layout, version, digest) {
|
|
1680
|
+
let names = [];
|
|
1681
|
+
try {
|
|
1682
|
+
names = readdirSync(layout.generationRoot);
|
|
1683
|
+
}
|
|
1684
|
+
catch {
|
|
1685
|
+
return undefined;
|
|
1686
|
+
}
|
|
1687
|
+
const canonical = generationId(version, digest);
|
|
1688
|
+
const matches = names.filter((name) => name === canonical || name.startsWith(`${canonical}.`)).sort();
|
|
1689
|
+
const active = wrapperGeneration((() => {
|
|
1690
|
+
try {
|
|
1691
|
+
return readFileSync(layout.command, "utf8");
|
|
1692
|
+
}
|
|
1693
|
+
catch {
|
|
1694
|
+
return "";
|
|
1695
|
+
}
|
|
1696
|
+
})());
|
|
1697
|
+
const ordered = active && matches.includes(active) ? [active, ...matches.filter((name) => name !== active)] : matches;
|
|
1698
|
+
for (const name of ordered) {
|
|
1699
|
+
if (classifyGeneration(join(layout.generationRoot, name), digest, process.execPath) === "verified")
|
|
1700
|
+
return name;
|
|
1701
|
+
}
|
|
1702
|
+
return undefined;
|
|
1703
|
+
}
|
|
1704
|
+
function unusedGenerationName(generationRoot, base) {
|
|
1705
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
1706
|
+
const name = `${base}.${randomBytes(8).toString("hex")}`;
|
|
1707
|
+
try {
|
|
1708
|
+
lstatSync(join(generationRoot, name));
|
|
1709
|
+
}
|
|
1710
|
+
catch (error) {
|
|
1711
|
+
if (isEnoent(error))
|
|
1712
|
+
return name;
|
|
1713
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner could not be installed");
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
fail("LOCAL_RUNNER_IO_ERROR", "Local CLI runner could not be installed");
|
|
1717
|
+
}
|