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.
Files changed (34) 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 +156 -10
  8. package/src/commands/plugin.js +35 -4
  9. package/src/lib/skill-catalog.js +20 -1
  10. package/src/lib/telemetry.js +2 -2
  11. package/src/lib/trellis-runner.js +82 -11
  12. package/src/plugin/application-service.js +81 -7
  13. package/src/plugin/auth/credential-store.js +25 -3
  14. package/src/plugin/auth/gitlab-credential-resolver.js +234 -0
  15. package/src/plugin/auth/gitlab-oauth.js +26 -5
  16. package/src/plugin/authoring/scaffold.js +6 -1
  17. package/src/plugin/authoring/templates/rd-guide/CODEOWNERS +1 -1
  18. package/src/plugin/authoring/templates/rd-guide/gitlab-ci.yml +2 -2
  19. package/src/plugin/authoring/templates/rd-guide/verify-integration-review.mjs +1 -1
  20. package/src/plugin/content-selection.js +189 -0
  21. package/src/plugin/contracts.js +21 -2
  22. package/src/plugin/formats/adapters.js +27 -3
  23. package/src/plugin/formats/normalized-package.js +126 -14
  24. package/src/plugin/gitlab/rest-client.js +21 -0
  25. package/src/plugin/install/content-projector.js +19 -7
  26. package/src/plugin/resolver/dependency-resolver.js +6 -0
  27. package/src/plugin/runtime-errors.js +1 -0
  28. package/src/plugin/schemas/marketplace-manifest.js +4 -1
  29. package/src/plugin/schemas/plugin-manifest.js +55 -3
  30. package/src/plugin/schemas/project-files.js +18 -0
  31. package/src/plugin/schemas/shared.js +10 -0
  32. package/src/plugin/sources/github-provider.js +1 -1
  33. package/src/plugin/sources/gitlab-provider.js +377 -51
  34. package/src/plugin/sources/user-source-store.js +17 -1
@@ -16,6 +16,11 @@ import { hashDirectoryIfExists, hashFileIfExists } from "./install/content-hash.
16
16
  import { getPluginPatchPlanner } from "./runtime-extensions.js";
17
17
  import { isRuntimeBuiltinProviderTrusted } from "./runtime-extensions.js";
18
18
  import { compareUtf8 } from "./stable-order.js";
19
+ import {
20
+ contentSelectionsEqual,
21
+ normalizeContentSelection,
22
+ selectContentSkillEntries,
23
+ } from "./content-selection.js";
19
24
 
20
25
  /**
21
26
  * 计算 lock roots 可达的全部 Plugin。
@@ -151,7 +156,7 @@ export class PluginApplicationService {
151
156
  /**
152
157
  * 添加或更新一个直接 Plugin 声明并应用完整图。
153
158
  *
154
- * @param {{id:string,version?:string,platforms?:string[],dryRun?:boolean,approvals?:string[],approvedDigests?:Map<string,string>|Record<string,string>,nonInteractive?:boolean,onPreflight?:(result:object)=>void}} options 添加选项
159
+ * @param {{id:string,version?:string,platforms?:string[],contentSelection?:import("./contracts.js").PluginContentSelection,dryRun?:boolean,approvals?:string[],approvedDigests?:Map<string,string>|Record<string,string>,nonInteractive?:boolean,onPreflight?:(result:object)=>void}} options 添加选项
155
160
  * @returns {object} 生命周期结果
156
161
  */
157
162
  add(options) {
@@ -168,6 +173,11 @@ export class PluginApplicationService {
168
173
  source: sourceId,
169
174
  version: options.version || "*",
170
175
  };
176
+ const contentSelection = normalizeContentSelection(options.contentSelection, {
177
+ code: PLUGIN_RUNTIME_ERROR_CODES.USAGE_ERROR,
178
+ path: options.id,
179
+ });
180
+ if (contentSelection) declaration.contentSelection = contentSelection;
171
181
  const declarations = pluginsFile.plugins.filter(({ id }) => id !== options.id);
172
182
  declarations.push(declaration);
173
183
  return this.#applyLifecycle("add", {
@@ -189,12 +199,13 @@ export class PluginApplicationService {
189
199
  * 其锁定包也已不可重放,逐个更新会在其它节点上撞 `已锁定 Plugin 包不可重放`。
190
200
  * 传入 `widen` 时按 `update: "all"` 解析,让全部节点都允许升级,一次性解开这种死锁。
191
201
  *
192
- * @param {{id?:string|null,version?:string,widen?:Map<string,string>|Record<string,string>,platforms?:string[],dryRun?:boolean,approvals?:string[],approvedDigests?:Map<string,string>|Record<string,string>,nonInteractive?:boolean,onPreflight?:(result:object)=>void}} [options] 更新选项
202
+ * @param {{id?:string|null,version?:string,widen?:Map<string,string>|Record<string,string>,contentSelection?:import("./contracts.js").PluginContentSelection|null,platforms?:string[],dryRun?:boolean,approvals?:string[],approvedDigests?:Map<string,string>|Record<string,string>,nonInteractive?:boolean,onPreflight?:(result:object)=>void}} [options] 更新选项
193
203
  * @returns {object} 生命周期结果
194
204
  */
195
205
  update(options = {}) {
196
206
  const pluginsFile = this.store.readPlugins();
197
207
  const widen = normalizeVersionOverrides(options.widen);
208
+ const hasContentSelection = Object.prototype.hasOwnProperty.call(options, "contentSelection");
198
209
  if (options.id && !pluginsFile.plugins.some(({ id }) => id === options.id)) {
199
210
  throw new PluginRuntimeError(`项目未声明 Plugin:${options.id}`, {
200
211
  code: PLUGIN_RUNTIME_ERROR_CODES.USAGE_ERROR,
@@ -214,6 +225,18 @@ export class PluginApplicationService {
214
225
  path: "widen",
215
226
  });
216
227
  }
228
+ if (hasContentSelection && !options.id) {
229
+ throw new PluginRuntimeError("更新 Content Skill 选择时必须指定 Plugin ID", {
230
+ code: PLUGIN_RUNTIME_ERROR_CODES.USAGE_ERROR,
231
+ path: "contentSelection",
232
+ });
233
+ }
234
+ if (hasContentSelection && widen.size > 0) {
235
+ throw new PluginRuntimeError("更新 Content Skill 选择时不能同时放宽声明范围", {
236
+ code: PLUGIN_RUNTIME_ERROR_CODES.USAGE_ERROR,
237
+ path: "contentSelection",
238
+ });
239
+ }
217
240
  const declaredIds = new Set(pluginsFile.plugins.map(({ id }) => id));
218
241
  for (const id of [...widen.keys()].sort(compareUtf8)) {
219
242
  if (!declaredIds.has(id)) {
@@ -229,12 +252,24 @@ export class PluginApplicationService {
229
252
  const overrides = options.version && options.id
230
253
  ? new Map([[options.id, options.version]])
231
254
  : widen;
232
- const nextPlugins = overrides.size > 0
255
+ const contentSelection = hasContentSelection
256
+ ? normalizeContentSelection(options.contentSelection, {
257
+ code: PLUGIN_RUNTIME_ERROR_CODES.USAGE_ERROR,
258
+ path: options.id,
259
+ })
260
+ : undefined;
261
+ const nextPlugins = (overrides.size > 0 || hasContentSelection)
233
262
  ? {
234
263
  ...pluginsFile,
235
- plugins: pluginsFile.plugins.map((plugin) => (
236
- overrides.has(plugin.id) ? { ...plugin, version: overrides.get(plugin.id) } : plugin
237
- )),
264
+ plugins: pluginsFile.plugins.map((plugin) => {
265
+ let next = overrides.has(plugin.id) ? { ...plugin, version: overrides.get(plugin.id) } : plugin;
266
+ if (hasContentSelection && plugin.id === options.id) {
267
+ next = { ...next };
268
+ if (contentSelection) next.contentSelection = contentSelection;
269
+ else delete next.contentSelection;
270
+ }
271
+ return next;
272
+ }),
238
273
  }
239
274
  : pluginsFile;
240
275
  return this.#applyLifecycle("update", {
@@ -410,13 +445,32 @@ export class PluginApplicationService {
410
445
  severity: "error",
411
446
  });
412
447
  }
448
+ const locked = lockById.get(declaration.id);
449
+ const applied = stateById.get(declaration.id);
450
+ if (locked && !contentSelectionsEqual(declaration.contentSelection, locked.contentSelection)) {
451
+ diagnostics.push({
452
+ code: "verify.content-selection-lock-mismatch",
453
+ path: declaration.id,
454
+ message: `Plugin 声明与 lock 的 Content Skill 选择不一致:${declaration.id}`,
455
+ severity: "error",
456
+ });
457
+ }
458
+ if (applied && !contentSelectionsEqual(declaration.contentSelection, applied.contentSelection)) {
459
+ diagnostics.push({
460
+ code: "verify.content-selection-state-mismatch",
461
+ path: declaration.id,
462
+ message: `Plugin 声明与 state 的 Content Skill 选择不一致:${declaration.id}`,
463
+ severity: "error",
464
+ });
465
+ }
413
466
  }
414
467
  for (const id of requestedIds) {
415
468
  const locked = lockById.get(id);
416
469
  const applied = stateById.get(id);
417
470
  if (!locked) continue;
471
+ let pluginPackage = null;
418
472
  try {
419
- this.registry.readPackage(locked);
473
+ pluginPackage = this.registry.readPackage(locked);
420
474
  } catch (error) {
421
475
  diagnostics.push({
422
476
  code: "verify.package-invalid",
@@ -425,6 +479,18 @@ export class PluginApplicationService {
425
479
  severity: "error",
426
480
  });
427
481
  }
482
+ if (pluginPackage) {
483
+ try {
484
+ selectContentSkillEntries(pluginPackage.manifest.content.skills || [], locked.contentSelection, id);
485
+ } catch (error) {
486
+ diagnostics.push({
487
+ code: "verify.content-selection-invalid",
488
+ path: id,
489
+ message: error.message,
490
+ severity: "error",
491
+ });
492
+ }
493
+ }
428
494
  if (!applied) {
429
495
  diagnostics.push({
430
496
  code: "verify.state-missing",
@@ -442,6 +508,14 @@ export class PluginApplicationService {
442
508
  severity: "error",
443
509
  });
444
510
  }
511
+ if (!contentSelectionsEqual(locked.contentSelection, applied.contentSelection)) {
512
+ diagnostics.push({
513
+ code: "verify.content-selection-apply-mismatch",
514
+ path: id,
515
+ message: `Plugin lock 与 state 的 Content Skill 选择不一致:${id}`,
516
+ severity: "error",
517
+ });
518
+ }
445
519
  for (const entry of applied.paths) {
446
520
  const target = path.join(this.projectRoot, ...entry.path.split("/"));
447
521
  let actual = null;
@@ -3,8 +3,20 @@ import { PLUGIN_RUNTIME_ERROR_CODES, PluginRuntimeError } from "../runtime-error
3
3
  /** 凭据载荷 schema 版本。 */
4
4
  export const CREDENTIAL_SCHEMA_VERSION = 1;
5
5
 
6
- /** Flower GitLab OAuth 固定 scope。 */
7
- export const GITLAB_OAUTH_SCOPES = Object.freeze(["read_api", "read_repository"]);
6
+ /** Flower GitLab OAuth 主动请求 scope。 */
7
+ export const GITLAB_OAUTH_REQUEST_SCOPES = Object.freeze([
8
+ "openid",
9
+ "profile",
10
+ "read_user",
11
+ "write_repository",
12
+ "api",
13
+ ]);
14
+
15
+ /** 旧版 GitLab OAuth 读取能力 scope。 */
16
+ export const GITLAB_OAUTH_LEGACY_SCOPES = Object.freeze(["read_api", "read_repository"]);
17
+
18
+ /** 兼容旧内部导入名;新代码应使用 GITLAB_OAUTH_REQUEST_SCOPES。 */
19
+ export const GITLAB_OAUTH_SCOPES = GITLAB_OAUTH_REQUEST_SCOPES;
8
20
 
9
21
  /**
10
22
  * 规范化 GitLab 来源凭据账户名。
@@ -18,6 +30,16 @@ export function credentialAccount(source) {
18
30
  return `${url.hostname.toLowerCase()}${port}/${source.id}`;
19
31
  }
20
32
 
33
+ /**
34
+ * 判断 GitLab OAuth scope 是否具备当前 REST 读取能力。
35
+ *
36
+ * @param {string[]} scopes 实际授权 scope
37
+ * @returns {boolean} 是否满足读取 Marketplace 所需能力
38
+ */
39
+ export function isGitLabCredentialScopeSufficient(scopes) {
40
+ return scopes.includes("api") || GITLAB_OAUTH_LEGACY_SCOPES.every((scope) => scopes.includes(scope));
41
+ }
42
+
21
43
  /**
22
44
  * 校验并规范化版本化 OAuth 凭据。
23
45
  *
@@ -38,7 +60,7 @@ export function validateCredential(value, source) {
38
60
  typeof credential.accessToken !== "string" ||
39
61
  credential.accessToken.length === 0 ||
40
62
  typeof credential.tokenType !== "string" ||
41
- !GITLAB_OAUTH_SCOPES.every((scope) => scopes.includes(scope)) ||
63
+ !isGitLabCredentialScopeSufficient(scopes) ||
42
64
  !(credential.refreshToken === null || typeof credential.refreshToken === "string") ||
43
65
  !(credential.createdAt === undefined || credential.createdAt === null || Number.isFinite(credential.createdAt)) ||
44
66
  !(credential.expiresAt === null || Number.isFinite(credential.expiresAt)) ||
@@ -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) {