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