agent-plugkit 0.1.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/LICENSE +21 -0
- package/README.md +295 -0
- package/dist/adapters/agent-plugins.d.ts +35 -0
- package/dist/adapters/agent-plugins.js +57 -0
- package/dist/adapters/agent-plugins.js.map +1 -0
- package/dist/adapters/claude.d.ts +64 -0
- package/dist/adapters/claude.js +123 -0
- package/dist/adapters/claude.js.map +1 -0
- package/dist/adapters/cli-validation.d.ts +12 -0
- package/dist/adapters/cli-validation.js +31 -0
- package/dist/adapters/cli-validation.js.map +1 -0
- package/dist/adapters/codex.d.ts +62 -0
- package/dist/adapters/codex.js +91 -0
- package/dist/adapters/codex.js.map +1 -0
- package/dist/adapters/marketplace-registration.d.ts +17 -0
- package/dist/adapters/marketplace-registration.js +369 -0
- package/dist/adapters/marketplace-registration.js.map +1 -0
- package/dist/adapters/marketplace.d.ts +11 -0
- package/dist/adapters/marketplace.js +96 -0
- package/dist/adapters/marketplace.js.map +1 -0
- package/dist/adapters/mcp.d.ts +17 -0
- package/dist/adapters/mcp.js +28 -0
- package/dist/adapters/mcp.js.map +1 -0
- package/dist/application/artifact-generation.d.ts +58 -0
- package/dist/application/artifact-generation.js +344 -0
- package/dist/application/artifact-generation.js.map +1 -0
- package/dist/application/local-release.d.ts +122 -0
- package/dist/application/local-release.js +940 -0
- package/dist/application/local-release.js.map +1 -0
- package/dist/application/marketplace-document-contract.d.ts +81 -0
- package/dist/application/marketplace-document-contract.js +2 -0
- package/dist/application/marketplace-document-contract.js.map +1 -0
- package/dist/application/marketplace-document.d.ts +45 -0
- package/dist/application/marketplace-document.js +557 -0
- package/dist/application/marketplace-document.js.map +1 -0
- package/dist/application/marketplace-registration-contract.d.ts +51 -0
- package/dist/application/marketplace-registration-contract.js +11 -0
- package/dist/application/marketplace-registration-contract.js.map +1 -0
- package/dist/application/marketplace-registration.d.ts +20 -0
- package/dist/application/marketplace-registration.js +304 -0
- package/dist/application/marketplace-registration.js.map +1 -0
- package/dist/application/marketplace.d.ts +9 -0
- package/dist/application/marketplace.js +32 -0
- package/dist/application/marketplace.js.map +1 -0
- package/dist/application/plugin-components-contract.d.ts +135 -0
- package/dist/application/plugin-components-contract.js +2 -0
- package/dist/application/plugin-components-contract.js.map +1 -0
- package/dist/application/plugin-components.d.ts +17 -0
- package/dist/application/plugin-components.js +1000 -0
- package/dist/application/plugin-components.js.map +1 -0
- package/dist/application/plugin-document-contract.d.ts +115 -0
- package/dist/application/plugin-document-contract.js +2 -0
- package/dist/application/plugin-document-contract.js.map +1 -0
- package/dist/application/plugin-document.d.ts +69 -0
- package/dist/application/plugin-document.js +1037 -0
- package/dist/application/plugin-document.js.map +1 -0
- package/dist/application/plugin-lifecycle-contract.d.ts +230 -0
- package/dist/application/plugin-lifecycle-contract.js +3 -0
- package/dist/application/plugin-lifecycle-contract.js.map +1 -0
- package/dist/application/plugin-lifecycle.d.ts +8 -0
- package/dist/application/plugin-lifecycle.js +1375 -0
- package/dist/application/plugin-lifecycle.js.map +1 -0
- package/dist/application/plugin-scaffold.d.ts +18 -0
- package/dist/application/plugin-scaffold.js +188 -0
- package/dist/application/plugin-scaffold.js.map +1 -0
- package/dist/application/plugin-source.d.ts +39 -0
- package/dist/application/plugin-source.js +59 -0
- package/dist/application/plugin-source.js.map +1 -0
- package/dist/application/skill-document-contract.d.ts +92 -0
- package/dist/application/skill-document-contract.js +8 -0
- package/dist/application/skill-document-contract.js.map +1 -0
- package/dist/application/skill-document-guided.d.ts +3 -0
- package/dist/application/skill-document-guided.js +53 -0
- package/dist/application/skill-document-guided.js.map +1 -0
- package/dist/application/skill-document.d.ts +19 -0
- package/dist/application/skill-document.js +368 -0
- package/dist/application/skill-document.js.map +1 -0
- package/dist/application/workspace-create-contract.d.ts +50 -0
- package/dist/application/workspace-create-contract.js +16 -0
- package/dist/application/workspace-create-contract.js.map +1 -0
- package/dist/application/workspace-create.d.ts +8 -0
- package/dist/application/workspace-create.js +656 -0
- package/dist/application/workspace-create.js.map +1 -0
- package/dist/application/workspace-health-contract.d.ts +133 -0
- package/dist/application/workspace-health-contract.js +272 -0
- package/dist/application/workspace-health-contract.js.map +1 -0
- package/dist/application/workspace-health.d.ts +10 -0
- package/dist/application/workspace-health.js +1114 -0
- package/dist/application/workspace-health.js.map +1 -0
- package/dist/application/workspace-operation-contract.d.ts +99 -0
- package/dist/application/workspace-operation-contract.js +450 -0
- package/dist/application/workspace-operation-contract.js.map +1 -0
- package/dist/application/workspace-operation.d.ts +42 -0
- package/dist/application/workspace-operation.js +517 -0
- package/dist/application/workspace-operation.js.map +1 -0
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +136 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/add.d.ts +8 -0
- package/dist/commands/add.js +77 -0
- package/dist/commands/add.js.map +1 -0
- package/dist/commands/build.d.ts +7 -0
- package/dist/commands/build.js +29 -0
- package/dist/commands/build.js.map +1 -0
- package/dist/commands/component-scaffold.d.ts +4 -0
- package/dist/commands/component-scaffold.js +14 -0
- package/dist/commands/component-scaffold.js.map +1 -0
- package/dist/commands/import-skill.d.ts +15 -0
- package/dist/commands/import-skill.js +41 -0
- package/dist/commands/import-skill.js.map +1 -0
- package/dist/commands/index-gen.d.ts +12 -0
- package/dist/commands/index-gen.js +33 -0
- package/dist/commands/index-gen.js.map +1 -0
- package/dist/commands/init-repo.d.ts +8 -0
- package/dist/commands/init-repo.js +53 -0
- package/dist/commands/init-repo.js.map +1 -0
- package/dist/commands/init.d.ts +9 -0
- package/dist/commands/init.js +33 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/install-repo.d.ts +20 -0
- package/dist/commands/install-repo.js +200 -0
- package/dist/commands/install-repo.js.map +1 -0
- package/dist/commands/release-local.d.ts +9 -0
- package/dist/commands/release-local.js +27 -0
- package/dist/commands/release-local.js.map +1 -0
- package/dist/commands/validate.d.ts +8 -0
- package/dist/commands/validate.js +45 -0
- package/dist/commands/validate.js.map +1 -0
- package/dist/core/errors.d.ts +5 -0
- package/dist/core/errors.js +12 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/generated/skill-content.d.ts +3 -0
- package/dist/generated/skill-content.js +9 -0
- package/dist/generated/skill-content.js.map +1 -0
- package/dist/infrastructure/authorized-path.d.ts +20 -0
- package/dist/infrastructure/authorized-path.js +85 -0
- package/dist/infrastructure/authorized-path.js.map +1 -0
- package/dist/infrastructure/document-commit.d.ts +165 -0
- package/dist/infrastructure/document-commit.js +1352 -0
- package/dist/infrastructure/document-commit.js.map +1 -0
- package/dist/infrastructure/process-runner.d.ts +29 -0
- package/dist/infrastructure/process-runner.js +130 -0
- package/dist/infrastructure/process-runner.js.map +1 -0
- package/dist/infrastructure/vscode-user-settings.d.ts +42 -0
- package/dist/infrastructure/vscode-user-settings.js +509 -0
- package/dist/infrastructure/vscode-user-settings.js.map +1 -0
- package/dist/schema/plugin-yaml.d.ts +89 -0
- package/dist/schema/plugin-yaml.js +241 -0
- package/dist/schema/plugin-yaml.js.map +1 -0
- package/dist/utils/frontmatter.d.ts +30 -0
- package/dist/utils/frontmatter.js +60 -0
- package/dist/utils/frontmatter.js.map +1 -0
- package/dist/utils/helpers.d.ts +44 -0
- package/dist/utils/helpers.js +94 -0
- package/dist/utils/helpers.js.map +1 -0
- package/package.json +56 -0
|
@@ -0,0 +1,1375 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants as fsConstants, chmodSync, closeSync, copyFileSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readlinkSync, readdirSync, rmSync, rmdirSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import { basename, dirname, join, relative, resolve, sep, } from 'node:path';
|
|
4
|
+
import { AuthorizedPathError, authorizeExistingDirectory, resolveAuthorizedPath, } from '../infrastructure/authorized-path.js';
|
|
5
|
+
import { frontmatterString, parseFrontmatter } from '../utils/frontmatter.js';
|
|
6
|
+
import { parsePluginYamlSource } from './plugin-source.js';
|
|
7
|
+
import { SKILL_IMPORT_MAX_BYTES, SKILL_IMPORT_MAX_FILES, } from './plugin-lifecycle-contract.js';
|
|
8
|
+
import { COMPONENT_TYPES, createInitialPluginConfig, createPluginScaffold, dumpPluginYaml, isComponentType, isPluginName, skillEntry, } from './plugin-scaffold.js';
|
|
9
|
+
const MAX_DESCRIPTION = 500;
|
|
10
|
+
const SKIP_ENTRIES = new Set(['.DS_Store', '.git', 'node_modules']);
|
|
11
|
+
const PLACEHOLDER_DESCRIPTION = 'TODO: 填写插件描述';
|
|
12
|
+
const TRANSACTION_LOCK = '.agent-plugkit-plugin-lifecycle.lock';
|
|
13
|
+
const STAGING_PREFIX = '.agent-plugkit-plugin-staging-';
|
|
14
|
+
class DirectoryIdentityChangedError extends Error {
|
|
15
|
+
scope;
|
|
16
|
+
constructor(scope) {
|
|
17
|
+
super(`${scope} directory identity changed during plugin transaction`);
|
|
18
|
+
this.scope = scope;
|
|
19
|
+
this.name = 'DirectoryIdentityChangedError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function captureDirectoryIdentity(path, scope) {
|
|
23
|
+
const stat = lstatSync(path, { bigint: true });
|
|
24
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
25
|
+
throw new DirectoryIdentityChangedError(scope);
|
|
26
|
+
}
|
|
27
|
+
return { canonicalPath: path, dev: stat.dev, ino: stat.ino };
|
|
28
|
+
}
|
|
29
|
+
function matchesDirectoryIdentity(identity) {
|
|
30
|
+
try {
|
|
31
|
+
const stat = lstatSync(identity.canonicalPath, { bigint: true });
|
|
32
|
+
return (!stat.isSymbolicLink() &&
|
|
33
|
+
stat.isDirectory() &&
|
|
34
|
+
stat.dev === identity.dev &&
|
|
35
|
+
stat.ino === identity.ino);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function assertDirectoryIdentity(identity, scope) {
|
|
42
|
+
if (!matchesDirectoryIdentity(identity)) {
|
|
43
|
+
throw new DirectoryIdentityChangedError(scope);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function problem(code, message, relativePath) {
|
|
47
|
+
return {
|
|
48
|
+
code,
|
|
49
|
+
message,
|
|
50
|
+
...(relativePath === undefined ? {} : { relativePath }),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function digestBytes(bytes) {
|
|
54
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
55
|
+
}
|
|
56
|
+
function digestJson(value) {
|
|
57
|
+
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
58
|
+
}
|
|
59
|
+
function toPosixPath(path) {
|
|
60
|
+
return path.split(sep).join('/');
|
|
61
|
+
}
|
|
62
|
+
function codepointLength(text) {
|
|
63
|
+
return Array.from(text).length;
|
|
64
|
+
}
|
|
65
|
+
function truncateToCodepoints(text, max) {
|
|
66
|
+
const chars = Array.from(text);
|
|
67
|
+
if (chars.length <= max)
|
|
68
|
+
return text;
|
|
69
|
+
return `${chars.slice(0, max - 1).join('')}…`;
|
|
70
|
+
}
|
|
71
|
+
function normalizeWhitespace(text) {
|
|
72
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
73
|
+
}
|
|
74
|
+
function normalizeName(raw) {
|
|
75
|
+
return raw
|
|
76
|
+
.toLowerCase()
|
|
77
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
78
|
+
.replace(/^-+|-+$/g, '');
|
|
79
|
+
}
|
|
80
|
+
function componentLabels(type) {
|
|
81
|
+
switch (type) {
|
|
82
|
+
case 'skill':
|
|
83
|
+
return ['Skill'];
|
|
84
|
+
case 'mcp':
|
|
85
|
+
return ['Skill', 'MCP'];
|
|
86
|
+
case 'lsp':
|
|
87
|
+
return ['Skill', 'LSP'];
|
|
88
|
+
case 'hook':
|
|
89
|
+
return ['Hook'];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function summarizeFiles(pluginName, type, files) {
|
|
93
|
+
return {
|
|
94
|
+
pluginName,
|
|
95
|
+
directoryName: pluginName,
|
|
96
|
+
componentType: type,
|
|
97
|
+
componentLabels: componentLabels(type),
|
|
98
|
+
relativePaths: files.map((file) => `plugins/${pluginName}/${file.relativePath}`),
|
|
99
|
+
fileCount: files.length,
|
|
100
|
+
totalBytes: files.reduce((total, file) => total + file.bytes.byteLength, 0),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function resolveWorkspaceTarget(workspaceDirectory, pluginName) {
|
|
104
|
+
let authorization;
|
|
105
|
+
try {
|
|
106
|
+
authorization = authorizeExistingDirectory(workspaceDirectory);
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
return problem('WORKSPACE_UNAVAILABLE', error instanceof AuthorizedPathError
|
|
110
|
+
? error.message
|
|
111
|
+
: 'Marketplace 文件夹当前不可访问');
|
|
112
|
+
}
|
|
113
|
+
let pluginsCanonicalPath;
|
|
114
|
+
try {
|
|
115
|
+
pluginsCanonicalPath = resolveAuthorizedPath(authorization, 'plugins');
|
|
116
|
+
if (!existsSync(pluginsCanonicalPath) || !lstatSync(pluginsCanonicalPath).isDirectory()) {
|
|
117
|
+
return problem('PLUGINS_DIRECTORY_UNAVAILABLE', 'Marketplace 的 plugins 文件夹不存在或不可用', 'plugins');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
return problem('PLUGINS_DIRECTORY_UNAVAILABLE', error instanceof AuthorizedPathError
|
|
122
|
+
? error.message
|
|
123
|
+
: 'Marketplace 的 plugins 文件夹当前不可访问', 'plugins');
|
|
124
|
+
}
|
|
125
|
+
const targetRelativePath = `plugins/${pluginName}`;
|
|
126
|
+
let targetCanonicalPath;
|
|
127
|
+
try {
|
|
128
|
+
targetCanonicalPath = resolveAuthorizedPath(authorization, targetRelativePath);
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
return problem('WORKSPACE_UNAVAILABLE', error instanceof AuthorizedPathError
|
|
132
|
+
? error.message
|
|
133
|
+
: '插件目标当前不可访问', targetRelativePath);
|
|
134
|
+
}
|
|
135
|
+
if (existsSync(targetCanonicalPath)) {
|
|
136
|
+
return problem('TARGET_CONFLICT', `插件目录已存在: plugins/${pluginName}`, targetRelativePath);
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
authorization,
|
|
140
|
+
pluginsCanonicalPath,
|
|
141
|
+
targetRelativePath,
|
|
142
|
+
targetCanonicalPath,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
function invalidName(name) {
|
|
146
|
+
return problem('INVALID_NAME', `插件名 必须使用 kebab-case 小写名称: ${name}`);
|
|
147
|
+
}
|
|
148
|
+
export function planPluginCreation(workspaceDirectory, name, type) {
|
|
149
|
+
if (!isPluginName(name)) {
|
|
150
|
+
return { status: 'invalid', problem: invalidName(name) };
|
|
151
|
+
}
|
|
152
|
+
if (!isComponentType(type)) {
|
|
153
|
+
return {
|
|
154
|
+
status: 'invalid',
|
|
155
|
+
problem: problem('INVALID_COMPONENT_TYPE', `组件类型非法: ${type}. 可选值: ${COMPONENT_TYPES.join(', ')}`),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const target = resolveWorkspaceTarget(workspaceDirectory, name);
|
|
159
|
+
if ('code' in target) {
|
|
160
|
+
return {
|
|
161
|
+
status: target.code === 'TARGET_CONFLICT' ? 'blocked' : 'invalid',
|
|
162
|
+
problem: target,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
const files = createPluginScaffold(name, type);
|
|
166
|
+
const fingerprint = digestJson({
|
|
167
|
+
kind: 'create',
|
|
168
|
+
workspace: target.authorization.canonicalPath,
|
|
169
|
+
target: target.targetRelativePath,
|
|
170
|
+
type,
|
|
171
|
+
files: files.map((file) => ({
|
|
172
|
+
relativePath: file.relativePath,
|
|
173
|
+
mode: file.mode,
|
|
174
|
+
digest: digestBytes(file.bytes),
|
|
175
|
+
})),
|
|
176
|
+
});
|
|
177
|
+
const plan = {
|
|
178
|
+
kind: 'create',
|
|
179
|
+
request: {
|
|
180
|
+
workspaceDirectory: target.authorization.canonicalPath,
|
|
181
|
+
name,
|
|
182
|
+
type,
|
|
183
|
+
},
|
|
184
|
+
workspaceCanonicalPath: target.authorization.canonicalPath,
|
|
185
|
+
targetRelativePath: target.targetRelativePath,
|
|
186
|
+
targetCanonicalPath: target.targetCanonicalPath,
|
|
187
|
+
fingerprint,
|
|
188
|
+
files,
|
|
189
|
+
summary: summarizeFiles(name, type, files),
|
|
190
|
+
};
|
|
191
|
+
return { status: 'planned', plan };
|
|
192
|
+
}
|
|
193
|
+
function authorizeSelectedSource(sourceDirectory) {
|
|
194
|
+
let authorization;
|
|
195
|
+
try {
|
|
196
|
+
authorization = authorizeExistingDirectory(sourceDirectory);
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
if (error instanceof AuthorizedPathError) {
|
|
200
|
+
if (error.code === 'PATH_NOT_FOUND') {
|
|
201
|
+
return problem('SOURCE_NOT_FOUND', `源路径不存在: ${resolve(sourceDirectory)}`);
|
|
202
|
+
}
|
|
203
|
+
if (error.code === 'NOT_A_DIRECTORY') {
|
|
204
|
+
return problem('SOURCE_NOT_DIRECTORY', `源路径不是目录: ${resolve(sourceDirectory)}. 请指向包含 SKILL.md 的 skill 目录`);
|
|
205
|
+
}
|
|
206
|
+
if (error.code === 'UNSAFE_SYMLINK') {
|
|
207
|
+
return problem('SOURCE_UNSAFE_ENTRY', `源目录包含符号链接,导入不支持: ${resolve(sourceDirectory)}`);
|
|
208
|
+
}
|
|
209
|
+
return problem('SOURCE_UNAVAILABLE', `源路径当前不可访问: ${resolve(sourceDirectory)}`);
|
|
210
|
+
}
|
|
211
|
+
return problem('SOURCE_UNAVAILABLE', `源路径当前不可访问: ${resolve(sourceDirectory)}`);
|
|
212
|
+
}
|
|
213
|
+
return authorization;
|
|
214
|
+
}
|
|
215
|
+
function unsafeSourceEntry(path, relativePath) {
|
|
216
|
+
return problem('SOURCE_UNSAFE_ENTRY', `源目录包含符号链接,导入不支持: ${path}`, relativePath);
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Locates one supported Skill using only the selected root and the direct
|
|
220
|
+
* `skills/<name>/SKILL.md` structure. Unrelated wrapper contents are never
|
|
221
|
+
* traversed or cached. Every path segment actually used for selection is
|
|
222
|
+
* lstat-checked before the selected Skill becomes its own authorization root.
|
|
223
|
+
*/
|
|
224
|
+
function locateSkillSource(sourceDirectory) {
|
|
225
|
+
const selectedRootAuthorization = authorizeSelectedSource(sourceDirectory);
|
|
226
|
+
if ('code' in selectedRootAuthorization)
|
|
227
|
+
return selectedRootAuthorization;
|
|
228
|
+
const selectedRoot = selectedRootAuthorization.canonicalPath;
|
|
229
|
+
const directSkill = join(selectedRoot, 'SKILL.md');
|
|
230
|
+
if (existsSync(directSkill)) {
|
|
231
|
+
const stat = lstatSync(directSkill);
|
|
232
|
+
if (stat.isSymbolicLink())
|
|
233
|
+
return unsafeSourceEntry(directSkill, 'SKILL.md');
|
|
234
|
+
if (!stat.isFile()) {
|
|
235
|
+
return problem('SOURCE_UNSAFE_ENTRY', `源目录包含不受支持的非普通文件: ${directSkill}`, 'SKILL.md');
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
selectedRootAuthorization,
|
|
239
|
+
skillAuthorization: selectedRootAuthorization,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
const skillsRoot = join(selectedRoot, 'skills');
|
|
243
|
+
if (!existsSync(skillsRoot)) {
|
|
244
|
+
return problem('SOURCE_SHAPE_UNSUPPORTED', `源目录中未找到 SKILL.md: ${selectedRoot}. 支持的源形态: 含 SKILL.md 的 skill 目录,或含 skills/ 的插件目录`);
|
|
245
|
+
}
|
|
246
|
+
const skillsRootStat = lstatSync(skillsRoot);
|
|
247
|
+
if (skillsRootStat.isSymbolicLink())
|
|
248
|
+
return unsafeSourceEntry(skillsRoot, 'skills');
|
|
249
|
+
if (!skillsRootStat.isDirectory()) {
|
|
250
|
+
return problem('SOURCE_SHAPE_UNSUPPORTED', `源插件目录的 skills/ 下没有 skill: ${selectedRoot}`);
|
|
251
|
+
}
|
|
252
|
+
const candidates = [];
|
|
253
|
+
for (const child of readdirSync(skillsRoot, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
|
|
254
|
+
const childPath = join(skillsRoot, child.name);
|
|
255
|
+
const childRelative = `skills/${child.name}`;
|
|
256
|
+
const childStat = lstatSync(childPath);
|
|
257
|
+
if (childStat.isSymbolicLink()) {
|
|
258
|
+
return unsafeSourceEntry(childPath, childRelative);
|
|
259
|
+
}
|
|
260
|
+
if (!childStat.isDirectory())
|
|
261
|
+
continue;
|
|
262
|
+
const skillMdPath = join(childPath, 'SKILL.md');
|
|
263
|
+
if (!existsSync(skillMdPath))
|
|
264
|
+
continue;
|
|
265
|
+
const skillMdStat = lstatSync(skillMdPath);
|
|
266
|
+
if (skillMdStat.isSymbolicLink()) {
|
|
267
|
+
return unsafeSourceEntry(skillMdPath, `${childRelative}/SKILL.md`);
|
|
268
|
+
}
|
|
269
|
+
if (!skillMdStat.isFile()) {
|
|
270
|
+
return problem('SOURCE_UNSAFE_ENTRY', `源目录包含不受支持的非普通文件: ${skillMdPath}`, `${childRelative}/SKILL.md`);
|
|
271
|
+
}
|
|
272
|
+
candidates.push(child.name);
|
|
273
|
+
}
|
|
274
|
+
if (candidates.length === 0) {
|
|
275
|
+
return problem('SOURCE_SHAPE_UNSUPPORTED', `源插件目录的 skills/ 下没有 skill: ${selectedRoot}`);
|
|
276
|
+
}
|
|
277
|
+
if (candidates.length > 1) {
|
|
278
|
+
return problem('SOURCE_MULTIPLE_SKILLS', `源插件目录包含多个 skill: ${candidates.join(', ')}. 请直接指向具体的 skill 子目录`);
|
|
279
|
+
}
|
|
280
|
+
const skillPath = join(skillsRoot, candidates[0]);
|
|
281
|
+
try {
|
|
282
|
+
return {
|
|
283
|
+
selectedRootAuthorization,
|
|
284
|
+
skillAuthorization: authorizeExistingDirectory(skillPath),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
return problem('SOURCE_CHANGED', `源 Skill 在检查期间发生变化: skills/${candidates[0]}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
function sourceLimitProblem(fileCount, totalBytes) {
|
|
292
|
+
const mb = (totalBytes / (1024 * 1024)).toFixed(1);
|
|
293
|
+
const maxMb = SKILL_IMPORT_MAX_BYTES / (1024 * 1024);
|
|
294
|
+
return problem('SOURCE_LIMIT_EXCEEDED', `源目录过大: ${fileCount} 个文件 / ${mb} MB,超过上限 ${SKILL_IMPORT_MAX_FILES} 个文件 / ${maxMb} MB. 请指向更精确的 skill 目录`);
|
|
295
|
+
}
|
|
296
|
+
/** Scans only the selected Skill tree; skipped entries preserve the CLI contract. */
|
|
297
|
+
function scanSkillSource(authorization) {
|
|
298
|
+
const entries = [];
|
|
299
|
+
let fileCount = 0;
|
|
300
|
+
let totalBytes = 0;
|
|
301
|
+
const walk = (current) => {
|
|
302
|
+
let children;
|
|
303
|
+
try {
|
|
304
|
+
children = readdirSync(current, { withFileTypes: true });
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
return problem('SOURCE_UNAVAILABLE', `源目录当前不可读取: ${current}`);
|
|
308
|
+
}
|
|
309
|
+
for (const child of children.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
310
|
+
// Compatibility boundary: these entries have never been imported or
|
|
311
|
+
// counted. They are skipped before lstat/traversal, even when huge.
|
|
312
|
+
if (SKIP_ENTRIES.has(child.name))
|
|
313
|
+
continue;
|
|
314
|
+
const childPath = join(current, child.name);
|
|
315
|
+
const childRelative = toPosixPath(relative(authorization.canonicalPath, childPath));
|
|
316
|
+
let stat;
|
|
317
|
+
try {
|
|
318
|
+
stat = lstatSync(childPath);
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
return problem('SOURCE_CHANGED', `源内容在检查期间发生变化: ${childRelative}`, childRelative);
|
|
322
|
+
}
|
|
323
|
+
if (stat.isSymbolicLink()) {
|
|
324
|
+
return problem('SOURCE_UNSAFE_ENTRY', `源目录包含符号链接,导入不支持: ${childPath}`, childRelative);
|
|
325
|
+
}
|
|
326
|
+
if (stat.isDirectory()) {
|
|
327
|
+
entries.push({
|
|
328
|
+
canonicalPath: childPath,
|
|
329
|
+
relativePath: childRelative,
|
|
330
|
+
kind: 'directory',
|
|
331
|
+
mode: stat.mode & 0o777,
|
|
332
|
+
});
|
|
333
|
+
const nestedProblem = walk(childPath);
|
|
334
|
+
if (nestedProblem !== undefined)
|
|
335
|
+
return nestedProblem;
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (!stat.isFile()) {
|
|
339
|
+
return problem('SOURCE_UNSAFE_ENTRY', `源目录包含不受支持的非普通文件: ${childPath}`, childRelative);
|
|
340
|
+
}
|
|
341
|
+
const nextFileCount = fileCount + 1;
|
|
342
|
+
const statTotalBytes = totalBytes + stat.size;
|
|
343
|
+
if (nextFileCount > SKILL_IMPORT_MAX_FILES ||
|
|
344
|
+
statTotalBytes > SKILL_IMPORT_MAX_BYTES) {
|
|
345
|
+
return sourceLimitProblem(nextFileCount, statTotalBytes);
|
|
346
|
+
}
|
|
347
|
+
let bytes;
|
|
348
|
+
try {
|
|
349
|
+
bytes = readFileSync(childPath);
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
return problem('SOURCE_CHANGED', `源文件在检查期间发生变化: ${childRelative}`, childRelative);
|
|
353
|
+
}
|
|
354
|
+
const after = lstatSync(childPath);
|
|
355
|
+
if (!after.isFile() || after.size !== bytes.byteLength || after.mtimeMs !== stat.mtimeMs) {
|
|
356
|
+
return problem('SOURCE_CHANGED', `源文件在检查期间发生变化: ${childRelative}`, childRelative);
|
|
357
|
+
}
|
|
358
|
+
const exactTotalBytes = totalBytes + bytes.byteLength;
|
|
359
|
+
if (nextFileCount > SKILL_IMPORT_MAX_FILES || exactTotalBytes > SKILL_IMPORT_MAX_BYTES) {
|
|
360
|
+
return sourceLimitProblem(nextFileCount, exactTotalBytes);
|
|
361
|
+
}
|
|
362
|
+
fileCount = nextFileCount;
|
|
363
|
+
totalBytes = exactTotalBytes;
|
|
364
|
+
entries.push({
|
|
365
|
+
canonicalPath: childPath,
|
|
366
|
+
relativePath: childRelative,
|
|
367
|
+
kind: 'file',
|
|
368
|
+
bytes,
|
|
369
|
+
mode: stat.mode & 0o777,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
return undefined;
|
|
373
|
+
};
|
|
374
|
+
const scanProblem = walk(authorization.canonicalPath);
|
|
375
|
+
return scanProblem ?? { authorization, entries, fileCount, totalBytes };
|
|
376
|
+
}
|
|
377
|
+
function parseSourceFrontmatter(skillMd) {
|
|
378
|
+
let raw;
|
|
379
|
+
try {
|
|
380
|
+
raw = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(skillMd.bytes);
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
return problem('SOURCE_INVALID_FRONTMATTER', `SKILL.md 不是有效的 UTF-8 文本: ${skillMd.sourceCanonicalPath}`, 'SKILL.md');
|
|
384
|
+
}
|
|
385
|
+
const parsed = parseFrontmatter(raw);
|
|
386
|
+
if (!parsed.ok) {
|
|
387
|
+
switch (parsed.failure.kind) {
|
|
388
|
+
case 'unterminated':
|
|
389
|
+
return problem('SOURCE_INVALID_FRONTMATTER', `SKILL.md frontmatter 未正确闭合: ${skillMd.sourceCanonicalPath}. 缺少结束的 --- 分隔符`, 'SKILL.md');
|
|
390
|
+
case 'invalid-yaml':
|
|
391
|
+
return problem('SOURCE_INVALID_FRONTMATTER', `SKILL.md frontmatter 解析失败: ${skillMd.sourceCanonicalPath}: ${parsed.failure.message}`, 'SKILL.md');
|
|
392
|
+
case 'not-object':
|
|
393
|
+
return problem('SOURCE_INVALID_FRONTMATTER', `SKILL.md frontmatter 不是对象: ${skillMd.sourceCanonicalPath}`, 'SKILL.md');
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return {
|
|
397
|
+
name: frontmatterString(parsed.data, 'name'),
|
|
398
|
+
description: frontmatterString(parsed.data, 'description'),
|
|
399
|
+
argumentHint: frontmatterString(parsed.data, 'argument-hint'),
|
|
400
|
+
warnings: parsed.present
|
|
401
|
+
? []
|
|
402
|
+
: [
|
|
403
|
+
{
|
|
404
|
+
code: 'MISSING_FRONTMATTER',
|
|
405
|
+
message: '源 SKILL.md 缺少 frontmatter,将按目录名推导插件名',
|
|
406
|
+
},
|
|
407
|
+
],
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
function validateImportOverrides(request) {
|
|
411
|
+
if (request.name !== undefined && !isPluginName(request.name)) {
|
|
412
|
+
return invalidName(request.name);
|
|
413
|
+
}
|
|
414
|
+
let description;
|
|
415
|
+
if (request.description !== undefined) {
|
|
416
|
+
description = request.description.trim();
|
|
417
|
+
if (description.length === 0) {
|
|
418
|
+
return problem('INVALID_DESCRIPTION', '--description 不能为空');
|
|
419
|
+
}
|
|
420
|
+
const length = codepointLength(description);
|
|
421
|
+
if (length > MAX_DESCRIPTION) {
|
|
422
|
+
return problem('INVALID_DESCRIPTION', `--description 超过 ${MAX_DESCRIPTION} 个字符: ${length}`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
let author;
|
|
426
|
+
if (request.author !== undefined) {
|
|
427
|
+
author = request.author.trim();
|
|
428
|
+
if (author.length === 0) {
|
|
429
|
+
return problem('INVALID_AUTHOR', '--author 不能为空');
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return { description, author };
|
|
433
|
+
}
|
|
434
|
+
function importedFilesForSkill(scan) {
|
|
435
|
+
return scan.entries
|
|
436
|
+
.filter((entry) => entry.kind === 'file')
|
|
437
|
+
.map((entry) => {
|
|
438
|
+
return {
|
|
439
|
+
sourceCanonicalPath: entry.canonicalPath,
|
|
440
|
+
relativePath: entry.relativePath,
|
|
441
|
+
bytes: entry.bytes,
|
|
442
|
+
mode: entry.mode,
|
|
443
|
+
digest: digestBytes(entry.bytes),
|
|
444
|
+
};
|
|
445
|
+
})
|
|
446
|
+
.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
|
|
447
|
+
}
|
|
448
|
+
function importedDirectoriesForSkill(scan) {
|
|
449
|
+
return scan.entries
|
|
450
|
+
.filter((entry) => entry.kind === 'directory')
|
|
451
|
+
.map((entry) => ({
|
|
452
|
+
relativePath: entry.relativePath,
|
|
453
|
+
mode: entry.mode,
|
|
454
|
+
}))
|
|
455
|
+
.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
|
|
456
|
+
}
|
|
457
|
+
function deriveSkillImport(inspection, request) {
|
|
458
|
+
const overrides = validateImportOverrides(request);
|
|
459
|
+
if ('code' in overrides)
|
|
460
|
+
return overrides;
|
|
461
|
+
const warnings = [
|
|
462
|
+
...inspection.frontmatter.warnings,
|
|
463
|
+
];
|
|
464
|
+
const rawName = request.name ??
|
|
465
|
+
inspection.frontmatter.name ??
|
|
466
|
+
inspection.sourceSkillDirectoryName;
|
|
467
|
+
const pluginName = request.name ?? normalizeName(rawName);
|
|
468
|
+
if (pluginName.length === 0) {
|
|
469
|
+
return problem('INVALID_NAME', `无法从源推导插件名: ${rawName}. 请显式传入 [name] 参数`);
|
|
470
|
+
}
|
|
471
|
+
if (!isPluginName(pluginName))
|
|
472
|
+
return invalidName(pluginName);
|
|
473
|
+
if (request.name === undefined && pluginName !== rawName) {
|
|
474
|
+
warnings.push({
|
|
475
|
+
code: 'NORMALIZED_NAME',
|
|
476
|
+
message: `插件名已规范化: ${rawName} → ${pluginName}`,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
if (inspection.frontmatter.name !== undefined &&
|
|
480
|
+
inspection.frontmatter.name !== pluginName) {
|
|
481
|
+
warnings.push({
|
|
482
|
+
code: 'CANONICAL_NAME_DIFFERENCE',
|
|
483
|
+
message: `SKILL.md frontmatter 中的 name (${inspection.frontmatter.name}) 与插件名 (${pluginName}) 不一致,导入不会改写 SKILL.md`,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
let description;
|
|
487
|
+
if (overrides.description !== undefined) {
|
|
488
|
+
description = overrides.description;
|
|
489
|
+
}
|
|
490
|
+
else if (inspection.frontmatter.description !== undefined) {
|
|
491
|
+
const normalized = normalizeWhitespace(inspection.frontmatter.description);
|
|
492
|
+
if (codepointLength(normalized) > MAX_DESCRIPTION) {
|
|
493
|
+
description = truncateToCodepoints(normalized, MAX_DESCRIPTION);
|
|
494
|
+
warnings.push({
|
|
495
|
+
code: 'TRUNCATED_DESCRIPTION',
|
|
496
|
+
message: '描述超过 500 字符,已截断写入 plugin.yaml;SKILL.md 原文未改动',
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
else {
|
|
500
|
+
description = normalized;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
description = PLACEHOLDER_DESCRIPTION;
|
|
505
|
+
warnings.push({
|
|
506
|
+
code: 'MISSING_DESCRIPTION',
|
|
507
|
+
message: '源 SKILL.md 缺少 description,已写入占位描述',
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
const canonicalName = inspection.frontmatter.name;
|
|
511
|
+
return {
|
|
512
|
+
sourceDisplayName: inspection.sourceDisplayName,
|
|
513
|
+
sourceSkillDirectoryName: inspection.sourceSkillDirectoryName,
|
|
514
|
+
directoryName: pluginName,
|
|
515
|
+
...(canonicalName === undefined ? {} : { canonicalName }),
|
|
516
|
+
...(canonicalName !== undefined && canonicalName !== pluginName
|
|
517
|
+
? {
|
|
518
|
+
nameRepairIssue: {
|
|
519
|
+
state: 'needs-review',
|
|
520
|
+
summary: `目录名 (${pluginName}) 与 SKILL.md name (${canonicalName}) 不一致`,
|
|
521
|
+
nextAction: '导入会保留两者;请稍后审阅名称差异,不会自动重命名目录或改写 SKILL.md。',
|
|
522
|
+
},
|
|
523
|
+
}
|
|
524
|
+
: {}),
|
|
525
|
+
...(canonicalName === undefined ? {} : { frontmatterName: canonicalName }),
|
|
526
|
+
...(inspection.frontmatter.description === undefined
|
|
527
|
+
? {}
|
|
528
|
+
: { frontmatterDescription: inspection.frontmatter.description }),
|
|
529
|
+
...(inspection.frontmatter.argumentHint === undefined
|
|
530
|
+
? {}
|
|
531
|
+
: { argumentHint: inspection.frontmatter.argumentHint }),
|
|
532
|
+
pluginName,
|
|
533
|
+
description,
|
|
534
|
+
...(overrides.author === undefined ? {} : { author: overrides.author }),
|
|
535
|
+
warnings,
|
|
536
|
+
budget: {
|
|
537
|
+
fileCount: inspection.importedFiles.length,
|
|
538
|
+
totalBytes: inspection.importedFiles.reduce((total, file) => total + file.bytes.byteLength, 0),
|
|
539
|
+
maxFiles: SKILL_IMPORT_MAX_FILES,
|
|
540
|
+
maxBytes: SKILL_IMPORT_MAX_BYTES,
|
|
541
|
+
},
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
export function inspectSkillImportSource(sourceDirectory) {
|
|
545
|
+
const located = locateSkillSource(sourceDirectory);
|
|
546
|
+
if ('code' in located)
|
|
547
|
+
return { status: 'invalid', problem: located };
|
|
548
|
+
const source = scanSkillSource(located.skillAuthorization);
|
|
549
|
+
if ('code' in source)
|
|
550
|
+
return { status: 'invalid', problem: source };
|
|
551
|
+
const importedDirectories = importedDirectoriesForSkill(source);
|
|
552
|
+
const importedFiles = importedFilesForSkill(source);
|
|
553
|
+
const skillMd = importedFiles.find((file) => file.relativePath === 'SKILL.md');
|
|
554
|
+
if (skillMd === undefined) {
|
|
555
|
+
return {
|
|
556
|
+
status: 'invalid',
|
|
557
|
+
problem: problem('SOURCE_SHAPE_UNSUPPORTED', `源目录中未找到 SKILL.md: ${source.authorization.canonicalPath}`),
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
const frontmatter = parseSourceFrontmatter(skillMd);
|
|
561
|
+
if ('code' in frontmatter)
|
|
562
|
+
return { status: 'invalid', problem: frontmatter };
|
|
563
|
+
const fingerprint = digestJson({
|
|
564
|
+
sourceRoot: located.selectedRootAuthorization.canonicalPath,
|
|
565
|
+
sourceSkill: source.authorization.canonicalPath,
|
|
566
|
+
files: importedFiles.map(({ relativePath, mode, digest }) => ({
|
|
567
|
+
relativePath,
|
|
568
|
+
mode,
|
|
569
|
+
digest,
|
|
570
|
+
})),
|
|
571
|
+
directories: importedDirectories.map(({ relativePath, mode }) => ({
|
|
572
|
+
relativePath,
|
|
573
|
+
mode,
|
|
574
|
+
})),
|
|
575
|
+
});
|
|
576
|
+
const base = {
|
|
577
|
+
sourceRootCanonicalPath: located.selectedRootAuthorization.canonicalPath,
|
|
578
|
+
sourceSkillCanonicalPath: source.authorization.canonicalPath,
|
|
579
|
+
fingerprint,
|
|
580
|
+
importedDirectories,
|
|
581
|
+
importedFiles,
|
|
582
|
+
sourceDisplayName: basename(located.selectedRootAuthorization.canonicalPath),
|
|
583
|
+
sourceSkillDirectoryName: basename(source.authorization.canonicalPath),
|
|
584
|
+
frontmatter,
|
|
585
|
+
};
|
|
586
|
+
const suggestion = deriveSkillImport(base, {});
|
|
587
|
+
if ('code' in suggestion)
|
|
588
|
+
return { status: 'invalid', problem: suggestion };
|
|
589
|
+
return {
|
|
590
|
+
status: 'inspected',
|
|
591
|
+
inspection: { ...base, suggestion },
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
export function planSkillImportFromInspection(request, inspection) {
|
|
595
|
+
const selectedSource = authorizeSelectedSource(request.sourceDirectory);
|
|
596
|
+
if ('code' in selectedSource)
|
|
597
|
+
return { status: 'invalid', problem: selectedSource };
|
|
598
|
+
if (selectedSource.canonicalPath !== inspection.sourceRootCanonicalPath) {
|
|
599
|
+
return {
|
|
600
|
+
status: 'invalid',
|
|
601
|
+
problem: problem('SOURCE_CHANGED', '导入源已改变;请重新选择本地 Skill'),
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
const derivation = deriveSkillImport(inspection, request);
|
|
605
|
+
if ('code' in derivation)
|
|
606
|
+
return { status: 'invalid', problem: derivation };
|
|
607
|
+
const pluginName = derivation.pluginName;
|
|
608
|
+
const target = resolveWorkspaceTarget(request.workspaceDirectory, pluginName);
|
|
609
|
+
if ('code' in target) {
|
|
610
|
+
return {
|
|
611
|
+
status: target.code === 'TARGET_CONFLICT' ? 'blocked' : 'invalid',
|
|
612
|
+
problem: target,
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
const config = createInitialPluginConfig(pluginName, 'skill');
|
|
616
|
+
config.description = derivation.description;
|
|
617
|
+
if (derivation.author !== undefined)
|
|
618
|
+
config.author.name = derivation.author;
|
|
619
|
+
config.components.skills = [
|
|
620
|
+
skillEntry(pluginName, derivation.description, inspection.frontmatter.argumentHint),
|
|
621
|
+
];
|
|
622
|
+
const pluginFile = {
|
|
623
|
+
relativePath: 'plugin.yaml',
|
|
624
|
+
bytes: Buffer.from(dumpPluginYaml(config), 'utf8'),
|
|
625
|
+
mode: 0o644,
|
|
626
|
+
};
|
|
627
|
+
const destinationFiles = [
|
|
628
|
+
{ ...pluginFile, digest: digestBytes(pluginFile.bytes) },
|
|
629
|
+
...inspection.importedFiles.map((file) => ({
|
|
630
|
+
relativePath: `skills/${pluginName}/${file.relativePath}`,
|
|
631
|
+
bytes: file.bytes,
|
|
632
|
+
mode: file.mode,
|
|
633
|
+
digest: file.digest,
|
|
634
|
+
})),
|
|
635
|
+
];
|
|
636
|
+
const normalizedRequest = {
|
|
637
|
+
workspaceDirectory: target.authorization.canonicalPath,
|
|
638
|
+
sourceDirectory: inspection.sourceRootCanonicalPath,
|
|
639
|
+
...(request.name === undefined ? {} : { name: request.name }),
|
|
640
|
+
...(request.description === undefined
|
|
641
|
+
? {}
|
|
642
|
+
: { description: derivation.description }),
|
|
643
|
+
...(derivation.author === undefined ? {} : { author: derivation.author }),
|
|
644
|
+
};
|
|
645
|
+
const fingerprint = digestJson({
|
|
646
|
+
kind: 'import-skill',
|
|
647
|
+
sourceFingerprint: inspection.fingerprint,
|
|
648
|
+
workspace: target.authorization.canonicalPath,
|
|
649
|
+
target: target.targetRelativePath,
|
|
650
|
+
request: normalizedRequest,
|
|
651
|
+
files: destinationFiles.map(({ relativePath, mode, digest }) => ({
|
|
652
|
+
relativePath,
|
|
653
|
+
mode,
|
|
654
|
+
digest,
|
|
655
|
+
})),
|
|
656
|
+
directories: inspection.importedDirectories.map(({ relativePath, mode }) => ({
|
|
657
|
+
relativePath,
|
|
658
|
+
mode,
|
|
659
|
+
})),
|
|
660
|
+
});
|
|
661
|
+
const fileSummary = summarizeFiles(pluginName, 'skill', destinationFiles);
|
|
662
|
+
const summary = {
|
|
663
|
+
...fileSummary,
|
|
664
|
+
relativePaths: [
|
|
665
|
+
...fileSummary.relativePaths,
|
|
666
|
+
...inspection.importedDirectories.map((directory) => `plugins/${pluginName}/skills/${pluginName}/${directory.relativePath}`),
|
|
667
|
+
].sort(),
|
|
668
|
+
};
|
|
669
|
+
const plan = {
|
|
670
|
+
kind: 'import-skill',
|
|
671
|
+
request: normalizedRequest,
|
|
672
|
+
workspaceCanonicalPath: target.authorization.canonicalPath,
|
|
673
|
+
sourceRootCanonicalPath: inspection.sourceRootCanonicalPath,
|
|
674
|
+
sourceSkillCanonicalPath: inspection.sourceSkillCanonicalPath,
|
|
675
|
+
targetRelativePath: target.targetRelativePath,
|
|
676
|
+
targetCanonicalPath: target.targetCanonicalPath,
|
|
677
|
+
fingerprint,
|
|
678
|
+
importedDirectories: inspection.importedDirectories,
|
|
679
|
+
importedFiles: inspection.importedFiles,
|
|
680
|
+
pluginFile,
|
|
681
|
+
derivation,
|
|
682
|
+
summary,
|
|
683
|
+
};
|
|
684
|
+
return { status: 'planned', plan };
|
|
685
|
+
}
|
|
686
|
+
export function planSkillImport(request) {
|
|
687
|
+
const inspected = inspectSkillImportSource(request.sourceDirectory);
|
|
688
|
+
if (inspected.status === 'invalid')
|
|
689
|
+
return inspected;
|
|
690
|
+
return planSkillImportFromInspection(request, inspected.inspection);
|
|
691
|
+
}
|
|
692
|
+
function isSafePluginDirectoryName(value) {
|
|
693
|
+
return (value.length > 0 &&
|
|
694
|
+
value.length <= 255 &&
|
|
695
|
+
value !== '.' &&
|
|
696
|
+
value !== '..' &&
|
|
697
|
+
!value.includes('/') &&
|
|
698
|
+
!value.includes('\\') &&
|
|
699
|
+
!value.includes('\0'));
|
|
700
|
+
}
|
|
701
|
+
function hashRegularFile(path) {
|
|
702
|
+
const hash = createHash('sha256');
|
|
703
|
+
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
704
|
+
const fd = openSync(path, 'r');
|
|
705
|
+
try {
|
|
706
|
+
while (true) {
|
|
707
|
+
const bytesRead = readSync(fd, buffer, 0, buffer.byteLength, null);
|
|
708
|
+
if (bytesRead === 0)
|
|
709
|
+
break;
|
|
710
|
+
hash.update(buffer.subarray(0, bytesRead));
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
finally {
|
|
714
|
+
closeSync(fd);
|
|
715
|
+
}
|
|
716
|
+
return hash.digest('hex');
|
|
717
|
+
}
|
|
718
|
+
function scanPluginTrashTarget(targetCanonicalPath) {
|
|
719
|
+
let targetIdentity;
|
|
720
|
+
try {
|
|
721
|
+
targetIdentity = captureDirectoryIdentity(targetCanonicalPath, 'target');
|
|
722
|
+
}
|
|
723
|
+
catch {
|
|
724
|
+
return problem('PLUGIN_CHANGED', '插件目录在预览期间发生变化');
|
|
725
|
+
}
|
|
726
|
+
const entries = [];
|
|
727
|
+
let regularFileCount = 0;
|
|
728
|
+
let totalRegularFileBytes = 0;
|
|
729
|
+
const walk = (current, parentRelative) => {
|
|
730
|
+
try {
|
|
731
|
+
assertDirectoryIdentity(targetIdentity, 'target');
|
|
732
|
+
}
|
|
733
|
+
catch {
|
|
734
|
+
return problem('PLUGIN_CHANGED', '插件目录在预览期间发生变化');
|
|
735
|
+
}
|
|
736
|
+
let children;
|
|
737
|
+
try {
|
|
738
|
+
children = readdirSync(current, { withFileTypes: true });
|
|
739
|
+
}
|
|
740
|
+
catch {
|
|
741
|
+
return problem('PLUGIN_CHANGED', '插件内容当前不可读取');
|
|
742
|
+
}
|
|
743
|
+
for (const child of children.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
744
|
+
try {
|
|
745
|
+
assertDirectoryIdentity(targetIdentity, 'target');
|
|
746
|
+
}
|
|
747
|
+
catch {
|
|
748
|
+
return problem('PLUGIN_CHANGED', '插件目录在预览期间发生变化');
|
|
749
|
+
}
|
|
750
|
+
const childPath = join(current, child.name);
|
|
751
|
+
const childRelative = parentRelative.length === 0
|
|
752
|
+
? child.name
|
|
753
|
+
: `${parentRelative}/${child.name}`;
|
|
754
|
+
let stat;
|
|
755
|
+
try {
|
|
756
|
+
stat = lstatSync(childPath);
|
|
757
|
+
}
|
|
758
|
+
catch {
|
|
759
|
+
return problem('PLUGIN_CHANGED', `插件内容发生变化: ${childRelative}`);
|
|
760
|
+
}
|
|
761
|
+
if (stat.isSymbolicLink()) {
|
|
762
|
+
let linkTarget;
|
|
763
|
+
try {
|
|
764
|
+
linkTarget = readlinkSync(childPath);
|
|
765
|
+
}
|
|
766
|
+
catch {
|
|
767
|
+
return problem('PLUGIN_CHANGED', `插件内容发生变化: ${childRelative}`);
|
|
768
|
+
}
|
|
769
|
+
entries.push({
|
|
770
|
+
relativePath: childRelative,
|
|
771
|
+
kind: 'symlink',
|
|
772
|
+
mode: stat.mode & 0o777,
|
|
773
|
+
size: stat.size,
|
|
774
|
+
linkTarget,
|
|
775
|
+
});
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
if (stat.isDirectory()) {
|
|
779
|
+
entries.push({
|
|
780
|
+
relativePath: childRelative,
|
|
781
|
+
kind: 'directory',
|
|
782
|
+
mode: stat.mode & 0o777,
|
|
783
|
+
size: 0,
|
|
784
|
+
});
|
|
785
|
+
const issue = walk(childPath, childRelative);
|
|
786
|
+
if (issue !== undefined)
|
|
787
|
+
return issue;
|
|
788
|
+
continue;
|
|
789
|
+
}
|
|
790
|
+
if (stat.isFile()) {
|
|
791
|
+
let digest;
|
|
792
|
+
try {
|
|
793
|
+
digest = hashRegularFile(childPath);
|
|
794
|
+
const after = lstatSync(childPath);
|
|
795
|
+
if (!after.isFile() ||
|
|
796
|
+
after.isSymbolicLink() ||
|
|
797
|
+
after.dev !== stat.dev ||
|
|
798
|
+
after.ino !== stat.ino ||
|
|
799
|
+
after.size !== stat.size ||
|
|
800
|
+
after.mtimeMs !== stat.mtimeMs) {
|
|
801
|
+
return problem('PLUGIN_CHANGED', `插件内容发生变化: ${childRelative}`);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
catch {
|
|
805
|
+
return problem('PLUGIN_CHANGED', `插件内容发生变化: ${childRelative}`);
|
|
806
|
+
}
|
|
807
|
+
regularFileCount += 1;
|
|
808
|
+
totalRegularFileBytes += stat.size;
|
|
809
|
+
entries.push({
|
|
810
|
+
relativePath: childRelative,
|
|
811
|
+
kind: 'file',
|
|
812
|
+
mode: stat.mode & 0o777,
|
|
813
|
+
size: stat.size,
|
|
814
|
+
digest,
|
|
815
|
+
});
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
entries.push({
|
|
819
|
+
relativePath: childRelative,
|
|
820
|
+
kind: 'other',
|
|
821
|
+
mode: stat.mode & 0o777,
|
|
822
|
+
size: stat.size,
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
return undefined;
|
|
826
|
+
};
|
|
827
|
+
const issue = walk(targetCanonicalPath, '');
|
|
828
|
+
if (issue !== undefined)
|
|
829
|
+
return issue;
|
|
830
|
+
try {
|
|
831
|
+
assertDirectoryIdentity(targetIdentity, 'target');
|
|
832
|
+
}
|
|
833
|
+
catch {
|
|
834
|
+
return problem('PLUGIN_CHANGED', '插件目录在预览期间发生变化');
|
|
835
|
+
}
|
|
836
|
+
return { entries, regularFileCount, totalRegularFileBytes };
|
|
837
|
+
}
|
|
838
|
+
function trashComponents(pluginYamlPath, scan) {
|
|
839
|
+
const pluginYaml = scan.entries.find((entry) => entry.kind === 'file' && entry.relativePath === 'plugin.yaml');
|
|
840
|
+
if (pluginYaml === undefined || pluginYaml.size > 5 * 1024 * 1024) {
|
|
841
|
+
return { components: [] };
|
|
842
|
+
}
|
|
843
|
+
try {
|
|
844
|
+
const bytes = readFileSync(pluginYamlPath);
|
|
845
|
+
if (digestBytes(bytes) !== pluginYaml.digest)
|
|
846
|
+
return { components: [] };
|
|
847
|
+
const parsed = parsePluginYamlSource(bytes.toString('utf8'));
|
|
848
|
+
if (parsed.status !== 'valid')
|
|
849
|
+
return { components: [] };
|
|
850
|
+
const components = [
|
|
851
|
+
...(parsed.value.components.skills ?? []).map((entry) => ({
|
|
852
|
+
kind: 'Skill',
|
|
853
|
+
name: entry.name,
|
|
854
|
+
})),
|
|
855
|
+
...(parsed.value.components.mcp ?? []).map((entry) => ({
|
|
856
|
+
kind: 'MCP',
|
|
857
|
+
name: entry.name,
|
|
858
|
+
})),
|
|
859
|
+
...(parsed.value.components.hooks ?? []).map((entry) => ({
|
|
860
|
+
kind: 'Hook',
|
|
861
|
+
name: `${entry.event}${entry.pattern ? ` · ${entry.pattern}` : ''}`,
|
|
862
|
+
})),
|
|
863
|
+
...(parsed.value.components.lsp ?? []).map((entry) => ({
|
|
864
|
+
kind: 'LSP',
|
|
865
|
+
name: entry.name,
|
|
866
|
+
})),
|
|
867
|
+
];
|
|
868
|
+
return { canonicalName: parsed.value.name, components };
|
|
869
|
+
}
|
|
870
|
+
catch {
|
|
871
|
+
return { components: [] };
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
const PLUGIN_GENERATED_SCOPE = [
|
|
875
|
+
{ label: 'Claude Code manifest', relativePath: '.claude-plugin' },
|
|
876
|
+
{ label: 'Codex manifest', relativePath: '.codex-plugin' },
|
|
877
|
+
{ label: 'MCP config', relativePath: '.mcp.json' },
|
|
878
|
+
{ label: 'LSP config', relativePath: '.lsp.json' },
|
|
879
|
+
{ label: 'Hook config', relativePath: 'hooks/hooks.json' },
|
|
880
|
+
];
|
|
881
|
+
function trashGeneratedFacts(scan) {
|
|
882
|
+
return PLUGIN_GENERATED_SCOPE.filter(({ relativePath }) => scan.entries.some((entry) => entry.relativePath === relativePath ||
|
|
883
|
+
entry.relativePath.startsWith(`${relativePath}/`)));
|
|
884
|
+
}
|
|
885
|
+
export function planPluginTrash(request) {
|
|
886
|
+
if (!isSafePluginDirectoryName(request.directoryName)) {
|
|
887
|
+
return {
|
|
888
|
+
status: 'invalid',
|
|
889
|
+
problem: problem('INVALID_NAME', '插件目录标识无效'),
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
let workspaceAuthorization;
|
|
893
|
+
let targetCanonicalPath;
|
|
894
|
+
try {
|
|
895
|
+
workspaceAuthorization = authorizeExistingDirectory(request.workspaceDirectory);
|
|
896
|
+
const pluginsPath = resolveAuthorizedPath(workspaceAuthorization, 'plugins');
|
|
897
|
+
const pluginsAuthorization = authorizeExistingDirectory(pluginsPath);
|
|
898
|
+
targetCanonicalPath = resolveAuthorizedPath(pluginsAuthorization, request.directoryName);
|
|
899
|
+
if (!existsSync(targetCanonicalPath)) {
|
|
900
|
+
return {
|
|
901
|
+
status: 'invalid',
|
|
902
|
+
problem: problem('PLUGIN_NOT_FOUND', `插件目录不存在: plugins/${request.directoryName}`, `plugins/${request.directoryName}`),
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
const stat = lstatSync(targetCanonicalPath);
|
|
906
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
907
|
+
return {
|
|
908
|
+
status: 'invalid',
|
|
909
|
+
problem: problem('PLUGIN_CHANGED', '插件对象不是可安全移动的目录'),
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
catch (error) {
|
|
914
|
+
return {
|
|
915
|
+
status: 'invalid',
|
|
916
|
+
problem: problem('WORKSPACE_UNAVAILABLE', error instanceof AuthorizedPathError
|
|
917
|
+
? error.message
|
|
918
|
+
: 'Marketplace 文件夹当前不可访问'),
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
const scan = scanPluginTrashTarget(targetCanonicalPath);
|
|
922
|
+
if ('code' in scan)
|
|
923
|
+
return { status: 'invalid', problem: scan };
|
|
924
|
+
const identity = trashComponents(join(targetCanonicalPath, 'plugin.yaml'), scan);
|
|
925
|
+
const hasDifference = identity.canonicalName !== undefined &&
|
|
926
|
+
identity.canonicalName !== request.directoryName;
|
|
927
|
+
const sortedPaths = scan.entries
|
|
928
|
+
.map((entry) => `plugins/${request.directoryName}/${entry.relativePath}`)
|
|
929
|
+
.sort();
|
|
930
|
+
const visiblePaths = sortedPaths.slice(0, 200);
|
|
931
|
+
const fingerprint = digestJson({
|
|
932
|
+
workspace: workspaceAuthorization.canonicalPath,
|
|
933
|
+
directoryName: request.directoryName,
|
|
934
|
+
entries: scan.entries.map((entry) => ({
|
|
935
|
+
relativePath: entry.relativePath,
|
|
936
|
+
kind: entry.kind,
|
|
937
|
+
mode: entry.mode,
|
|
938
|
+
size: entry.size,
|
|
939
|
+
digest: entry.digest,
|
|
940
|
+
linkTarget: entry.linkTarget,
|
|
941
|
+
})),
|
|
942
|
+
});
|
|
943
|
+
const plan = {
|
|
944
|
+
kind: 'trash-plugin',
|
|
945
|
+
request: {
|
|
946
|
+
workspaceDirectory: workspaceAuthorization.canonicalPath,
|
|
947
|
+
directoryName: request.directoryName,
|
|
948
|
+
},
|
|
949
|
+
workspaceCanonicalPath: workspaceAuthorization.canonicalPath,
|
|
950
|
+
targetRelativePath: `plugins/${request.directoryName}`,
|
|
951
|
+
targetCanonicalPath,
|
|
952
|
+
fingerprint,
|
|
953
|
+
plugin: {
|
|
954
|
+
directoryName: request.directoryName,
|
|
955
|
+
...(identity.canonicalName === undefined
|
|
956
|
+
? {}
|
|
957
|
+
: { canonicalName: identity.canonicalName }),
|
|
958
|
+
displayName: identity.canonicalName ?? request.directoryName,
|
|
959
|
+
hasCanonicalDirectoryDifference: hasDifference,
|
|
960
|
+
...(hasDifference
|
|
961
|
+
? {
|
|
962
|
+
nameRepairIssue: {
|
|
963
|
+
state: 'needs-review',
|
|
964
|
+
summary: `目录名 (${request.directoryName}) 与 plugin.yaml name (${identity.canonicalName}) 不一致`,
|
|
965
|
+
nextAction: '移到废纸篓不会自动重命名目录或改写 plugin.yaml;取消后可先审阅此差异。',
|
|
966
|
+
},
|
|
967
|
+
}
|
|
968
|
+
: {}),
|
|
969
|
+
},
|
|
970
|
+
components: identity.components,
|
|
971
|
+
generated: trashGeneratedFacts(scan),
|
|
972
|
+
scope: {
|
|
973
|
+
entryCount: scan.entries.length,
|
|
974
|
+
regularFileCount: scan.regularFileCount,
|
|
975
|
+
totalRegularFileBytes: scan.totalRegularFileBytes,
|
|
976
|
+
relativePaths: visiblePaths,
|
|
977
|
+
additionalEntryCount: sortedPaths.length - visiblePaths.length,
|
|
978
|
+
},
|
|
979
|
+
workspaceGeneratedNotice: {
|
|
980
|
+
status: 'not-moved-becomes-stale',
|
|
981
|
+
labels: ['Marketplace index', 'CATALOG'],
|
|
982
|
+
message: 'Marketplace 级 index 与 CATALOG 不会在本次操作中修改;移除成功后它们会显示为待重新生成。',
|
|
983
|
+
},
|
|
984
|
+
};
|
|
985
|
+
return { status: 'planned', plan };
|
|
986
|
+
}
|
|
987
|
+
export function verifyPluginTrashPlan(plan) {
|
|
988
|
+
const current = planPluginTrash(plan.request);
|
|
989
|
+
if (current.status !== 'planned') {
|
|
990
|
+
return { status: 'invalid', problem: current.problem };
|
|
991
|
+
}
|
|
992
|
+
if (current.plan.fingerprint !== plan.fingerprint) {
|
|
993
|
+
return {
|
|
994
|
+
status: 'stale',
|
|
995
|
+
problem: problem('PLUGIN_CHANGED', '插件内容在预览后发生变化;请重新预览影响范围'),
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
return { status: 'verified', plan: current.plan };
|
|
999
|
+
}
|
|
1000
|
+
function transactionFiles(plan) {
|
|
1001
|
+
if (plan.kind === 'create') {
|
|
1002
|
+
return plan.files.map((file) => ({
|
|
1003
|
+
relativePath: file.relativePath,
|
|
1004
|
+
bytes: file.bytes,
|
|
1005
|
+
mode: file.mode,
|
|
1006
|
+
digest: digestBytes(file.bytes),
|
|
1007
|
+
}));
|
|
1008
|
+
}
|
|
1009
|
+
return [
|
|
1010
|
+
{
|
|
1011
|
+
relativePath: plan.pluginFile.relativePath,
|
|
1012
|
+
bytes: plan.pluginFile.bytes,
|
|
1013
|
+
mode: plan.pluginFile.mode,
|
|
1014
|
+
digest: digestBytes(plan.pluginFile.bytes),
|
|
1015
|
+
},
|
|
1016
|
+
...plan.importedFiles.map((file) => ({
|
|
1017
|
+
relativePath: `skills/${plan.derivation.pluginName}/${file.relativePath}`,
|
|
1018
|
+
bytes: file.bytes,
|
|
1019
|
+
mode: file.mode,
|
|
1020
|
+
digest: file.digest,
|
|
1021
|
+
})),
|
|
1022
|
+
];
|
|
1023
|
+
}
|
|
1024
|
+
function transactionDirectories(plan) {
|
|
1025
|
+
if (plan.kind === 'create')
|
|
1026
|
+
return [];
|
|
1027
|
+
return plan.importedDirectories.map((directory) => ({
|
|
1028
|
+
relativePath: `skills/${plan.derivation.pluginName}/${directory.relativePath}`,
|
|
1029
|
+
mode: directory.mode,
|
|
1030
|
+
}));
|
|
1031
|
+
}
|
|
1032
|
+
function byPathDepthThenName(left, right) {
|
|
1033
|
+
const depth = left.relativePath.split('/').length - right.relativePath.split('/').length;
|
|
1034
|
+
return depth === 0
|
|
1035
|
+
? left.relativePath.localeCompare(right.relativePath)
|
|
1036
|
+
: depth;
|
|
1037
|
+
}
|
|
1038
|
+
function replan(plan) {
|
|
1039
|
+
return plan.kind === 'create'
|
|
1040
|
+
? planPluginCreation(plan.request.workspaceDirectory, plan.request.name, plan.request.type)
|
|
1041
|
+
: planSkillImport(plan.request);
|
|
1042
|
+
}
|
|
1043
|
+
function failure(status, issue, changedPaths = [], rollbackComplete = true, cleanupComplete = true) {
|
|
1044
|
+
return {
|
|
1045
|
+
status,
|
|
1046
|
+
problem: issue,
|
|
1047
|
+
changedPaths,
|
|
1048
|
+
rollbackComplete,
|
|
1049
|
+
cleanupComplete,
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
function relativeChangedPath(plan, child = '') {
|
|
1053
|
+
return child.length === 0
|
|
1054
|
+
? plan.targetRelativePath
|
|
1055
|
+
: `${plan.targetRelativePath}/${child}`;
|
|
1056
|
+
}
|
|
1057
|
+
function ensureParentDirectories(targetAuthorization, relativePath, createdDirectories, createdDirectorySet, assertRoots) {
|
|
1058
|
+
const parent = dirname(relativePath);
|
|
1059
|
+
if (parent === '.' || parent.length === 0)
|
|
1060
|
+
return;
|
|
1061
|
+
const segments = parent.split(sep);
|
|
1062
|
+
let cursor = '';
|
|
1063
|
+
for (const segment of segments) {
|
|
1064
|
+
cursor = cursor.length === 0 ? segment : join(cursor, segment);
|
|
1065
|
+
if (createdDirectorySet.has(cursor))
|
|
1066
|
+
continue;
|
|
1067
|
+
assertRoots();
|
|
1068
|
+
const candidate = resolveAuthorizedPath(targetAuthorization, cursor);
|
|
1069
|
+
assertRoots();
|
|
1070
|
+
mkdirSync(candidate, { mode: 0o755 });
|
|
1071
|
+
createdDirectorySet.add(cursor);
|
|
1072
|
+
createdDirectories.push(candidate);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
function safeDigest(path) {
|
|
1076
|
+
try {
|
|
1077
|
+
const stat = lstatSync(path);
|
|
1078
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
1079
|
+
return undefined;
|
|
1080
|
+
return digestBytes(readFileSync(path));
|
|
1081
|
+
}
|
|
1082
|
+
catch {
|
|
1083
|
+
return undefined;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
function removeOwnedLock(lockPath, token, pluginsIdentity) {
|
|
1087
|
+
try {
|
|
1088
|
+
assertDirectoryIdentity(pluginsIdentity, 'plugins');
|
|
1089
|
+
if (!existsSync(lockPath))
|
|
1090
|
+
return true;
|
|
1091
|
+
assertDirectoryIdentity(pluginsIdentity, 'plugins');
|
|
1092
|
+
if (readFileSync(lockPath, 'utf8') !== token)
|
|
1093
|
+
return false;
|
|
1094
|
+
assertDirectoryIdentity(pluginsIdentity, 'plugins');
|
|
1095
|
+
unlinkSync(lockPath);
|
|
1096
|
+
return true;
|
|
1097
|
+
}
|
|
1098
|
+
catch {
|
|
1099
|
+
return false;
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
function cleanupOwnedStaging(stagingIdentity, pluginsIdentity) {
|
|
1103
|
+
if (stagingIdentity === undefined)
|
|
1104
|
+
return true;
|
|
1105
|
+
try {
|
|
1106
|
+
assertDirectoryIdentity(pluginsIdentity, 'plugins');
|
|
1107
|
+
assertDirectoryIdentity(stagingIdentity, 'staging');
|
|
1108
|
+
rmSync(stagingIdentity.canonicalPath, { recursive: true, force: true });
|
|
1109
|
+
assertDirectoryIdentity(pluginsIdentity, 'plugins');
|
|
1110
|
+
return !existsSync(stagingIdentity.canonicalPath);
|
|
1111
|
+
}
|
|
1112
|
+
catch {
|
|
1113
|
+
return false;
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
export function executePluginTransaction(plan, hooks = {}) {
|
|
1117
|
+
const files = transactionFiles(plan);
|
|
1118
|
+
const directories = [...transactionDirectories(plan)].sort(byPathDepthThenName);
|
|
1119
|
+
const token = randomUUID();
|
|
1120
|
+
const pluginsRelative = 'plugins';
|
|
1121
|
+
let workspaceAuthorization;
|
|
1122
|
+
let pluginsPath;
|
|
1123
|
+
let pluginsAuthorization;
|
|
1124
|
+
let pluginsIdentity;
|
|
1125
|
+
try {
|
|
1126
|
+
workspaceAuthorization = authorizeExistingDirectory(plan.workspaceCanonicalPath);
|
|
1127
|
+
pluginsPath = resolveAuthorizedPath(workspaceAuthorization, pluginsRelative);
|
|
1128
|
+
if (!lstatSync(pluginsPath).isDirectory()) {
|
|
1129
|
+
return failure('failed', problem('PLUGINS_DIRECTORY_UNAVAILABLE', 'Marketplace 的 plugins 文件夹当前不可用'));
|
|
1130
|
+
}
|
|
1131
|
+
pluginsAuthorization = authorizeExistingDirectory(pluginsPath);
|
|
1132
|
+
pluginsIdentity = captureDirectoryIdentity(pluginsPath, 'plugins');
|
|
1133
|
+
}
|
|
1134
|
+
catch (error) {
|
|
1135
|
+
return failure('failed', problem('WORKSPACE_UNAVAILABLE', error instanceof Error ? error.message : 'Marketplace 文件夹当前不可用'));
|
|
1136
|
+
}
|
|
1137
|
+
const targetLeaf = plan.targetRelativePath.slice(`${pluginsRelative}/`.length);
|
|
1138
|
+
if (targetLeaf.length === 0 ||
|
|
1139
|
+
targetLeaf.includes('/') ||
|
|
1140
|
+
targetLeaf.includes('\\')) {
|
|
1141
|
+
return failure('failed', problem('PLAN_STALE', '插件目标不再符合已授权的目录范围'));
|
|
1142
|
+
}
|
|
1143
|
+
const targetPath = resolveAuthorizedPath(pluginsAuthorization, targetLeaf);
|
|
1144
|
+
if (targetPath !== plan.targetCanonicalPath) {
|
|
1145
|
+
return failure('stale', problem('PLAN_STALE', '插件目标在预览后发生变化;请重新预览'));
|
|
1146
|
+
}
|
|
1147
|
+
const lockPath = resolveAuthorizedPath(pluginsAuthorization, TRANSACTION_LOCK);
|
|
1148
|
+
let lockOwned = false;
|
|
1149
|
+
try {
|
|
1150
|
+
assertDirectoryIdentity(pluginsIdentity, 'plugins');
|
|
1151
|
+
const fd = openSync(lockPath, 'wx', 0o600);
|
|
1152
|
+
try {
|
|
1153
|
+
writeFileSync(fd, token, 'utf8');
|
|
1154
|
+
}
|
|
1155
|
+
finally {
|
|
1156
|
+
closeSync(fd);
|
|
1157
|
+
}
|
|
1158
|
+
lockOwned = true;
|
|
1159
|
+
assertDirectoryIdentity(pluginsIdentity, 'plugins');
|
|
1160
|
+
}
|
|
1161
|
+
catch {
|
|
1162
|
+
return failure('blocked', problem('TRANSACTION_BUSY', '另一个插件创建或导入操作正在提交;请稍后重试'));
|
|
1163
|
+
}
|
|
1164
|
+
let stagingRoot;
|
|
1165
|
+
let stagingIdentity;
|
|
1166
|
+
let stagingAuthorization;
|
|
1167
|
+
let targetRootCreated = false;
|
|
1168
|
+
let targetIdentity;
|
|
1169
|
+
let targetAuthorization;
|
|
1170
|
+
const createdFiles = [];
|
|
1171
|
+
const createdDirectories = [];
|
|
1172
|
+
const createdDirectorySet = new Set();
|
|
1173
|
+
const changedPaths = new Set();
|
|
1174
|
+
const finishCleanup = () => {
|
|
1175
|
+
const stagingClean = cleanupOwnedStaging(stagingIdentity, pluginsIdentity);
|
|
1176
|
+
const lockClean = !lockOwned || removeOwnedLock(lockPath, token, pluginsIdentity);
|
|
1177
|
+
if (lockClean)
|
|
1178
|
+
lockOwned = false;
|
|
1179
|
+
return stagingClean && lockClean;
|
|
1180
|
+
};
|
|
1181
|
+
const assertPluginRoot = () => {
|
|
1182
|
+
assertDirectoryIdentity(pluginsIdentity, 'plugins');
|
|
1183
|
+
};
|
|
1184
|
+
const assertStagingRoots = () => {
|
|
1185
|
+
assertPluginRoot();
|
|
1186
|
+
if (stagingIdentity === undefined) {
|
|
1187
|
+
throw new DirectoryIdentityChangedError('staging');
|
|
1188
|
+
}
|
|
1189
|
+
assertDirectoryIdentity(stagingIdentity, 'staging');
|
|
1190
|
+
};
|
|
1191
|
+
const assertTargetRoots = () => {
|
|
1192
|
+
assertPluginRoot();
|
|
1193
|
+
if (targetIdentity === undefined) {
|
|
1194
|
+
throw new DirectoryIdentityChangedError('target');
|
|
1195
|
+
}
|
|
1196
|
+
assertDirectoryIdentity(targetIdentity, 'target');
|
|
1197
|
+
};
|
|
1198
|
+
const rollback = () => {
|
|
1199
|
+
let rollbackComplete = true;
|
|
1200
|
+
for (const file of [...createdFiles].reverse()) {
|
|
1201
|
+
try {
|
|
1202
|
+
assertTargetRoots();
|
|
1203
|
+
}
|
|
1204
|
+
catch {
|
|
1205
|
+
rollbackComplete = false;
|
|
1206
|
+
break;
|
|
1207
|
+
}
|
|
1208
|
+
if (safeDigest(file.canonicalPath) !== file.digest) {
|
|
1209
|
+
rollbackComplete = false;
|
|
1210
|
+
continue;
|
|
1211
|
+
}
|
|
1212
|
+
try {
|
|
1213
|
+
unlinkSync(file.canonicalPath);
|
|
1214
|
+
changedPaths.delete(relativeChangedPath(plan, file.relativePath));
|
|
1215
|
+
}
|
|
1216
|
+
catch {
|
|
1217
|
+
rollbackComplete = false;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
for (const directory of [...createdDirectories].reverse()) {
|
|
1221
|
+
try {
|
|
1222
|
+
assertTargetRoots();
|
|
1223
|
+
rmdirSync(directory);
|
|
1224
|
+
changedPaths.delete(relativeChangedPath(plan, toPosixPath(relative(plan.targetCanonicalPath, directory))));
|
|
1225
|
+
}
|
|
1226
|
+
catch {
|
|
1227
|
+
// A non-empty directory may contain externally added content. It is
|
|
1228
|
+
// deliberately left in place rather than recursively removed.
|
|
1229
|
+
rollbackComplete = false;
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
if (targetRootCreated) {
|
|
1233
|
+
try {
|
|
1234
|
+
assertTargetRoots();
|
|
1235
|
+
rmdirSync(targetPath);
|
|
1236
|
+
changedPaths.delete(plan.targetRelativePath);
|
|
1237
|
+
}
|
|
1238
|
+
catch {
|
|
1239
|
+
// Only an empty root created by this transaction may be removed.
|
|
1240
|
+
rollbackComplete = false;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
return { rollbackComplete, changed: [...changedPaths].sort() };
|
|
1244
|
+
};
|
|
1245
|
+
try {
|
|
1246
|
+
const beforeStage = replan(plan);
|
|
1247
|
+
if (beforeStage.status !== 'planned') {
|
|
1248
|
+
const cleanupComplete = finishCleanup();
|
|
1249
|
+
return failure(beforeStage.status === 'blocked' ? 'blocked' : 'stale', beforeStage.problem, [], true, cleanupComplete);
|
|
1250
|
+
}
|
|
1251
|
+
if (beforeStage.plan.fingerprint !== plan.fingerprint) {
|
|
1252
|
+
const cleanupComplete = finishCleanup();
|
|
1253
|
+
return failure('stale', problem('PLAN_STALE', '创建或导入预览已过期;请重新检查后再确认'), [], true, cleanupComplete);
|
|
1254
|
+
}
|
|
1255
|
+
assertPluginRoot();
|
|
1256
|
+
stagingRoot = resolveAuthorizedPath(pluginsAuthorization, `${STAGING_PREFIX}${token}`);
|
|
1257
|
+
assertPluginRoot();
|
|
1258
|
+
mkdirSync(stagingRoot, { mode: 0o700 });
|
|
1259
|
+
stagingIdentity = captureDirectoryIdentity(stagingRoot, 'staging');
|
|
1260
|
+
stagingAuthorization = authorizeExistingDirectory(stagingRoot);
|
|
1261
|
+
for (const directory of directories) {
|
|
1262
|
+
assertStagingRoots();
|
|
1263
|
+
const stagedDirectory = resolveAuthorizedPath(stagingAuthorization, directory.relativePath);
|
|
1264
|
+
assertStagingRoots();
|
|
1265
|
+
mkdirSync(stagedDirectory, { recursive: true, mode: directory.mode });
|
|
1266
|
+
assertStagingRoots();
|
|
1267
|
+
chmodSync(stagedDirectory, directory.mode);
|
|
1268
|
+
}
|
|
1269
|
+
for (const file of files) {
|
|
1270
|
+
assertStagingRoots();
|
|
1271
|
+
const stagedPath = resolveAuthorizedPath(stagingAuthorization, file.relativePath);
|
|
1272
|
+
assertStagingRoots();
|
|
1273
|
+
mkdirSync(dirname(stagedPath), { recursive: true, mode: 0o700 });
|
|
1274
|
+
assertStagingRoots();
|
|
1275
|
+
writeFileSync(stagedPath, file.bytes, { flag: 'wx', mode: file.mode });
|
|
1276
|
+
assertStagingRoots();
|
|
1277
|
+
if (safeDigest(stagedPath) !== file.digest) {
|
|
1278
|
+
throw new Error(`staging byte verification failed: ${file.relativePath}`);
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
hooks.afterStaging?.(plan);
|
|
1282
|
+
// Import sources and target absence are checked again after the complete
|
|
1283
|
+
// staging tree exists and immediately before target reservation.
|
|
1284
|
+
const beforeCommit = replan(plan);
|
|
1285
|
+
if (beforeCommit.status !== 'planned' ||
|
|
1286
|
+
beforeCommit.plan.fingerprint !== plan.fingerprint) {
|
|
1287
|
+
const cleanupComplete = finishCleanup();
|
|
1288
|
+
const issue = beforeCommit.status === 'planned'
|
|
1289
|
+
? problem('SOURCE_CHANGED', '导入源或目标在预览后发生变化;请重新预览')
|
|
1290
|
+
: beforeCommit.problem;
|
|
1291
|
+
return failure(beforeCommit.status === 'blocked' ? 'blocked' : 'stale', issue, [], true, cleanupComplete);
|
|
1292
|
+
}
|
|
1293
|
+
hooks.beforeTargetReservation?.(plan);
|
|
1294
|
+
assertPluginRoot();
|
|
1295
|
+
mkdirSync(targetPath, { mode: 0o755 });
|
|
1296
|
+
targetRootCreated = true;
|
|
1297
|
+
changedPaths.add(plan.targetRelativePath);
|
|
1298
|
+
targetIdentity = captureDirectoryIdentity(targetPath, 'target');
|
|
1299
|
+
targetAuthorization = authorizeExistingDirectory(targetPath);
|
|
1300
|
+
for (const directory of directories) {
|
|
1301
|
+
ensureParentDirectories(targetAuthorization, directory.relativePath, createdDirectories, createdDirectorySet, assertTargetRoots);
|
|
1302
|
+
if (createdDirectorySet.has(directory.relativePath)) {
|
|
1303
|
+
assertTargetRoots();
|
|
1304
|
+
chmodSync(resolveAuthorizedPath(targetAuthorization, directory.relativePath), directory.mode);
|
|
1305
|
+
continue;
|
|
1306
|
+
}
|
|
1307
|
+
assertTargetRoots();
|
|
1308
|
+
const targetDirectory = resolveAuthorizedPath(targetAuthorization, directory.relativePath);
|
|
1309
|
+
assertTargetRoots();
|
|
1310
|
+
mkdirSync(targetDirectory, { mode: directory.mode });
|
|
1311
|
+
createdDirectorySet.add(directory.relativePath);
|
|
1312
|
+
createdDirectories.push(targetDirectory);
|
|
1313
|
+
changedPaths.add(relativeChangedPath(plan, directory.relativePath));
|
|
1314
|
+
}
|
|
1315
|
+
for (const file of files) {
|
|
1316
|
+
ensureParentDirectories(targetAuthorization, file.relativePath, createdDirectories, createdDirectorySet, assertTargetRoots);
|
|
1317
|
+
assertStagingRoots();
|
|
1318
|
+
const stagedPath = resolveAuthorizedPath(stagingAuthorization, file.relativePath);
|
|
1319
|
+
assertTargetRoots();
|
|
1320
|
+
const targetPath = resolveAuthorizedPath(targetAuthorization, file.relativePath);
|
|
1321
|
+
assertStagingRoots();
|
|
1322
|
+
assertTargetRoots();
|
|
1323
|
+
copyFileSync(stagedPath, targetPath, fsConstants.COPYFILE_EXCL);
|
|
1324
|
+
createdFiles.push({ ...file, canonicalPath: targetPath });
|
|
1325
|
+
changedPaths.add(relativeChangedPath(plan, file.relativePath));
|
|
1326
|
+
assertTargetRoots();
|
|
1327
|
+
chmodSync(targetPath, file.mode);
|
|
1328
|
+
assertTargetRoots();
|
|
1329
|
+
if (safeDigest(targetPath) !== file.digest) {
|
|
1330
|
+
throw new Error(`target byte verification failed: ${file.relativePath}`);
|
|
1331
|
+
}
|
|
1332
|
+
hooks.afterTargetFileCreated?.(file.relativePath, createdFiles.length);
|
|
1333
|
+
assertTargetRoots();
|
|
1334
|
+
}
|
|
1335
|
+
assertTargetRoots();
|
|
1336
|
+
const cleanupComplete = finishCleanup();
|
|
1337
|
+
if (!cleanupComplete) {
|
|
1338
|
+
return failure('failed', problem('TRANSACTION_FAILED', '插件已写入,但事务临时痕迹未能完全清理'), [...changedPaths].sort(), false, false);
|
|
1339
|
+
}
|
|
1340
|
+
return {
|
|
1341
|
+
status: 'created',
|
|
1342
|
+
pluginName: plan.summary.pluginName,
|
|
1343
|
+
written: files
|
|
1344
|
+
.map((file) => relativeChangedPath(plan, file.relativePath))
|
|
1345
|
+
.concat(directories.map((directory) => relativeChangedPath(plan, directory.relativePath)))
|
|
1346
|
+
.sort(),
|
|
1347
|
+
cleanupComplete: true,
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
catch (error) {
|
|
1351
|
+
const rolledBack = rollback();
|
|
1352
|
+
const cleanupComplete = finishCleanup();
|
|
1353
|
+
const identityChanged = error instanceof DirectoryIdentityChangedError;
|
|
1354
|
+
let targetConflict = false;
|
|
1355
|
+
if (!targetRootCreated && !identityChanged) {
|
|
1356
|
+
try {
|
|
1357
|
+
assertPluginRoot();
|
|
1358
|
+
targetConflict = existsSync(targetPath);
|
|
1359
|
+
}
|
|
1360
|
+
catch {
|
|
1361
|
+
targetConflict = false;
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
return failure(identityChanged ? 'stale' : targetConflict ? 'blocked' : 'failed', identityChanged
|
|
1365
|
+
? problem('PLAN_STALE', '插件目录身份在提交期间发生变化;已停止操作,请重新检查 workspace')
|
|
1366
|
+
: targetConflict
|
|
1367
|
+
? problem('TARGET_CONFLICT', `插件目录已存在: ${plan.targetRelativePath}`, plan.targetRelativePath)
|
|
1368
|
+
: problem('TRANSACTION_FAILED', error instanceof Error ? error.message : '插件事务提交失败'), rolledBack.changed, rolledBack.rollbackComplete, cleanupComplete);
|
|
1369
|
+
}
|
|
1370
|
+
finally {
|
|
1371
|
+
if (lockOwned)
|
|
1372
|
+
removeOwnedLock(lockPath, token, pluginsIdentity);
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
//# sourceMappingURL=plugin-lifecycle.js.map
|