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.
Files changed (31) hide show
  1. package/package.json +3 -3
  2. package/src/builtin-marketplaces/rd-guide.json +6 -3
  3. package/src/builtin-plugins/flower-plugin-author/plugin.json +6 -1
  4. package/src/builtin-plugins/flower-plugin-author/skills/flower-plugin-author/references/ci-and-review.md +2 -2
  5. package/src/commands/plugin-interactive.js +546 -38
  6. package/src/commands/plugin-remote.js +156 -10
  7. package/src/commands/plugin.js +35 -4
  8. package/src/lib/skill-catalog.js +20 -1
  9. package/src/plugin/application-service.js +81 -7
  10. package/src/plugin/auth/credential-store.js +25 -3
  11. package/src/plugin/auth/gitlab-credential-resolver.js +234 -0
  12. package/src/plugin/auth/gitlab-oauth.js +26 -5
  13. package/src/plugin/authoring/scaffold.js +6 -1
  14. package/src/plugin/authoring/templates/rd-guide/CODEOWNERS +1 -1
  15. package/src/plugin/authoring/templates/rd-guide/gitlab-ci.yml +2 -2
  16. package/src/plugin/authoring/templates/rd-guide/verify-integration-review.mjs +1 -1
  17. package/src/plugin/content-selection.js +189 -0
  18. package/src/plugin/contracts.js +21 -2
  19. package/src/plugin/formats/adapters.js +27 -3
  20. package/src/plugin/formats/normalized-package.js +126 -14
  21. package/src/plugin/gitlab/rest-client.js +21 -0
  22. package/src/plugin/install/content-projector.js +19 -7
  23. package/src/plugin/resolver/dependency-resolver.js +6 -0
  24. package/src/plugin/runtime-errors.js +1 -0
  25. package/src/plugin/schemas/marketplace-manifest.js +4 -1
  26. package/src/plugin/schemas/plugin-manifest.js +55 -3
  27. package/src/plugin/schemas/project-files.js +18 -0
  28. package/src/plugin/schemas/shared.js +10 -0
  29. package/src/plugin/sources/github-provider.js +1 -1
  30. package/src/plugin/sources/gitlab-provider.js +377 -51
  31. package/src/plugin/sources/user-source-store.js +17 -1
@@ -0,0 +1,234 @@
1
+ import { execFile } from "node:child_process";
2
+ import { PLUGIN_RUNTIME_ERROR_CODES, PluginRuntimeError } from "../runtime-errors.js";
3
+
4
+ const GLAB_COMMAND = "glab";
5
+ const GLAB_AUTH_TIMEOUT_MS = 5_000;
6
+ const GLAB_AUTH_MAX_BUFFER = 1024 * 1024;
7
+ const HOST_BOUND_TOKEN_NAMES = ["FLOWER_GITLAB_TOKEN", "GITLAB_TOKEN", "GLAB_TOKEN"];
8
+ const HOST_BOUND_HOST_NAMES = ["FLOWER_GITLAB_HOST", "GITLAB_HOST", "GLAB_HOST", "CI_SERVER_HOST", "CI_SERVER_URL"];
9
+
10
+ /**
11
+ * 返回 GitLab source 的小写 host[:port]。
12
+ *
13
+ * @param {{baseUrl:string}} source GitLab 来源
14
+ * @returns {string} host[:port]
15
+ */
16
+ export function gitLabCredentialHost(source) {
17
+ return new URL(source.baseUrl).host.toLowerCase();
18
+ }
19
+
20
+ /**
21
+ * 把 host、origin 或项目 URL 规范化为小写 host[:port]。
22
+ *
23
+ * @param {unknown} value 原始 host 或 URL
24
+ * @returns {string|null} 规范化 host;无法解析时返回 null
25
+ */
26
+ export function normalizeGitLabCredentialHost(value) {
27
+ const raw = String(value || "").trim();
28
+ if (!raw) return null;
29
+ try {
30
+ const url = /^https?:\/\//i.test(raw) ? new URL(raw) : new URL(`https://${raw.replace(/^\/+/, "")}`);
31
+ return url.host.toLowerCase();
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * 判断原始 host/URL 是否与 GitLab source 完全同 host。
39
+ *
40
+ * @param {unknown} value 原始 host 或 URL
41
+ * @param {{baseUrl:string}} source GitLab 来源
42
+ * @returns {boolean} 是否同 host
43
+ */
44
+ export function isSameGitLabCredentialHost(value, source) {
45
+ return normalizeGitLabCredentialHost(value) === gitLabCredentialHost(source);
46
+ }
47
+
48
+ /**
49
+ * 从 `glab auth status --show-token` 输出中提取同 host token。
50
+ *
51
+ * @param {string} output glab stdout/stderr 合并文本
52
+ * @param {{baseUrl:string}} source GitLab 来源
53
+ * @returns {string|null} access token;无法证明同 host 或未输出 token 时返回 null
54
+ */
55
+ export function parseGlabAuthStatusToken(output, source) {
56
+ const text = String(output || "");
57
+ const host = gitLabCredentialHost(source);
58
+ const escapedHost = host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
59
+ const hostPattern = new RegExp(`(^|[^A-Za-z0-9.-])${escapedHost}($|[^A-Za-z0-9.-])`, "i");
60
+ if (!hostPattern.test(text)) return null;
61
+ for (const line of text.split(/\r?\n/)) {
62
+ const match = line.match(/\btoken\b[^:]*:\s*([^\s]+)/i);
63
+ if (!match) continue;
64
+ const token = match[1].replace(/^['"]|['"]$/g, "");
65
+ if (!token || token.includes("*") || token === "[REDACTED]") continue;
66
+ return token;
67
+ }
68
+ return null;
69
+ }
70
+
71
+ /**
72
+ * 从 host 绑定的环境变量中解析 GitLab token。
73
+ *
74
+ * @param {{baseUrl:string}} source GitLab 来源
75
+ * @param {NodeJS.ProcessEnv|Record<string,string|undefined>} [env] 环境变量
76
+ * @returns {{authorized:true,scopes:string[],expiresAt:null,persistent:false,accessToken:string,credentialSource:"env"}|null} 环境凭据
77
+ */
78
+ export function resolveGitLabEnvironmentCredential(source, env = process.env) {
79
+ const hostKey = gitLabCredentialHost(source).toUpperCase().replace(/[^A-Z0-9]+/g, "_");
80
+ for (const name of HOST_BOUND_TOKEN_NAMES.map((prefix) => `${prefix}_${hostKey}`)) {
81
+ const accessToken = String(env[name] || "").trim();
82
+ if (accessToken) return externalCredential(accessToken, "env");
83
+ }
84
+ const hostMatched = HOST_BOUND_HOST_NAMES.some((name) => isSameGitLabCredentialHost(env[name], source));
85
+ if (!hostMatched) return null;
86
+ for (const name of HOST_BOUND_TOKEN_NAMES) {
87
+ const accessToken = String(env[name] || "").trim();
88
+ if (accessToken) return externalCredential(accessToken, "env");
89
+ }
90
+ return null;
91
+ }
92
+
93
+ /**
94
+ * 默认执行 glab 的安全包装。
95
+ *
96
+ * @param {string[]} args glab 参数
97
+ * @param {{command?:string,timeoutMs?:number}} [options] 命令配置
98
+ * @returns {Promise<{stdout:string,stderr:string}>} 子进程输出
99
+ */
100
+ export async function runGlab(args, options = {}) {
101
+ return new Promise((resolve, reject) => {
102
+ execFile(options.command || GLAB_COMMAND, args, {
103
+ timeout: options.timeoutMs || GLAB_AUTH_TIMEOUT_MS,
104
+ maxBuffer: GLAB_AUTH_MAX_BUFFER,
105
+ windowsHide: true,
106
+ }, (error, stdout, stderr) => {
107
+ if (error) {
108
+ reject(error);
109
+ return;
110
+ }
111
+ resolve({ stdout, stderr });
112
+ });
113
+ });
114
+ }
115
+
116
+ /**
117
+ * GitLab 凭据解析器,统一 Flower Keyring、glab 与环境 fallback。
118
+ */
119
+ export class GitLabCredentialResolver {
120
+ /**
121
+ * 创建 GitLab 凭据解析器。
122
+ *
123
+ * @param {{store:import("./credential-store.js").CredentialStore,persistent?:boolean,env?:NodeJS.ProcessEnv|Record<string,string|undefined>,runGlab?:(args:string[],options?:object)=>Promise<{stdout?:string,stderr?:string}>,glabCommand?:string,glabTimeoutMs?:number}} options 依赖注入
124
+ */
125
+ constructor(options) {
126
+ this.store = options.store;
127
+ this.persistent = Boolean(options.persistent);
128
+ this.env = options.env || process.env;
129
+ this.runGlab = options.runGlab || runGlab;
130
+ this.glabCommand = options.glabCommand || GLAB_COMMAND;
131
+ this.glabTimeoutMs = options.glabTimeoutMs || GLAB_AUTH_TIMEOUT_MS;
132
+ }
133
+
134
+ /**
135
+ * 按 Flower Keyring、glab、环境变量顺序解析凭据。
136
+ *
137
+ * @param {object} source GitLab 来源
138
+ * @returns {Promise<{authorized:boolean,scopes:string[],expiresAt:number|null,persistent:boolean,accessToken?:string,credentialSource?:string,credential?:object}>} 解析结果
139
+ */
140
+ async resolve(source) {
141
+ const credential = await this.store.get(source);
142
+ if (credential) {
143
+ return {
144
+ authorized: true,
145
+ scopes: credential.scope,
146
+ expiresAt: credential.expiresAt,
147
+ persistent: this.persistent,
148
+ accessToken: credential.accessToken,
149
+ credentialSource: "flower",
150
+ credential,
151
+ };
152
+ }
153
+ const external = await this.resolveExternal(source);
154
+ return external || {
155
+ authorized: false,
156
+ scopes: [],
157
+ expiresAt: null,
158
+ persistent: false,
159
+ };
160
+ }
161
+
162
+ /**
163
+ * 解析非 Flower 管理的全局 GitLab 凭据。
164
+ *
165
+ * @param {object} source GitLab 来源
166
+ * @returns {Promise<{authorized:true,scopes:string[],expiresAt:null,persistent:false,accessToken:string,credentialSource:string}|null>} 外部凭据
167
+ */
168
+ async resolveExternal(source) {
169
+ const glabCredential = await this.#resolveGlab(source);
170
+ if (glabCredential) return glabCredential;
171
+ return resolveGitLabEnvironmentCredential(source, this.env);
172
+ }
173
+
174
+ /**
175
+ * 返回不含 token 的登录状态。
176
+ *
177
+ * @param {object} source GitLab 来源
178
+ * @returns {Promise<{authorized:boolean,scopes:string[],expiresAt:number|null,persistent:boolean}>} 非敏感状态
179
+ */
180
+ async status(source) {
181
+ const resolution = await this.resolve(source);
182
+ return {
183
+ authorized: resolution.authorized,
184
+ scopes: resolution.scopes,
185
+ expiresAt: resolution.expiresAt,
186
+ persistent: resolution.persistent,
187
+ };
188
+ }
189
+
190
+ /** @param {object} source @returns {Promise<{authorized:true,scopes:string[],expiresAt:null,persistent:false,accessToken:string,credentialSource:"glab"}|null>} */
191
+ async #resolveGlab(source) {
192
+ let output;
193
+ try {
194
+ output = await this.runGlab([
195
+ "auth",
196
+ "status",
197
+ "--hostname",
198
+ gitLabCredentialHost(source),
199
+ "--show-token",
200
+ ], {
201
+ command: this.glabCommand,
202
+ timeoutMs: this.glabTimeoutMs,
203
+ source,
204
+ });
205
+ } catch {
206
+ return null;
207
+ }
208
+ const accessToken = parseGlabAuthStatusToken(`${output?.stdout || ""}\n${output?.stderr || ""}`, source);
209
+ return accessToken ? externalCredential(accessToken, "glab") : null;
210
+ }
211
+ }
212
+
213
+ /**
214
+ * 构造非持久外部凭据结果。
215
+ *
216
+ * @param {string} accessToken access token
217
+ * @param {"glab"|"env"} credentialSource 凭据来源
218
+ * @returns {{authorized:true,scopes:string[],expiresAt:null,persistent:false,accessToken:string,credentialSource:"glab"|"env"}} 外部凭据
219
+ */
220
+ function externalCredential(accessToken, credentialSource) {
221
+ if (!accessToken) {
222
+ throw new PluginRuntimeError("GitLab 外部凭据为空", {
223
+ code: PLUGIN_RUNTIME_ERROR_CODES.AUTH_REQUIRED,
224
+ });
225
+ }
226
+ return {
227
+ authorized: true,
228
+ scopes: [],
229
+ expiresAt: null,
230
+ persistent: false,
231
+ accessToken,
232
+ credentialSource,
233
+ };
234
+ }
@@ -1,7 +1,13 @@
1
1
  import crypto from "node:crypto";
2
2
  import http from "node:http";
3
3
  import { spawn } from "node:child_process";
4
- import { CREDENTIAL_SCHEMA_VERSION, GITLAB_OAUTH_SCOPES, validateCredential } from "./credential-store.js";
4
+ import { GitLabCredentialResolver } from "./gitlab-credential-resolver.js";
5
+ import {
6
+ CREDENTIAL_SCHEMA_VERSION,
7
+ GITLAB_OAUTH_REQUEST_SCOPES,
8
+ isGitLabCredentialScopeSufficient,
9
+ validateCredential,
10
+ } from "./credential-store.js";
5
11
  import { PLUGIN_RUNTIME_ERROR_CODES, PluginRuntimeError } from "../runtime-errors.js";
6
12
 
7
13
  const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
@@ -131,7 +137,7 @@ export class GitLabOAuthClient {
131
137
  redirect_uri: callback.redirectUri,
132
138
  response_type: "code",
133
139
  state: pkce.state,
134
- scope: GITLAB_OAUTH_SCOPES.join(" "),
140
+ scope: GITLAB_OAUTH_REQUEST_SCOPES.join(" "),
135
141
  code_challenge: pkce.challenge,
136
142
  code_challenge_method: "S256",
137
143
  }).toString();
@@ -167,7 +173,7 @@ export class GitLabOAuthClient {
167
173
  async loginWithDevice(source, options = {}) {
168
174
  const device = await this.#postForm(source, "/oauth/authorize_device", {
169
175
  client_id: source.oauth.applicationId,
170
- scope: GITLAB_OAUTH_SCOPES.join(" "),
176
+ scope: GITLAB_OAUTH_REQUEST_SCOPES.join(" "),
171
177
  }, { signal: options.signal });
172
178
  if (
173
179
  typeof device.device_code !== "string" ||
@@ -253,7 +259,7 @@ export class GitLabOAuthClient {
253
259
  ? payload.scope.split(/[ ,]+/).filter(Boolean)
254
260
  : [];
255
261
  let tokenPayload = payload;
256
- if (!GITLAB_OAUTH_SCOPES.every((scope) => responseScopes.includes(scope))) {
262
+ if (!isGitLabCredentialScopeSufficient(responseScopes)) {
257
263
  const tokenInfo = await this.#readTokenInfo(source, payload.access_token);
258
264
  tokenPayload = { ...payload, scope: tokenInfo.scope || tokenInfo.scopes || [] };
259
265
  }
@@ -395,13 +401,21 @@ export class GitLabCredentialManager {
395
401
  /**
396
402
  * 创建凭据管理器。
397
403
  *
398
- * @param {{store:import("./credential-store.js").CredentialStore,oauth:GitLabOAuthClient,now?:()=>number}} options 依赖
404
+ * @param {{store:import("./credential-store.js").CredentialStore,oauth:GitLabOAuthClient,now?:()=>number,persistent?:boolean,credentialResolver?:GitLabCredentialResolver,env?:NodeJS.ProcessEnv|Record<string,string|undefined>,runGlab?:(args:string[],options?:object)=>Promise<{stdout?:string,stderr?:string}>,glabCommand?:string}} options 依赖
399
405
  */
400
406
  constructor(options) {
401
407
  this.store = options.store;
402
408
  this.oauth = options.oauth;
403
409
  this.now = options.now || Date.now;
410
+ this.resolver = options.credentialResolver || new GitLabCredentialResolver({
411
+ store: options.store,
412
+ persistent: options.persistent,
413
+ env: options.env,
414
+ runGlab: options.runGlab,
415
+ glabCommand: options.glabCommand,
416
+ });
404
417
  this.refreshes = new Map();
418
+ this.externalTokens = new Map();
405
419
  }
406
420
 
407
421
  /**
@@ -413,6 +427,13 @@ export class GitLabCredentialManager {
413
427
  async getAccessToken(source) {
414
428
  const credential = await this.store.get(source);
415
429
  if (!credential) {
430
+ const externalKey = `${source.id}\u0000${source.baseUrl}`;
431
+ if (this.externalTokens.has(externalKey)) return this.externalTokens.get(externalKey);
432
+ const external = await this.resolver.resolveExternal(source);
433
+ if (external) {
434
+ this.externalTokens.set(externalKey, external.accessToken);
435
+ return external.accessToken;
436
+ }
416
437
  throw new PluginRuntimeError(`GitLab source 尚未登录:${source.id}`, {
417
438
  code: PLUGIN_RUNTIME_ERROR_CODES.AUTH_REQUIRED,
418
439
  path: source.id,
@@ -133,7 +133,12 @@ function renderPackageFiles(options) {
133
133
  dependencies: {},
134
134
  capabilities: { profile: options.profile, required },
135
135
  content: {
136
- skills: [`skills/${options.pluginId}`],
136
+ skills: [{
137
+ name: options.pluginId,
138
+ path: `skills/${options.pluginId}`,
139
+ version: options.version,
140
+ description: `${options.name}。用于执行该 Plugin 提供的专业工作流。`,
141
+ }],
137
142
  tests: ["tests"],
138
143
  },
139
144
  ...(options.includePatches ? {
@@ -1,2 +1,2 @@
1
1
  # integration 审核材料必须由受保护 owner 审批。
2
- /.flower-marketplace/integration-review.json @digital-rd-governance/plugin-integration-owners
2
+ /.flower-plugin/integration-review.json @digital-rd-governance/plugin-integration-owners
@@ -2,8 +2,8 @@ flower-plugin-validate:
2
2
  image: node:22
3
3
  script:
4
4
  - npm install --global flower-trellis
5
- - flower-trellis plugin validate .flower-marketplace/marketplace.json --subject marketplace --checkout-map .flower-marketplace/checkouts.json --ci --json > flower-plugin-validation.json
6
- - node .flower-marketplace/verify-integration-review.mjs flower-plugin-validation.json .flower-marketplace/integration-review.json
5
+ - flower-trellis plugin validate .flower-plugin/marketplace.json --subject marketplace --checkout-map .flower-plugin/checkouts.json --ci --json > flower-plugin-validation.json
6
+ - node .flower-plugin/verify-integration-review.mjs flower-plugin-validation.json .flower-plugin/integration-review.json
7
7
  artifacts:
8
8
  when: always
9
9
  paths:
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
 
3
3
  const validationPath = process.argv[2] || "flower-plugin-validation.json";
4
- const reviewPath = process.argv[3] || ".flower-marketplace/integration-review.json";
4
+ const reviewPath = process.argv[3] || ".flower-plugin/integration-review.json";
5
5
  const validation = JSON.parse(fs.readFileSync(validationPath, "utf8"));
6
6
 
7
7
  if (!validation.ok) {
@@ -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
+ }
@@ -15,13 +15,26 @@
15
15
 
16
16
  /**
17
17
  * @typedef {object} PluginContent
18
- * @property {string[]} [skills] Skill 内容路径
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
  */