@netpilot/skills 0.3.2 → 0.6.0
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +25 -9
- package/CHANGELOG.md +27 -0
- package/README.md +78 -112
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/agents/codex/architecture-designer.toml +2 -1
- package/agents/codex/backend-reviewer.toml +3 -1
- package/agents/codex/frontend-reviewer.toml +3 -1
- package/agents/codex/test-verifier.toml +4 -1
- package/bin/netpilot-skills.mjs +130 -6
- package/docs/agent-authoring.md +15 -5
- package/package.json +1 -1
- package/scripts/sync.mjs +1304 -101
- package/scripts/validate.mjs +68 -14
- package/skills/ask/SKILL.md +51 -47
- package/skills/ask/agents/openai.yaml +3 -3
- package/skills/code-review/SKILL.md +68 -52
- package/skills/code-review/agents/openai.yaml +2 -2
- package/skills/codebase-design/SKILL.md +87 -50
- package/skills/codebase-design/agents/openai.yaml +2 -2
- package/skills/codebase-design/references/deepening.md +60 -0
- package/skills/codebase-design/references/design-it-twice.md +54 -0
- package/skills/diagnosing-bugs/SKILL.md +124 -54
- package/skills/diagnosing-bugs/agents/openai.yaml +2 -2
- package/skills/diagnosing-bugs/scripts/hitl-loop.template.mjs +52 -0
- package/skills/domain-modeling/SKILL.md +65 -55
- package/skills/domain-modeling/agents/openai.yaml +2 -2
- package/skills/domain-modeling/references/adr-format.md +47 -0
- package/skills/domain-modeling/references/context-format.md +60 -0
- package/skills/domain-modeling/references/domain-docs.md +53 -0
- package/skills/grill-me/SKILL.md +13 -0
- package/skills/grill-me/agents/openai.yaml +6 -0
- package/skills/grill-with-docs/SKILL.md +16 -63
- package/skills/grill-with-docs/agents/openai.yaml +3 -3
- package/skills/grilling/SKILL.md +10 -54
- package/skills/grilling/agents/openai.yaml +2 -2
- package/skills/handoff/SKILL.md +24 -42
- package/skills/handoff/agents/openai.yaml +3 -3
- package/skills/implement/SKILL.md +18 -55
- package/skills/implement/agents/openai.yaml +3 -3
- package/skills/improve-codebase-architecture/SKILL.md +88 -0
- package/skills/improve-codebase-architecture/agents/openai.yaml +6 -0
- package/skills/improve-codebase-architecture/references/html-report.md +158 -0
- package/skills/prototype/SKILL.md +21 -53
- package/skills/prototype/agents/openai.yaml +2 -2
- package/skills/prototype/references/logic.md +87 -0
- package/skills/prototype/references/ui.md +108 -0
- package/skills/research/SKILL.md +9 -66
- package/skills/research/agents/openai.yaml +2 -2
- package/skills/resolving-merge-conflicts/SKILL.md +94 -0
- package/skills/resolving-merge-conflicts/agents/openai.yaml +6 -0
- package/skills/tdd/SKILL.md +30 -46
- package/skills/tdd/agents/openai.yaml +2 -2
- package/skills/tdd/references/mocking.md +70 -0
- package/skills/tdd/references/tests.md +95 -0
- package/skills/teach/SKILL.md +115 -47
- package/skills/teach/agents/openai.yaml +3 -3
- package/skills/teach/references/glossary-format.md +35 -10
- package/skills/teach/references/learning-record-format.md +41 -11
- package/skills/teach/references/mission-format.md +20 -17
- package/skills/teach/references/resources-format.md +34 -16
- package/skills/to-spec/SKILL.md +56 -51
- package/skills/to-spec/agents/openai.yaml +3 -3
- package/skills/to-tickets/SKILL.md +84 -45
- package/skills/to-tickets/agents/openai.yaml +3 -3
- package/skills/triage/SKILL.md +171 -0
- package/skills/triage/agents/openai.yaml +6 -0
- package/skills/triage/references/agent-brief.md +168 -0
- package/skills/triage/references/issue-tracker-github.md +42 -0
- package/skills/triage/references/issue-tracker-gitlab.md +42 -0
- package/skills/triage/references/issue-tracker-local.md +28 -0
- package/skills/triage/references/out-of-scope.md +113 -0
- package/skills/triage/references/project-config.md +57 -0
- package/skills/triage/references/triage-labels.md +13 -0
- package/skills/wayfinder/SKILL.md +158 -51
- package/skills/wayfinder/agents/openai.yaml +3 -3
- package/skills/writing-great-skills/SKILL.md +96 -54
- package/skills/writing-great-skills/agents/openai.yaml +3 -3
- package/skills/writing-great-skills/references/glossary.md +279 -0
- package/agents/codex/code-reader.toml +0 -11
- package/skills/grill/SKILL.md +0 -54
- package/skills/grill/agents/openai.yaml +0 -6
package/scripts/sync.mjs
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
copyFile,
|
|
4
|
+
link,
|
|
5
|
+
lstat,
|
|
6
|
+
mkdir,
|
|
7
|
+
readFile,
|
|
8
|
+
readdir,
|
|
9
|
+
realpath,
|
|
10
|
+
rename,
|
|
11
|
+
rm,
|
|
12
|
+
rmdir,
|
|
13
|
+
writeFile,
|
|
14
|
+
} from "node:fs/promises";
|
|
3
15
|
import os from "node:os";
|
|
4
16
|
import path from "node:path";
|
|
5
17
|
import { fileURLToPath } from "node:url";
|
|
@@ -12,10 +24,22 @@ const HOST_DESTINATIONS = {
|
|
|
12
24
|
};
|
|
13
25
|
|
|
14
26
|
const CODEX_AGENT_DESTINATION = [".codex", "agents"];
|
|
27
|
+
const CURRENT_STATE_DESTINATION = [".agents", ".state", "skills-installer"];
|
|
28
|
+
const LEGACY_STATE_DESTINATION = [".netpilot-skills"];
|
|
29
|
+
const LAYOUT_LOCK_DIRECTORY = [".agents", ".state"];
|
|
30
|
+
const LAYOUT_LOCK_NAME = "skills-installer.layout.lock";
|
|
15
31
|
|
|
16
32
|
const ALL_HOSTS = Object.freeze(Object.keys(HOST_DESTINATIONS));
|
|
17
33
|
const ALL_COMPONENTS = Object.freeze(["skills", "agents"]);
|
|
34
|
+
const COMPONENT_ALIASES = Object.freeze({
|
|
35
|
+
"system-agents": "agents",
|
|
36
|
+
});
|
|
18
37
|
const SUPPORT_FILE_NAMES = Object.freeze(["LICENSE", "THIRD_PARTY_NOTICES.md"]);
|
|
38
|
+
const MANAGED_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
39
|
+
|
|
40
|
+
function canonicalComponent(component) {
|
|
41
|
+
return COMPONENT_ALIASES[component] ?? component;
|
|
42
|
+
}
|
|
19
43
|
|
|
20
44
|
export class SyncConflictError extends Error {
|
|
21
45
|
constructor(conflicts) {
|
|
@@ -27,16 +51,30 @@ export class SyncConflictError extends Error {
|
|
|
27
51
|
|
|
28
52
|
export function parseArgs(argv) {
|
|
29
53
|
let apply = false;
|
|
54
|
+
let requestedMode = null;
|
|
30
55
|
let homeDir = os.homedir();
|
|
31
|
-
let hosts = [
|
|
32
|
-
let components = [
|
|
56
|
+
let hosts = ["codex"];
|
|
57
|
+
let components = [...ALL_COMPONENTS];
|
|
58
|
+
let componentSpecified = false;
|
|
33
59
|
|
|
34
60
|
for (let index = 0; index < argv.length; index += 1) {
|
|
35
61
|
const argument = argv[index];
|
|
36
62
|
if (argument === "--apply") {
|
|
63
|
+
if (requestedMode === "dry-run") {
|
|
64
|
+
throw new Error("--apply 与 --dry-run 不能同时使用");
|
|
65
|
+
}
|
|
66
|
+
requestedMode = "apply";
|
|
37
67
|
apply = true;
|
|
38
68
|
continue;
|
|
39
69
|
}
|
|
70
|
+
if (argument === "--dry-run") {
|
|
71
|
+
if (requestedMode === "apply") {
|
|
72
|
+
throw new Error("--apply 与 --dry-run 不能同时使用");
|
|
73
|
+
}
|
|
74
|
+
requestedMode = "dry-run";
|
|
75
|
+
apply = false;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
40
78
|
if (argument === "--host") {
|
|
41
79
|
const host = argv[index + 1];
|
|
42
80
|
if (!host) throw new Error("--host 需要 codex、claude 或 all");
|
|
@@ -52,12 +90,15 @@ export function parseArgs(argv) {
|
|
|
52
90
|
}
|
|
53
91
|
if (argument === "--component") {
|
|
54
92
|
const component = argv[index + 1];
|
|
55
|
-
if (!component)
|
|
93
|
+
if (!component) {
|
|
94
|
+
throw new Error("--component 需要 skills、system-agents、agents 或 all");
|
|
95
|
+
}
|
|
56
96
|
index += 1;
|
|
97
|
+
componentSpecified = true;
|
|
57
98
|
if (component === "all") {
|
|
58
99
|
components = [...ALL_COMPONENTS];
|
|
59
|
-
} else if (ALL_COMPONENTS.includes(component)) {
|
|
60
|
-
components = [component];
|
|
100
|
+
} else if (ALL_COMPONENTS.includes(canonicalComponent(component))) {
|
|
101
|
+
components = [canonicalComponent(component)];
|
|
61
102
|
} else {
|
|
62
103
|
throw new Error(`不支持的组件:${component}`);
|
|
63
104
|
}
|
|
@@ -75,6 +116,10 @@ export function parseArgs(argv) {
|
|
|
75
116
|
throw new Error(`未知参数:${argument}`);
|
|
76
117
|
}
|
|
77
118
|
|
|
119
|
+
if (!componentSpecified && !hosts.includes("codex")) {
|
|
120
|
+
components = ["skills"];
|
|
121
|
+
}
|
|
122
|
+
|
|
78
123
|
return { apply, homeDir, hosts, components };
|
|
79
124
|
}
|
|
80
125
|
|
|
@@ -86,6 +131,35 @@ async function hashFile(filePath) {
|
|
|
86
131
|
return sha256(await readFile(filePath));
|
|
87
132
|
}
|
|
88
133
|
|
|
134
|
+
function projectCodexSkill(skillContent) {
|
|
135
|
+
const frontmatter = skillContent.match(/^---\r?\n([\s\S]*?)\r?\n---(?=\r?\n|$)/u);
|
|
136
|
+
if (!frontmatter) return skillContent;
|
|
137
|
+
|
|
138
|
+
const newline = frontmatter[0].includes("\r\n") ? "\r\n" : "\n";
|
|
139
|
+
const omittedKeys = new Set(["argument-hint", "disable-model-invocation"]);
|
|
140
|
+
const projectedLines = [];
|
|
141
|
+
let omittingValue = false;
|
|
142
|
+
|
|
143
|
+
for (const line of frontmatter[1].split(/\r?\n/u)) {
|
|
144
|
+
const key = /^([a-z0-9-]+):(?:\s|$)/iu.exec(line)?.[1] ?? null;
|
|
145
|
+
if (key) {
|
|
146
|
+
omittingValue = omittedKeys.has(key);
|
|
147
|
+
if (!omittingValue) projectedLines.push(line);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (omittingValue && (/^[ \t]/u.test(line) || line.trim() === "")) continue;
|
|
151
|
+
omittingValue = false;
|
|
152
|
+
projectedLines.push(line);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const projectedFrontmatter = [
|
|
156
|
+
"---",
|
|
157
|
+
...projectedLines,
|
|
158
|
+
"---",
|
|
159
|
+
].join(newline);
|
|
160
|
+
return projectedFrontmatter + skillContent.slice(frontmatter[0].length);
|
|
161
|
+
}
|
|
162
|
+
|
|
89
163
|
async function readManifest(manifestPath) {
|
|
90
164
|
try {
|
|
91
165
|
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
@@ -228,12 +302,517 @@ async function inspectAncestorPath(basePath, targetDirectory) {
|
|
|
228
302
|
return { safe: true };
|
|
229
303
|
}
|
|
230
304
|
|
|
305
|
+
async function inspectHomeRoot(homeDir) {
|
|
306
|
+
const resolvedHome = path.resolve(homeDir);
|
|
307
|
+
const parsed = path.parse(resolvedHome);
|
|
308
|
+
let currentPath = parsed.root;
|
|
309
|
+
const segments = path.relative(parsed.root, resolvedHome).split(path.sep).filter(Boolean);
|
|
310
|
+
|
|
311
|
+
for (const segment of segments) {
|
|
312
|
+
currentPath = path.join(currentPath, segment);
|
|
313
|
+
try {
|
|
314
|
+
const stats = await lstat(currentPath);
|
|
315
|
+
if (stats.isSymbolicLink()) {
|
|
316
|
+
const isHomeRoot = path.resolve(currentPath) === resolvedHome;
|
|
317
|
+
return {
|
|
318
|
+
safe: false,
|
|
319
|
+
path: currentPath,
|
|
320
|
+
reason: isHomeRoot
|
|
321
|
+
? "home 根目录是 symlink 或 junction"
|
|
322
|
+
: "home 路径的父目录是 symlink 或 junction",
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
if (!stats.isDirectory()) {
|
|
326
|
+
return {
|
|
327
|
+
safe: false,
|
|
328
|
+
path: currentPath,
|
|
329
|
+
reason:
|
|
330
|
+
path.resolve(currentPath) === resolvedHome
|
|
331
|
+
? "home 根路径不是目录"
|
|
332
|
+
: "home 路径的父路径不是目录",
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
} catch (error) {
|
|
336
|
+
if (error.code === "ENOENT") return { safe: true };
|
|
337
|
+
throw error;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return { safe: true };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function assertSafeHomeRoot(homeDir) {
|
|
345
|
+
const homeSafety = await inspectHomeRoot(homeDir);
|
|
346
|
+
if (homeSafety.safe) return;
|
|
347
|
+
throw new SyncConflictError([
|
|
348
|
+
{
|
|
349
|
+
host: "state",
|
|
350
|
+
component: "home",
|
|
351
|
+
assetName: "home",
|
|
352
|
+
skillName: "home",
|
|
353
|
+
relativePath: ".",
|
|
354
|
+
targetPath: homeDir,
|
|
355
|
+
action: "conflict",
|
|
356
|
+
reason: `${homeSafety.reason}:${homeSafety.path}`,
|
|
357
|
+
},
|
|
358
|
+
]);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function resolveStatePaths(homeDir) {
|
|
362
|
+
const currentStateDir = path.join(homeDir, ...CURRENT_STATE_DESTINATION);
|
|
363
|
+
const legacyStateDir = path.join(homeDir, ...LEGACY_STATE_DESTINATION);
|
|
364
|
+
return {
|
|
365
|
+
currentStateDir,
|
|
366
|
+
currentManifestPath: path.join(currentStateDir, "manifest.json"),
|
|
367
|
+
legacyStateDir,
|
|
368
|
+
legacyManifestPath: path.join(legacyStateDir, "manifest.json"),
|
|
369
|
+
layoutLockDir: path.join(homeDir, ...LAYOUT_LOCK_DIRECTORY),
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function stateLayoutConflict(homeDir, reason, targetPath = homeDir) {
|
|
374
|
+
return new SyncConflictError([
|
|
375
|
+
{
|
|
376
|
+
host: "state",
|
|
377
|
+
component: "layout",
|
|
378
|
+
assetName: "state-layout",
|
|
379
|
+
skillName: "state-layout",
|
|
380
|
+
relativePath: ".",
|
|
381
|
+
targetPath,
|
|
382
|
+
action: "conflict",
|
|
383
|
+
reason,
|
|
384
|
+
},
|
|
385
|
+
]);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function inspectStateDirectory(homeDir, targetPath) {
|
|
389
|
+
const ancestor = await inspectAncestorPath(homeDir, targetPath);
|
|
390
|
+
if (!ancestor.safe) {
|
|
391
|
+
throw stateLayoutConflict(
|
|
392
|
+
homeDir,
|
|
393
|
+
`${ancestor.reason}:${ancestor.path}`,
|
|
394
|
+
targetPath,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
try {
|
|
398
|
+
const stats = await lstat(targetPath);
|
|
399
|
+
if (stats.isSymbolicLink() || !stats.isDirectory()) {
|
|
400
|
+
throw stateLayoutConflict(
|
|
401
|
+
homeDir,
|
|
402
|
+
"状态路径必须是非符号链接目录",
|
|
403
|
+
targetPath,
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
return {
|
|
407
|
+
exists: true,
|
|
408
|
+
path: targetPath,
|
|
409
|
+
identity: { dev: stats.dev, ino: stats.ino },
|
|
410
|
+
};
|
|
411
|
+
} catch (error) {
|
|
412
|
+
if (error.code === "ENOENT") return { exists: false, path: targetPath };
|
|
413
|
+
throw error;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function sameStateDirectoryIdentity(left, right) {
|
|
418
|
+
return left?.dev === right?.dev && left?.ino === right?.ino;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function inspectStateLayout(homeDir, paths = resolveStatePaths(homeDir)) {
|
|
422
|
+
const [current, legacy] = await Promise.all([
|
|
423
|
+
inspectStateDirectory(homeDir, paths.currentStateDir),
|
|
424
|
+
inspectStateDirectory(homeDir, paths.legacyStateDir),
|
|
425
|
+
]);
|
|
426
|
+
if (current.exists && legacy.exists) {
|
|
427
|
+
const legacyEntries = await readdir(legacy.path);
|
|
428
|
+
const latestLegacy = await inspectStateDirectory(homeDir, legacy.path);
|
|
429
|
+
if (
|
|
430
|
+
legacyEntries.length === 0 &&
|
|
431
|
+
latestLegacy.exists &&
|
|
432
|
+
sameStateDirectoryIdentity(legacy.identity, latestLegacy.identity)
|
|
433
|
+
) {
|
|
434
|
+
return {
|
|
435
|
+
kind: "current-with-empty-legacy",
|
|
436
|
+
current,
|
|
437
|
+
legacy: latestLegacy,
|
|
438
|
+
paths,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
throw stateLayoutConflict(
|
|
442
|
+
homeDir,
|
|
443
|
+
"新旧状态目录同时存在,拒绝自动合并 ownership",
|
|
444
|
+
paths.currentStateDir,
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
return {
|
|
448
|
+
kind: current.exists ? "current" : legacy.exists ? "legacy" : "fresh",
|
|
449
|
+
current,
|
|
450
|
+
legacy,
|
|
451
|
+
paths,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function parseLockRecord(content) {
|
|
456
|
+
const trimmed = content.trim();
|
|
457
|
+
if (/^[1-9][0-9]*$/u.test(trimmed)) {
|
|
458
|
+
return { schemaVersion: 0, pid: Number(trimmed), owner: `legacy-${trimmed}` };
|
|
459
|
+
}
|
|
460
|
+
try {
|
|
461
|
+
const record = JSON.parse(trimmed);
|
|
462
|
+
if (
|
|
463
|
+
record.schemaVersion !== 1 ||
|
|
464
|
+
!Number.isSafeInteger(record.pid) ||
|
|
465
|
+
record.pid <= 0 ||
|
|
466
|
+
typeof record.owner !== "string" ||
|
|
467
|
+
record.owner.length < 8 ||
|
|
468
|
+
typeof record.createdAt !== "string"
|
|
469
|
+
) {
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
return record;
|
|
473
|
+
} catch {
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function isProcessAlive(pid) {
|
|
479
|
+
if (pid === process.pid) return true;
|
|
480
|
+
try {
|
|
481
|
+
process.kill(pid, 0);
|
|
482
|
+
return true;
|
|
483
|
+
} catch (error) {
|
|
484
|
+
if (error.code === "ESRCH" || error.code === "EINVAL") return false;
|
|
485
|
+
return true;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async function moveLockForCompare({ lockPath, expectedContent, stateDir, purpose }) {
|
|
490
|
+
const movedPath = path.join(stateDir, `${purpose}-${randomUUID()}.lock`);
|
|
491
|
+
try {
|
|
492
|
+
await rename(lockPath, movedPath);
|
|
493
|
+
} catch (error) {
|
|
494
|
+
if (error.code === "ENOENT") return null;
|
|
495
|
+
throw error;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const movedContent = await readFile(movedPath, "utf8");
|
|
499
|
+
if (movedContent === expectedContent) return movedPath;
|
|
500
|
+
|
|
501
|
+
try {
|
|
502
|
+
await restoreQuarantinedRemoval(lockPath, movedPath);
|
|
503
|
+
} catch (restoreError) {
|
|
504
|
+
restoreError.preserveEvidence = true;
|
|
505
|
+
throw restoreError;
|
|
506
|
+
}
|
|
507
|
+
const error = new Error(`同步锁在回收期间发生变化:${lockPath}`);
|
|
508
|
+
error.preserveEvidence = true;
|
|
509
|
+
throw error;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async function acquireSyncLock({
|
|
513
|
+
homeDir,
|
|
514
|
+
stateDir,
|
|
515
|
+
lockName = "sync.lock",
|
|
516
|
+
beforeLockPublish,
|
|
517
|
+
}) {
|
|
518
|
+
let stateSafety = await inspectAncestorPath(homeDir, stateDir);
|
|
519
|
+
if (!stateSafety.safe) {
|
|
520
|
+
throw new SyncConflictError([
|
|
521
|
+
{
|
|
522
|
+
host: "state",
|
|
523
|
+
component: "lock",
|
|
524
|
+
assetName: "sync",
|
|
525
|
+
skillName: "sync",
|
|
526
|
+
relativePath: lockName,
|
|
527
|
+
targetPath: path.join(stateDir, lockName),
|
|
528
|
+
action: "conflict",
|
|
529
|
+
reason: `${stateSafety.reason}:${stateSafety.path}`,
|
|
530
|
+
},
|
|
531
|
+
]);
|
|
532
|
+
}
|
|
533
|
+
await mkdir(stateDir, { recursive: true });
|
|
534
|
+
stateSafety = await inspectAncestorPath(homeDir, stateDir);
|
|
535
|
+
if (!stateSafety.safe) {
|
|
536
|
+
throw stateLayoutConflict(
|
|
537
|
+
homeDir,
|
|
538
|
+
`${stateSafety.reason}:${stateSafety.path}`,
|
|
539
|
+
stateDir,
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const lockPath = path.join(stateDir, lockName);
|
|
544
|
+
const owner = randomUUID();
|
|
545
|
+
const recordContent = `${JSON.stringify({
|
|
546
|
+
schemaVersion: 1,
|
|
547
|
+
pid: process.pid,
|
|
548
|
+
owner,
|
|
549
|
+
createdAt: new Date().toISOString(),
|
|
550
|
+
})}\n`;
|
|
551
|
+
const stagedLockPath = path.join(
|
|
552
|
+
stateDir,
|
|
553
|
+
`${lockName.replaceAll(".", "-")}-${owner}.tmp`,
|
|
554
|
+
);
|
|
555
|
+
await writeFile(stagedLockPath, recordContent, { encoding: "utf8", flag: "wx" });
|
|
556
|
+
if ((await readFile(stagedLockPath, "utf8")) !== recordContent) {
|
|
557
|
+
await rm(stagedLockPath, { force: true });
|
|
558
|
+
throw new Error(`同步锁记录暂存校验失败:${stagedLockPath}`);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
try {
|
|
562
|
+
await beforeLockPublish?.();
|
|
563
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
564
|
+
try {
|
|
565
|
+
await link(stagedLockPath, lockPath);
|
|
566
|
+
await rm(stagedLockPath).catch(() => {});
|
|
567
|
+
return { lockPath, recordContent, stateDir };
|
|
568
|
+
} catch (error) {
|
|
569
|
+
if (error.code !== "EEXIST") throw error;
|
|
570
|
+
|
|
571
|
+
let existingContent;
|
|
572
|
+
try {
|
|
573
|
+
existingContent = await readFile(lockPath, "utf8");
|
|
574
|
+
} catch (readError) {
|
|
575
|
+
if (readError.code === "ENOENT") continue;
|
|
576
|
+
throw readError;
|
|
577
|
+
}
|
|
578
|
+
const existingRecord = parseLockRecord(existingContent);
|
|
579
|
+
if (!existingRecord) {
|
|
580
|
+
throw new Error(
|
|
581
|
+
`同步锁格式无法验证:${lockPath}。确认没有安装进程后再手工删除该文件。`,
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
if (isProcessAlive(existingRecord.pid)) {
|
|
585
|
+
throw new Error(
|
|
586
|
+
`已有同步进程持有锁:${lockPath}(PID ${existingRecord.pid})`,
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const stalePath = await moveLockForCompare({
|
|
591
|
+
lockPath,
|
|
592
|
+
expectedContent: existingContent,
|
|
593
|
+
stateDir,
|
|
594
|
+
purpose: "stale-sync-lock",
|
|
595
|
+
});
|
|
596
|
+
if (!stalePath) continue;
|
|
597
|
+
await rm(stalePath);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
throw new Error(`无法安全获取同步锁:${lockPath}`);
|
|
602
|
+
} catch (error) {
|
|
603
|
+
await rm(stagedLockPath, { force: true }).catch(() => {});
|
|
604
|
+
throw error;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
async function releaseSyncLock(lock) {
|
|
609
|
+
const ownedPath = await moveLockForCompare({
|
|
610
|
+
lockPath: lock.lockPath,
|
|
611
|
+
expectedContent: lock.recordContent,
|
|
612
|
+
stateDir: lock.stateDir,
|
|
613
|
+
purpose: "released-sync-lock",
|
|
614
|
+
});
|
|
615
|
+
if (!ownedPath) {
|
|
616
|
+
const error = new Error(`同步锁在释放前已消失:${lock.lockPath}`);
|
|
617
|
+
error.preserveEvidence = true;
|
|
618
|
+
throw error;
|
|
619
|
+
}
|
|
620
|
+
await rm(ownedPath);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function conflictForOperation(operation, reason) {
|
|
624
|
+
return new SyncConflictError([
|
|
625
|
+
{
|
|
626
|
+
...operation,
|
|
627
|
+
action: "conflict",
|
|
628
|
+
reason,
|
|
629
|
+
},
|
|
630
|
+
]);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
async function assertSafeOperationTarget(operation, homeDir) {
|
|
634
|
+
const targetParent = path.dirname(operation.targetPath);
|
|
635
|
+
const ancestor = await inspectAncestorPath(homeDir, targetParent);
|
|
636
|
+
if (!ancestor.safe) {
|
|
637
|
+
throw conflictForOperation(operation, `${ancestor.reason}:${ancestor.path}`);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const realHome = await realpath(homeDir);
|
|
641
|
+
const realParent = await realpath(targetParent);
|
|
642
|
+
const relativeParent = path.relative(realHome, realParent);
|
|
643
|
+
if (relativeParent.startsWith("..") || path.isAbsolute(relativeParent)) {
|
|
644
|
+
throw conflictForOperation(operation, `目标父目录解析到 home 之外:${realParent}`);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
async function prepareSafeTargetParent(operation, homeDir) {
|
|
649
|
+
const targetParent = path.dirname(operation.targetPath);
|
|
650
|
+
const ancestor = await inspectAncestorPath(homeDir, targetParent);
|
|
651
|
+
if (!ancestor.safe) {
|
|
652
|
+
throw conflictForOperation(operation, `${ancestor.reason}:${ancestor.path}`);
|
|
653
|
+
}
|
|
654
|
+
await mkdir(targetParent, { recursive: true });
|
|
655
|
+
await assertSafeOperationTarget(operation, homeDir);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
async function assertSameTransactionDevice(operation, stageRoot) {
|
|
659
|
+
const [stageStats, targetParentStats] = await Promise.all([
|
|
660
|
+
lstat(stageRoot),
|
|
661
|
+
lstat(path.dirname(operation.targetPath)),
|
|
662
|
+
]);
|
|
663
|
+
if (stageStats.dev !== targetParentStats.dev) {
|
|
664
|
+
throw conflictForOperation(
|
|
665
|
+
operation,
|
|
666
|
+
"目标与事务 staging 不在同一文件系统;无法保证原子提交与回滚",
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
async function linkStagedFileNoOverwrite({ operation, stagedPath }) {
|
|
672
|
+
try {
|
|
673
|
+
await link(stagedPath, operation.targetPath);
|
|
674
|
+
} catch (error) {
|
|
675
|
+
if (error.code === "EEXIST") {
|
|
676
|
+
throw conflictForOperation(operation, "目标在最终提交前被创建");
|
|
677
|
+
}
|
|
678
|
+
if (error.code === "EXDEV") {
|
|
679
|
+
throw conflictForOperation(
|
|
680
|
+
operation,
|
|
681
|
+
"目标与事务 staging 不在同一文件系统;无法原子发布",
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
throw error;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
231
688
|
function manifestKey(host, component, assetName, relativePath) {
|
|
232
689
|
if (component === "skills") return `${host}/${assetName}/${relativePath}`;
|
|
233
690
|
if (component === "agents") return `${host}/agents/${assetName}/${relativePath}`;
|
|
234
691
|
return `${host}/${assetName}/${relativePath}`;
|
|
235
692
|
}
|
|
236
693
|
|
|
694
|
+
function resolveManagedTarget(manifestKeyValue, homeDir, stateDir) {
|
|
695
|
+
const segments = manifestKeyValue.split("/");
|
|
696
|
+
if (
|
|
697
|
+
segments.some(
|
|
698
|
+
(segment) =>
|
|
699
|
+
segment.length === 0 ||
|
|
700
|
+
segment === "." ||
|
|
701
|
+
segment === ".." ||
|
|
702
|
+
segment.includes("\\") ||
|
|
703
|
+
/[\u0000-\u001f:*?"<>|]/u.test(segment) ||
|
|
704
|
+
/[. ]$/u.test(segment),
|
|
705
|
+
)
|
|
706
|
+
) {
|
|
707
|
+
return null;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
if (
|
|
711
|
+
segments[0] === "state" &&
|
|
712
|
+
segments[1] === "notices" &&
|
|
713
|
+
segments.length === 3 &&
|
|
714
|
+
SUPPORT_FILE_NAMES.includes(segments[2])
|
|
715
|
+
) {
|
|
716
|
+
const relativeSegments = segments.slice(2);
|
|
717
|
+
return {
|
|
718
|
+
host: "state",
|
|
719
|
+
component: "support",
|
|
720
|
+
assetName: "notices",
|
|
721
|
+
skillName: "notices",
|
|
722
|
+
relativePath: relativeSegments.join("/"),
|
|
723
|
+
targetPath: path.join(stateDir, "notices", ...relativeSegments),
|
|
724
|
+
pruneRoot: path.join(stateDir, "notices"),
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
if (segments[0] === "codex" && segments[1] === "agents") {
|
|
729
|
+
if (
|
|
730
|
+
segments.length !== 4 ||
|
|
731
|
+
!MANAGED_NAME_PATTERN.test(segments[2]) ||
|
|
732
|
+
segments[3] !== `${segments[2]}.toml`
|
|
733
|
+
) {
|
|
734
|
+
return null;
|
|
735
|
+
}
|
|
736
|
+
const relativeSegments = segments.slice(3);
|
|
737
|
+
return {
|
|
738
|
+
host: "codex",
|
|
739
|
+
component: "agents",
|
|
740
|
+
assetName: segments[2],
|
|
741
|
+
skillName: segments[2],
|
|
742
|
+
relativePath: relativeSegments.join("/"),
|
|
743
|
+
targetPath: path.join(homeDir, ...CODEX_AGENT_DESTINATION, ...relativeSegments),
|
|
744
|
+
pruneRoot: path.join(homeDir, ...CODEX_AGENT_DESTINATION),
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (
|
|
749
|
+
segments[0] in HOST_DESTINATIONS &&
|
|
750
|
+
segments.length >= 3 &&
|
|
751
|
+
MANAGED_NAME_PATTERN.test(segments[1])
|
|
752
|
+
) {
|
|
753
|
+
const relativeSegments = segments.slice(2);
|
|
754
|
+
return {
|
|
755
|
+
host: segments[0],
|
|
756
|
+
component: "skills",
|
|
757
|
+
assetName: segments[1],
|
|
758
|
+
skillName: segments[1],
|
|
759
|
+
relativePath: relativeSegments.join("/"),
|
|
760
|
+
targetPath: path.join(
|
|
761
|
+
homeDir,
|
|
762
|
+
...HOST_DESTINATIONS[segments[0]],
|
|
763
|
+
segments[1],
|
|
764
|
+
...relativeSegments,
|
|
765
|
+
),
|
|
766
|
+
pruneRoot: path.join(homeDir, ...HOST_DESTINATIONS[segments[0]]),
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
return null;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function targetIdentity(targetPath) {
|
|
774
|
+
const resolved = path.resolve(targetPath);
|
|
775
|
+
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function collectManifestTargetConflicts(manifest, homeDir, stateDir) {
|
|
779
|
+
const conflicts = [];
|
|
780
|
+
const targetOwners = new Map();
|
|
781
|
+
|
|
782
|
+
for (const key of Object.keys(manifest.files)) {
|
|
783
|
+
const target = resolveManagedTarget(key, homeDir, stateDir);
|
|
784
|
+
if (!target) {
|
|
785
|
+
conflicts.push({
|
|
786
|
+
host: "state",
|
|
787
|
+
component: "manifest",
|
|
788
|
+
assetName: key,
|
|
789
|
+
skillName: key,
|
|
790
|
+
relativePath: key,
|
|
791
|
+
targetPath: path.join(stateDir, "manifest.json"),
|
|
792
|
+
action: "conflict",
|
|
793
|
+
reason: `manifest key 非规范或不受支持:${key}`,
|
|
794
|
+
manifestKey: key,
|
|
795
|
+
});
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const identity = targetIdentity(target.targetPath);
|
|
800
|
+
const existingKey = targetOwners.get(identity);
|
|
801
|
+
if (existingKey && existingKey !== key) {
|
|
802
|
+
conflicts.push({
|
|
803
|
+
...target,
|
|
804
|
+
action: "conflict",
|
|
805
|
+
reason: `manifest key 目标冲突:${existingKey} 与 ${key}`,
|
|
806
|
+
manifestKey: key,
|
|
807
|
+
});
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
targetOwners.set(identity, key);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
return conflicts;
|
|
814
|
+
}
|
|
815
|
+
|
|
237
816
|
async function planFileOperation({
|
|
238
817
|
host,
|
|
239
818
|
component,
|
|
@@ -241,12 +820,15 @@ async function planFileOperation({
|
|
|
241
820
|
skillName = assetName,
|
|
242
821
|
relativePath,
|
|
243
822
|
sourcePath,
|
|
823
|
+
sourceContent = null,
|
|
244
824
|
targetPath,
|
|
245
825
|
manifest,
|
|
246
826
|
homeDir,
|
|
247
827
|
}) {
|
|
248
828
|
const key = manifestKey(host, component, assetName, relativePath);
|
|
249
|
-
const sourceHash =
|
|
829
|
+
const sourceHash = sourceContent === null
|
|
830
|
+
? await hashFile(sourcePath)
|
|
831
|
+
: sha256(Buffer.from(sourceContent, "utf8"));
|
|
250
832
|
const ancestor = await inspectAncestorPath(homeDir, path.dirname(targetPath));
|
|
251
833
|
const target = ancestor.safe
|
|
252
834
|
? await inspectTarget(targetPath)
|
|
@@ -282,6 +864,7 @@ async function planFileOperation({
|
|
|
282
864
|
skillName,
|
|
283
865
|
relativePath,
|
|
284
866
|
sourcePath,
|
|
867
|
+
sourceContent,
|
|
285
868
|
targetPath,
|
|
286
869
|
sourceHash,
|
|
287
870
|
targetHash: target.hash,
|
|
@@ -292,6 +875,42 @@ async function planFileOperation({
|
|
|
292
875
|
};
|
|
293
876
|
}
|
|
294
877
|
|
|
878
|
+
async function planRemovalOperation({
|
|
879
|
+
manifestKeyValue,
|
|
880
|
+
previousHash,
|
|
881
|
+
target,
|
|
882
|
+
homeDir,
|
|
883
|
+
}) {
|
|
884
|
+
const ancestor = await inspectAncestorPath(homeDir, path.dirname(target.targetPath));
|
|
885
|
+
const installed = ancestor.safe
|
|
886
|
+
? await inspectTarget(target.targetPath)
|
|
887
|
+
: { exists: false, regularFile: false, hash: null };
|
|
888
|
+
let action = "remove";
|
|
889
|
+
let reason = installed.exists ? "源中已移除" : "源中和安装目录中均已不存在,仅清理记录";
|
|
890
|
+
|
|
891
|
+
if (!ancestor.safe) {
|
|
892
|
+
action = "conflict";
|
|
893
|
+
reason = `${ancestor.reason}:${ancestor.path}`;
|
|
894
|
+
} else if (installed.exists && !installed.regularFile) {
|
|
895
|
+
action = "conflict";
|
|
896
|
+
reason = "源中已移除,但目标路径不是普通文件";
|
|
897
|
+
} else if (installed.exists && installed.hash !== previousHash) {
|
|
898
|
+
action = "conflict";
|
|
899
|
+
reason = "源中已移除,但文件在上次同步后被修改";
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
return {
|
|
903
|
+
...target,
|
|
904
|
+
sourcePath: null,
|
|
905
|
+
sourceHash: null,
|
|
906
|
+
targetHash: installed.hash,
|
|
907
|
+
previousHash,
|
|
908
|
+
action,
|
|
909
|
+
reason,
|
|
910
|
+
manifestKey: manifestKeyValue,
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
|
|
295
914
|
async function collectChangedTargetConflicts(operations, homeDir) {
|
|
296
915
|
const conflicts = [];
|
|
297
916
|
for (const operation of operations) {
|
|
@@ -318,28 +937,227 @@ async function collectChangedTargetConflicts(operations, homeDir) {
|
|
|
318
937
|
(!target.regularFile || target.hash !== operation.sourceHash)
|
|
319
938
|
) {
|
|
320
939
|
conflicts.push({ ...operation, action: "conflict", reason: "目标在预检后发生变化" });
|
|
940
|
+
} else if (
|
|
941
|
+
operation.action === "remove" &&
|
|
942
|
+
((operation.targetHash === null && target.exists) ||
|
|
943
|
+
(operation.targetHash !== null &&
|
|
944
|
+
(!target.regularFile || target.hash !== operation.targetHash)))
|
|
945
|
+
) {
|
|
946
|
+
conflicts.push({ ...operation, action: "conflict", reason: "待移除目标在预检后发生变化" });
|
|
321
947
|
}
|
|
322
948
|
}
|
|
323
949
|
return conflicts;
|
|
324
950
|
}
|
|
325
951
|
|
|
326
|
-
async function
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
952
|
+
async function collectFinalTargetConflicts(operations, homeDir) {
|
|
953
|
+
const conflicts = [];
|
|
954
|
+
for (const operation of operations) {
|
|
955
|
+
if (operation.action === "conflict") continue;
|
|
956
|
+
const ancestor = await inspectAncestorPath(homeDir, path.dirname(operation.targetPath));
|
|
957
|
+
if (!ancestor.safe) {
|
|
958
|
+
conflicts.push({
|
|
959
|
+
...operation,
|
|
960
|
+
action: "conflict",
|
|
961
|
+
reason: `${ancestor.reason}:${ancestor.path}`,
|
|
962
|
+
});
|
|
963
|
+
continue;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const target = await inspectTarget(operation.targetPath);
|
|
967
|
+
if (operation.action === "remove") {
|
|
968
|
+
if (target.exists) {
|
|
969
|
+
conflicts.push({
|
|
970
|
+
...operation,
|
|
971
|
+
action: "conflict",
|
|
972
|
+
reason: "已移除目标在 manifest 提交前重新出现",
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
continue;
|
|
976
|
+
}
|
|
977
|
+
if (!target.regularFile || target.hash !== operation.sourceHash) {
|
|
978
|
+
conflicts.push({
|
|
979
|
+
...operation,
|
|
980
|
+
action: "conflict",
|
|
981
|
+
reason: "目标在发布后、manifest 提交前发生变化",
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
return conflicts;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
async function removeEmptyParentDirectories(startDirectory, stopDirectory) {
|
|
989
|
+
const stop = path.resolve(stopDirectory);
|
|
990
|
+
let current = path.resolve(startDirectory);
|
|
991
|
+
|
|
992
|
+
while (current !== stop) {
|
|
993
|
+
const relative = path.relative(stop, current);
|
|
994
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
995
|
+
throw new Error(`拒绝清理受管理根目录之外的目录:${current}`);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
try {
|
|
999
|
+
await rmdir(current);
|
|
1000
|
+
} catch (error) {
|
|
1001
|
+
if (error.code === "ENOENT") {
|
|
1002
|
+
current = path.dirname(current);
|
|
1003
|
+
continue;
|
|
1004
|
+
}
|
|
1005
|
+
if (error.code === "ENOTEMPTY" || error.code === "EEXIST") return;
|
|
1006
|
+
throw error;
|
|
1007
|
+
}
|
|
1008
|
+
current = path.dirname(current);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
export async function restoreQuarantinedRemoval(targetPath, backupPath) {
|
|
1013
|
+
await mkdir(path.dirname(targetPath), { recursive: true });
|
|
1014
|
+
try {
|
|
1015
|
+
await link(backupPath, targetPath);
|
|
1016
|
+
} catch (error) {
|
|
1017
|
+
const restoreError = new Error(
|
|
1018
|
+
`无法无覆盖地恢复隔离文件;隔离副本保留在:${backupPath}`,
|
|
1019
|
+
{ cause: error },
|
|
1020
|
+
);
|
|
1021
|
+
restoreError.preserveEvidence = true;
|
|
1022
|
+
throw restoreError;
|
|
1023
|
+
}
|
|
1024
|
+
try {
|
|
1025
|
+
await rm(backupPath);
|
|
1026
|
+
} catch (error) {
|
|
1027
|
+
const cleanupError = new Error(
|
|
1028
|
+
`目标已恢复,但隔离副本未能清理:${backupPath}`,
|
|
1029
|
+
{ cause: error },
|
|
1030
|
+
);
|
|
1031
|
+
cleanupError.preserveEvidence = true;
|
|
1032
|
+
throw cleanupError;
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
export async function quarantineManagedRemoval({
|
|
1037
|
+
targetPath,
|
|
1038
|
+
backupPath,
|
|
1039
|
+
expectedHash,
|
|
1040
|
+
}) {
|
|
1041
|
+
await mkdir(path.dirname(backupPath), { recursive: true });
|
|
1042
|
+
try {
|
|
1043
|
+
await rename(targetPath, backupPath);
|
|
1044
|
+
} catch (error) {
|
|
331
1045
|
throw new SyncConflictError([
|
|
332
1046
|
{
|
|
333
1047
|
host: "state",
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
1048
|
+
component: "removal",
|
|
1049
|
+
assetName: path.basename(targetPath),
|
|
1050
|
+
skillName: path.basename(targetPath),
|
|
1051
|
+
relativePath: path.basename(targetPath),
|
|
1052
|
+
targetPath,
|
|
337
1053
|
action: "conflict",
|
|
338
|
-
reason:
|
|
1054
|
+
reason: `无法原子隔离待移除文件:${error.message}`,
|
|
1055
|
+
},
|
|
1056
|
+
]);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
let actualHash;
|
|
1060
|
+
try {
|
|
1061
|
+
actualHash = await hashFile(backupPath);
|
|
1062
|
+
} catch (error) {
|
|
1063
|
+
try {
|
|
1064
|
+
await restoreQuarantinedRemoval(targetPath, backupPath);
|
|
1065
|
+
} catch (restoreError) {
|
|
1066
|
+
restoreError.cause = error;
|
|
1067
|
+
throw restoreError;
|
|
1068
|
+
}
|
|
1069
|
+
throw error;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
if (actualHash !== expectedHash) {
|
|
1073
|
+
try {
|
|
1074
|
+
await restoreQuarantinedRemoval(targetPath, backupPath);
|
|
1075
|
+
} catch (restoreError) {
|
|
1076
|
+
const conflict = new SyncConflictError([
|
|
1077
|
+
{
|
|
1078
|
+
host: "state",
|
|
1079
|
+
component: "removal",
|
|
1080
|
+
assetName: path.basename(targetPath),
|
|
1081
|
+
skillName: path.basename(targetPath),
|
|
1082
|
+
relativePath: path.basename(targetPath),
|
|
1083
|
+
targetPath,
|
|
1084
|
+
action: "conflict",
|
|
1085
|
+
reason: `待移除文件已变化且无法安全恢复;隔离副本保留在 ${backupPath}`,
|
|
1086
|
+
},
|
|
1087
|
+
]);
|
|
1088
|
+
conflict.cause = restoreError;
|
|
1089
|
+
conflict.preserveEvidence = true;
|
|
1090
|
+
throw conflict;
|
|
1091
|
+
}
|
|
1092
|
+
throw new SyncConflictError([
|
|
1093
|
+
{
|
|
1094
|
+
host: "state",
|
|
1095
|
+
component: "removal",
|
|
1096
|
+
assetName: path.basename(targetPath),
|
|
1097
|
+
skillName: path.basename(targetPath),
|
|
1098
|
+
relativePath: path.basename(targetPath),
|
|
1099
|
+
targetPath,
|
|
1100
|
+
action: "conflict",
|
|
1101
|
+
reason: "待移除目标在最终原子隔离时发生变化",
|
|
339
1102
|
},
|
|
340
1103
|
]);
|
|
341
1104
|
}
|
|
342
1105
|
|
|
1106
|
+
return { backupPath, actualHash };
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
async function rollbackAppliedOperation({ entry, stageRoot, homeDir }) {
|
|
1110
|
+
const { operation, backupPath, published } = entry;
|
|
1111
|
+
await prepareSafeTargetParent(operation, homeDir);
|
|
1112
|
+
await assertSameTransactionDevice(operation, stageRoot);
|
|
1113
|
+
|
|
1114
|
+
let publishedBackupPath = null;
|
|
1115
|
+
const target = await inspectTarget(operation.targetPath);
|
|
1116
|
+
if (published && target.exists) {
|
|
1117
|
+
if (!target.regularFile || target.hash !== operation.sourceHash) {
|
|
1118
|
+
const error = new Error(
|
|
1119
|
+
`回滚拒绝覆盖事务期间变化的目标:${operation.targetPath}`,
|
|
1120
|
+
);
|
|
1121
|
+
error.preserveEvidence = true;
|
|
1122
|
+
throw error;
|
|
1123
|
+
}
|
|
1124
|
+
publishedBackupPath = path.join(
|
|
1125
|
+
stageRoot,
|
|
1126
|
+
"rollback-current",
|
|
1127
|
+
...operation.manifestKey.split("/"),
|
|
1128
|
+
);
|
|
1129
|
+
try {
|
|
1130
|
+
await quarantineManagedRemoval({
|
|
1131
|
+
targetPath: operation.targetPath,
|
|
1132
|
+
backupPath: publishedBackupPath,
|
|
1133
|
+
expectedHash: operation.sourceHash,
|
|
1134
|
+
});
|
|
1135
|
+
} catch (error) {
|
|
1136
|
+
error.preserveEvidence = true;
|
|
1137
|
+
throw error;
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
if (operation.action === "create") {
|
|
1142
|
+
if (publishedBackupPath) await rm(publishedBackupPath);
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
if (backupPath) {
|
|
1147
|
+
await restoreQuarantinedRemoval(operation.targetPath, backupPath);
|
|
1148
|
+
}
|
|
1149
|
+
if (publishedBackupPath) await rm(publishedBackupPath);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
async function applyOperationsTransactional({
|
|
1153
|
+
operations,
|
|
1154
|
+
homeDir,
|
|
1155
|
+
manifestPath,
|
|
1156
|
+
expectedManifestHash,
|
|
1157
|
+
transactionHooks = {},
|
|
1158
|
+
}) {
|
|
1159
|
+
await assertSafeHomeRoot(homeDir);
|
|
1160
|
+
const stateDir = path.dirname(manifestPath);
|
|
343
1161
|
const stagingDir = path.join(stateDir, "staging");
|
|
344
1162
|
const stagingSafety = await inspectAncestorPath(homeDir, stagingDir);
|
|
345
1163
|
if (!stagingSafety.safe) {
|
|
@@ -355,29 +1173,10 @@ async function applyOperationsTransactional({ operations, homeDir, manifestPath
|
|
|
355
1173
|
]);
|
|
356
1174
|
}
|
|
357
1175
|
|
|
358
|
-
const
|
|
359
|
-
let lockHandle;
|
|
360
|
-
try {
|
|
361
|
-
lockHandle = await open(lockPath, "wx");
|
|
362
|
-
} catch (error) {
|
|
363
|
-
if (error.code === "EEXIST") throw new Error(`已有同步进程持有锁:${lockPath}`);
|
|
364
|
-
throw error;
|
|
365
|
-
}
|
|
366
|
-
try {
|
|
367
|
-
await lockHandle.writeFile(`${process.pid}\n`, "utf8");
|
|
368
|
-
} catch (error) {
|
|
369
|
-
await lockHandle.close();
|
|
370
|
-
await rm(lockPath, { force: true });
|
|
371
|
-
throw error;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
const stageRoot = path.join(stateDir, "staging", randomUUID());
|
|
1176
|
+
const stageRoot = path.join(stagingDir, randomUUID());
|
|
375
1177
|
const stagedPaths = new Map();
|
|
376
1178
|
const applied = [];
|
|
377
1179
|
const rollbackErrors = [];
|
|
378
|
-
let manifestBackupPath = null;
|
|
379
|
-
let manifestExisted = false;
|
|
380
|
-
let manifestChanged = false;
|
|
381
1180
|
let preserveStage = false;
|
|
382
1181
|
|
|
383
1182
|
try {
|
|
@@ -408,90 +1207,205 @@ async function applyOperationsTransactional({ operations, homeDir, manifestPath
|
|
|
408
1207
|
if (operation.action !== "create" && operation.action !== "update") continue;
|
|
409
1208
|
const stagedPath = path.join(stageRoot, "next", ...operation.manifestKey.split("/"));
|
|
410
1209
|
await mkdir(path.dirname(stagedPath), { recursive: true });
|
|
411
|
-
|
|
1210
|
+
if (operation.sourceContent === null) {
|
|
1211
|
+
await copyFile(operation.sourcePath, stagedPath);
|
|
1212
|
+
} else {
|
|
1213
|
+
await writeFile(stagedPath, operation.sourceContent, "utf8");
|
|
1214
|
+
}
|
|
412
1215
|
if ((await hashFile(stagedPath)) !== operation.sourceHash) {
|
|
413
1216
|
throw new Error(`暂存文件校验失败:${operation.sourcePath}`);
|
|
414
1217
|
}
|
|
415
1218
|
stagedPaths.set(operation.manifestKey, stagedPath);
|
|
416
1219
|
}
|
|
417
1220
|
|
|
418
|
-
const lockedManifest = await readManifest(manifestPath);
|
|
419
1221
|
const manifestTarget = await inspectTarget(manifestPath);
|
|
420
1222
|
if (manifestTarget.exists && !manifestTarget.regularFile) {
|
|
421
1223
|
throw new Error(`同步 manifest 不是普通文件:${manifestPath}`);
|
|
422
1224
|
}
|
|
423
|
-
if (manifestTarget.
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
1225
|
+
if (manifestTarget.hash !== expectedManifestHash) {
|
|
1226
|
+
throw conflictForOperation(
|
|
1227
|
+
{
|
|
1228
|
+
host: "state",
|
|
1229
|
+
component: "manifest",
|
|
1230
|
+
assetName: "manifest",
|
|
1231
|
+
skillName: "manifest",
|
|
1232
|
+
relativePath: "manifest.json",
|
|
1233
|
+
targetPath: manifestPath,
|
|
1234
|
+
},
|
|
1235
|
+
"同步 manifest 在规划后发生变化",
|
|
1236
|
+
);
|
|
1237
|
+
}
|
|
1238
|
+
const lockedManifest = await readManifest(manifestPath);
|
|
1239
|
+
const lockedManifestConflicts = collectManifestTargetConflicts(
|
|
1240
|
+
lockedManifest,
|
|
1241
|
+
homeDir,
|
|
1242
|
+
stateDir,
|
|
1243
|
+
);
|
|
1244
|
+
if (lockedManifestConflicts.length > 0) {
|
|
1245
|
+
throw new SyncConflictError(lockedManifestConflicts);
|
|
428
1246
|
}
|
|
429
1247
|
|
|
430
1248
|
const changedTargetConflicts = await collectChangedTargetConflicts(operations, homeDir);
|
|
431
1249
|
if (changedTargetConflicts.length > 0) throw new SyncConflictError(changedTargetConflicts);
|
|
432
1250
|
|
|
433
1251
|
for (const operation of operations) {
|
|
434
|
-
if (
|
|
1252
|
+
if (!["create", "update", "remove"].includes(operation.action)) continue;
|
|
1253
|
+
if (operation.action === "remove" && operation.targetHash === null) continue;
|
|
1254
|
+
|
|
1255
|
+
await prepareSafeTargetParent(operation, homeDir);
|
|
1256
|
+
await transactionHooks.beforeOperationCommit?.(operation);
|
|
1257
|
+
await assertSafeOperationTarget(operation, homeDir);
|
|
1258
|
+
await assertSameTransactionDevice(operation, stageRoot);
|
|
1259
|
+
|
|
435
1260
|
let backupPath = null;
|
|
436
|
-
|
|
1261
|
+
let entry = null;
|
|
1262
|
+
if (operation.action === "update" || operation.action === "remove") {
|
|
437
1263
|
backupPath = path.join(stageRoot, "backups", ...operation.manifestKey.split("/"));
|
|
438
|
-
await
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
}
|
|
1264
|
+
await quarantineManagedRemoval({
|
|
1265
|
+
targetPath: operation.targetPath,
|
|
1266
|
+
backupPath,
|
|
1267
|
+
expectedHash: operation.targetHash,
|
|
1268
|
+
});
|
|
1269
|
+
entry = { operation, backupPath, published: false };
|
|
1270
|
+
applied.push(entry);
|
|
1271
|
+
await transactionHooks.afterOperationQuarantine?.(operation);
|
|
443
1272
|
}
|
|
1273
|
+
if (operation.action === "remove") continue;
|
|
444
1274
|
|
|
445
|
-
|
|
446
|
-
await
|
|
447
|
-
await
|
|
1275
|
+
await assertSafeOperationTarget(operation, homeDir);
|
|
1276
|
+
await assertSameTransactionDevice(operation, stageRoot);
|
|
1277
|
+
await linkStagedFileNoOverwrite({
|
|
1278
|
+
operation,
|
|
1279
|
+
stagedPath: stagedPaths.get(operation.manifestKey),
|
|
1280
|
+
});
|
|
1281
|
+
if (!entry) {
|
|
1282
|
+
entry = { operation, backupPath: null, published: true };
|
|
1283
|
+
applied.push(entry);
|
|
1284
|
+
} else {
|
|
1285
|
+
entry.published = true;
|
|
1286
|
+
}
|
|
448
1287
|
if ((await hashFile(operation.targetPath)) !== operation.sourceHash) {
|
|
449
1288
|
throw new Error(`同步后校验失败:${operation.targetPath}`);
|
|
450
1289
|
}
|
|
1290
|
+
await transactionHooks.afterOperationPublish?.(operation);
|
|
451
1291
|
}
|
|
452
1292
|
|
|
453
1293
|
const nextFiles = { ...lockedManifest.files };
|
|
454
|
-
for (const operation of operations)
|
|
1294
|
+
for (const operation of operations) {
|
|
1295
|
+
if (operation.action === "remove") {
|
|
1296
|
+
delete nextFiles[operation.manifestKey];
|
|
1297
|
+
} else {
|
|
1298
|
+
nextFiles[operation.manifestKey] = operation.sourceHash;
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
455
1301
|
const nextManifest = {
|
|
456
1302
|
schemaVersion: 1,
|
|
457
1303
|
managedBy: "netpilot-skills",
|
|
458
1304
|
updatedAt: new Date().toISOString(),
|
|
459
|
-
files: Object.fromEntries(
|
|
1305
|
+
files: Object.fromEntries(
|
|
1306
|
+
Object.entries(nextFiles).sort(([left], [right]) => left.localeCompare(right)),
|
|
1307
|
+
),
|
|
460
1308
|
};
|
|
461
1309
|
const stagedManifestPath = path.join(stageRoot, "next-manifest.json");
|
|
462
|
-
await mkdir(path.dirname(stagedManifestPath), { recursive: true });
|
|
463
1310
|
await writeFile(stagedManifestPath, `${JSON.stringify(nextManifest, null, 2)}\n`, "utf8");
|
|
464
1311
|
JSON.parse(await readFile(stagedManifestPath, "utf8"));
|
|
465
|
-
|
|
466
|
-
|
|
1312
|
+
const manifestOperation = {
|
|
1313
|
+
host: "state",
|
|
1314
|
+
component: "manifest",
|
|
1315
|
+
assetName: "manifest",
|
|
1316
|
+
skillName: "manifest",
|
|
1317
|
+
relativePath: "manifest.json",
|
|
1318
|
+
targetPath: manifestPath,
|
|
1319
|
+
manifestKey: "state/manifest.json",
|
|
1320
|
+
targetHash: manifestTarget.hash,
|
|
1321
|
+
sourceHash: await hashFile(stagedManifestPath),
|
|
1322
|
+
action: manifestTarget.exists ? "update" : "create",
|
|
1323
|
+
};
|
|
1324
|
+
await prepareSafeTargetParent(manifestOperation, homeDir);
|
|
1325
|
+
await transactionHooks.beforeOperationCommit?.(manifestOperation);
|
|
1326
|
+
await assertSafeOperationTarget(manifestOperation, homeDir);
|
|
1327
|
+
await assertSameTransactionDevice(manifestOperation, stageRoot);
|
|
1328
|
+
const finalTargetConflicts = await collectFinalTargetConflicts(operations, homeDir);
|
|
1329
|
+
if (finalTargetConflicts.length > 0) {
|
|
1330
|
+
throw new SyncConflictError(finalTargetConflicts);
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
let manifestEntry;
|
|
1334
|
+
if (manifestOperation.action === "update") {
|
|
1335
|
+
const manifestBackupPath = path.join(stageRoot, "backups", "manifest.json");
|
|
1336
|
+
await quarantineManagedRemoval({
|
|
1337
|
+
targetPath: manifestPath,
|
|
1338
|
+
backupPath: manifestBackupPath,
|
|
1339
|
+
expectedHash: manifestOperation.targetHash,
|
|
1340
|
+
});
|
|
1341
|
+
manifestEntry = {
|
|
1342
|
+
operation: manifestOperation,
|
|
1343
|
+
backupPath: manifestBackupPath,
|
|
1344
|
+
published: false,
|
|
1345
|
+
};
|
|
1346
|
+
applied.push(manifestEntry);
|
|
1347
|
+
await transactionHooks.afterOperationQuarantine?.(manifestOperation);
|
|
1348
|
+
}
|
|
1349
|
+
await assertSafeOperationTarget(manifestOperation, homeDir);
|
|
1350
|
+
await assertSameTransactionDevice(manifestOperation, stageRoot);
|
|
1351
|
+
await linkStagedFileNoOverwrite({
|
|
1352
|
+
operation: manifestOperation,
|
|
1353
|
+
stagedPath: stagedManifestPath,
|
|
1354
|
+
});
|
|
1355
|
+
if (!manifestEntry) {
|
|
1356
|
+
manifestEntry = {
|
|
1357
|
+
operation: manifestOperation,
|
|
1358
|
+
backupPath: null,
|
|
1359
|
+
published: true,
|
|
1360
|
+
};
|
|
1361
|
+
applied.push(manifestEntry);
|
|
1362
|
+
} else {
|
|
1363
|
+
manifestEntry.published = true;
|
|
1364
|
+
}
|
|
1365
|
+
if ((await hashFile(manifestPath)) !== manifestOperation.sourceHash) {
|
|
1366
|
+
throw new Error(`同步后校验失败:${manifestPath}`);
|
|
1367
|
+
}
|
|
467
1368
|
JSON.parse(await readFile(manifestPath, "utf8"));
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
1369
|
+
await transactionHooks.afterOperationPublish?.(manifestOperation);
|
|
1370
|
+
const postManifestTargetConflicts = await collectFinalTargetConflicts(operations, homeDir);
|
|
1371
|
+
if (postManifestTargetConflicts.length > 0) {
|
|
1372
|
+
throw new SyncConflictError(postManifestTargetConflicts);
|
|
1373
|
+
}
|
|
1374
|
+
const committedManifest = await inspectTarget(manifestPath);
|
|
1375
|
+
if (
|
|
1376
|
+
!committedManifest.regularFile ||
|
|
1377
|
+
committedManifest.hash !== manifestOperation.sourceHash
|
|
1378
|
+
) {
|
|
1379
|
+
throw conflictForOperation(
|
|
1380
|
+
manifestOperation,
|
|
1381
|
+
"manifest 在最终提交后发生变化",
|
|
1382
|
+
);
|
|
479
1383
|
}
|
|
480
1384
|
|
|
481
|
-
|
|
1385
|
+
for (const operation of operations) {
|
|
1386
|
+
if (
|
|
1387
|
+
operation.action === "remove" &&
|
|
1388
|
+
operation.targetHash !== null &&
|
|
1389
|
+
operation.pruneRoot
|
|
1390
|
+
) {
|
|
1391
|
+
await removeEmptyParentDirectories(
|
|
1392
|
+
path.dirname(operation.targetPath),
|
|
1393
|
+
operation.pruneRoot,
|
|
1394
|
+
);
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
} catch (error) {
|
|
1398
|
+
if (error.preserveEvidence) preserveStage = true;
|
|
1399
|
+
for (const entry of [...applied].reverse()) {
|
|
482
1400
|
try {
|
|
483
|
-
|
|
484
|
-
await copyFile(manifestBackupPath, manifestPath);
|
|
485
|
-
} else {
|
|
486
|
-
await rm(manifestPath, { force: true });
|
|
487
|
-
}
|
|
1401
|
+
await rollbackAppliedOperation({ entry, stageRoot, homeDir });
|
|
488
1402
|
} catch (rollbackError) {
|
|
489
|
-
|
|
1403
|
+
preserveStage = true;
|
|
1404
|
+
rollbackErrors.push(`${entry.operation.targetPath}: ${rollbackError.message}`);
|
|
490
1405
|
}
|
|
491
1406
|
}
|
|
492
1407
|
|
|
493
1408
|
if (rollbackErrors.length > 0) {
|
|
494
|
-
preserveStage = true;
|
|
495
1409
|
throw new Error(
|
|
496
1410
|
`同步失败且回滚不完整;暂存保留在 ${stageRoot}\n${rollbackErrors.join("\n")}`,
|
|
497
1411
|
{ cause: error },
|
|
@@ -499,24 +1413,125 @@ async function applyOperationsTransactional({ operations, homeDir, manifestPath
|
|
|
499
1413
|
}
|
|
500
1414
|
throw error;
|
|
501
1415
|
} finally {
|
|
1416
|
+
if (!preserveStage) await rm(stageRoot, { recursive: true, force: true });
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
async function migrateLegacyState({
|
|
1421
|
+
homeDir,
|
|
1422
|
+
paths,
|
|
1423
|
+
transactionHooks,
|
|
1424
|
+
}) {
|
|
1425
|
+
let syncLock = await acquireSyncLock({
|
|
1426
|
+
homeDir,
|
|
1427
|
+
stateDir: paths.legacyStateDir,
|
|
1428
|
+
beforeLockPublish: transactionHooks.beforeLockPublish,
|
|
1429
|
+
});
|
|
1430
|
+
let moved = false;
|
|
1431
|
+
try {
|
|
1432
|
+
const freshLayout = await inspectStateLayout(homeDir, paths);
|
|
1433
|
+
if (freshLayout.kind !== "legacy") {
|
|
1434
|
+
throw stateLayoutConflict(
|
|
1435
|
+
homeDir,
|
|
1436
|
+
"获取旧状态锁后布局发生变化,拒绝继续迁移",
|
|
1437
|
+
paths.legacyStateDir,
|
|
1438
|
+
);
|
|
1439
|
+
}
|
|
1440
|
+
const lockedSourceIdentity = freshLayout.legacy.identity;
|
|
1441
|
+
const legacyManifest = await inspectTarget(paths.legacyManifestPath);
|
|
1442
|
+
if (!legacyManifest.exists || !legacyManifest.regularFile) {
|
|
1443
|
+
throw stateLayoutConflict(
|
|
1444
|
+
homeDir,
|
|
1445
|
+
"旧状态目录缺少可信的普通 manifest.json",
|
|
1446
|
+
paths.legacyManifestPath,
|
|
1447
|
+
);
|
|
1448
|
+
}
|
|
1449
|
+
await readManifest(paths.legacyManifestPath);
|
|
1450
|
+
await transactionHooks.beforeStateMigrationRename?.();
|
|
1451
|
+
const beforeRename = await inspectStateLayout(homeDir, paths);
|
|
1452
|
+
if (
|
|
1453
|
+
beforeRename.kind !== "legacy" ||
|
|
1454
|
+
!sameStateDirectoryIdentity(
|
|
1455
|
+
lockedSourceIdentity,
|
|
1456
|
+
beforeRename.legacy.identity,
|
|
1457
|
+
)
|
|
1458
|
+
) {
|
|
1459
|
+
throw stateLayoutConflict(
|
|
1460
|
+
homeDir,
|
|
1461
|
+
"旧状态目录身份在迁移提交前发生变化",
|
|
1462
|
+
paths.currentStateDir,
|
|
1463
|
+
);
|
|
1464
|
+
}
|
|
1465
|
+
try {
|
|
1466
|
+
await rename(paths.legacyStateDir, paths.currentStateDir);
|
|
1467
|
+
} catch (error) {
|
|
1468
|
+
if (error.code === "EXDEV") {
|
|
1469
|
+
throw stateLayoutConflict(
|
|
1470
|
+
homeDir,
|
|
1471
|
+
"新旧状态目录不在同一文件系统,拒绝退化为非原子复制",
|
|
1472
|
+
paths.currentStateDir,
|
|
1473
|
+
);
|
|
1474
|
+
}
|
|
1475
|
+
if (["EEXIST", "ENOTEMPTY"].includes(error.code)) {
|
|
1476
|
+
throw stateLayoutConflict(
|
|
1477
|
+
homeDir,
|
|
1478
|
+
"新状态目录在原子迁移前已出现",
|
|
1479
|
+
paths.currentStateDir,
|
|
1480
|
+
);
|
|
1481
|
+
}
|
|
1482
|
+
throw error;
|
|
1483
|
+
}
|
|
1484
|
+
moved = true;
|
|
1485
|
+
syncLock = {
|
|
1486
|
+
...syncLock,
|
|
1487
|
+
stateDir: paths.currentStateDir,
|
|
1488
|
+
lockPath: path.join(paths.currentStateDir, "sync.lock"),
|
|
1489
|
+
};
|
|
1490
|
+
const migratedLayout = await inspectStateLayout(homeDir, paths);
|
|
1491
|
+
if (
|
|
1492
|
+
migratedLayout.kind !== "current" ||
|
|
1493
|
+
!sameStateDirectoryIdentity(
|
|
1494
|
+
lockedSourceIdentity,
|
|
1495
|
+
migratedLayout.current.identity,
|
|
1496
|
+
)
|
|
1497
|
+
) {
|
|
1498
|
+
const error = new Error("状态目录迁移后的目标身份不一致");
|
|
1499
|
+
error.preserveEvidence = true;
|
|
1500
|
+
throw error;
|
|
1501
|
+
}
|
|
1502
|
+
const migratedManifest = await inspectTarget(paths.currentManifestPath);
|
|
1503
|
+
if (
|
|
1504
|
+
!migratedManifest.regularFile ||
|
|
1505
|
+
migratedManifest.hash !== legacyManifest.hash
|
|
1506
|
+
) {
|
|
1507
|
+
const error = new Error("状态目录迁移后 manifest digest 不一致");
|
|
1508
|
+
error.preserveEvidence = true;
|
|
1509
|
+
throw error;
|
|
1510
|
+
}
|
|
1511
|
+
await transactionHooks.afterStateMigrationRename?.();
|
|
1512
|
+
return syncLock;
|
|
1513
|
+
} catch (error) {
|
|
502
1514
|
try {
|
|
503
|
-
await
|
|
504
|
-
}
|
|
505
|
-
|
|
1515
|
+
await releaseSyncLock(syncLock);
|
|
1516
|
+
} catch (releaseError) {
|
|
1517
|
+
error.releaseError = releaseError;
|
|
1518
|
+
error.preserveEvidence = true;
|
|
506
1519
|
}
|
|
507
|
-
if (
|
|
1520
|
+
if (moved) error.stateMigrationCommitted = true;
|
|
1521
|
+
throw error;
|
|
508
1522
|
}
|
|
509
1523
|
}
|
|
510
1524
|
|
|
511
1525
|
export async function syncSkills({
|
|
512
1526
|
rootDir,
|
|
513
1527
|
homeDir,
|
|
514
|
-
hosts = [
|
|
515
|
-
components = [
|
|
1528
|
+
hosts = ["codex"],
|
|
1529
|
+
components = [...ALL_COMPONENTS],
|
|
516
1530
|
apply = false,
|
|
1531
|
+
transactionHooks = {},
|
|
517
1532
|
}) {
|
|
518
1533
|
const selectedHosts = [...new Set(hosts)];
|
|
519
|
-
const selectedComponents = [...new Set(components)];
|
|
1534
|
+
const selectedComponents = [...new Set(components.map(canonicalComponent))];
|
|
520
1535
|
for (const host of selectedHosts) {
|
|
521
1536
|
if (!(host in HOST_DESTINATIONS)) throw new Error(`不支持的宿主:${host}`);
|
|
522
1537
|
}
|
|
@@ -525,15 +1540,123 @@ export async function syncSkills({
|
|
|
525
1540
|
}
|
|
526
1541
|
if (
|
|
527
1542
|
selectedComponents.includes("agents") &&
|
|
528
|
-
|
|
1543
|
+
!selectedHosts.includes("codex")
|
|
529
1544
|
) {
|
|
530
|
-
throw new Error("Codex agents
|
|
1545
|
+
throw new Error("Codex agents 组件要求 --host 包含 codex");
|
|
531
1546
|
}
|
|
532
1547
|
|
|
533
1548
|
const resolvedRoot = path.resolve(rootDir);
|
|
534
1549
|
const resolvedHome = path.resolve(homeDir);
|
|
535
|
-
|
|
536
|
-
const
|
|
1550
|
+
await assertSafeHomeRoot(resolvedHome);
|
|
1551
|
+
const statePaths = resolveStatePaths(resolvedHome);
|
|
1552
|
+
let stateLayout = await inspectStateLayout(resolvedHome, statePaths);
|
|
1553
|
+
let stateDir =
|
|
1554
|
+
stateLayout.kind === "legacy"
|
|
1555
|
+
? statePaths.legacyStateDir
|
|
1556
|
+
: statePaths.currentStateDir;
|
|
1557
|
+
let manifestPath =
|
|
1558
|
+
stateLayout.kind === "legacy"
|
|
1559
|
+
? statePaths.legacyManifestPath
|
|
1560
|
+
: statePaths.currentManifestPath;
|
|
1561
|
+
let stateMigration =
|
|
1562
|
+
stateLayout.kind === "legacy"
|
|
1563
|
+
? {
|
|
1564
|
+
action: "move",
|
|
1565
|
+
from: statePaths.legacyStateDir,
|
|
1566
|
+
to: statePaths.currentStateDir,
|
|
1567
|
+
}
|
|
1568
|
+
: null;
|
|
1569
|
+
let stateRepair =
|
|
1570
|
+
stateLayout.kind === "current-with-empty-legacy"
|
|
1571
|
+
? {
|
|
1572
|
+
action: "remove-empty-legacy-state",
|
|
1573
|
+
target: statePaths.legacyStateDir,
|
|
1574
|
+
}
|
|
1575
|
+
: null;
|
|
1576
|
+
let layoutLock = null;
|
|
1577
|
+
let syncLock = null;
|
|
1578
|
+
let syncError = null;
|
|
1579
|
+
|
|
1580
|
+
try {
|
|
1581
|
+
if (apply) {
|
|
1582
|
+
layoutLock = await acquireSyncLock({
|
|
1583
|
+
homeDir: resolvedHome,
|
|
1584
|
+
stateDir: statePaths.layoutLockDir,
|
|
1585
|
+
lockName: LAYOUT_LOCK_NAME,
|
|
1586
|
+
beforeLockPublish: transactionHooks.beforeLayoutLockPublish,
|
|
1587
|
+
});
|
|
1588
|
+
stateLayout = await inspectStateLayout(resolvedHome, statePaths);
|
|
1589
|
+
if (stateLayout.kind === "current-with-empty-legacy") {
|
|
1590
|
+
stateRepair = {
|
|
1591
|
+
action: "remove-empty-legacy-state",
|
|
1592
|
+
target: statePaths.legacyStateDir,
|
|
1593
|
+
};
|
|
1594
|
+
const expectedCurrentIdentity = stateLayout.current.identity;
|
|
1595
|
+
try {
|
|
1596
|
+
await rmdir(statePaths.legacyStateDir);
|
|
1597
|
+
} catch (error) {
|
|
1598
|
+
if (["ENOTEMPTY", "EEXIST"].includes(error.code)) {
|
|
1599
|
+
throw stateLayoutConflict(
|
|
1600
|
+
resolvedHome,
|
|
1601
|
+
"空旧状态目录在清理前出现内容,拒绝继续",
|
|
1602
|
+
statePaths.legacyStateDir,
|
|
1603
|
+
);
|
|
1604
|
+
}
|
|
1605
|
+
throw error;
|
|
1606
|
+
}
|
|
1607
|
+
const repairedLayout = await inspectStateLayout(
|
|
1608
|
+
resolvedHome,
|
|
1609
|
+
statePaths,
|
|
1610
|
+
);
|
|
1611
|
+
if (
|
|
1612
|
+
repairedLayout.kind !== "current" ||
|
|
1613
|
+
!sameStateDirectoryIdentity(
|
|
1614
|
+
expectedCurrentIdentity,
|
|
1615
|
+
repairedLayout.current.identity,
|
|
1616
|
+
)
|
|
1617
|
+
) {
|
|
1618
|
+
throw stateLayoutConflict(
|
|
1619
|
+
resolvedHome,
|
|
1620
|
+
"清理旧状态残留后当前状态目录身份发生变化",
|
|
1621
|
+
statePaths.currentStateDir,
|
|
1622
|
+
);
|
|
1623
|
+
}
|
|
1624
|
+
stateLayout = repairedLayout;
|
|
1625
|
+
}
|
|
1626
|
+
if (stateLayout.kind === "legacy") {
|
|
1627
|
+
stateMigration = {
|
|
1628
|
+
action: "move",
|
|
1629
|
+
from: statePaths.legacyStateDir,
|
|
1630
|
+
to: statePaths.currentStateDir,
|
|
1631
|
+
};
|
|
1632
|
+
syncLock = await migrateLegacyState({
|
|
1633
|
+
homeDir: resolvedHome,
|
|
1634
|
+
paths: statePaths,
|
|
1635
|
+
transactionHooks,
|
|
1636
|
+
});
|
|
1637
|
+
} else {
|
|
1638
|
+
syncLock = await acquireSyncLock({
|
|
1639
|
+
homeDir: resolvedHome,
|
|
1640
|
+
stateDir: statePaths.currentStateDir,
|
|
1641
|
+
beforeLockPublish: transactionHooks.beforeLockPublish,
|
|
1642
|
+
});
|
|
1643
|
+
}
|
|
1644
|
+
const lockedLayout = await inspectStateLayout(
|
|
1645
|
+
resolvedHome,
|
|
1646
|
+
statePaths,
|
|
1647
|
+
);
|
|
1648
|
+
if (lockedLayout.kind !== "current") {
|
|
1649
|
+
throw stateLayoutConflict(
|
|
1650
|
+
resolvedHome,
|
|
1651
|
+
"获取同步锁后项目状态布局发生变化,拒绝继续",
|
|
1652
|
+
statePaths.currentStateDir,
|
|
1653
|
+
);
|
|
1654
|
+
}
|
|
1655
|
+
stateLayout = lockedLayout;
|
|
1656
|
+
stateDir = statePaths.currentStateDir;
|
|
1657
|
+
manifestPath = statePaths.currentManifestPath;
|
|
1658
|
+
}
|
|
1659
|
+
if (syncLock) await transactionHooks.afterLockAcquired?.();
|
|
537
1660
|
const stateSafety = await inspectAncestorPath(resolvedHome, stateDir);
|
|
538
1661
|
if (!stateSafety.safe) {
|
|
539
1662
|
throw new SyncConflictError([
|
|
@@ -561,6 +1684,12 @@ export async function syncSkills({
|
|
|
561
1684
|
]);
|
|
562
1685
|
}
|
|
563
1686
|
const manifest = await readManifest(manifestPath);
|
|
1687
|
+
const manifestConflicts = collectManifestTargetConflicts(
|
|
1688
|
+
manifest,
|
|
1689
|
+
resolvedHome,
|
|
1690
|
+
stateDir,
|
|
1691
|
+
);
|
|
1692
|
+
if (manifestConflicts.length > 0) throw new SyncConflictError(manifestConflicts);
|
|
564
1693
|
const sourceFiles = selectedComponents.includes("skills")
|
|
565
1694
|
? await discoverSourceFiles(resolvedRoot)
|
|
566
1695
|
: [];
|
|
@@ -581,6 +1710,10 @@ export async function syncSkills({
|
|
|
581
1710
|
const hostRoot = path.join(resolvedHome, ...HOST_DESTINATIONS[host]);
|
|
582
1711
|
for (const source of sourceFiles) {
|
|
583
1712
|
const targetPath = path.join(hostRoot, source.skillName, ...source.relativePath.split("/"));
|
|
1713
|
+
const sourceContent =
|
|
1714
|
+
host === "codex" && source.relativePath === "SKILL.md"
|
|
1715
|
+
? projectCodexSkill(await readFile(source.absolutePath, "utf8"))
|
|
1716
|
+
: null;
|
|
584
1717
|
operations.push(await planFileOperation({
|
|
585
1718
|
host,
|
|
586
1719
|
component: "skills",
|
|
@@ -588,6 +1721,7 @@ export async function syncSkills({
|
|
|
588
1721
|
skillName: source.skillName,
|
|
589
1722
|
relativePath: source.relativePath,
|
|
590
1723
|
sourcePath: source.absolutePath,
|
|
1724
|
+
sourceContent,
|
|
591
1725
|
targetPath,
|
|
592
1726
|
manifest,
|
|
593
1727
|
homeDir: resolvedHome,
|
|
@@ -627,46 +1761,114 @@ export async function syncSkills({
|
|
|
627
1761
|
}));
|
|
628
1762
|
}
|
|
629
1763
|
|
|
1764
|
+
const desiredManifestKeys = new Set(operations.map((operation) => operation.manifestKey));
|
|
1765
|
+
for (const [key, previousHash] of Object.entries(manifest.files)) {
|
|
1766
|
+
if (desiredManifestKeys.has(key)) continue;
|
|
1767
|
+
const target = resolveManagedTarget(key, resolvedHome, stateDir);
|
|
1768
|
+
if (!target) continue;
|
|
1769
|
+
const selected =
|
|
1770
|
+
target.component === "support" ||
|
|
1771
|
+
(target.component === "skills" &&
|
|
1772
|
+
selectedComponents.includes("skills") &&
|
|
1773
|
+
selectedHosts.includes(target.host)) ||
|
|
1774
|
+
(target.component === "agents" &&
|
|
1775
|
+
selectedComponents.includes("agents") &&
|
|
1776
|
+
selectedHosts.includes("codex"));
|
|
1777
|
+
if (!selected) continue;
|
|
1778
|
+
operations.push(await planRemovalOperation({
|
|
1779
|
+
manifestKeyValue: key,
|
|
1780
|
+
previousHash,
|
|
1781
|
+
target,
|
|
1782
|
+
homeDir: resolvedHome,
|
|
1783
|
+
}));
|
|
1784
|
+
}
|
|
1785
|
+
|
|
630
1786
|
const conflicts = operations.filter((operation) => operation.action === "conflict");
|
|
631
1787
|
const result = {
|
|
632
1788
|
mode: apply ? "apply" : "dry-run",
|
|
633
1789
|
homeDir: resolvedHome,
|
|
634
|
-
manifestPath,
|
|
1790
|
+
manifestPath: statePaths.currentManifestPath,
|
|
1791
|
+
...(manifestPath !== statePaths.currentManifestPath
|
|
1792
|
+
? { sourceManifestPath: manifestPath }
|
|
1793
|
+
: {}),
|
|
1794
|
+
...(stateMigration ? { stateMigration } : {}),
|
|
1795
|
+
...(stateRepair ? { stateRepair } : {}),
|
|
635
1796
|
operations,
|
|
636
1797
|
conflicts,
|
|
637
1798
|
};
|
|
638
1799
|
|
|
639
1800
|
if (!apply) return result;
|
|
640
1801
|
if (conflicts.length > 0) throw new SyncConflictError(conflicts);
|
|
641
|
-
await applyOperationsTransactional({
|
|
1802
|
+
await applyOperationsTransactional({
|
|
1803
|
+
operations,
|
|
1804
|
+
homeDir: resolvedHome,
|
|
1805
|
+
manifestPath,
|
|
1806
|
+
expectedManifestHash: manifestTarget.hash,
|
|
1807
|
+
transactionHooks,
|
|
1808
|
+
});
|
|
642
1809
|
|
|
643
1810
|
return result;
|
|
1811
|
+
} catch (error) {
|
|
1812
|
+
syncError = error;
|
|
1813
|
+
throw error;
|
|
1814
|
+
} finally {
|
|
1815
|
+
let releaseFailure = null;
|
|
1816
|
+
for (const lock of [syncLock, layoutLock]) {
|
|
1817
|
+
if (!lock) continue;
|
|
1818
|
+
try {
|
|
1819
|
+
await releaseSyncLock(lock);
|
|
1820
|
+
} catch (releaseError) {
|
|
1821
|
+
if (syncError) {
|
|
1822
|
+
syncError.releaseError = releaseError;
|
|
1823
|
+
syncError.preserveEvidence = true;
|
|
1824
|
+
} else {
|
|
1825
|
+
releaseFailure ??= releaseError;
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
if (!syncError && releaseFailure) throw releaseFailure;
|
|
1830
|
+
}
|
|
644
1831
|
}
|
|
645
1832
|
|
|
646
1833
|
function printHelp() {
|
|
647
1834
|
console.log(`用法:
|
|
648
|
-
netpilot-skills
|
|
649
|
-
npm run sync -- [--host codex|claude|all] [--component skills|agents|all] [--home PATH] [--apply]
|
|
1835
|
+
netpilot-skills sync [--host codex|claude|all] [--component skills|system-agents|agents|all] [--home PATH] [--apply|--dry-run]
|
|
1836
|
+
npm run sync -- [--host codex|claude|all] [--component skills|system-agents|agents|all] [--home PATH] [--apply|--dry-run]
|
|
650
1837
|
|
|
651
|
-
默认执行 dry-run,只展示计划。只有传入 --apply 才会写入用户级目录。
|
|
652
|
-
Codex
|
|
653
|
-
|
|
654
|
-
Codex
|
|
1838
|
+
底层 sync 默认执行 dry-run,只展示计划。只有传入 --apply 才会写入用户级目录。
|
|
1839
|
+
默认目标:Codex skills 与 agents。
|
|
1840
|
+
Codex Skills 目标:~/.agents/skills
|
|
1841
|
+
Codex 系统级(用户全局)Agents 目标:~/.codex/agents
|
|
1842
|
+
system-agents 是 agents 的语义化别名,二者安装内容与 manifest 所有权相同。
|
|
1843
|
+
Claude Code 目标:~/.claude/skills(选择 --host claude 时默认只安装 skills)。
|
|
1844
|
+
选择 --host all 时,Skills 同步到 Codex 与 Claude,Agents 只同步到 Codex。`);
|
|
655
1845
|
}
|
|
656
1846
|
|
|
657
1847
|
function printPlan(result) {
|
|
1848
|
+
if (result.stateRepair) {
|
|
1849
|
+
console.log(
|
|
1850
|
+
`[remove-empty] state ${result.stateRepair.target}`,
|
|
1851
|
+
);
|
|
1852
|
+
}
|
|
1853
|
+
if (result.stateMigration) {
|
|
1854
|
+
console.log(
|
|
1855
|
+
`[move] state ${result.stateMigration.from} -> ${result.stateMigration.to}`,
|
|
1856
|
+
);
|
|
1857
|
+
}
|
|
658
1858
|
for (const operation of result.operations) {
|
|
659
1859
|
const reason = operation.reason ? `(${operation.reason})` : "";
|
|
660
1860
|
const component = operation.component === "skills" ? "skills" : operation.component;
|
|
661
1861
|
console.log(`[${operation.action}] ${operation.host} ${component} ${operation.assetName}/${operation.relativePath}${reason}`);
|
|
662
1862
|
}
|
|
663
1863
|
const counts = Object.fromEntries(
|
|
664
|
-
["create", "update", "unchanged", "conflict"].map((action) => [
|
|
1864
|
+
["create", "update", "remove", "unchanged", "conflict"].map((action) => [
|
|
665
1865
|
action,
|
|
666
1866
|
result.operations.filter((operation) => operation.action === action).length,
|
|
667
1867
|
]),
|
|
668
1868
|
);
|
|
669
|
-
console.log(
|
|
1869
|
+
console.log(
|
|
1870
|
+
`汇总:新增 ${counts.create},更新 ${counts.update},移除 ${counts.remove},不变 ${counts.unchanged},冲突 ${counts.conflict}`,
|
|
1871
|
+
);
|
|
670
1872
|
if (result.mode === "dry-run") console.log("当前为 dry-run;确认计划后追加 --apply。 ");
|
|
671
1873
|
}
|
|
672
1874
|
|
|
@@ -675,9 +1877,9 @@ async function main() {
|
|
|
675
1877
|
printHelp();
|
|
676
1878
|
return;
|
|
677
1879
|
}
|
|
678
|
-
const options = parseArgs(process.argv.slice(2));
|
|
679
|
-
const rootDir = fileURLToPath(new URL("..", import.meta.url));
|
|
680
1880
|
try {
|
|
1881
|
+
const options = parseArgs(process.argv.slice(2));
|
|
1882
|
+
const rootDir = fileURLToPath(new URL("..", import.meta.url));
|
|
681
1883
|
const result = await syncSkills({ rootDir, ...options });
|
|
682
1884
|
printPlan(result);
|
|
683
1885
|
if (result.conflicts.length > 0) process.exitCode = 2;
|
|
@@ -690,7 +1892,8 @@ async function main() {
|
|
|
690
1892
|
process.exitCode = 2;
|
|
691
1893
|
return;
|
|
692
1894
|
}
|
|
693
|
-
|
|
1895
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
1896
|
+
process.exitCode = 1;
|
|
694
1897
|
}
|
|
695
1898
|
}
|
|
696
1899
|
|