flower-trellis 0.6.2 → 0.6.3
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/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 +156 -10
- package/src/commands/plugin.js +35 -4
- package/src/lib/skill-catalog.js +20 -1
- package/src/plugin/application-service.js +81 -7
- 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 +6 -0
- 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
|
});
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
PluginRuntimeError,
|
|
5
5
|
} from "../runtime-errors.js";
|
|
6
6
|
import { compareUtf8 } from "../stable-order.js";
|
|
7
|
+
import { normalizeContentSelection } from "../content-selection.js";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* 比较候选的稳定优先级。
|
|
@@ -173,6 +174,7 @@ export function resolvePluginGraph(declarations, registry, options = {}) {
|
|
|
173
174
|
: new Set(options.update || []);
|
|
174
175
|
const grantCapabilities = options.grantCapabilities || defaultCapabilityGrant;
|
|
175
176
|
const initialConstraints = new Map();
|
|
177
|
+
const rootDeclarations = new Map(declarations.map((declaration) => [declaration.id, declaration]));
|
|
176
178
|
|
|
177
179
|
for (const declaration of [...declarations].sort((left, right) => compareUtf8(left.id, right.id))) {
|
|
178
180
|
const entries = initialConstraints.get(declaration.id) || [];
|
|
@@ -279,6 +281,9 @@ export function resolvePluginGraph(declarations, registry, options = {}) {
|
|
|
279
281
|
const order = stableTopologicalOrder(roots, selected);
|
|
280
282
|
const resolvedPlugins = order.map((id) => {
|
|
281
283
|
const candidate = selected.get(id);
|
|
284
|
+
const contentSelection = rootDeclarations.has(id)
|
|
285
|
+
? normalizeContentSelection(rootDeclarations.get(id).contentSelection)
|
|
286
|
+
: undefined;
|
|
282
287
|
const dependencies = Object.fromEntries(
|
|
283
288
|
Object.keys(candidate.manifest.dependencies || {}).sort(compareUtf8).map((dependency) => [
|
|
284
289
|
dependency,
|
|
@@ -294,6 +299,7 @@ export function resolvePluginGraph(declarations, registry, options = {}) {
|
|
|
294
299
|
dependencies,
|
|
295
300
|
compatibility: candidate.manifest.compatibility,
|
|
296
301
|
capabilities: grantCapabilities(candidate.manifest.capabilities, candidate),
|
|
302
|
+
...(contentSelection ? { contentSelection } : {}),
|
|
297
303
|
};
|
|
298
304
|
});
|
|
299
305
|
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: {
|
|
@@ -12,6 +12,9 @@ export const PLUGIN_ID_PATTERN = "^[a-z0-9]+(?:-[a-z0-9]+)*$";
|
|
|
12
12
|
export const CANONICAL_PLUGIN_ID_PATTERN =
|
|
13
13
|
"^[a-z0-9]+(?:-[a-z0-9]+)*/[a-z0-9]+(?:-[a-z0-9]+)*$";
|
|
14
14
|
|
|
15
|
+
/** contentSelection.skills 与 manifest Skill 条目名称的正则源。 */
|
|
16
|
+
export const CONTENT_SKILL_NAME_PATTERN = "^(?!\\.{1,2}$)[^/\\\\]+$";
|
|
17
|
+
|
|
15
18
|
/** SHA-256 摘要的正则源。 */
|
|
16
19
|
export const SHA256_DIGEST_PATTERN = "^sha256:[a-f0-9]{64}$";
|
|
17
20
|
|
|
@@ -180,6 +183,13 @@ export function isGitHubRepository(value) {
|
|
|
180
183
|
/** 可复用的安全相对路径 JSON Schema。 */
|
|
181
184
|
export const SAFE_PATH_SCHEMA = Object.freeze({ type: "string", format: "posix-relative-path" });
|
|
182
185
|
|
|
186
|
+
/** 可复用的内容 Skill 名称 JSON Schema。 */
|
|
187
|
+
export const CONTENT_SKILL_NAME_SCHEMA = Object.freeze({
|
|
188
|
+
type: "string",
|
|
189
|
+
minLength: 1,
|
|
190
|
+
pattern: CONTENT_SKILL_NAME_PATTERN,
|
|
191
|
+
});
|
|
192
|
+
|
|
183
193
|
/** 可复用的 capability 名称 JSON Schema。 */
|
|
184
194
|
export const CAPABILITY_NAME_SCHEMA = Object.freeze({
|
|
185
195
|
type: "string",
|
|
@@ -433,7 +433,7 @@ export class GitHubSourceProvider {
|
|
|
433
433
|
committedAt: resolved.committedAt,
|
|
434
434
|
subdir,
|
|
435
435
|
format: "flower",
|
|
436
|
-
entryPath: null,
|
|
436
|
+
entryPath: entry.source.manifestPath || null,
|
|
437
437
|
});
|
|
438
438
|
if (prepared.packages.length !== 1) {
|
|
439
439
|
throw new PluginRuntimeError(`Flower Marketplace 条目不是单一 Plugin:${entry.id}`, {
|