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.
Files changed (33) hide show
  1. package/enhancements/MANIFEST.json +1 -1
  2. package/package.json +3 -3
  3. package/src/builtin-marketplaces/rd-guide.json +6 -3
  4. package/src/builtin-plugins/flower-plugin-author/plugin.json +6 -1
  5. package/src/builtin-plugins/flower-plugin-author/skills/flower-plugin-author/references/ci-and-review.md +2 -2
  6. package/src/commands/plugin-interactive.js +546 -38
  7. package/src/commands/plugin-remote.js +223 -26
  8. package/src/commands/plugin.js +46 -6
  9. package/src/commands/update.js +10 -2
  10. package/src/lib/skill-catalog.js +20 -1
  11. package/src/plugin/application-service.js +245 -37
  12. package/src/plugin/auth/credential-store.js +25 -3
  13. package/src/plugin/auth/gitlab-credential-resolver.js +234 -0
  14. package/src/plugin/auth/gitlab-oauth.js +26 -5
  15. package/src/plugin/authoring/scaffold.js +6 -1
  16. package/src/plugin/authoring/templates/rd-guide/CODEOWNERS +1 -1
  17. package/src/plugin/authoring/templates/rd-guide/gitlab-ci.yml +2 -2
  18. package/src/plugin/authoring/templates/rd-guide/verify-integration-review.mjs +1 -1
  19. package/src/plugin/content-selection.js +189 -0
  20. package/src/plugin/contracts.js +21 -2
  21. package/src/plugin/formats/adapters.js +27 -3
  22. package/src/plugin/formats/normalized-package.js +126 -14
  23. package/src/plugin/gitlab/rest-client.js +21 -0
  24. package/src/plugin/install/content-projector.js +19 -7
  25. package/src/plugin/resolver/dependency-resolver.js +64 -7
  26. package/src/plugin/runtime-errors.js +1 -0
  27. package/src/plugin/schemas/marketplace-manifest.js +4 -1
  28. package/src/plugin/schemas/plugin-manifest.js +55 -3
  29. package/src/plugin/schemas/project-files.js +18 -0
  30. package/src/plugin/schemas/shared.js +10 -0
  31. package/src/plugin/sources/github-provider.js +1 -1
  32. package/src/plugin/sources/gitlab-provider.js +377 -51
  33. 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-marketplace", "marketplace.json");
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-marketplace/marketplace.json",
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
- for (const file of listCanonicalTreeFiles(options.pluginRoot)) {
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
- fs.readFileSync(path.join(packageRoot, "plugin.json"), "utf8"),
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: Object.entries(manifest.content).flatMap(([kind, paths]) => (
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.map(({ path: value }) => value).sort(compareUtf8),
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 = path.posix.basename(entry);
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
- for (const entry of [...(pluginPackage.manifest.content[kind] || [])].sort(compareUtf8)) {
188
- const expanded = expandContentEntry(pluginPackage.root, entry);
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, entry, platformTarget.root);
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, entry, file.relative, canonical)
226
+ resolvePlatformContent(pluginPackage.root, platform, contentPath, file.relative, canonical)
216
227
  ))
217
- : [{ content: canonical, source: `${entry}${file.relative ? `/${file.relative}` : ""}` }];
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
- try {
203
- candidates = registry.listCandidates(nextId);
204
- } catch (error) {
205
- if (error?.code !== PLUGIN_RUNTIME_ERROR_CODES.SOURCE_NOT_FOUND) throw error;
206
- candidates = [];
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: grantCapabilities(candidate.manifest.capabilities, candidate),
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", "path"],
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: stringList,
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
- const validate = createSchemaValidator(PLUGIN_MANIFEST_SCHEMA, "Flower Plugin manifest");
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: {