flower-trellis 0.6.1 → 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/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 +156 -10
- package/src/commands/plugin.js +35 -4
- package/src/lib/skill-catalog.js +20 -1
- package/src/lib/telemetry.js +2 -2
- package/src/lib/trellis-runner.js +82 -11
- 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
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PLUGIN_RUNTIME_ERROR_CODES,
|
|
3
|
+
PluginRuntimeError,
|
|
4
|
+
} from "./runtime-errors.js";
|
|
5
|
+
import {
|
|
6
|
+
CONTENT_SKILL_NAME_PATTERN,
|
|
7
|
+
assertSafePosixRelativePath,
|
|
8
|
+
} from "./schemas/shared.js";
|
|
9
|
+
import { compareUtf8 } from "./stable-order.js";
|
|
10
|
+
|
|
11
|
+
const CONTENT_SKILL_NAME_RE = new RegExp(CONTENT_SKILL_NAME_PATTERN);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 判断 Skill 选择名是否是单段安全名称。
|
|
15
|
+
*
|
|
16
|
+
* @param {unknown} value 待判断值
|
|
17
|
+
* @returns {boolean} 是否安全
|
|
18
|
+
*/
|
|
19
|
+
export function isContentSkillSelectionName(value) {
|
|
20
|
+
return typeof value === "string" && CONTENT_SKILL_NAME_RE.test(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 归一化用户选择的 Skill 名称列表。
|
|
25
|
+
*
|
|
26
|
+
* @param {string[]} values 原始名称或逗号列表
|
|
27
|
+
* @param {{code?:string,path?:string}} [options] 错误选项
|
|
28
|
+
* @returns {string[]} 稳定去重后的名称
|
|
29
|
+
*/
|
|
30
|
+
export function normalizeContentSkillNames(values, options = {}) {
|
|
31
|
+
const code = options.code || PLUGIN_RUNTIME_ERROR_CODES.CONTENT_SELECTION_INVALID;
|
|
32
|
+
const names = [...new Set((values || [])
|
|
33
|
+
.flatMap((value) => String(value).split(","))
|
|
34
|
+
.map((value) => value.trim())
|
|
35
|
+
.filter(Boolean))]
|
|
36
|
+
.sort(compareUtf8);
|
|
37
|
+
if (names.length === 0) {
|
|
38
|
+
throw new PluginRuntimeError("Content Skill 选择不能为空", {
|
|
39
|
+
code,
|
|
40
|
+
path: options.path || "contentSelection.skills",
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
const invalid = names.find((name) => !isContentSkillSelectionName(name));
|
|
44
|
+
if (invalid) {
|
|
45
|
+
throw new PluginRuntimeError(`Content Skill 名称非法:${invalid}`, {
|
|
46
|
+
code,
|
|
47
|
+
path: options.path || invalid,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return names;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 从 Skill 名称列表构造内容选择对象。
|
|
55
|
+
*
|
|
56
|
+
* @param {string[]} values 原始名称或逗号列表
|
|
57
|
+
* @param {{code?:string,path?:string}} [options] 错误选项
|
|
58
|
+
* @returns {import("./contracts.js").PluginContentSelection} 内容选择
|
|
59
|
+
*/
|
|
60
|
+
export function contentSelectionFromSkillNames(values, options = {}) {
|
|
61
|
+
return { skills: normalizeContentSkillNames(values, options) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 归一化内容选择对象。
|
|
66
|
+
*
|
|
67
|
+
* @param {import("./contracts.js").PluginContentSelection|null|undefined} selection 内容选择
|
|
68
|
+
* @param {{code?:string,path?:string}} [options] 错误选项
|
|
69
|
+
* @returns {import("./contracts.js").PluginContentSelection|undefined} 归一化选择;缺失时为 undefined
|
|
70
|
+
*/
|
|
71
|
+
export function normalizeContentSelection(selection, options = {}) {
|
|
72
|
+
if (!selection) return undefined;
|
|
73
|
+
if (!Array.isArray(selection.skills)) {
|
|
74
|
+
throw new PluginRuntimeError("contentSelection.skills 必须是数组", {
|
|
75
|
+
code: options.code || PLUGIN_RUNTIME_ERROR_CODES.CONTENT_SELECTION_INVALID,
|
|
76
|
+
path: options.path || "contentSelection.skills",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return { skills: normalizeContentSkillNames(selection.skills, options) };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 判断两个内容选择是否等价。
|
|
84
|
+
*
|
|
85
|
+
* @param {import("./contracts.js").PluginContentSelection|null|undefined} left 左侧选择
|
|
86
|
+
* @param {import("./contracts.js").PluginContentSelection|null|undefined} right 右侧选择
|
|
87
|
+
* @returns {boolean} 是否等价
|
|
88
|
+
*/
|
|
89
|
+
export function contentSelectionsEqual(left, right) {
|
|
90
|
+
const normalizedLeft = normalizeContentSelection(left);
|
|
91
|
+
const normalizedRight = normalizeContentSelection(right);
|
|
92
|
+
if (!normalizedLeft && !normalizedRight) return true;
|
|
93
|
+
if (!normalizedLeft || !normalizedRight) return false;
|
|
94
|
+
if (normalizedLeft.skills.length !== normalizedRight.skills.length) return false;
|
|
95
|
+
return normalizedLeft.skills.every((name, index) => name === normalizedRight.skills[index]);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 列出 manifest `content.skills` 的可选 Skill。
|
|
100
|
+
*
|
|
101
|
+
* @param {import("./contracts.js").PluginContentSkillEntry[]} entries manifest 中的 Skill 内容条目
|
|
102
|
+
* @param {string} owner 诊断中的 Plugin ID
|
|
103
|
+
* @returns {Array<import("./contracts.js").PluginContentSkillEntry>} 稳定 Skill 清单
|
|
104
|
+
*/
|
|
105
|
+
export function listContentSkillChoices(entries, owner) {
|
|
106
|
+
const choices = [];
|
|
107
|
+
const byName = new Map();
|
|
108
|
+
const byPath = new Map();
|
|
109
|
+
const sorted = [...(entries || [])]
|
|
110
|
+
.sort((left, right) => (
|
|
111
|
+
compareUtf8(String(left?.name || ""), String(right?.name || "")) ||
|
|
112
|
+
compareUtf8(String(left?.path || ""), String(right?.path || ""))
|
|
113
|
+
));
|
|
114
|
+
for (const entry of sorted) {
|
|
115
|
+
const name = entry?.name;
|
|
116
|
+
const skillPath = entry?.path;
|
|
117
|
+
if (!isContentSkillSelectionName(name)) {
|
|
118
|
+
throw new PluginRuntimeError(`Content Skill 名称非法:${name}`, {
|
|
119
|
+
code: PLUGIN_RUNTIME_ERROR_CODES.CONTENT_SELECTION_INVALID,
|
|
120
|
+
path: `${owner}:${String(skillPath || name || "")}`,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
assertSafePosixRelativePath(skillPath, "Content Skill 路径");
|
|
124
|
+
const previous = byName.get(name);
|
|
125
|
+
if (previous) {
|
|
126
|
+
throw new PluginRuntimeError(`Plugin content.skills 名称重复:${name}`, {
|
|
127
|
+
code: PLUGIN_RUNTIME_ERROR_CODES.CONTENT_CONFLICT,
|
|
128
|
+
path: owner,
|
|
129
|
+
details: { entries: [previous, skillPath].sort(compareUtf8) },
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
const previousPath = byPath.get(skillPath);
|
|
133
|
+
if (previousPath) {
|
|
134
|
+
throw new PluginRuntimeError(`Plugin content.skills 路径重复:${skillPath}`, {
|
|
135
|
+
code: PLUGIN_RUNTIME_ERROR_CODES.CONTENT_CONFLICT,
|
|
136
|
+
path: owner,
|
|
137
|
+
details: { names: [previousPath, name].sort(compareUtf8) },
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
byName.set(name, skillPath);
|
|
141
|
+
byPath.set(skillPath, name);
|
|
142
|
+
choices.push({
|
|
143
|
+
name,
|
|
144
|
+
path: skillPath,
|
|
145
|
+
version: entry.version,
|
|
146
|
+
...(entry.description ? { description: entry.description } : {}),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return choices.sort((left, right) => compareUtf8(left.name, right.name));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 根据内容选择过滤 manifest `content.skills`。
|
|
154
|
+
*
|
|
155
|
+
* @param {import("./contracts.js").PluginContentSkillEntry[]} entries manifest 中的 Skill 内容条目
|
|
156
|
+
* @param {import("./contracts.js").PluginContentSelection|null|undefined} selection 内容选择
|
|
157
|
+
* @param {string} owner 诊断中的 Plugin ID
|
|
158
|
+
* @returns {Array<import("./contracts.js").PluginContentSkillEntry>} 应投影的 Skill 内容条目
|
|
159
|
+
*/
|
|
160
|
+
export function selectContentSkillEntries(entries, selection, owner) {
|
|
161
|
+
const choices = listContentSkillChoices(entries, owner);
|
|
162
|
+
const normalized = normalizeContentSelection(selection);
|
|
163
|
+
if (!normalized) return choices;
|
|
164
|
+
const byName = new Map(choices.map((choice) => [choice.name, choice]));
|
|
165
|
+
const missing = normalized.skills.filter((name) => !byName.has(name));
|
|
166
|
+
if (missing.length > 0) {
|
|
167
|
+
throw new PluginRuntimeError(`Content Skill 选择不存在:${missing.join(",")}`, {
|
|
168
|
+
code: PLUGIN_RUNTIME_ERROR_CODES.CONTENT_SELECTION_INVALID,
|
|
169
|
+
path: owner,
|
|
170
|
+
details: {
|
|
171
|
+
requested: normalized.skills,
|
|
172
|
+
available: choices.map(({ name }) => name),
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return normalized.skills.map((name) => byName.get(name));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 把内容选择转换为 CLI 参数。
|
|
181
|
+
*
|
|
182
|
+
* @param {import("./contracts.js").PluginContentSelection|null|undefined} selection 内容选择
|
|
183
|
+
* @returns {string[]} CLI 参数
|
|
184
|
+
*/
|
|
185
|
+
export function contentSelectionArgs(selection) {
|
|
186
|
+
const normalized = normalizeContentSelection(selection);
|
|
187
|
+
if (!normalized) return [];
|
|
188
|
+
return normalized.skills.flatMap((name) => ["--content-skill", name]);
|
|
189
|
+
}
|
package/src/plugin/contracts.js
CHANGED
|
@@ -15,13 +15,26 @@
|
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* @typedef {object} PluginContent
|
|
18
|
-
* @property {
|
|
18
|
+
* @property {PluginContentSkillEntry[]} [skills] Skill 内容条目
|
|
19
19
|
* @property {string[]} [specs] 规范内容路径
|
|
20
20
|
* @property {string[]} [assets] 资源内容路径
|
|
21
21
|
* @property {string[]} [scripts] 被动脚本资源路径
|
|
22
22
|
* @property {string[]} [tests] 测试资源路径
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {object} PluginContentSkillEntry
|
|
27
|
+
* @property {string} name Skill 选择名
|
|
28
|
+
* @property {string} path Skill 内容路径
|
|
29
|
+
* @property {string} version Skill 版本
|
|
30
|
+
* @property {string} [description] Skill 描述
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @typedef {object} PluginContentSelection
|
|
35
|
+
* @property {string[]} [skills] 当前项目选择启用的 Skill 名称
|
|
36
|
+
*/
|
|
37
|
+
|
|
25
38
|
/**
|
|
26
39
|
* @typedef {object} PluginManifest
|
|
27
40
|
* @property {1} schemaVersion schema 版本
|
|
@@ -38,7 +51,8 @@
|
|
|
38
51
|
/**
|
|
39
52
|
* @typedef {object} MarketplacePathSource
|
|
40
53
|
* @property {"path"} type 共仓来源
|
|
41
|
-
* @property {string} path Marketplace 内 POSIX
|
|
54
|
+
* @property {string} [path] Marketplace 内 POSIX 相对目录;缺省表示仓库根
|
|
55
|
+
* @property {string} [manifestPath] Plugin manifest 相对包根路径
|
|
42
56
|
*/
|
|
43
57
|
|
|
44
58
|
/**
|
|
@@ -46,6 +60,7 @@
|
|
|
46
60
|
* @property {"gitlab"} type GitLab 来源
|
|
47
61
|
* @property {string} project GitLab project path
|
|
48
62
|
* @property {string} [subdir] 仓库内 POSIX 相对目录
|
|
63
|
+
* @property {string} [manifestPath] Plugin manifest 相对包根路径
|
|
49
64
|
*/
|
|
50
65
|
|
|
51
66
|
/**
|
|
@@ -53,6 +68,7 @@
|
|
|
53
68
|
* @property {"github"} type GitHub 来源
|
|
54
69
|
* @property {string} repository GitHub `owner/repository`
|
|
55
70
|
* @property {string} [subdir] 仓库内 POSIX 相对目录
|
|
71
|
+
* @property {string} [manifestPath] Plugin manifest 相对包根路径
|
|
56
72
|
*/
|
|
57
73
|
|
|
58
74
|
/** @typedef {MarketplacePathSource|MarketplaceGitLabSource|MarketplaceGitHubSource} MarketplaceSource */
|
|
@@ -146,6 +162,7 @@
|
|
|
146
162
|
* @property {Record<string,string>} dependencies 已解析依赖版本
|
|
147
163
|
* @property {CompatibilityConstraint} compatibility 兼容范围
|
|
148
164
|
* @property {CapabilityGrant} capabilities 实际能力授权
|
|
165
|
+
* @property {PluginContentSelection} [contentSelection] 内容选择
|
|
149
166
|
*/
|
|
150
167
|
|
|
151
168
|
/**
|
|
@@ -210,6 +227,7 @@
|
|
|
210
227
|
* @property {string} source 来源 ID
|
|
211
228
|
* @property {string} version SemVer range
|
|
212
229
|
* @property {string[]} [platforms] 显式平台限制
|
|
230
|
+
* @property {PluginContentSelection} [contentSelection] 内容选择
|
|
213
231
|
*/
|
|
214
232
|
|
|
215
233
|
/**
|
|
@@ -245,6 +263,7 @@
|
|
|
245
263
|
* @property {string} id canonical Plugin ID
|
|
246
264
|
* @property {string} version 已应用版本
|
|
247
265
|
* @property {string[]} platforms 实际投影平台
|
|
266
|
+
* @property {PluginContentSelection} [contentSelection] 内容选择
|
|
248
267
|
* @property {PluginPathState[]} paths 受管路径
|
|
249
268
|
* @property {PluginPatchState[]} patches Patch 应用状态
|
|
250
269
|
*/
|
|
@@ -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",
|