@netpilot/skills 0.3.2 → 0.4.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 +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +25 -9
- package/CHANGELOG.md +21 -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 +965 -99
- 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";
|
|
@@ -15,7 +27,15 @@ const CODEX_AGENT_DESTINATION = [".codex", "agents"];
|
|
|
15
27
|
|
|
16
28
|
const ALL_HOSTS = Object.freeze(Object.keys(HOST_DESTINATIONS));
|
|
17
29
|
const ALL_COMPONENTS = Object.freeze(["skills", "agents"]);
|
|
30
|
+
const COMPONENT_ALIASES = Object.freeze({
|
|
31
|
+
"system-agents": "agents",
|
|
32
|
+
});
|
|
18
33
|
const SUPPORT_FILE_NAMES = Object.freeze(["LICENSE", "THIRD_PARTY_NOTICES.md"]);
|
|
34
|
+
const MANAGED_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
35
|
+
|
|
36
|
+
function canonicalComponent(component) {
|
|
37
|
+
return COMPONENT_ALIASES[component] ?? component;
|
|
38
|
+
}
|
|
19
39
|
|
|
20
40
|
export class SyncConflictError extends Error {
|
|
21
41
|
constructor(conflicts) {
|
|
@@ -27,16 +47,30 @@ export class SyncConflictError extends Error {
|
|
|
27
47
|
|
|
28
48
|
export function parseArgs(argv) {
|
|
29
49
|
let apply = false;
|
|
50
|
+
let requestedMode = null;
|
|
30
51
|
let homeDir = os.homedir();
|
|
31
|
-
let hosts = [
|
|
32
|
-
let components = [
|
|
52
|
+
let hosts = ["codex"];
|
|
53
|
+
let components = [...ALL_COMPONENTS];
|
|
54
|
+
let componentSpecified = false;
|
|
33
55
|
|
|
34
56
|
for (let index = 0; index < argv.length; index += 1) {
|
|
35
57
|
const argument = argv[index];
|
|
36
58
|
if (argument === "--apply") {
|
|
59
|
+
if (requestedMode === "dry-run") {
|
|
60
|
+
throw new Error("--apply 与 --dry-run 不能同时使用");
|
|
61
|
+
}
|
|
62
|
+
requestedMode = "apply";
|
|
37
63
|
apply = true;
|
|
38
64
|
continue;
|
|
39
65
|
}
|
|
66
|
+
if (argument === "--dry-run") {
|
|
67
|
+
if (requestedMode === "apply") {
|
|
68
|
+
throw new Error("--apply 与 --dry-run 不能同时使用");
|
|
69
|
+
}
|
|
70
|
+
requestedMode = "dry-run";
|
|
71
|
+
apply = false;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
40
74
|
if (argument === "--host") {
|
|
41
75
|
const host = argv[index + 1];
|
|
42
76
|
if (!host) throw new Error("--host 需要 codex、claude 或 all");
|
|
@@ -52,12 +86,15 @@ export function parseArgs(argv) {
|
|
|
52
86
|
}
|
|
53
87
|
if (argument === "--component") {
|
|
54
88
|
const component = argv[index + 1];
|
|
55
|
-
if (!component)
|
|
89
|
+
if (!component) {
|
|
90
|
+
throw new Error("--component 需要 skills、system-agents、agents 或 all");
|
|
91
|
+
}
|
|
56
92
|
index += 1;
|
|
93
|
+
componentSpecified = true;
|
|
57
94
|
if (component === "all") {
|
|
58
95
|
components = [...ALL_COMPONENTS];
|
|
59
|
-
} else if (ALL_COMPONENTS.includes(component)) {
|
|
60
|
-
components = [component];
|
|
96
|
+
} else if (ALL_COMPONENTS.includes(canonicalComponent(component))) {
|
|
97
|
+
components = [canonicalComponent(component)];
|
|
61
98
|
} else {
|
|
62
99
|
throw new Error(`不支持的组件:${component}`);
|
|
63
100
|
}
|
|
@@ -75,6 +112,10 @@ export function parseArgs(argv) {
|
|
|
75
112
|
throw new Error(`未知参数:${argument}`);
|
|
76
113
|
}
|
|
77
114
|
|
|
115
|
+
if (!componentSpecified && !hosts.includes("codex")) {
|
|
116
|
+
components = ["skills"];
|
|
117
|
+
}
|
|
118
|
+
|
|
78
119
|
return { apply, homeDir, hosts, components };
|
|
79
120
|
}
|
|
80
121
|
|
|
@@ -86,6 +127,35 @@ async function hashFile(filePath) {
|
|
|
86
127
|
return sha256(await readFile(filePath));
|
|
87
128
|
}
|
|
88
129
|
|
|
130
|
+
function projectCodexSkill(skillContent) {
|
|
131
|
+
const frontmatter = skillContent.match(/^---\r?\n([\s\S]*?)\r?\n---(?=\r?\n|$)/u);
|
|
132
|
+
if (!frontmatter) return skillContent;
|
|
133
|
+
|
|
134
|
+
const newline = frontmatter[0].includes("\r\n") ? "\r\n" : "\n";
|
|
135
|
+
const omittedKeys = new Set(["argument-hint", "disable-model-invocation"]);
|
|
136
|
+
const projectedLines = [];
|
|
137
|
+
let omittingValue = false;
|
|
138
|
+
|
|
139
|
+
for (const line of frontmatter[1].split(/\r?\n/u)) {
|
|
140
|
+
const key = /^([a-z0-9-]+):(?:\s|$)/iu.exec(line)?.[1] ?? null;
|
|
141
|
+
if (key) {
|
|
142
|
+
omittingValue = omittedKeys.has(key);
|
|
143
|
+
if (!omittingValue) projectedLines.push(line);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (omittingValue && (/^[ \t]/u.test(line) || line.trim() === "")) continue;
|
|
147
|
+
omittingValue = false;
|
|
148
|
+
projectedLines.push(line);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const projectedFrontmatter = [
|
|
152
|
+
"---",
|
|
153
|
+
...projectedLines,
|
|
154
|
+
"---",
|
|
155
|
+
].join(newline);
|
|
156
|
+
return projectedFrontmatter + skillContent.slice(frontmatter[0].length);
|
|
157
|
+
}
|
|
158
|
+
|
|
89
159
|
async function readManifest(manifestPath) {
|
|
90
160
|
try {
|
|
91
161
|
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
@@ -228,12 +298,407 @@ async function inspectAncestorPath(basePath, targetDirectory) {
|
|
|
228
298
|
return { safe: true };
|
|
229
299
|
}
|
|
230
300
|
|
|
301
|
+
async function inspectHomeRoot(homeDir) {
|
|
302
|
+
const resolvedHome = path.resolve(homeDir);
|
|
303
|
+
const parsed = path.parse(resolvedHome);
|
|
304
|
+
let currentPath = parsed.root;
|
|
305
|
+
const segments = path.relative(parsed.root, resolvedHome).split(path.sep).filter(Boolean);
|
|
306
|
+
|
|
307
|
+
for (const segment of segments) {
|
|
308
|
+
currentPath = path.join(currentPath, segment);
|
|
309
|
+
try {
|
|
310
|
+
const stats = await lstat(currentPath);
|
|
311
|
+
if (stats.isSymbolicLink()) {
|
|
312
|
+
const isHomeRoot = path.resolve(currentPath) === resolvedHome;
|
|
313
|
+
return {
|
|
314
|
+
safe: false,
|
|
315
|
+
path: currentPath,
|
|
316
|
+
reason: isHomeRoot
|
|
317
|
+
? "home 根目录是 symlink 或 junction"
|
|
318
|
+
: "home 路径的父目录是 symlink 或 junction",
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
if (!stats.isDirectory()) {
|
|
322
|
+
return {
|
|
323
|
+
safe: false,
|
|
324
|
+
path: currentPath,
|
|
325
|
+
reason:
|
|
326
|
+
path.resolve(currentPath) === resolvedHome
|
|
327
|
+
? "home 根路径不是目录"
|
|
328
|
+
: "home 路径的父路径不是目录",
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
} catch (error) {
|
|
332
|
+
if (error.code === "ENOENT") return { safe: true };
|
|
333
|
+
throw error;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return { safe: true };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function assertSafeHomeRoot(homeDir) {
|
|
341
|
+
const homeSafety = await inspectHomeRoot(homeDir);
|
|
342
|
+
if (homeSafety.safe) return;
|
|
343
|
+
throw new SyncConflictError([
|
|
344
|
+
{
|
|
345
|
+
host: "state",
|
|
346
|
+
component: "home",
|
|
347
|
+
assetName: "home",
|
|
348
|
+
skillName: "home",
|
|
349
|
+
relativePath: ".",
|
|
350
|
+
targetPath: homeDir,
|
|
351
|
+
action: "conflict",
|
|
352
|
+
reason: `${homeSafety.reason}:${homeSafety.path}`,
|
|
353
|
+
},
|
|
354
|
+
]);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function parseLockRecord(content) {
|
|
358
|
+
const trimmed = content.trim();
|
|
359
|
+
if (/^[1-9][0-9]*$/u.test(trimmed)) {
|
|
360
|
+
return { schemaVersion: 0, pid: Number(trimmed), owner: `legacy-${trimmed}` };
|
|
361
|
+
}
|
|
362
|
+
try {
|
|
363
|
+
const record = JSON.parse(trimmed);
|
|
364
|
+
if (
|
|
365
|
+
record.schemaVersion !== 1 ||
|
|
366
|
+
!Number.isSafeInteger(record.pid) ||
|
|
367
|
+
record.pid <= 0 ||
|
|
368
|
+
typeof record.owner !== "string" ||
|
|
369
|
+
record.owner.length < 8 ||
|
|
370
|
+
typeof record.createdAt !== "string"
|
|
371
|
+
) {
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
return record;
|
|
375
|
+
} catch {
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function isProcessAlive(pid) {
|
|
381
|
+
if (pid === process.pid) return true;
|
|
382
|
+
try {
|
|
383
|
+
process.kill(pid, 0);
|
|
384
|
+
return true;
|
|
385
|
+
} catch (error) {
|
|
386
|
+
if (error.code === "ESRCH" || error.code === "EINVAL") return false;
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function moveLockForCompare({ lockPath, expectedContent, stateDir, purpose }) {
|
|
392
|
+
const movedPath = path.join(stateDir, `${purpose}-${randomUUID()}.lock`);
|
|
393
|
+
try {
|
|
394
|
+
await rename(lockPath, movedPath);
|
|
395
|
+
} catch (error) {
|
|
396
|
+
if (error.code === "ENOENT") return null;
|
|
397
|
+
throw error;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const movedContent = await readFile(movedPath, "utf8");
|
|
401
|
+
if (movedContent === expectedContent) return movedPath;
|
|
402
|
+
|
|
403
|
+
try {
|
|
404
|
+
await restoreQuarantinedRemoval(lockPath, movedPath);
|
|
405
|
+
} catch (restoreError) {
|
|
406
|
+
restoreError.preserveEvidence = true;
|
|
407
|
+
throw restoreError;
|
|
408
|
+
}
|
|
409
|
+
const error = new Error(`同步锁在回收期间发生变化:${lockPath}`);
|
|
410
|
+
error.preserveEvidence = true;
|
|
411
|
+
throw error;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function acquireSyncLock({ homeDir, stateDir, transactionHooks = {} }) {
|
|
415
|
+
await mkdir(stateDir, { recursive: true });
|
|
416
|
+
const stateSafety = await inspectAncestorPath(homeDir, stateDir);
|
|
417
|
+
if (!stateSafety.safe) {
|
|
418
|
+
throw new SyncConflictError([
|
|
419
|
+
{
|
|
420
|
+
host: "state",
|
|
421
|
+
component: "lock",
|
|
422
|
+
assetName: "sync",
|
|
423
|
+
skillName: "sync",
|
|
424
|
+
relativePath: "sync.lock",
|
|
425
|
+
targetPath: path.join(stateDir, "sync.lock"),
|
|
426
|
+
action: "conflict",
|
|
427
|
+
reason: `${stateSafety.reason}:${stateSafety.path}`,
|
|
428
|
+
},
|
|
429
|
+
]);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const lockPath = path.join(stateDir, "sync.lock");
|
|
433
|
+
const owner = randomUUID();
|
|
434
|
+
const recordContent = `${JSON.stringify({
|
|
435
|
+
schemaVersion: 1,
|
|
436
|
+
pid: process.pid,
|
|
437
|
+
owner,
|
|
438
|
+
createdAt: new Date().toISOString(),
|
|
439
|
+
})}\n`;
|
|
440
|
+
const stagedLockPath = path.join(stateDir, `sync-lock-${owner}.tmp`);
|
|
441
|
+
await writeFile(stagedLockPath, recordContent, { encoding: "utf8", flag: "wx" });
|
|
442
|
+
if ((await readFile(stagedLockPath, "utf8")) !== recordContent) {
|
|
443
|
+
await rm(stagedLockPath, { force: true });
|
|
444
|
+
throw new Error(`同步锁记录暂存校验失败:${stagedLockPath}`);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
try {
|
|
448
|
+
await transactionHooks.beforeLockPublish?.();
|
|
449
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
450
|
+
try {
|
|
451
|
+
await link(stagedLockPath, lockPath);
|
|
452
|
+
await rm(stagedLockPath).catch(() => {});
|
|
453
|
+
return { lockPath, recordContent, stateDir };
|
|
454
|
+
} catch (error) {
|
|
455
|
+
if (error.code !== "EEXIST") throw error;
|
|
456
|
+
|
|
457
|
+
let existingContent;
|
|
458
|
+
try {
|
|
459
|
+
existingContent = await readFile(lockPath, "utf8");
|
|
460
|
+
} catch (readError) {
|
|
461
|
+
if (readError.code === "ENOENT") continue;
|
|
462
|
+
throw readError;
|
|
463
|
+
}
|
|
464
|
+
const existingRecord = parseLockRecord(existingContent);
|
|
465
|
+
if (!existingRecord) {
|
|
466
|
+
throw new Error(
|
|
467
|
+
`同步锁格式无法验证:${lockPath}。确认没有安装进程后再手工删除该文件。`,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
if (isProcessAlive(existingRecord.pid)) {
|
|
471
|
+
throw new Error(
|
|
472
|
+
`已有同步进程持有锁:${lockPath}(PID ${existingRecord.pid})`,
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const stalePath = await moveLockForCompare({
|
|
477
|
+
lockPath,
|
|
478
|
+
expectedContent: existingContent,
|
|
479
|
+
stateDir,
|
|
480
|
+
purpose: "stale-sync-lock",
|
|
481
|
+
});
|
|
482
|
+
if (!stalePath) continue;
|
|
483
|
+
await rm(stalePath);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
throw new Error(`无法安全获取同步锁:${lockPath}`);
|
|
488
|
+
} catch (error) {
|
|
489
|
+
await rm(stagedLockPath, { force: true }).catch(() => {});
|
|
490
|
+
throw error;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async function releaseSyncLock(lock) {
|
|
495
|
+
const ownedPath = await moveLockForCompare({
|
|
496
|
+
lockPath: lock.lockPath,
|
|
497
|
+
expectedContent: lock.recordContent,
|
|
498
|
+
stateDir: lock.stateDir,
|
|
499
|
+
purpose: "released-sync-lock",
|
|
500
|
+
});
|
|
501
|
+
if (!ownedPath) {
|
|
502
|
+
const error = new Error(`同步锁在释放前已消失:${lock.lockPath}`);
|
|
503
|
+
error.preserveEvidence = true;
|
|
504
|
+
throw error;
|
|
505
|
+
}
|
|
506
|
+
await rm(ownedPath);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function conflictForOperation(operation, reason) {
|
|
510
|
+
return new SyncConflictError([
|
|
511
|
+
{
|
|
512
|
+
...operation,
|
|
513
|
+
action: "conflict",
|
|
514
|
+
reason,
|
|
515
|
+
},
|
|
516
|
+
]);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async function assertSafeOperationTarget(operation, homeDir) {
|
|
520
|
+
const targetParent = path.dirname(operation.targetPath);
|
|
521
|
+
const ancestor = await inspectAncestorPath(homeDir, targetParent);
|
|
522
|
+
if (!ancestor.safe) {
|
|
523
|
+
throw conflictForOperation(operation, `${ancestor.reason}:${ancestor.path}`);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const realHome = await realpath(homeDir);
|
|
527
|
+
const realParent = await realpath(targetParent);
|
|
528
|
+
const relativeParent = path.relative(realHome, realParent);
|
|
529
|
+
if (relativeParent.startsWith("..") || path.isAbsolute(relativeParent)) {
|
|
530
|
+
throw conflictForOperation(operation, `目标父目录解析到 home 之外:${realParent}`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
async function prepareSafeTargetParent(operation, homeDir) {
|
|
535
|
+
const targetParent = path.dirname(operation.targetPath);
|
|
536
|
+
const ancestor = await inspectAncestorPath(homeDir, targetParent);
|
|
537
|
+
if (!ancestor.safe) {
|
|
538
|
+
throw conflictForOperation(operation, `${ancestor.reason}:${ancestor.path}`);
|
|
539
|
+
}
|
|
540
|
+
await mkdir(targetParent, { recursive: true });
|
|
541
|
+
await assertSafeOperationTarget(operation, homeDir);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function assertSameTransactionDevice(operation, stageRoot) {
|
|
545
|
+
const [stageStats, targetParentStats] = await Promise.all([
|
|
546
|
+
lstat(stageRoot),
|
|
547
|
+
lstat(path.dirname(operation.targetPath)),
|
|
548
|
+
]);
|
|
549
|
+
if (stageStats.dev !== targetParentStats.dev) {
|
|
550
|
+
throw conflictForOperation(
|
|
551
|
+
operation,
|
|
552
|
+
"目标与事务 staging 不在同一文件系统;无法保证原子提交与回滚",
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async function linkStagedFileNoOverwrite({ operation, stagedPath }) {
|
|
558
|
+
try {
|
|
559
|
+
await link(stagedPath, operation.targetPath);
|
|
560
|
+
} catch (error) {
|
|
561
|
+
if (error.code === "EEXIST") {
|
|
562
|
+
throw conflictForOperation(operation, "目标在最终提交前被创建");
|
|
563
|
+
}
|
|
564
|
+
if (error.code === "EXDEV") {
|
|
565
|
+
throw conflictForOperation(
|
|
566
|
+
operation,
|
|
567
|
+
"目标与事务 staging 不在同一文件系统;无法原子发布",
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
throw error;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
231
574
|
function manifestKey(host, component, assetName, relativePath) {
|
|
232
575
|
if (component === "skills") return `${host}/${assetName}/${relativePath}`;
|
|
233
576
|
if (component === "agents") return `${host}/agents/${assetName}/${relativePath}`;
|
|
234
577
|
return `${host}/${assetName}/${relativePath}`;
|
|
235
578
|
}
|
|
236
579
|
|
|
580
|
+
function resolveManagedTarget(manifestKeyValue, homeDir, stateDir) {
|
|
581
|
+
const segments = manifestKeyValue.split("/");
|
|
582
|
+
if (
|
|
583
|
+
segments.some(
|
|
584
|
+
(segment) =>
|
|
585
|
+
segment.length === 0 ||
|
|
586
|
+
segment === "." ||
|
|
587
|
+
segment === ".." ||
|
|
588
|
+
segment.includes("\\") ||
|
|
589
|
+
/[\u0000-\u001f:*?"<>|]/u.test(segment) ||
|
|
590
|
+
/[. ]$/u.test(segment),
|
|
591
|
+
)
|
|
592
|
+
) {
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (
|
|
597
|
+
segments[0] === "state" &&
|
|
598
|
+
segments[1] === "notices" &&
|
|
599
|
+
segments.length === 3 &&
|
|
600
|
+
SUPPORT_FILE_NAMES.includes(segments[2])
|
|
601
|
+
) {
|
|
602
|
+
const relativeSegments = segments.slice(2);
|
|
603
|
+
return {
|
|
604
|
+
host: "state",
|
|
605
|
+
component: "support",
|
|
606
|
+
assetName: "notices",
|
|
607
|
+
skillName: "notices",
|
|
608
|
+
relativePath: relativeSegments.join("/"),
|
|
609
|
+
targetPath: path.join(stateDir, "notices", ...relativeSegments),
|
|
610
|
+
pruneRoot: path.join(stateDir, "notices"),
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (segments[0] === "codex" && segments[1] === "agents") {
|
|
615
|
+
if (
|
|
616
|
+
segments.length !== 4 ||
|
|
617
|
+
!MANAGED_NAME_PATTERN.test(segments[2]) ||
|
|
618
|
+
segments[3] !== `${segments[2]}.toml`
|
|
619
|
+
) {
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
622
|
+
const relativeSegments = segments.slice(3);
|
|
623
|
+
return {
|
|
624
|
+
host: "codex",
|
|
625
|
+
component: "agents",
|
|
626
|
+
assetName: segments[2],
|
|
627
|
+
skillName: segments[2],
|
|
628
|
+
relativePath: relativeSegments.join("/"),
|
|
629
|
+
targetPath: path.join(homeDir, ...CODEX_AGENT_DESTINATION, ...relativeSegments),
|
|
630
|
+
pruneRoot: path.join(homeDir, ...CODEX_AGENT_DESTINATION),
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (
|
|
635
|
+
segments[0] in HOST_DESTINATIONS &&
|
|
636
|
+
segments.length >= 3 &&
|
|
637
|
+
MANAGED_NAME_PATTERN.test(segments[1])
|
|
638
|
+
) {
|
|
639
|
+
const relativeSegments = segments.slice(2);
|
|
640
|
+
return {
|
|
641
|
+
host: segments[0],
|
|
642
|
+
component: "skills",
|
|
643
|
+
assetName: segments[1],
|
|
644
|
+
skillName: segments[1],
|
|
645
|
+
relativePath: relativeSegments.join("/"),
|
|
646
|
+
targetPath: path.join(
|
|
647
|
+
homeDir,
|
|
648
|
+
...HOST_DESTINATIONS[segments[0]],
|
|
649
|
+
segments[1],
|
|
650
|
+
...relativeSegments,
|
|
651
|
+
),
|
|
652
|
+
pruneRoot: path.join(homeDir, ...HOST_DESTINATIONS[segments[0]]),
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
return null;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function targetIdentity(targetPath) {
|
|
660
|
+
const resolved = path.resolve(targetPath);
|
|
661
|
+
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function collectManifestTargetConflicts(manifest, homeDir, stateDir) {
|
|
665
|
+
const conflicts = [];
|
|
666
|
+
const targetOwners = new Map();
|
|
667
|
+
|
|
668
|
+
for (const key of Object.keys(manifest.files)) {
|
|
669
|
+
const target = resolveManagedTarget(key, homeDir, stateDir);
|
|
670
|
+
if (!target) {
|
|
671
|
+
conflicts.push({
|
|
672
|
+
host: "state",
|
|
673
|
+
component: "manifest",
|
|
674
|
+
assetName: key,
|
|
675
|
+
skillName: key,
|
|
676
|
+
relativePath: key,
|
|
677
|
+
targetPath: path.join(stateDir, "manifest.json"),
|
|
678
|
+
action: "conflict",
|
|
679
|
+
reason: `manifest key 非规范或不受支持:${key}`,
|
|
680
|
+
manifestKey: key,
|
|
681
|
+
});
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const identity = targetIdentity(target.targetPath);
|
|
686
|
+
const existingKey = targetOwners.get(identity);
|
|
687
|
+
if (existingKey && existingKey !== key) {
|
|
688
|
+
conflicts.push({
|
|
689
|
+
...target,
|
|
690
|
+
action: "conflict",
|
|
691
|
+
reason: `manifest key 目标冲突:${existingKey} 与 ${key}`,
|
|
692
|
+
manifestKey: key,
|
|
693
|
+
});
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
targetOwners.set(identity, key);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
return conflicts;
|
|
700
|
+
}
|
|
701
|
+
|
|
237
702
|
async function planFileOperation({
|
|
238
703
|
host,
|
|
239
704
|
component,
|
|
@@ -241,12 +706,15 @@ async function planFileOperation({
|
|
|
241
706
|
skillName = assetName,
|
|
242
707
|
relativePath,
|
|
243
708
|
sourcePath,
|
|
709
|
+
sourceContent = null,
|
|
244
710
|
targetPath,
|
|
245
711
|
manifest,
|
|
246
712
|
homeDir,
|
|
247
713
|
}) {
|
|
248
714
|
const key = manifestKey(host, component, assetName, relativePath);
|
|
249
|
-
const sourceHash =
|
|
715
|
+
const sourceHash = sourceContent === null
|
|
716
|
+
? await hashFile(sourcePath)
|
|
717
|
+
: sha256(Buffer.from(sourceContent, "utf8"));
|
|
250
718
|
const ancestor = await inspectAncestorPath(homeDir, path.dirname(targetPath));
|
|
251
719
|
const target = ancestor.safe
|
|
252
720
|
? await inspectTarget(targetPath)
|
|
@@ -282,6 +750,7 @@ async function planFileOperation({
|
|
|
282
750
|
skillName,
|
|
283
751
|
relativePath,
|
|
284
752
|
sourcePath,
|
|
753
|
+
sourceContent,
|
|
285
754
|
targetPath,
|
|
286
755
|
sourceHash,
|
|
287
756
|
targetHash: target.hash,
|
|
@@ -292,6 +761,42 @@ async function planFileOperation({
|
|
|
292
761
|
};
|
|
293
762
|
}
|
|
294
763
|
|
|
764
|
+
async function planRemovalOperation({
|
|
765
|
+
manifestKeyValue,
|
|
766
|
+
previousHash,
|
|
767
|
+
target,
|
|
768
|
+
homeDir,
|
|
769
|
+
}) {
|
|
770
|
+
const ancestor = await inspectAncestorPath(homeDir, path.dirname(target.targetPath));
|
|
771
|
+
const installed = ancestor.safe
|
|
772
|
+
? await inspectTarget(target.targetPath)
|
|
773
|
+
: { exists: false, regularFile: false, hash: null };
|
|
774
|
+
let action = "remove";
|
|
775
|
+
let reason = installed.exists ? "源中已移除" : "源中和安装目录中均已不存在,仅清理记录";
|
|
776
|
+
|
|
777
|
+
if (!ancestor.safe) {
|
|
778
|
+
action = "conflict";
|
|
779
|
+
reason = `${ancestor.reason}:${ancestor.path}`;
|
|
780
|
+
} else if (installed.exists && !installed.regularFile) {
|
|
781
|
+
action = "conflict";
|
|
782
|
+
reason = "源中已移除,但目标路径不是普通文件";
|
|
783
|
+
} else if (installed.exists && installed.hash !== previousHash) {
|
|
784
|
+
action = "conflict";
|
|
785
|
+
reason = "源中已移除,但文件在上次同步后被修改";
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
return {
|
|
789
|
+
...target,
|
|
790
|
+
sourcePath: null,
|
|
791
|
+
sourceHash: null,
|
|
792
|
+
targetHash: installed.hash,
|
|
793
|
+
previousHash,
|
|
794
|
+
action,
|
|
795
|
+
reason,
|
|
796
|
+
manifestKey: manifestKeyValue,
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
|
|
295
800
|
async function collectChangedTargetConflicts(operations, homeDir) {
|
|
296
801
|
const conflicts = [];
|
|
297
802
|
for (const operation of operations) {
|
|
@@ -318,28 +823,227 @@ async function collectChangedTargetConflicts(operations, homeDir) {
|
|
|
318
823
|
(!target.regularFile || target.hash !== operation.sourceHash)
|
|
319
824
|
) {
|
|
320
825
|
conflicts.push({ ...operation, action: "conflict", reason: "目标在预检后发生变化" });
|
|
826
|
+
} else if (
|
|
827
|
+
operation.action === "remove" &&
|
|
828
|
+
((operation.targetHash === null && target.exists) ||
|
|
829
|
+
(operation.targetHash !== null &&
|
|
830
|
+
(!target.regularFile || target.hash !== operation.targetHash)))
|
|
831
|
+
) {
|
|
832
|
+
conflicts.push({ ...operation, action: "conflict", reason: "待移除目标在预检后发生变化" });
|
|
321
833
|
}
|
|
322
834
|
}
|
|
323
835
|
return conflicts;
|
|
324
836
|
}
|
|
325
837
|
|
|
326
|
-
async function
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
838
|
+
async function collectFinalTargetConflicts(operations, homeDir) {
|
|
839
|
+
const conflicts = [];
|
|
840
|
+
for (const operation of operations) {
|
|
841
|
+
if (operation.action === "conflict") continue;
|
|
842
|
+
const ancestor = await inspectAncestorPath(homeDir, path.dirname(operation.targetPath));
|
|
843
|
+
if (!ancestor.safe) {
|
|
844
|
+
conflicts.push({
|
|
845
|
+
...operation,
|
|
846
|
+
action: "conflict",
|
|
847
|
+
reason: `${ancestor.reason}:${ancestor.path}`,
|
|
848
|
+
});
|
|
849
|
+
continue;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const target = await inspectTarget(operation.targetPath);
|
|
853
|
+
if (operation.action === "remove") {
|
|
854
|
+
if (target.exists) {
|
|
855
|
+
conflicts.push({
|
|
856
|
+
...operation,
|
|
857
|
+
action: "conflict",
|
|
858
|
+
reason: "已移除目标在 manifest 提交前重新出现",
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
if (!target.regularFile || target.hash !== operation.sourceHash) {
|
|
864
|
+
conflicts.push({
|
|
865
|
+
...operation,
|
|
866
|
+
action: "conflict",
|
|
867
|
+
reason: "目标在发布后、manifest 提交前发生变化",
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
return conflicts;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
async function removeEmptyParentDirectories(startDirectory, stopDirectory) {
|
|
875
|
+
const stop = path.resolve(stopDirectory);
|
|
876
|
+
let current = path.resolve(startDirectory);
|
|
877
|
+
|
|
878
|
+
while (current !== stop) {
|
|
879
|
+
const relative = path.relative(stop, current);
|
|
880
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
881
|
+
throw new Error(`拒绝清理受管理根目录之外的目录:${current}`);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
try {
|
|
885
|
+
await rmdir(current);
|
|
886
|
+
} catch (error) {
|
|
887
|
+
if (error.code === "ENOENT") {
|
|
888
|
+
current = path.dirname(current);
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
if (error.code === "ENOTEMPTY" || error.code === "EEXIST") return;
|
|
892
|
+
throw error;
|
|
893
|
+
}
|
|
894
|
+
current = path.dirname(current);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
export async function restoreQuarantinedRemoval(targetPath, backupPath) {
|
|
899
|
+
await mkdir(path.dirname(targetPath), { recursive: true });
|
|
900
|
+
try {
|
|
901
|
+
await link(backupPath, targetPath);
|
|
902
|
+
} catch (error) {
|
|
903
|
+
const restoreError = new Error(
|
|
904
|
+
`无法无覆盖地恢复隔离文件;隔离副本保留在:${backupPath}`,
|
|
905
|
+
{ cause: error },
|
|
906
|
+
);
|
|
907
|
+
restoreError.preserveEvidence = true;
|
|
908
|
+
throw restoreError;
|
|
909
|
+
}
|
|
910
|
+
try {
|
|
911
|
+
await rm(backupPath);
|
|
912
|
+
} catch (error) {
|
|
913
|
+
const cleanupError = new Error(
|
|
914
|
+
`目标已恢复,但隔离副本未能清理:${backupPath}`,
|
|
915
|
+
{ cause: error },
|
|
916
|
+
);
|
|
917
|
+
cleanupError.preserveEvidence = true;
|
|
918
|
+
throw cleanupError;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
export async function quarantineManagedRemoval({
|
|
923
|
+
targetPath,
|
|
924
|
+
backupPath,
|
|
925
|
+
expectedHash,
|
|
926
|
+
}) {
|
|
927
|
+
await mkdir(path.dirname(backupPath), { recursive: true });
|
|
928
|
+
try {
|
|
929
|
+
await rename(targetPath, backupPath);
|
|
930
|
+
} catch (error) {
|
|
331
931
|
throw new SyncConflictError([
|
|
332
932
|
{
|
|
333
933
|
host: "state",
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
934
|
+
component: "removal",
|
|
935
|
+
assetName: path.basename(targetPath),
|
|
936
|
+
skillName: path.basename(targetPath),
|
|
937
|
+
relativePath: path.basename(targetPath),
|
|
938
|
+
targetPath,
|
|
337
939
|
action: "conflict",
|
|
338
|
-
reason:
|
|
940
|
+
reason: `无法原子隔离待移除文件:${error.message}`,
|
|
941
|
+
},
|
|
942
|
+
]);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
let actualHash;
|
|
946
|
+
try {
|
|
947
|
+
actualHash = await hashFile(backupPath);
|
|
948
|
+
} catch (error) {
|
|
949
|
+
try {
|
|
950
|
+
await restoreQuarantinedRemoval(targetPath, backupPath);
|
|
951
|
+
} catch (restoreError) {
|
|
952
|
+
restoreError.cause = error;
|
|
953
|
+
throw restoreError;
|
|
954
|
+
}
|
|
955
|
+
throw error;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
if (actualHash !== expectedHash) {
|
|
959
|
+
try {
|
|
960
|
+
await restoreQuarantinedRemoval(targetPath, backupPath);
|
|
961
|
+
} catch (restoreError) {
|
|
962
|
+
const conflict = new SyncConflictError([
|
|
963
|
+
{
|
|
964
|
+
host: "state",
|
|
965
|
+
component: "removal",
|
|
966
|
+
assetName: path.basename(targetPath),
|
|
967
|
+
skillName: path.basename(targetPath),
|
|
968
|
+
relativePath: path.basename(targetPath),
|
|
969
|
+
targetPath,
|
|
970
|
+
action: "conflict",
|
|
971
|
+
reason: `待移除文件已变化且无法安全恢复;隔离副本保留在 ${backupPath}`,
|
|
972
|
+
},
|
|
973
|
+
]);
|
|
974
|
+
conflict.cause = restoreError;
|
|
975
|
+
conflict.preserveEvidence = true;
|
|
976
|
+
throw conflict;
|
|
977
|
+
}
|
|
978
|
+
throw new SyncConflictError([
|
|
979
|
+
{
|
|
980
|
+
host: "state",
|
|
981
|
+
component: "removal",
|
|
982
|
+
assetName: path.basename(targetPath),
|
|
983
|
+
skillName: path.basename(targetPath),
|
|
984
|
+
relativePath: path.basename(targetPath),
|
|
985
|
+
targetPath,
|
|
986
|
+
action: "conflict",
|
|
987
|
+
reason: "待移除目标在最终原子隔离时发生变化",
|
|
339
988
|
},
|
|
340
989
|
]);
|
|
341
990
|
}
|
|
342
991
|
|
|
992
|
+
return { backupPath, actualHash };
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
async function rollbackAppliedOperation({ entry, stageRoot, homeDir }) {
|
|
996
|
+
const { operation, backupPath, published } = entry;
|
|
997
|
+
await prepareSafeTargetParent(operation, homeDir);
|
|
998
|
+
await assertSameTransactionDevice(operation, stageRoot);
|
|
999
|
+
|
|
1000
|
+
let publishedBackupPath = null;
|
|
1001
|
+
const target = await inspectTarget(operation.targetPath);
|
|
1002
|
+
if (published && target.exists) {
|
|
1003
|
+
if (!target.regularFile || target.hash !== operation.sourceHash) {
|
|
1004
|
+
const error = new Error(
|
|
1005
|
+
`回滚拒绝覆盖事务期间变化的目标:${operation.targetPath}`,
|
|
1006
|
+
);
|
|
1007
|
+
error.preserveEvidence = true;
|
|
1008
|
+
throw error;
|
|
1009
|
+
}
|
|
1010
|
+
publishedBackupPath = path.join(
|
|
1011
|
+
stageRoot,
|
|
1012
|
+
"rollback-current",
|
|
1013
|
+
...operation.manifestKey.split("/"),
|
|
1014
|
+
);
|
|
1015
|
+
try {
|
|
1016
|
+
await quarantineManagedRemoval({
|
|
1017
|
+
targetPath: operation.targetPath,
|
|
1018
|
+
backupPath: publishedBackupPath,
|
|
1019
|
+
expectedHash: operation.sourceHash,
|
|
1020
|
+
});
|
|
1021
|
+
} catch (error) {
|
|
1022
|
+
error.preserveEvidence = true;
|
|
1023
|
+
throw error;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
if (operation.action === "create") {
|
|
1028
|
+
if (publishedBackupPath) await rm(publishedBackupPath);
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
if (backupPath) {
|
|
1033
|
+
await restoreQuarantinedRemoval(operation.targetPath, backupPath);
|
|
1034
|
+
}
|
|
1035
|
+
if (publishedBackupPath) await rm(publishedBackupPath);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
async function applyOperationsTransactional({
|
|
1039
|
+
operations,
|
|
1040
|
+
homeDir,
|
|
1041
|
+
manifestPath,
|
|
1042
|
+
expectedManifestHash,
|
|
1043
|
+
transactionHooks = {},
|
|
1044
|
+
}) {
|
|
1045
|
+
await assertSafeHomeRoot(homeDir);
|
|
1046
|
+
const stateDir = path.dirname(manifestPath);
|
|
343
1047
|
const stagingDir = path.join(stateDir, "staging");
|
|
344
1048
|
const stagingSafety = await inspectAncestorPath(homeDir, stagingDir);
|
|
345
1049
|
if (!stagingSafety.safe) {
|
|
@@ -355,29 +1059,10 @@ async function applyOperationsTransactional({ operations, homeDir, manifestPath
|
|
|
355
1059
|
]);
|
|
356
1060
|
}
|
|
357
1061
|
|
|
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());
|
|
1062
|
+
const stageRoot = path.join(stagingDir, randomUUID());
|
|
375
1063
|
const stagedPaths = new Map();
|
|
376
1064
|
const applied = [];
|
|
377
1065
|
const rollbackErrors = [];
|
|
378
|
-
let manifestBackupPath = null;
|
|
379
|
-
let manifestExisted = false;
|
|
380
|
-
let manifestChanged = false;
|
|
381
1066
|
let preserveStage = false;
|
|
382
1067
|
|
|
383
1068
|
try {
|
|
@@ -408,90 +1093,205 @@ async function applyOperationsTransactional({ operations, homeDir, manifestPath
|
|
|
408
1093
|
if (operation.action !== "create" && operation.action !== "update") continue;
|
|
409
1094
|
const stagedPath = path.join(stageRoot, "next", ...operation.manifestKey.split("/"));
|
|
410
1095
|
await mkdir(path.dirname(stagedPath), { recursive: true });
|
|
411
|
-
|
|
1096
|
+
if (operation.sourceContent === null) {
|
|
1097
|
+
await copyFile(operation.sourcePath, stagedPath);
|
|
1098
|
+
} else {
|
|
1099
|
+
await writeFile(stagedPath, operation.sourceContent, "utf8");
|
|
1100
|
+
}
|
|
412
1101
|
if ((await hashFile(stagedPath)) !== operation.sourceHash) {
|
|
413
1102
|
throw new Error(`暂存文件校验失败:${operation.sourcePath}`);
|
|
414
1103
|
}
|
|
415
1104
|
stagedPaths.set(operation.manifestKey, stagedPath);
|
|
416
1105
|
}
|
|
417
1106
|
|
|
418
|
-
const lockedManifest = await readManifest(manifestPath);
|
|
419
1107
|
const manifestTarget = await inspectTarget(manifestPath);
|
|
420
1108
|
if (manifestTarget.exists && !manifestTarget.regularFile) {
|
|
421
1109
|
throw new Error(`同步 manifest 不是普通文件:${manifestPath}`);
|
|
422
1110
|
}
|
|
423
|
-
if (manifestTarget.
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
1111
|
+
if (manifestTarget.hash !== expectedManifestHash) {
|
|
1112
|
+
throw conflictForOperation(
|
|
1113
|
+
{
|
|
1114
|
+
host: "state",
|
|
1115
|
+
component: "manifest",
|
|
1116
|
+
assetName: "manifest",
|
|
1117
|
+
skillName: "manifest",
|
|
1118
|
+
relativePath: "manifest.json",
|
|
1119
|
+
targetPath: manifestPath,
|
|
1120
|
+
},
|
|
1121
|
+
"同步 manifest 在规划后发生变化",
|
|
1122
|
+
);
|
|
1123
|
+
}
|
|
1124
|
+
const lockedManifest = await readManifest(manifestPath);
|
|
1125
|
+
const lockedManifestConflicts = collectManifestTargetConflicts(
|
|
1126
|
+
lockedManifest,
|
|
1127
|
+
homeDir,
|
|
1128
|
+
stateDir,
|
|
1129
|
+
);
|
|
1130
|
+
if (lockedManifestConflicts.length > 0) {
|
|
1131
|
+
throw new SyncConflictError(lockedManifestConflicts);
|
|
428
1132
|
}
|
|
429
1133
|
|
|
430
1134
|
const changedTargetConflicts = await collectChangedTargetConflicts(operations, homeDir);
|
|
431
1135
|
if (changedTargetConflicts.length > 0) throw new SyncConflictError(changedTargetConflicts);
|
|
432
1136
|
|
|
433
1137
|
for (const operation of operations) {
|
|
434
|
-
if (
|
|
1138
|
+
if (!["create", "update", "remove"].includes(operation.action)) continue;
|
|
1139
|
+
if (operation.action === "remove" && operation.targetHash === null) continue;
|
|
1140
|
+
|
|
1141
|
+
await prepareSafeTargetParent(operation, homeDir);
|
|
1142
|
+
await transactionHooks.beforeOperationCommit?.(operation);
|
|
1143
|
+
await assertSafeOperationTarget(operation, homeDir);
|
|
1144
|
+
await assertSameTransactionDevice(operation, stageRoot);
|
|
1145
|
+
|
|
435
1146
|
let backupPath = null;
|
|
436
|
-
|
|
1147
|
+
let entry = null;
|
|
1148
|
+
if (operation.action === "update" || operation.action === "remove") {
|
|
437
1149
|
backupPath = path.join(stageRoot, "backups", ...operation.manifestKey.split("/"));
|
|
438
|
-
await
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
}
|
|
1150
|
+
await quarantineManagedRemoval({
|
|
1151
|
+
targetPath: operation.targetPath,
|
|
1152
|
+
backupPath,
|
|
1153
|
+
expectedHash: operation.targetHash,
|
|
1154
|
+
});
|
|
1155
|
+
entry = { operation, backupPath, published: false };
|
|
1156
|
+
applied.push(entry);
|
|
1157
|
+
await transactionHooks.afterOperationQuarantine?.(operation);
|
|
443
1158
|
}
|
|
1159
|
+
if (operation.action === "remove") continue;
|
|
444
1160
|
|
|
445
|
-
|
|
446
|
-
await
|
|
447
|
-
await
|
|
1161
|
+
await assertSafeOperationTarget(operation, homeDir);
|
|
1162
|
+
await assertSameTransactionDevice(operation, stageRoot);
|
|
1163
|
+
await linkStagedFileNoOverwrite({
|
|
1164
|
+
operation,
|
|
1165
|
+
stagedPath: stagedPaths.get(operation.manifestKey),
|
|
1166
|
+
});
|
|
1167
|
+
if (!entry) {
|
|
1168
|
+
entry = { operation, backupPath: null, published: true };
|
|
1169
|
+
applied.push(entry);
|
|
1170
|
+
} else {
|
|
1171
|
+
entry.published = true;
|
|
1172
|
+
}
|
|
448
1173
|
if ((await hashFile(operation.targetPath)) !== operation.sourceHash) {
|
|
449
1174
|
throw new Error(`同步后校验失败:${operation.targetPath}`);
|
|
450
1175
|
}
|
|
1176
|
+
await transactionHooks.afterOperationPublish?.(operation);
|
|
451
1177
|
}
|
|
452
1178
|
|
|
453
1179
|
const nextFiles = { ...lockedManifest.files };
|
|
454
|
-
for (const operation of operations)
|
|
1180
|
+
for (const operation of operations) {
|
|
1181
|
+
if (operation.action === "remove") {
|
|
1182
|
+
delete nextFiles[operation.manifestKey];
|
|
1183
|
+
} else {
|
|
1184
|
+
nextFiles[operation.manifestKey] = operation.sourceHash;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
455
1187
|
const nextManifest = {
|
|
456
1188
|
schemaVersion: 1,
|
|
457
1189
|
managedBy: "netpilot-skills",
|
|
458
1190
|
updatedAt: new Date().toISOString(),
|
|
459
|
-
files: Object.fromEntries(
|
|
1191
|
+
files: Object.fromEntries(
|
|
1192
|
+
Object.entries(nextFiles).sort(([left], [right]) => left.localeCompare(right)),
|
|
1193
|
+
),
|
|
460
1194
|
};
|
|
461
1195
|
const stagedManifestPath = path.join(stageRoot, "next-manifest.json");
|
|
462
|
-
await mkdir(path.dirname(stagedManifestPath), { recursive: true });
|
|
463
1196
|
await writeFile(stagedManifestPath, `${JSON.stringify(nextManifest, null, 2)}\n`, "utf8");
|
|
464
1197
|
JSON.parse(await readFile(stagedManifestPath, "utf8"));
|
|
465
|
-
|
|
466
|
-
|
|
1198
|
+
const manifestOperation = {
|
|
1199
|
+
host: "state",
|
|
1200
|
+
component: "manifest",
|
|
1201
|
+
assetName: "manifest",
|
|
1202
|
+
skillName: "manifest",
|
|
1203
|
+
relativePath: "manifest.json",
|
|
1204
|
+
targetPath: manifestPath,
|
|
1205
|
+
manifestKey: "state/manifest.json",
|
|
1206
|
+
targetHash: manifestTarget.hash,
|
|
1207
|
+
sourceHash: await hashFile(stagedManifestPath),
|
|
1208
|
+
action: manifestTarget.exists ? "update" : "create",
|
|
1209
|
+
};
|
|
1210
|
+
await prepareSafeTargetParent(manifestOperation, homeDir);
|
|
1211
|
+
await transactionHooks.beforeOperationCommit?.(manifestOperation);
|
|
1212
|
+
await assertSafeOperationTarget(manifestOperation, homeDir);
|
|
1213
|
+
await assertSameTransactionDevice(manifestOperation, stageRoot);
|
|
1214
|
+
const finalTargetConflicts = await collectFinalTargetConflicts(operations, homeDir);
|
|
1215
|
+
if (finalTargetConflicts.length > 0) {
|
|
1216
|
+
throw new SyncConflictError(finalTargetConflicts);
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
let manifestEntry;
|
|
1220
|
+
if (manifestOperation.action === "update") {
|
|
1221
|
+
const manifestBackupPath = path.join(stageRoot, "backups", "manifest.json");
|
|
1222
|
+
await quarantineManagedRemoval({
|
|
1223
|
+
targetPath: manifestPath,
|
|
1224
|
+
backupPath: manifestBackupPath,
|
|
1225
|
+
expectedHash: manifestOperation.targetHash,
|
|
1226
|
+
});
|
|
1227
|
+
manifestEntry = {
|
|
1228
|
+
operation: manifestOperation,
|
|
1229
|
+
backupPath: manifestBackupPath,
|
|
1230
|
+
published: false,
|
|
1231
|
+
};
|
|
1232
|
+
applied.push(manifestEntry);
|
|
1233
|
+
await transactionHooks.afterOperationQuarantine?.(manifestOperation);
|
|
1234
|
+
}
|
|
1235
|
+
await assertSafeOperationTarget(manifestOperation, homeDir);
|
|
1236
|
+
await assertSameTransactionDevice(manifestOperation, stageRoot);
|
|
1237
|
+
await linkStagedFileNoOverwrite({
|
|
1238
|
+
operation: manifestOperation,
|
|
1239
|
+
stagedPath: stagedManifestPath,
|
|
1240
|
+
});
|
|
1241
|
+
if (!manifestEntry) {
|
|
1242
|
+
manifestEntry = {
|
|
1243
|
+
operation: manifestOperation,
|
|
1244
|
+
backupPath: null,
|
|
1245
|
+
published: true,
|
|
1246
|
+
};
|
|
1247
|
+
applied.push(manifestEntry);
|
|
1248
|
+
} else {
|
|
1249
|
+
manifestEntry.published = true;
|
|
1250
|
+
}
|
|
1251
|
+
if ((await hashFile(manifestPath)) !== manifestOperation.sourceHash) {
|
|
1252
|
+
throw new Error(`同步后校验失败:${manifestPath}`);
|
|
1253
|
+
}
|
|
467
1254
|
JSON.parse(await readFile(manifestPath, "utf8"));
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
1255
|
+
await transactionHooks.afterOperationPublish?.(manifestOperation);
|
|
1256
|
+
const postManifestTargetConflicts = await collectFinalTargetConflicts(operations, homeDir);
|
|
1257
|
+
if (postManifestTargetConflicts.length > 0) {
|
|
1258
|
+
throw new SyncConflictError(postManifestTargetConflicts);
|
|
1259
|
+
}
|
|
1260
|
+
const committedManifest = await inspectTarget(manifestPath);
|
|
1261
|
+
if (
|
|
1262
|
+
!committedManifest.regularFile ||
|
|
1263
|
+
committedManifest.hash !== manifestOperation.sourceHash
|
|
1264
|
+
) {
|
|
1265
|
+
throw conflictForOperation(
|
|
1266
|
+
manifestOperation,
|
|
1267
|
+
"manifest 在最终提交后发生变化",
|
|
1268
|
+
);
|
|
479
1269
|
}
|
|
480
1270
|
|
|
481
|
-
|
|
1271
|
+
for (const operation of operations) {
|
|
1272
|
+
if (
|
|
1273
|
+
operation.action === "remove" &&
|
|
1274
|
+
operation.targetHash !== null &&
|
|
1275
|
+
operation.pruneRoot
|
|
1276
|
+
) {
|
|
1277
|
+
await removeEmptyParentDirectories(
|
|
1278
|
+
path.dirname(operation.targetPath),
|
|
1279
|
+
operation.pruneRoot,
|
|
1280
|
+
);
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
} catch (error) {
|
|
1284
|
+
if (error.preserveEvidence) preserveStage = true;
|
|
1285
|
+
for (const entry of [...applied].reverse()) {
|
|
482
1286
|
try {
|
|
483
|
-
|
|
484
|
-
await copyFile(manifestBackupPath, manifestPath);
|
|
485
|
-
} else {
|
|
486
|
-
await rm(manifestPath, { force: true });
|
|
487
|
-
}
|
|
1287
|
+
await rollbackAppliedOperation({ entry, stageRoot, homeDir });
|
|
488
1288
|
} catch (rollbackError) {
|
|
489
|
-
|
|
1289
|
+
preserveStage = true;
|
|
1290
|
+
rollbackErrors.push(`${entry.operation.targetPath}: ${rollbackError.message}`);
|
|
490
1291
|
}
|
|
491
1292
|
}
|
|
492
1293
|
|
|
493
1294
|
if (rollbackErrors.length > 0) {
|
|
494
|
-
preserveStage = true;
|
|
495
1295
|
throw new Error(
|
|
496
1296
|
`同步失败且回滚不完整;暂存保留在 ${stageRoot}\n${rollbackErrors.join("\n")}`,
|
|
497
1297
|
{ cause: error },
|
|
@@ -499,11 +1299,6 @@ async function applyOperationsTransactional({ operations, homeDir, manifestPath
|
|
|
499
1299
|
}
|
|
500
1300
|
throw error;
|
|
501
1301
|
} finally {
|
|
502
|
-
try {
|
|
503
|
-
await lockHandle.close();
|
|
504
|
-
} finally {
|
|
505
|
-
await rm(lockPath, { force: true });
|
|
506
|
-
}
|
|
507
1302
|
if (!preserveStage) await rm(stageRoot, { recursive: true, force: true });
|
|
508
1303
|
}
|
|
509
1304
|
}
|
|
@@ -511,12 +1306,13 @@ async function applyOperationsTransactional({ operations, homeDir, manifestPath
|
|
|
511
1306
|
export async function syncSkills({
|
|
512
1307
|
rootDir,
|
|
513
1308
|
homeDir,
|
|
514
|
-
hosts = [
|
|
515
|
-
components = [
|
|
1309
|
+
hosts = ["codex"],
|
|
1310
|
+
components = [...ALL_COMPONENTS],
|
|
516
1311
|
apply = false,
|
|
1312
|
+
transactionHooks = {},
|
|
517
1313
|
}) {
|
|
518
1314
|
const selectedHosts = [...new Set(hosts)];
|
|
519
|
-
const selectedComponents = [...new Set(components)];
|
|
1315
|
+
const selectedComponents = [...new Set(components.map(canonicalComponent))];
|
|
520
1316
|
for (const host of selectedHosts) {
|
|
521
1317
|
if (!(host in HOST_DESTINATIONS)) throw new Error(`不支持的宿主:${host}`);
|
|
522
1318
|
}
|
|
@@ -525,15 +1321,23 @@ export async function syncSkills({
|
|
|
525
1321
|
}
|
|
526
1322
|
if (
|
|
527
1323
|
selectedComponents.includes("agents") &&
|
|
528
|
-
|
|
1324
|
+
!selectedHosts.includes("codex")
|
|
529
1325
|
) {
|
|
530
|
-
throw new Error("Codex agents
|
|
1326
|
+
throw new Error("Codex agents 组件要求 --host 包含 codex");
|
|
531
1327
|
}
|
|
532
1328
|
|
|
533
1329
|
const resolvedRoot = path.resolve(rootDir);
|
|
534
1330
|
const resolvedHome = path.resolve(homeDir);
|
|
1331
|
+
await assertSafeHomeRoot(resolvedHome);
|
|
535
1332
|
const manifestPath = path.join(resolvedHome, ".netpilot-skills", "manifest.json");
|
|
536
1333
|
const stateDir = path.dirname(manifestPath);
|
|
1334
|
+
const syncLock = apply
|
|
1335
|
+
? await acquireSyncLock({ homeDir: resolvedHome, stateDir, transactionHooks })
|
|
1336
|
+
: null;
|
|
1337
|
+
let syncError = null;
|
|
1338
|
+
|
|
1339
|
+
try {
|
|
1340
|
+
if (syncLock) await transactionHooks.afterLockAcquired?.();
|
|
537
1341
|
const stateSafety = await inspectAncestorPath(resolvedHome, stateDir);
|
|
538
1342
|
if (!stateSafety.safe) {
|
|
539
1343
|
throw new SyncConflictError([
|
|
@@ -561,6 +1365,12 @@ export async function syncSkills({
|
|
|
561
1365
|
]);
|
|
562
1366
|
}
|
|
563
1367
|
const manifest = await readManifest(manifestPath);
|
|
1368
|
+
const manifestConflicts = collectManifestTargetConflicts(
|
|
1369
|
+
manifest,
|
|
1370
|
+
resolvedHome,
|
|
1371
|
+
stateDir,
|
|
1372
|
+
);
|
|
1373
|
+
if (manifestConflicts.length > 0) throw new SyncConflictError(manifestConflicts);
|
|
564
1374
|
const sourceFiles = selectedComponents.includes("skills")
|
|
565
1375
|
? await discoverSourceFiles(resolvedRoot)
|
|
566
1376
|
: [];
|
|
@@ -581,6 +1391,10 @@ export async function syncSkills({
|
|
|
581
1391
|
const hostRoot = path.join(resolvedHome, ...HOST_DESTINATIONS[host]);
|
|
582
1392
|
for (const source of sourceFiles) {
|
|
583
1393
|
const targetPath = path.join(hostRoot, source.skillName, ...source.relativePath.split("/"));
|
|
1394
|
+
const sourceContent =
|
|
1395
|
+
host === "codex" && source.relativePath === "SKILL.md"
|
|
1396
|
+
? projectCodexSkill(await readFile(source.absolutePath, "utf8"))
|
|
1397
|
+
: null;
|
|
584
1398
|
operations.push(await planFileOperation({
|
|
585
1399
|
host,
|
|
586
1400
|
component: "skills",
|
|
@@ -588,6 +1402,7 @@ export async function syncSkills({
|
|
|
588
1402
|
skillName: source.skillName,
|
|
589
1403
|
relativePath: source.relativePath,
|
|
590
1404
|
sourcePath: source.absolutePath,
|
|
1405
|
+
sourceContent,
|
|
591
1406
|
targetPath,
|
|
592
1407
|
manifest,
|
|
593
1408
|
homeDir: resolvedHome,
|
|
@@ -627,6 +1442,28 @@ export async function syncSkills({
|
|
|
627
1442
|
}));
|
|
628
1443
|
}
|
|
629
1444
|
|
|
1445
|
+
const desiredManifestKeys = new Set(operations.map((operation) => operation.manifestKey));
|
|
1446
|
+
for (const [key, previousHash] of Object.entries(manifest.files)) {
|
|
1447
|
+
if (desiredManifestKeys.has(key)) continue;
|
|
1448
|
+
const target = resolveManagedTarget(key, resolvedHome, stateDir);
|
|
1449
|
+
if (!target) continue;
|
|
1450
|
+
const selected =
|
|
1451
|
+
target.component === "support" ||
|
|
1452
|
+
(target.component === "skills" &&
|
|
1453
|
+
selectedComponents.includes("skills") &&
|
|
1454
|
+
selectedHosts.includes(target.host)) ||
|
|
1455
|
+
(target.component === "agents" &&
|
|
1456
|
+
selectedComponents.includes("agents") &&
|
|
1457
|
+
selectedHosts.includes("codex"));
|
|
1458
|
+
if (!selected) continue;
|
|
1459
|
+
operations.push(await planRemovalOperation({
|
|
1460
|
+
manifestKeyValue: key,
|
|
1461
|
+
previousHash,
|
|
1462
|
+
target,
|
|
1463
|
+
homeDir: resolvedHome,
|
|
1464
|
+
}));
|
|
1465
|
+
}
|
|
1466
|
+
|
|
630
1467
|
const conflicts = operations.filter((operation) => operation.action === "conflict");
|
|
631
1468
|
const result = {
|
|
632
1469
|
mode: apply ? "apply" : "dry-run",
|
|
@@ -638,20 +1475,46 @@ export async function syncSkills({
|
|
|
638
1475
|
|
|
639
1476
|
if (!apply) return result;
|
|
640
1477
|
if (conflicts.length > 0) throw new SyncConflictError(conflicts);
|
|
641
|
-
await applyOperationsTransactional({
|
|
1478
|
+
await applyOperationsTransactional({
|
|
1479
|
+
operations,
|
|
1480
|
+
homeDir: resolvedHome,
|
|
1481
|
+
manifestPath,
|
|
1482
|
+
expectedManifestHash: manifestTarget.hash,
|
|
1483
|
+
transactionHooks,
|
|
1484
|
+
});
|
|
642
1485
|
|
|
643
1486
|
return result;
|
|
1487
|
+
} catch (error) {
|
|
1488
|
+
syncError = error;
|
|
1489
|
+
throw error;
|
|
1490
|
+
} finally {
|
|
1491
|
+
if (syncLock) {
|
|
1492
|
+
try {
|
|
1493
|
+
await releaseSyncLock(syncLock);
|
|
1494
|
+
} catch (releaseError) {
|
|
1495
|
+
if (syncError) {
|
|
1496
|
+
syncError.releaseError = releaseError;
|
|
1497
|
+
syncError.preserveEvidence = true;
|
|
1498
|
+
} else {
|
|
1499
|
+
throw releaseError;
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
644
1504
|
}
|
|
645
1505
|
|
|
646
1506
|
function printHelp() {
|
|
647
1507
|
console.log(`用法:
|
|
648
|
-
netpilot-skills
|
|
649
|
-
npm run sync -- [--host codex|claude|all] [--component skills|agents|all] [--home PATH] [--apply]
|
|
1508
|
+
netpilot-skills sync [--host codex|claude|all] [--component skills|system-agents|agents|all] [--home PATH] [--apply|--dry-run]
|
|
1509
|
+
npm run sync -- [--host codex|claude|all] [--component skills|system-agents|agents|all] [--home PATH] [--apply|--dry-run]
|
|
650
1510
|
|
|
651
|
-
默认执行 dry-run,只展示计划。只有传入 --apply 才会写入用户级目录。
|
|
652
|
-
Codex
|
|
653
|
-
|
|
654
|
-
Codex
|
|
1511
|
+
底层 sync 默认执行 dry-run,只展示计划。只有传入 --apply 才会写入用户级目录。
|
|
1512
|
+
默认目标:Codex skills 与 agents。
|
|
1513
|
+
Codex Skills 目标:~/.agents/skills
|
|
1514
|
+
Codex 系统级(用户全局)Agents 目标:~/.codex/agents
|
|
1515
|
+
system-agents 是 agents 的语义化别名,二者安装内容与 manifest 所有权相同。
|
|
1516
|
+
Claude Code 目标:~/.claude/skills(选择 --host claude 时默认只安装 skills)。
|
|
1517
|
+
选择 --host all 时,Skills 同步到 Codex 与 Claude,Agents 只同步到 Codex。`);
|
|
655
1518
|
}
|
|
656
1519
|
|
|
657
1520
|
function printPlan(result) {
|
|
@@ -661,12 +1524,14 @@ function printPlan(result) {
|
|
|
661
1524
|
console.log(`[${operation.action}] ${operation.host} ${component} ${operation.assetName}/${operation.relativePath}${reason}`);
|
|
662
1525
|
}
|
|
663
1526
|
const counts = Object.fromEntries(
|
|
664
|
-
["create", "update", "unchanged", "conflict"].map((action) => [
|
|
1527
|
+
["create", "update", "remove", "unchanged", "conflict"].map((action) => [
|
|
665
1528
|
action,
|
|
666
1529
|
result.operations.filter((operation) => operation.action === action).length,
|
|
667
1530
|
]),
|
|
668
1531
|
);
|
|
669
|
-
console.log(
|
|
1532
|
+
console.log(
|
|
1533
|
+
`汇总:新增 ${counts.create},更新 ${counts.update},移除 ${counts.remove},不变 ${counts.unchanged},冲突 ${counts.conflict}`,
|
|
1534
|
+
);
|
|
670
1535
|
if (result.mode === "dry-run") console.log("当前为 dry-run;确认计划后追加 --apply。 ");
|
|
671
1536
|
}
|
|
672
1537
|
|
|
@@ -675,9 +1540,9 @@ async function main() {
|
|
|
675
1540
|
printHelp();
|
|
676
1541
|
return;
|
|
677
1542
|
}
|
|
678
|
-
const options = parseArgs(process.argv.slice(2));
|
|
679
|
-
const rootDir = fileURLToPath(new URL("..", import.meta.url));
|
|
680
1543
|
try {
|
|
1544
|
+
const options = parseArgs(process.argv.slice(2));
|
|
1545
|
+
const rootDir = fileURLToPath(new URL("..", import.meta.url));
|
|
681
1546
|
const result = await syncSkills({ rootDir, ...options });
|
|
682
1547
|
printPlan(result);
|
|
683
1548
|
if (result.conflicts.length > 0) process.exitCode = 2;
|
|
@@ -690,7 +1555,8 @@ async function main() {
|
|
|
690
1555
|
process.exitCode = 2;
|
|
691
1556
|
return;
|
|
692
1557
|
}
|
|
693
|
-
|
|
1558
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
1559
|
+
process.exitCode = 1;
|
|
694
1560
|
}
|
|
695
1561
|
}
|
|
696
1562
|
|