flower-trellis 0.6.2 → 0.6.4
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/enhancements/MANIFEST.json +1 -1
- package/package.json +3 -3
- package/src/builtin-marketplaces/rd-guide.json +6 -3
- package/src/builtin-plugins/flower-plugin-author/plugin.json +6 -1
- package/src/builtin-plugins/flower-plugin-author/skills/flower-plugin-author/references/ci-and-review.md +2 -2
- package/src/commands/plugin-interactive.js +546 -38
- package/src/commands/plugin-remote.js +223 -26
- package/src/commands/plugin.js +46 -6
- package/src/commands/update.js +10 -2
- package/src/lib/skill-catalog.js +20 -1
- package/src/plugin/application-service.js +245 -37
- package/src/plugin/auth/credential-store.js +25 -3
- package/src/plugin/auth/gitlab-credential-resolver.js +234 -0
- package/src/plugin/auth/gitlab-oauth.js +26 -5
- package/src/plugin/authoring/scaffold.js +6 -1
- package/src/plugin/authoring/templates/rd-guide/CODEOWNERS +1 -1
- package/src/plugin/authoring/templates/rd-guide/gitlab-ci.yml +2 -2
- package/src/plugin/authoring/templates/rd-guide/verify-integration-review.mjs +1 -1
- package/src/plugin/content-selection.js +189 -0
- package/src/plugin/contracts.js +21 -2
- package/src/plugin/formats/adapters.js +27 -3
- package/src/plugin/formats/normalized-package.js +126 -14
- package/src/plugin/gitlab/rest-client.js +21 -0
- package/src/plugin/install/content-projector.js +19 -7
- package/src/plugin/resolver/dependency-resolver.js +64 -7
- package/src/plugin/runtime-errors.js +1 -0
- package/src/plugin/schemas/marketplace-manifest.js +4 -1
- package/src/plugin/schemas/plugin-manifest.js +55 -3
- package/src/plugin/schemas/project-files.js +18 -0
- package/src/plugin/schemas/shared.js +10 -0
- package/src/plugin/sources/github-provider.js +1 -1
- package/src/plugin/sources/gitlab-provider.js +377 -51
- package/src/plugin/sources/user-source-store.js +17 -1
|
@@ -15,18 +15,36 @@ function detection(values) {
|
|
|
15
15
|
kind: values.kind,
|
|
16
16
|
entryPath: values.entryPath,
|
|
17
17
|
pluginRoot: values.pluginRoot,
|
|
18
|
+
manifestPath: values.manifestPath,
|
|
18
19
|
displayName: values.displayName,
|
|
19
20
|
manifest: values.manifest || null,
|
|
20
21
|
};
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* 判断 manifest 声明的内容路径是否在指定根目录下存在。
|
|
26
|
+
*
|
|
27
|
+
* @param {string} root 检测根
|
|
28
|
+
* @param {object} manifest Flower manifest
|
|
29
|
+
* @returns {boolean} 是否存在声明内容
|
|
30
|
+
*/
|
|
31
|
+
function hasDeclaredContent(root, manifest) {
|
|
32
|
+
const content = manifest?.content || {};
|
|
33
|
+
const skillPaths = (content.skills || []).map((entry) => entry?.path).filter(Boolean);
|
|
34
|
+
const passivePaths = ["specs", "assets", "scripts", "tests"]
|
|
35
|
+
.flatMap((kind) => content[kind] || []);
|
|
36
|
+
return [...skillPaths, ...passivePaths].some((entry) => (
|
|
37
|
+
fs.existsSync(path.join(root, ...String(entry).split("/")))
|
|
38
|
+
));
|
|
39
|
+
}
|
|
40
|
+
|
|
23
41
|
/** Flower 原生格式 Adapter。 */
|
|
24
42
|
export const flowerFormatAdapter = Object.freeze({
|
|
25
43
|
format: "flower",
|
|
26
44
|
/** @param {string} root 检测根 @returns {object[]} 检测结果 */
|
|
27
45
|
detect(root) {
|
|
28
46
|
const results = [];
|
|
29
|
-
const marketplace = path.join(root, ".flower-
|
|
47
|
+
const marketplace = path.join(root, ".flower-plugin", "marketplace.json");
|
|
30
48
|
if (fs.existsSync(marketplace)) {
|
|
31
49
|
const manifest = readFormatJson(marketplace, "Flower Marketplace");
|
|
32
50
|
results.push(detection({
|
|
@@ -42,11 +60,16 @@ export const flowerFormatAdapter = Object.freeze({
|
|
|
42
60
|
const manifestFile = path.join(pluginRoot, "plugin.json");
|
|
43
61
|
if (!fs.existsSync(manifestFile)) continue;
|
|
44
62
|
const manifest = readFormatJson(manifestFile, "Flower Plugin");
|
|
63
|
+
const metadataRoot = path.join(root, ".flower-plugin");
|
|
64
|
+
const rootLevelMetadata = pluginRoot === metadataRoot &&
|
|
65
|
+
hasDeclaredContent(root, manifest) &&
|
|
66
|
+
!hasDeclaredContent(metadataRoot, manifest);
|
|
45
67
|
results.push(detection({
|
|
46
68
|
format: "flower",
|
|
47
69
|
kind: "plugin",
|
|
48
70
|
entryPath: relativeEntryPath(root, manifestFile),
|
|
49
|
-
pluginRoot,
|
|
71
|
+
pluginRoot: rootLevelMetadata ? root : pluginRoot,
|
|
72
|
+
manifestPath: rootLevelMetadata ? ".flower-plugin/plugin.json" : "plugin.json",
|
|
50
73
|
displayName: manifest.name || manifest.id || path.basename(pluginRoot),
|
|
51
74
|
manifest,
|
|
52
75
|
}));
|
|
@@ -58,6 +81,7 @@ export const flowerFormatAdapter = Object.freeze({
|
|
|
58
81
|
return normalizeFlowerPlugin({
|
|
59
82
|
...context,
|
|
60
83
|
pluginRoot: selected.pluginRoot,
|
|
84
|
+
manifestPath: selected.manifestPath,
|
|
61
85
|
});
|
|
62
86
|
},
|
|
63
87
|
});
|
|
@@ -159,7 +183,7 @@ export const skillOnlyFormatAdapter = Object.freeze({
|
|
|
159
183
|
/** @param {string} root 检测根 @returns {object[]} 检测结果 */
|
|
160
184
|
detect(root) {
|
|
161
185
|
const knownEntries = [
|
|
162
|
-
".flower-
|
|
186
|
+
".flower-plugin/marketplace.json",
|
|
163
187
|
".flower-plugin/plugin.json",
|
|
164
188
|
"plugin.json",
|
|
165
189
|
".agents/plugins/marketplace.json",
|
|
@@ -26,6 +26,105 @@ const OMITTED_COMPONENTS = Object.freeze([
|
|
|
26
26
|
["apps", ".app.json"],
|
|
27
27
|
]);
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* 列出原生 Flower manifest 声明的包内容路径。
|
|
31
|
+
*
|
|
32
|
+
* @param {import("../contracts.js").PluginManifest} manifest Flower manifest
|
|
33
|
+
* @returns {string[]} 需要进入规范化包的相对路径
|
|
34
|
+
*/
|
|
35
|
+
function declaredPackagePaths(manifest) {
|
|
36
|
+
const content = manifest.content || {};
|
|
37
|
+
const skills = (content.skills || []).map(({ path: skillPath }) => skillPath);
|
|
38
|
+
const passive = ["specs", "assets", "scripts", "tests"].flatMap((kind) => content[kind] || []);
|
|
39
|
+
const patches = [
|
|
40
|
+
manifest.patches?.catalog,
|
|
41
|
+
manifest.patches?.bundles,
|
|
42
|
+
].filter(Boolean);
|
|
43
|
+
return [...new Set([...skills, ...passive, ...patches])].sort(compareUtf8);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 复制 manifest 显式声明的文件或目录。
|
|
48
|
+
*
|
|
49
|
+
* @param {string} sourceRoot 源仓库根
|
|
50
|
+
* @param {string} targetRoot 规范化包根
|
|
51
|
+
* @param {string} relative POSIX 相对路径
|
|
52
|
+
* @returns {void}
|
|
53
|
+
*/
|
|
54
|
+
function copyDeclaredPath(sourceRoot, targetRoot, relative) {
|
|
55
|
+
const safeRelative = assertSafePosixRelativePath(relative, "Flower Plugin content 路径");
|
|
56
|
+
const source = path.join(sourceRoot, ...safeRelative.split("/"));
|
|
57
|
+
const target = path.join(targetRoot, ...safeRelative.split("/"));
|
|
58
|
+
let stat;
|
|
59
|
+
try {
|
|
60
|
+
stat = fs.lstatSync(source);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
throw new PluginRuntimeError(`Flower Plugin 声明内容不存在:${safeRelative}`, {
|
|
63
|
+
code: PLUGIN_RUNTIME_ERROR_CODES.FORMAT_UNSUPPORTED,
|
|
64
|
+
path: safeRelative,
|
|
65
|
+
cause: error,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (stat.isSymbolicLink()) {
|
|
69
|
+
throw new PluginRuntimeError(`Flower Plugin 声明内容不能是软链:${safeRelative}`, {
|
|
70
|
+
code: PLUGIN_RUNTIME_ERROR_CODES.FORMAT_UNSUPPORTED,
|
|
71
|
+
path: safeRelative,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
if (stat.isDirectory()) {
|
|
75
|
+
for (const file of listCanonicalTreeFiles(source)) {
|
|
76
|
+
const destination = path.join(target, ...file.path.split("/"));
|
|
77
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
78
|
+
fs.copyFileSync(file.absolutePath, destination);
|
|
79
|
+
}
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (!stat.isFile()) {
|
|
83
|
+
throw new PluginRuntimeError(`Flower Plugin 声明内容必须是普通文件或目录:${safeRelative}`, {
|
|
84
|
+
code: PLUGIN_RUNTIME_ERROR_CODES.FORMAT_UNSUPPORTED,
|
|
85
|
+
path: safeRelative,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
89
|
+
fs.copyFileSync(source, target);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 复制与声明内容匹配的平台覆盖。
|
|
94
|
+
*
|
|
95
|
+
* @param {string} sourceRoot 源仓库根
|
|
96
|
+
* @param {string} targetRoot 规范化包根
|
|
97
|
+
* @param {import("../contracts.js").PluginManifest} manifest Flower manifest
|
|
98
|
+
* @returns {void}
|
|
99
|
+
*/
|
|
100
|
+
function copyPlatformOverrides(sourceRoot, targetRoot, manifest) {
|
|
101
|
+
const platformsRoot = path.join(sourceRoot, "platforms");
|
|
102
|
+
if (!fs.existsSync(platformsRoot)) return;
|
|
103
|
+
for (const platform of fs.readdirSync(platformsRoot, { withFileTypes: true }).sort((left, right) => compareUtf8(left.name, right.name))) {
|
|
104
|
+
if (!platform.isDirectory()) continue;
|
|
105
|
+
for (const relative of declaredPackagePaths(manifest)) {
|
|
106
|
+
const override = `platforms/${platform.name}/${relative}`;
|
|
107
|
+
if (fs.existsSync(path.join(sourceRoot, ...override.split("/")))) {
|
|
108
|
+
copyDeclaredPath(sourceRoot, targetRoot, override);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 展开 Flower manifest 声明内容为兼容性报告条目。
|
|
116
|
+
*
|
|
117
|
+
* @param {import("../contracts.js").PluginManifest} manifest Flower manifest
|
|
118
|
+
* @returns {Array<{kind:string,path:string,name?:string}>} 内容条目
|
|
119
|
+
*/
|
|
120
|
+
function manifestContentItems(manifest) {
|
|
121
|
+
return Object.entries(manifest.content).flatMap(([kind, entries]) => (
|
|
122
|
+
kind === "skills"
|
|
123
|
+
? entries.map((entry) => ({ kind, name: entry.name, path: entry.path }))
|
|
124
|
+
: entries.map((entry) => ({ kind, path: entry }))
|
|
125
|
+
)).sort((left, right) => compareUtf8(left.path, right.path));
|
|
126
|
+
}
|
|
127
|
+
|
|
29
128
|
/**
|
|
30
129
|
* 生成外部 Plugin 缺省内部版本。
|
|
31
130
|
*
|
|
@@ -92,11 +191,15 @@ function copySkill(source, target, name, description) {
|
|
|
92
191
|
/**
|
|
93
192
|
* 复制并复核原生 Flower Plugin 包。
|
|
94
193
|
*
|
|
95
|
-
* @param {{pluginRoot:string,outputRoot:string}} options 规范化参数
|
|
194
|
+
* @param {{pluginRoot:string,outputRoot:string,manifestPath?:string}} options 规范化参数
|
|
96
195
|
* @returns {{root:string,manifest:import("../contracts.js").PluginManifest,integrity:string,compatibilityReport:object,externalVersion:string,description:string}} 标准包
|
|
97
196
|
*/
|
|
98
197
|
export function normalizeFlowerPlugin(options) {
|
|
99
198
|
const packageRoot = path.resolve(options.outputRoot);
|
|
199
|
+
const manifestPath = assertSafePosixRelativePath(
|
|
200
|
+
options.manifestPath || "plugin.json",
|
|
201
|
+
"Flower Plugin manifest 路径",
|
|
202
|
+
);
|
|
100
203
|
if (fs.existsSync(packageRoot)) {
|
|
101
204
|
throw new PluginRuntimeError(`规范化包目标已存在:${packageRoot}`, {
|
|
102
205
|
code: PLUGIN_RUNTIME_ERROR_CODES.CONTENT_CONFLICT,
|
|
@@ -105,14 +208,23 @@ export function normalizeFlowerPlugin(options) {
|
|
|
105
208
|
}
|
|
106
209
|
fs.mkdirSync(packageRoot, { recursive: true });
|
|
107
210
|
try {
|
|
108
|
-
|
|
109
|
-
const destination = path.join(packageRoot, ...file.path.split("/"));
|
|
110
|
-
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
111
|
-
fs.copyFileSync(file.absolutePath, destination);
|
|
112
|
-
}
|
|
211
|
+
const raw = fs.readFileSync(path.join(options.pluginRoot, ...manifestPath.split("/")), "utf8");
|
|
113
212
|
const manifest = validatePluginManifest(JSON.parse(
|
|
114
|
-
|
|
213
|
+
raw,
|
|
115
214
|
));
|
|
215
|
+
if (manifestPath === "plugin.json") {
|
|
216
|
+
for (const file of listCanonicalTreeFiles(options.pluginRoot)) {
|
|
217
|
+
const destination = path.join(packageRoot, ...file.path.split("/"));
|
|
218
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
219
|
+
fs.copyFileSync(file.absolutePath, destination);
|
|
220
|
+
}
|
|
221
|
+
} else {
|
|
222
|
+
fs.writeFileSync(path.join(packageRoot, "plugin.json"), raw);
|
|
223
|
+
for (const relative of declaredPackagePaths(manifest)) {
|
|
224
|
+
copyDeclaredPath(options.pluginRoot, packageRoot, relative);
|
|
225
|
+
}
|
|
226
|
+
copyPlatformOverrides(options.pluginRoot, packageRoot, manifest);
|
|
227
|
+
}
|
|
116
228
|
return {
|
|
117
229
|
root: packageRoot,
|
|
118
230
|
manifest,
|
|
@@ -120,9 +232,7 @@ export function normalizeFlowerPlugin(options) {
|
|
|
120
232
|
compatibilityReport: {
|
|
121
233
|
status: "compatible",
|
|
122
234
|
format: "flower",
|
|
123
|
-
imported:
|
|
124
|
-
paths.map((value) => ({ kind, path: value }))
|
|
125
|
-
)).sort((left, right) => compareUtf8(left.path, right.path)),
|
|
235
|
+
imported: manifestContentItems(manifest),
|
|
126
236
|
omitted: [],
|
|
127
237
|
diagnostics: [],
|
|
128
238
|
},
|
|
@@ -206,12 +316,12 @@ export function normalizeExternalPlugin(options) {
|
|
|
206
316
|
if (!fs.existsSync(path.join(source, "SKILL.md"))) continue;
|
|
207
317
|
const name = registerName(directory, "Skill");
|
|
208
318
|
copySkill(source, path.join(packageRoot, "skills", name), name, `${pluginName} 的 ${name} 工作流`);
|
|
209
|
-
imported.push({ kind: "skills", path: `skills/${name}` });
|
|
319
|
+
imported.push({ kind: "skills", name, path: `skills/${name}` });
|
|
210
320
|
}
|
|
211
321
|
} else if (fs.existsSync(path.join(root, "SKILL.md"))) {
|
|
212
322
|
const name = registerName(path.basename(root), "Skill");
|
|
213
323
|
copySkill(root, path.join(packageRoot, "skills", name), name, `${pluginName} 的 ${name} 工作流`);
|
|
214
|
-
imported.push({ kind: "skills", path: `skills/${name}` });
|
|
324
|
+
imported.push({ kind: "skills", name, path: `skills/${name}` });
|
|
215
325
|
}
|
|
216
326
|
}
|
|
217
327
|
|
|
@@ -226,7 +336,7 @@ export function normalizeExternalPlugin(options) {
|
|
|
226
336
|
path.join(packageRoot, "skills", name, "SKILL.md"),
|
|
227
337
|
normalizedSkillContent(content, name, `${pluginName} 的 ${name} 命令工作流`),
|
|
228
338
|
);
|
|
229
|
-
imported.push({ kind: "commands", path: `skills/${name}` });
|
|
339
|
+
imported.push({ kind: "commands", name, path: `skills/${name}` });
|
|
230
340
|
diagnostics.push({
|
|
231
341
|
code: "external.command-converted",
|
|
232
342
|
path: `commands/${file}`,
|
|
@@ -260,7 +370,9 @@ export function normalizeExternalPlugin(options) {
|
|
|
260
370
|
dependencies: {},
|
|
261
371
|
capabilities: { profile: "standard", required: ["content.skills"] },
|
|
262
372
|
content: {
|
|
263
|
-
skills: imported
|
|
373
|
+
skills: imported
|
|
374
|
+
.map(({ name, path: value }) => ({ name, path: value, version }))
|
|
375
|
+
.sort((left, right) => compareUtf8(left.name, right.name)),
|
|
264
376
|
},
|
|
265
377
|
});
|
|
266
378
|
fs.writeFileSync(path.join(packageRoot, "plugin.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
@@ -216,6 +216,18 @@ export class GitLabRestClient {
|
|
|
216
216
|
{ status: response.status, endpoint: pathname, locationOrigin },
|
|
217
217
|
);
|
|
218
218
|
}
|
|
219
|
+
if (response.status === 401) {
|
|
220
|
+
throw this.#authError(`GitLab REST 认证失败,请重新登录:${this.source.id}`, PLUGIN_RUNTIME_ERROR_CODES.AUTH_REQUIRED, {
|
|
221
|
+
status: response.status,
|
|
222
|
+
endpoint: pathname,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
if (response.status === 403) {
|
|
226
|
+
throw this.#authError(`GitLab REST scope 不足或访问被拒绝:${this.source.id}`, PLUGIN_RUNTIME_ERROR_CODES.AUTH_SCOPE_INVALID, {
|
|
227
|
+
status: response.status,
|
|
228
|
+
endpoint: pathname,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
219
231
|
throw this.#remoteError(`GitLab REST 请求失败:${response.status}`, this.source.id, undefined, {
|
|
220
232
|
status: response.status,
|
|
221
233
|
endpoint: pathname,
|
|
@@ -240,4 +252,13 @@ export class GitLabRestClient {
|
|
|
240
252
|
details,
|
|
241
253
|
});
|
|
242
254
|
}
|
|
255
|
+
|
|
256
|
+
/** @param {string} message @param {string} code @param {object} details @returns {PluginRuntimeError} */
|
|
257
|
+
#authError(message, code, details) {
|
|
258
|
+
return new PluginRuntimeError(message, {
|
|
259
|
+
code,
|
|
260
|
+
path: this.source.id,
|
|
261
|
+
details,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
243
264
|
}
|
|
@@ -9,6 +9,10 @@ import {
|
|
|
9
9
|
import { assertSafePosixRelativePath } from "../schemas/shared.js";
|
|
10
10
|
import { compareUtf8 } from "../stable-order.js";
|
|
11
11
|
import { isRuntimeBuiltinProviderTrusted } from "../runtime-extensions.js";
|
|
12
|
+
import {
|
|
13
|
+
normalizeContentSelection,
|
|
14
|
+
selectContentSkillEntries,
|
|
15
|
+
} from "../content-selection.js";
|
|
12
16
|
import { hashContent, hashFileIfExists } from "./content-hash.js";
|
|
13
17
|
|
|
14
18
|
const CONTENT_KINDS = ["skills", "specs", "assets", "scripts", "tests"];
|
|
@@ -99,10 +103,11 @@ function resolvePlatformContent(packageRoot, platform, entry, relative, canonica
|
|
|
99
103
|
* @param {string} kind 内容类型
|
|
100
104
|
* @param {string} entry manifest 内容路径
|
|
101
105
|
* @param {string|null} skillRoot 平台 Skill root
|
|
106
|
+
* @param {string} [entryName] 内容目标名称
|
|
102
107
|
* @returns {string} 项目内目标前缀
|
|
103
108
|
*/
|
|
104
|
-
function targetPrefix(owner, kind, entry, skillRoot) {
|
|
105
|
-
const name =
|
|
109
|
+
function targetPrefix(owner, kind, entry, skillRoot, entryName = path.posix.basename(entry)) {
|
|
110
|
+
const name = entryName;
|
|
106
111
|
if (kind === "skills") return `${skillRoot}/${name}`;
|
|
107
112
|
return `.flower/content/${owner}/${kind}/${name}`;
|
|
108
113
|
}
|
|
@@ -146,6 +151,7 @@ export function projectPluginContent(options) {
|
|
|
146
151
|
const candidate = selectedById.get(resolved.id);
|
|
147
152
|
if (!candidate) throw new Error(`Resolved Plugin 缺少候选:${resolved.id}`);
|
|
148
153
|
const pluginPackage = options.registry.readPackage(candidate);
|
|
154
|
+
const contentSelection = normalizeContentSelection(resolved.contentSelection);
|
|
149
155
|
const provider = typeof options.registry.get === "function"
|
|
150
156
|
? options.registry.get(candidate.source.id)
|
|
151
157
|
: null;
|
|
@@ -184,11 +190,16 @@ export function projectPluginContent(options) {
|
|
|
184
190
|
}
|
|
185
191
|
const paths = new Map();
|
|
186
192
|
for (const kind of CONTENT_KINDS) {
|
|
187
|
-
|
|
188
|
-
|
|
193
|
+
const entries = kind === "skills"
|
|
194
|
+
? selectContentSkillEntries(pluginPackage.manifest.content.skills || [], contentSelection, resolved.id)
|
|
195
|
+
: [...(pluginPackage.manifest.content[kind] || [])].sort(compareUtf8);
|
|
196
|
+
for (const entry of entries) {
|
|
197
|
+
const contentPath = kind === "skills" ? entry.path : entry;
|
|
198
|
+
const contentName = kind === "skills" ? entry.name : undefined;
|
|
199
|
+
const expanded = expandContentEntry(pluginPackage.root, contentPath);
|
|
189
200
|
const targets = kind === "skills" ? options.platformSelection.targets : [{ root: null }];
|
|
190
201
|
for (const platformTarget of targets) {
|
|
191
|
-
const prefix = targetPrefix(resolved.id, kind,
|
|
202
|
+
const prefix = targetPrefix(resolved.id, kind, contentPath, platformTarget.root, contentName);
|
|
192
203
|
if (kind !== "skills") {
|
|
193
204
|
addDirectoryClaims(directoryClaims, resolved.id, `.flower/content/${resolved.id}`, "");
|
|
194
205
|
addDirectoryClaims(directoryClaims, resolved.id, `.flower/content/${resolved.id}/${kind}`, "");
|
|
@@ -212,9 +223,9 @@ export function projectPluginContent(options) {
|
|
|
212
223
|
const canonical = fs.readFileSync(file.absolutePath);
|
|
213
224
|
const platformContents = kind === "skills"
|
|
214
225
|
? platformTarget.platforms.map((platform) => (
|
|
215
|
-
resolvePlatformContent(pluginPackage.root, platform,
|
|
226
|
+
resolvePlatformContent(pluginPackage.root, platform, contentPath, file.relative, canonical)
|
|
216
227
|
))
|
|
217
|
-
: [{ content: canonical, source: `${
|
|
228
|
+
: [{ content: canonical, source: `${contentPath}${file.relative ? `/${file.relative}` : ""}` }];
|
|
218
229
|
const contentHashes = new Set(platformContents.map(({ content }) => hashContent(content)));
|
|
219
230
|
if (contentHashes.size > 1) {
|
|
220
231
|
throw new PluginRuntimeError(`共享 Skill root 的 platform override 内容不一致:${target}`, {
|
|
@@ -257,6 +268,7 @@ export function projectPluginContent(options) {
|
|
|
257
268
|
id: resolved.id,
|
|
258
269
|
version: resolved.version,
|
|
259
270
|
platforms: [...options.platformSelection.platforms],
|
|
271
|
+
...(contentSelection ? { contentSelection } : {}),
|
|
260
272
|
paths: [...paths.values()].sort((left, right) => compareUtf8(left.path, right.path)),
|
|
261
273
|
patches: [],
|
|
262
274
|
});
|
|
@@ -3,7 +3,9 @@ import {
|
|
|
3
3
|
PLUGIN_RUNTIME_ERROR_CODES,
|
|
4
4
|
PluginRuntimeError,
|
|
5
5
|
} from "../runtime-errors.js";
|
|
6
|
+
import { parseCanonicalPluginId } from "../schemas/shared.js";
|
|
6
7
|
import { compareUtf8 } from "../stable-order.js";
|
|
8
|
+
import { normalizeContentSelection } from "../content-selection.js";
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* 比较候选的稳定优先级。
|
|
@@ -40,6 +42,40 @@ function defaultCapabilityGrant(request) {
|
|
|
40
42
|
};
|
|
41
43
|
}
|
|
42
44
|
|
|
45
|
+
/**
|
|
46
|
+
* 从旧 lock 合成只参与依赖求解的冻结候选。
|
|
47
|
+
*
|
|
48
|
+
* 冻结候选不代表本地仍有可读取的 Plugin 包,因此 content 只放合法占位值;调用方必须在
|
|
49
|
+
* 内容投影前剥离该候选,并原样复用旧 lock/state。
|
|
50
|
+
*
|
|
51
|
+
* @param {import("../contracts.js").ResolvedPlugin} locked 已锁定 Plugin
|
|
52
|
+
* @returns {import("../contracts.js").PluginCandidate} 冻结候选
|
|
53
|
+
*/
|
|
54
|
+
function createPreservedCandidate(locked) {
|
|
55
|
+
const { pluginId } = parseCanonicalPluginId(locked.id);
|
|
56
|
+
return {
|
|
57
|
+
id: locked.id,
|
|
58
|
+
version: locked.version,
|
|
59
|
+
source: structuredClone(locked.source),
|
|
60
|
+
commit: locked.commit,
|
|
61
|
+
integrity: locked.integrity,
|
|
62
|
+
manifest: {
|
|
63
|
+
schemaVersion: 1,
|
|
64
|
+
id: pluginId,
|
|
65
|
+
name: locked.id,
|
|
66
|
+
version: locked.version,
|
|
67
|
+
compatibility: structuredClone(locked.compatibility),
|
|
68
|
+
dependencies: structuredClone(locked.dependencies),
|
|
69
|
+
capabilities: {
|
|
70
|
+
profile: locked.capabilities.profile,
|
|
71
|
+
required: [...locked.capabilities.granted],
|
|
72
|
+
optional: [...locked.capabilities.denied],
|
|
73
|
+
},
|
|
74
|
+
content: { skills: [] },
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
43
79
|
/**
|
|
44
80
|
* 深复制约束映射。
|
|
45
81
|
*
|
|
@@ -161,7 +197,7 @@ function stableTopologicalOrder(roots, selected) {
|
|
|
161
197
|
*
|
|
162
198
|
* @param {import("../contracts.js").ProjectPluginDeclaration[]} declarations 直接声明
|
|
163
199
|
* @param {{listCandidates:(id:string)=>import("../contracts.js").PluginCandidate[]}} registry Source Registry
|
|
164
|
-
* @param {{lockedPlugins?:import("../contracts.js").ResolvedPlugin[],update?:string[]|"all",grantCapabilities?:(request:import("../contracts.js").CapabilityRequest,candidate:import("../contracts.js").PluginCandidate)=>import("../contracts.js").CapabilityGrant}} [options] 解析选项
|
|
200
|
+
* @param {{lockedPlugins?:import("../contracts.js").ResolvedPlugin[],update?:string[]|"all",preserveIds?:string[],grantCapabilities?:(request:import("../contracts.js").CapabilityRequest,candidate:import("../contracts.js").PluginCandidate)=>import("../contracts.js").CapabilityGrant}} [options] 解析选项
|
|
165
201
|
* @returns {{graph:import("../contracts.js").ResolvedGraph,selected:import("../contracts.js").PluginCandidate[],orphans:string[],constraints:Record<string,Array<{range:string,from:string}>>}} 解析结果
|
|
166
202
|
*/
|
|
167
203
|
export function resolvePluginGraph(declarations, registry, options = {}) {
|
|
@@ -171,8 +207,19 @@ export function resolvePluginGraph(declarations, registry, options = {}) {
|
|
|
171
207
|
const updateIds = options.update === "all"
|
|
172
208
|
? new Set(lockedPlugins.map(({ id }) => id))
|
|
173
209
|
: new Set(options.update || []);
|
|
210
|
+
const preservedIds = new Set(options.preserveIds || []);
|
|
174
211
|
const grantCapabilities = options.grantCapabilities || defaultCapabilityGrant;
|
|
175
212
|
const initialConstraints = new Map();
|
|
213
|
+
const rootDeclarations = new Map(declarations.map((declaration) => [declaration.id, declaration]));
|
|
214
|
+
|
|
215
|
+
for (const id of [...preservedIds].sort(compareUtf8)) {
|
|
216
|
+
if (!lockedById.has(id)) {
|
|
217
|
+
throw new PluginRuntimeError(`冻结 Plugin 缺少既有 lock:${id}`, {
|
|
218
|
+
code: PLUGIN_RUNTIME_ERROR_CODES.TARGET_DRIFT,
|
|
219
|
+
path: id,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
176
223
|
|
|
177
224
|
for (const declaration of [...declarations].sort((left, right) => compareUtf8(left.id, right.id))) {
|
|
178
225
|
const entries = initialConstraints.get(declaration.id) || [];
|
|
@@ -199,11 +246,15 @@ export function resolvePluginGraph(declarations, registry, options = {}) {
|
|
|
199
246
|
if (!nextId) return { selected, constraints };
|
|
200
247
|
|
|
201
248
|
let candidates;
|
|
202
|
-
|
|
203
|
-
candidates =
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
|
|
249
|
+
if (preservedIds.has(nextId)) {
|
|
250
|
+
candidates = [createPreservedCandidate(lockedById.get(nextId))];
|
|
251
|
+
} else {
|
|
252
|
+
try {
|
|
253
|
+
candidates = registry.listCandidates(nextId);
|
|
254
|
+
} catch (error) {
|
|
255
|
+
if (error?.code !== PLUGIN_RUNTIME_ERROR_CODES.SOURCE_NOT_FOUND) throw error;
|
|
256
|
+
candidates = [];
|
|
257
|
+
}
|
|
207
258
|
}
|
|
208
259
|
if (candidates.length === 0) {
|
|
209
260
|
throw new PluginRuntimeError(`找不到 Plugin 依赖:${nextId}`, {
|
|
@@ -279,6 +330,9 @@ export function resolvePluginGraph(declarations, registry, options = {}) {
|
|
|
279
330
|
const order = stableTopologicalOrder(roots, selected);
|
|
280
331
|
const resolvedPlugins = order.map((id) => {
|
|
281
332
|
const candidate = selected.get(id);
|
|
333
|
+
const contentSelection = rootDeclarations.has(id)
|
|
334
|
+
? normalizeContentSelection(rootDeclarations.get(id).contentSelection)
|
|
335
|
+
: undefined;
|
|
282
336
|
const dependencies = Object.fromEntries(
|
|
283
337
|
Object.keys(candidate.manifest.dependencies || {}).sort(compareUtf8).map((dependency) => [
|
|
284
338
|
dependency,
|
|
@@ -293,7 +347,10 @@ export function resolvePluginGraph(declarations, registry, options = {}) {
|
|
|
293
347
|
integrity: candidate.integrity,
|
|
294
348
|
dependencies,
|
|
295
349
|
compatibility: candidate.manifest.compatibility,
|
|
296
|
-
capabilities:
|
|
350
|
+
capabilities: preservedIds.has(id)
|
|
351
|
+
? structuredClone(lockedById.get(id).capabilities)
|
|
352
|
+
: grantCapabilities(candidate.manifest.capabilities, candidate),
|
|
353
|
+
...(contentSelection ? { contentSelection } : {}),
|
|
297
354
|
};
|
|
298
355
|
});
|
|
299
356
|
const graph = { roots, plugins: resolvedPlugins };
|
|
@@ -20,6 +20,7 @@ export const PLUGIN_RUNTIME_ERROR_CODES = Object.freeze({
|
|
|
20
20
|
DEPENDENCY_CYCLE: "PLUGIN_DEPENDENCY_CYCLE",
|
|
21
21
|
PLATFORM_SELECTION_REQUIRED: "PLUGIN_PLATFORM_SELECTION_REQUIRED",
|
|
22
22
|
PLATFORM_UNKNOWN: "PLUGIN_PLATFORM_UNKNOWN",
|
|
23
|
+
CONTENT_SELECTION_INVALID: "PLUGIN_CONTENT_SELECTION_INVALID",
|
|
23
24
|
CONTENT_CONFLICT: "PLUGIN_CONTENT_CONFLICT",
|
|
24
25
|
TARGET_DRIFT: "PLUGIN_TARGET_DRIFT",
|
|
25
26
|
TRANSACTION_FAILED: "PLUGIN_TRANSACTION_FAILED",
|
|
@@ -6,10 +6,11 @@ const sourceSchema = {
|
|
|
6
6
|
{
|
|
7
7
|
type: "object",
|
|
8
8
|
additionalProperties: false,
|
|
9
|
-
required: ["type"
|
|
9
|
+
required: ["type"],
|
|
10
10
|
properties: {
|
|
11
11
|
type: { const: "path" },
|
|
12
12
|
path: SAFE_PATH_SCHEMA,
|
|
13
|
+
manifestPath: SAFE_PATH_SCHEMA,
|
|
13
14
|
},
|
|
14
15
|
},
|
|
15
16
|
{
|
|
@@ -20,6 +21,7 @@ const sourceSchema = {
|
|
|
20
21
|
type: { const: "gitlab" },
|
|
21
22
|
project: { type: "string", format: "gitlab-project" },
|
|
22
23
|
subdir: SAFE_PATH_SCHEMA,
|
|
24
|
+
manifestPath: SAFE_PATH_SCHEMA,
|
|
23
25
|
},
|
|
24
26
|
},
|
|
25
27
|
{
|
|
@@ -30,6 +32,7 @@ const sourceSchema = {
|
|
|
30
32
|
type: { const: "github" },
|
|
31
33
|
repository: { type: "string", format: "github-repository" },
|
|
32
34
|
subdir: SAFE_PATH_SCHEMA,
|
|
35
|
+
manifestPath: SAFE_PATH_SCHEMA,
|
|
33
36
|
},
|
|
34
37
|
},
|
|
35
38
|
],
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
CAPABILITY_NAME_SCHEMA,
|
|
3
|
+
CONTENT_SKILL_NAME_SCHEMA,
|
|
3
4
|
PLUGIN_SCHEMA_VERSION,
|
|
4
5
|
SAFE_PATH_SCHEMA,
|
|
5
6
|
} from "./shared.js";
|
|
6
|
-
import { createSchemaValidator } from "./validator.js";
|
|
7
|
+
import { createSchemaValidator, schemaIssue } from "./validator.js";
|
|
7
8
|
|
|
8
9
|
const stringList = {
|
|
9
10
|
type: "array",
|
|
@@ -11,6 +12,21 @@ const stringList = {
|
|
|
11
12
|
uniqueItems: true,
|
|
12
13
|
};
|
|
13
14
|
|
|
15
|
+
const skillEntryList = {
|
|
16
|
+
type: "array",
|
|
17
|
+
items: {
|
|
18
|
+
type: "object",
|
|
19
|
+
additionalProperties: false,
|
|
20
|
+
required: ["name", "path", "version"],
|
|
21
|
+
properties: {
|
|
22
|
+
name: CONTENT_SKILL_NAME_SCHEMA,
|
|
23
|
+
path: SAFE_PATH_SCHEMA,
|
|
24
|
+
version: { type: "string", format: "semver" },
|
|
25
|
+
description: { type: "string", minLength: 1 },
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
14
30
|
/** Flower Plugin Manifest v1 JSON Schema。 */
|
|
15
31
|
export const PLUGIN_MANIFEST_SCHEMA = Object.freeze({
|
|
16
32
|
$id: "https://flower-trellis.local/schema/plugin-manifest-v1.json",
|
|
@@ -59,7 +75,7 @@ export const PLUGIN_MANIFEST_SCHEMA = Object.freeze({
|
|
|
59
75
|
additionalProperties: false,
|
|
60
76
|
minProperties: 1,
|
|
61
77
|
properties: {
|
|
62
|
-
skills:
|
|
78
|
+
skills: skillEntryList,
|
|
63
79
|
specs: stringList,
|
|
64
80
|
assets: stringList,
|
|
65
81
|
scripts: stringList,
|
|
@@ -78,7 +94,43 @@ export const PLUGIN_MANIFEST_SCHEMA = Object.freeze({
|
|
|
78
94
|
},
|
|
79
95
|
});
|
|
80
96
|
|
|
81
|
-
|
|
97
|
+
/**
|
|
98
|
+
* 校验 manifest 中 Skill 条目的唯一性。
|
|
99
|
+
*
|
|
100
|
+
* @param {unknown} value 已通过结构校验的 manifest
|
|
101
|
+
* @returns {Array<{code:string,path:string,message:string}>} 语义 issue
|
|
102
|
+
*/
|
|
103
|
+
function pluginManifestIssues(value) {
|
|
104
|
+
const manifest = /** @type {{content?:{skills?:Array<{name:string,path:string}>}}} */ (value);
|
|
105
|
+
const issues = [];
|
|
106
|
+
const names = new Set();
|
|
107
|
+
const paths = new Set();
|
|
108
|
+
for (const [index, skill] of (manifest.content?.skills || []).entries()) {
|
|
109
|
+
if (names.has(skill.name)) {
|
|
110
|
+
issues.push(schemaIssue(
|
|
111
|
+
"manifest.duplicate-skill-name",
|
|
112
|
+
`/content/skills/${index}/name`,
|
|
113
|
+
`Skill 名称重复:${skill.name}`,
|
|
114
|
+
));
|
|
115
|
+
}
|
|
116
|
+
names.add(skill.name);
|
|
117
|
+
if (paths.has(skill.path)) {
|
|
118
|
+
issues.push(schemaIssue(
|
|
119
|
+
"manifest.duplicate-skill-path",
|
|
120
|
+
`/content/skills/${index}/path`,
|
|
121
|
+
`Skill 路径重复:${skill.path}`,
|
|
122
|
+
));
|
|
123
|
+
}
|
|
124
|
+
paths.add(skill.path);
|
|
125
|
+
}
|
|
126
|
+
return issues;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const validate = createSchemaValidator(
|
|
130
|
+
PLUGIN_MANIFEST_SCHEMA,
|
|
131
|
+
"Flower Plugin manifest",
|
|
132
|
+
pluginManifestIssues,
|
|
133
|
+
);
|
|
82
134
|
|
|
83
135
|
/**
|
|
84
136
|
* 校验 Flower Plugin Manifest v1。
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
CAPABILITY_NAME_SCHEMA,
|
|
3
|
+
CONTENT_SKILL_NAME_SCHEMA,
|
|
3
4
|
PLUGIN_SCHEMA_VERSION,
|
|
4
5
|
SAFE_PATH_SCHEMA,
|
|
5
6
|
parseCanonicalPluginId,
|
|
@@ -69,6 +70,20 @@ const capabilityGrantSchema = {
|
|
|
69
70
|
},
|
|
70
71
|
};
|
|
71
72
|
|
|
73
|
+
const contentSelectionSchema = {
|
|
74
|
+
type: "object",
|
|
75
|
+
additionalProperties: false,
|
|
76
|
+
required: ["skills"],
|
|
77
|
+
properties: {
|
|
78
|
+
skills: {
|
|
79
|
+
type: "array",
|
|
80
|
+
minItems: 1,
|
|
81
|
+
items: CONTENT_SKILL_NAME_SCHEMA,
|
|
82
|
+
uniqueItems: true,
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
72
87
|
/** `.flower/plugins.json` v1 JSON Schema。 */
|
|
73
88
|
export const PLUGINS_FILE_SCHEMA = Object.freeze({
|
|
74
89
|
$id: "https://flower-trellis.local/schema/plugins-file-v1.json",
|
|
@@ -92,6 +107,7 @@ export const PLUGINS_FILE_SCHEMA = Object.freeze({
|
|
|
92
107
|
items: { type: "string", format: "plugin-id" },
|
|
93
108
|
uniqueItems: true,
|
|
94
109
|
},
|
|
110
|
+
contentSelection: contentSelectionSchema,
|
|
95
111
|
},
|
|
96
112
|
},
|
|
97
113
|
},
|
|
@@ -147,6 +163,7 @@ export const PLUGIN_LOCK_SCHEMA = Object.freeze({
|
|
|
147
163
|
},
|
|
148
164
|
},
|
|
149
165
|
capabilities: capabilityGrantSchema,
|
|
166
|
+
contentSelection: contentSelectionSchema,
|
|
150
167
|
},
|
|
151
168
|
},
|
|
152
169
|
},
|
|
@@ -176,6 +193,7 @@ export const PLUGIN_STATE_SCHEMA = Object.freeze({
|
|
|
176
193
|
items: { type: "string", format: "plugin-id" },
|
|
177
194
|
uniqueItems: true,
|
|
178
195
|
},
|
|
196
|
+
contentSelection: contentSelectionSchema,
|
|
179
197
|
paths: {
|
|
180
198
|
type: "array",
|
|
181
199
|
items: {
|