flower-trellis 0.6.2 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/enhancements/MANIFEST.json +1 -1
- package/package.json +3 -3
- package/src/builtin-marketplaces/rd-guide.json +6 -3
- package/src/builtin-plugins/flower-plugin-author/plugin.json +6 -1
- package/src/builtin-plugins/flower-plugin-author/skills/flower-plugin-author/references/ci-and-review.md +2 -2
- package/src/commands/plugin-interactive.js +546 -38
- package/src/commands/plugin-remote.js +223 -26
- package/src/commands/plugin.js +46 -6
- package/src/commands/update.js +10 -2
- package/src/lib/skill-catalog.js +20 -1
- package/src/plugin/application-service.js +245 -37
- package/src/plugin/auth/credential-store.js +25 -3
- package/src/plugin/auth/gitlab-credential-resolver.js +234 -0
- package/src/plugin/auth/gitlab-oauth.js +26 -5
- package/src/plugin/authoring/scaffold.js +6 -1
- package/src/plugin/authoring/templates/rd-guide/CODEOWNERS +1 -1
- package/src/plugin/authoring/templates/rd-guide/gitlab-ci.yml +2 -2
- package/src/plugin/authoring/templates/rd-guide/verify-integration-review.mjs +1 -1
- package/src/plugin/content-selection.js +189 -0
- package/src/plugin/contracts.js +21 -2
- package/src/plugin/formats/adapters.js +27 -3
- package/src/plugin/formats/normalized-package.js +126 -14
- package/src/plugin/gitlab/rest-client.js +21 -0
- package/src/plugin/install/content-projector.js +19 -7
- package/src/plugin/resolver/dependency-resolver.js +64 -7
- package/src/plugin/runtime-errors.js +1 -0
- package/src/plugin/schemas/marketplace-manifest.js +4 -1
- package/src/plugin/schemas/plugin-manifest.js +55 -3
- package/src/plugin/schemas/project-files.js +18 -0
- package/src/plugin/schemas/shared.js +10 -0
- package/src/plugin/sources/github-provider.js +1 -1
- package/src/plugin/sources/gitlab-provider.js +377 -51
- package/src/plugin/sources/user-source-store.js +17 -1
|
@@ -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 {
|
|
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:
|
|
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:
|
|
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 (!
|
|
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: [
|
|
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-
|
|
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-
|
|
6
|
-
- node .flower-
|
|
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-
|
|
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
|
+
}
|
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
|
*/
|