flower-trellis 0.6.2 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/enhancements/MANIFEST.json +1 -1
  2. package/package.json +3 -3
  3. package/src/builtin-marketplaces/rd-guide.json +6 -3
  4. package/src/builtin-plugins/flower-plugin-author/plugin.json +6 -1
  5. package/src/builtin-plugins/flower-plugin-author/skills/flower-plugin-author/references/ci-and-review.md +2 -2
  6. package/src/commands/plugin-interactive.js +546 -38
  7. package/src/commands/plugin-remote.js +223 -26
  8. package/src/commands/plugin.js +46 -6
  9. package/src/commands/update.js +10 -2
  10. package/src/lib/skill-catalog.js +20 -1
  11. package/src/plugin/application-service.js +245 -37
  12. package/src/plugin/auth/credential-store.js +25 -3
  13. package/src/plugin/auth/gitlab-credential-resolver.js +234 -0
  14. package/src/plugin/auth/gitlab-oauth.js +26 -5
  15. package/src/plugin/authoring/scaffold.js +6 -1
  16. package/src/plugin/authoring/templates/rd-guide/CODEOWNERS +1 -1
  17. package/src/plugin/authoring/templates/rd-guide/gitlab-ci.yml +2 -2
  18. package/src/plugin/authoring/templates/rd-guide/verify-integration-review.mjs +1 -1
  19. package/src/plugin/content-selection.js +189 -0
  20. package/src/plugin/contracts.js +21 -2
  21. package/src/plugin/formats/adapters.js +27 -3
  22. package/src/plugin/formats/normalized-package.js +126 -14
  23. package/src/plugin/gitlab/rest-client.js +21 -0
  24. package/src/plugin/install/content-projector.js +19 -7
  25. package/src/plugin/resolver/dependency-resolver.js +64 -7
  26. package/src/plugin/runtime-errors.js +1 -0
  27. package/src/plugin/schemas/marketplace-manifest.js +4 -1
  28. package/src/plugin/schemas/plugin-manifest.js +55 -3
  29. package/src/plugin/schemas/project-files.js +18 -0
  30. package/src/plugin/schemas/shared.js +10 -0
  31. package/src/plugin/sources/github-provider.js +1 -1
  32. package/src/plugin/sources/gitlab-provider.js +377 -51
  33. package/src/plugin/sources/user-source-store.js +17 -1
@@ -16,6 +16,13 @@ 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";
24
+
25
+ const SKILL_GARDEN_MIGRATION_OWNER_ID = "flower/skill-garden";
19
26
 
20
27
  /**
21
28
  * 计算 lock roots 可达的全部 Plugin。
@@ -117,6 +124,94 @@ function standardContentPlan(graph) {
117
124
  };
118
125
  }
119
126
 
127
+ /**
128
+ * 校验冻结 Plugin 的 lock/state 对应关系与当前目标摘要。
129
+ *
130
+ * @param {string} projectRoot 项目根
131
+ * @param {import("./contracts.js").ResolvedPlugin} locked 冻结 lock entry
132
+ * @param {import("./contracts.js").PluginStateEntry} applied 冻结 state entry
133
+ * @returns {void}
134
+ */
135
+ function assertPreservedState(projectRoot, locked, applied) {
136
+ if (
137
+ applied.version !== locked.version ||
138
+ !contentSelectionsEqual(applied.contentSelection, locked.contentSelection)
139
+ ) {
140
+ throw new PluginRuntimeError(`冻结 Plugin 的 lock/state 不一致:${locked.id}`, {
141
+ code: PLUGIN_RUNTIME_ERROR_CODES.TARGET_DRIFT,
142
+ path: locked.id,
143
+ });
144
+ }
145
+ for (const entry of applied.paths) {
146
+ const target = path.join(projectRoot, ...entry.path.split("/"));
147
+ const actual = entry.kind === "directory"
148
+ ? hashDirectoryIfExists(target)
149
+ : hashFileIfExists(target);
150
+ if (actual !== entry.hash) {
151
+ throw new PluginRuntimeError(`冻结 Plugin 目标摘要漂移:${entry.path}`, {
152
+ code: PLUGIN_RUNTIME_ERROR_CODES.TARGET_DRIFT,
153
+ path: entry.path,
154
+ details: { expected: entry.hash, actual },
155
+ });
156
+ }
157
+ }
158
+ for (const patchEntry of applied.patches) {
159
+ const target = path.join(projectRoot, ...patchEntry.target.split("/"));
160
+ const actual = hashFileIfExists(target);
161
+ if (actual !== patchEntry.resultHash) {
162
+ throw new PluginRuntimeError(`冻结 Plugin Patch 目标摘要漂移:${patchEntry.target}`, {
163
+ code: PLUGIN_RUNTIME_ERROR_CODES.TARGET_DRIFT,
164
+ path: patchEntry.target,
165
+ details: { expected: patchEntry.resultHash, actual },
166
+ });
167
+ }
168
+ }
169
+ }
170
+
171
+ /**
172
+ * 判断两个项目内路径是否相同或存在父子关系。
173
+ *
174
+ * @param {string} left 左路径
175
+ * @param {string} right 右路径
176
+ * @returns {boolean} 是否相交
177
+ */
178
+ function pathsOverlap(left, right) {
179
+ return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
180
+ }
181
+
182
+ /**
183
+ * 阻止活跃 Plugin 的计划改写冻结 Plugin 的受管目标。
184
+ *
185
+ * @param {import("./contracts.js").PluginStateEntry[]} preservedEntries 冻结 state entries
186
+ * @param {{mutations?:Array<{owner:string,target:string}>,directoryClaims?:Array<{owner:string,path:string}>,directoryRemovals?:Array<{owner:string,path:string}>,patchMutations?:Array<{owner:string,target:string}>}} plan 活跃计划片段
187
+ * @returns {void}
188
+ */
189
+ function assertNoPreservedTargetConflicts(preservedEntries, plan) {
190
+ const protectedTargets = preservedEntries.flatMap((plugin) => [
191
+ ...plugin.paths.map(({ path: target }) => ({ owner: plugin.id, target })),
192
+ ...plugin.patches.map(({ target }) => ({ owner: plugin.id, target })),
193
+ ]);
194
+ const activeTargets = [
195
+ ...(plan.mutations || []).map(({ owner, target }) => ({ owner, target })),
196
+ ...(plan.directoryClaims || []).map(({ owner, path: target }) => ({ owner, target })),
197
+ ...(plan.directoryRemovals || []).map(({ owner, path: target }) => ({ owner, target })),
198
+ ...(plan.patchMutations || []).map(({ owner, target }) => ({ owner, target })),
199
+ ];
200
+ for (const active of activeTargets) {
201
+ const protectedTarget = protectedTargets.find((entry) => pathsOverlap(active.target, entry.target));
202
+ if (!protectedTarget) continue;
203
+ throw new PluginRuntimeError(`活跃 Plugin 计划与冻结目标冲突:${active.target}`, {
204
+ code: PLUGIN_RUNTIME_ERROR_CODES.CONTENT_CONFLICT,
205
+ path: active.target,
206
+ details: {
207
+ activeOwner: active.owner,
208
+ preservedOwner: protectedTarget.owner,
209
+ preservedTarget: protectedTarget.target,
210
+ },
211
+ });
212
+ }
213
+ }
214
+
120
215
  /**
121
216
  * Flower Plugin 生命周期应用服务。
122
217
  */
@@ -151,7 +246,7 @@ export class PluginApplicationService {
151
246
  /**
152
247
  * 添加或更新一个直接 Plugin 声明并应用完整图。
153
248
  *
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 添加选项
249
+ * @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,preserveIds?:string[],onPreflight?:(result:object)=>void}} options 添加选项
155
250
  * @returns {object} 生命周期结果
156
251
  */
157
252
  add(options) {
@@ -168,6 +263,11 @@ export class PluginApplicationService {
168
263
  source: sourceId,
169
264
  version: options.version || "*",
170
265
  };
266
+ const contentSelection = normalizeContentSelection(options.contentSelection, {
267
+ code: PLUGIN_RUNTIME_ERROR_CODES.USAGE_ERROR,
268
+ path: options.id,
269
+ });
270
+ if (contentSelection) declaration.contentSelection = contentSelection;
171
271
  const declarations = pluginsFile.plugins.filter(({ id }) => id !== options.id);
172
272
  declarations.push(declaration);
173
273
  return this.#applyLifecycle("add", {
@@ -178,6 +278,7 @@ export class PluginApplicationService {
178
278
  approvals: options.approvals || [],
179
279
  approvedDigests: options.approvedDigests,
180
280
  nonInteractive: options.nonInteractive,
281
+ preserveIds: options.preserveIds || [],
181
282
  onPreflight: options.onPreflight,
182
283
  });
183
284
  }
@@ -189,12 +290,13 @@ export class PluginApplicationService {
189
290
  * 其锁定包也已不可重放,逐个更新会在其它节点上撞 `已锁定 Plugin 包不可重放`。
190
291
  * 传入 `widen` 时按 `update: "all"` 解析,让全部节点都允许升级,一次性解开这种死锁。
191
292
  *
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] 更新选项
293
+ * @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,preserveIds?:string[],onPreflight?:(result:object)=>void}} [options] 更新选项
193
294
  * @returns {object} 生命周期结果
194
295
  */
195
296
  update(options = {}) {
196
297
  const pluginsFile = this.store.readPlugins();
197
298
  const widen = normalizeVersionOverrides(options.widen);
299
+ const hasContentSelection = Object.prototype.hasOwnProperty.call(options, "contentSelection");
198
300
  if (options.id && !pluginsFile.plugins.some(({ id }) => id === options.id)) {
199
301
  throw new PluginRuntimeError(`项目未声明 Plugin:${options.id}`, {
200
302
  code: PLUGIN_RUNTIME_ERROR_CODES.USAGE_ERROR,
@@ -214,6 +316,18 @@ export class PluginApplicationService {
214
316
  path: "widen",
215
317
  });
216
318
  }
319
+ if (hasContentSelection && !options.id) {
320
+ throw new PluginRuntimeError("更新 Content Skill 选择时必须指定 Plugin ID", {
321
+ code: PLUGIN_RUNTIME_ERROR_CODES.USAGE_ERROR,
322
+ path: "contentSelection",
323
+ });
324
+ }
325
+ if (hasContentSelection && widen.size > 0) {
326
+ throw new PluginRuntimeError("更新 Content Skill 选择时不能同时放宽声明范围", {
327
+ code: PLUGIN_RUNTIME_ERROR_CODES.USAGE_ERROR,
328
+ path: "contentSelection",
329
+ });
330
+ }
217
331
  const declaredIds = new Set(pluginsFile.plugins.map(({ id }) => id));
218
332
  for (const id of [...widen.keys()].sort(compareUtf8)) {
219
333
  if (!declaredIds.has(id)) {
@@ -229,12 +343,24 @@ export class PluginApplicationService {
229
343
  const overrides = options.version && options.id
230
344
  ? new Map([[options.id, options.version]])
231
345
  : widen;
232
- const nextPlugins = overrides.size > 0
346
+ const contentSelection = hasContentSelection
347
+ ? normalizeContentSelection(options.contentSelection, {
348
+ code: PLUGIN_RUNTIME_ERROR_CODES.USAGE_ERROR,
349
+ path: options.id,
350
+ })
351
+ : undefined;
352
+ const nextPlugins = (overrides.size > 0 || hasContentSelection)
233
353
  ? {
234
354
  ...pluginsFile,
235
- plugins: pluginsFile.plugins.map((plugin) => (
236
- overrides.has(plugin.id) ? { ...plugin, version: overrides.get(plugin.id) } : plugin
237
- )),
355
+ plugins: pluginsFile.plugins.map((plugin) => {
356
+ let next = overrides.has(plugin.id) ? { ...plugin, version: overrides.get(plugin.id) } : plugin;
357
+ if (hasContentSelection && plugin.id === options.id) {
358
+ next = { ...next };
359
+ if (contentSelection) next.contentSelection = contentSelection;
360
+ else delete next.contentSelection;
361
+ }
362
+ return next;
363
+ }),
238
364
  }
239
365
  : pluginsFile;
240
366
  return this.#applyLifecycle("update", {
@@ -245,6 +371,7 @@ export class PluginApplicationService {
245
371
  approvals: options.approvals || [],
246
372
  approvedDigests: options.approvedDigests,
247
373
  nonInteractive: options.nonInteractive,
374
+ preserveIds: options.preserveIds || [],
248
375
  onPreflight: options.onPreflight,
249
376
  });
250
377
  }
@@ -410,13 +537,32 @@ export class PluginApplicationService {
410
537
  severity: "error",
411
538
  });
412
539
  }
540
+ const locked = lockById.get(declaration.id);
541
+ const applied = stateById.get(declaration.id);
542
+ if (locked && !contentSelectionsEqual(declaration.contentSelection, locked.contentSelection)) {
543
+ diagnostics.push({
544
+ code: "verify.content-selection-lock-mismatch",
545
+ path: declaration.id,
546
+ message: `Plugin 声明与 lock 的 Content Skill 选择不一致:${declaration.id}`,
547
+ severity: "error",
548
+ });
549
+ }
550
+ if (applied && !contentSelectionsEqual(declaration.contentSelection, applied.contentSelection)) {
551
+ diagnostics.push({
552
+ code: "verify.content-selection-state-mismatch",
553
+ path: declaration.id,
554
+ message: `Plugin 声明与 state 的 Content Skill 选择不一致:${declaration.id}`,
555
+ severity: "error",
556
+ });
557
+ }
413
558
  }
414
559
  for (const id of requestedIds) {
415
560
  const locked = lockById.get(id);
416
561
  const applied = stateById.get(id);
417
562
  if (!locked) continue;
563
+ let pluginPackage = null;
418
564
  try {
419
- this.registry.readPackage(locked);
565
+ pluginPackage = this.registry.readPackage(locked);
420
566
  } catch (error) {
421
567
  diagnostics.push({
422
568
  code: "verify.package-invalid",
@@ -425,6 +571,18 @@ export class PluginApplicationService {
425
571
  severity: "error",
426
572
  });
427
573
  }
574
+ if (pluginPackage) {
575
+ try {
576
+ selectContentSkillEntries(pluginPackage.manifest.content.skills || [], locked.contentSelection, id);
577
+ } catch (error) {
578
+ diagnostics.push({
579
+ code: "verify.content-selection-invalid",
580
+ path: id,
581
+ message: error.message,
582
+ severity: "error",
583
+ });
584
+ }
585
+ }
428
586
  if (!applied) {
429
587
  diagnostics.push({
430
588
  code: "verify.state-missing",
@@ -442,6 +600,14 @@ export class PluginApplicationService {
442
600
  severity: "error",
443
601
  });
444
602
  }
603
+ if (!contentSelectionsEqual(locked.contentSelection, applied.contentSelection)) {
604
+ diagnostics.push({
605
+ code: "verify.content-selection-apply-mismatch",
606
+ path: id,
607
+ message: `Plugin lock 与 state 的 Content Skill 选择不一致:${id}`,
608
+ severity: "error",
609
+ });
610
+ }
445
611
  for (const entry of applied.paths) {
446
612
  const target = path.join(this.projectRoot, ...entry.path.split("/"));
447
613
  let actual = null;
@@ -483,10 +649,39 @@ export class PluginApplicationService {
483
649
  const pluginsFile = validatePluginsFile(input.plugins);
484
650
  const previousLock = this.store.readLock();
485
651
  const previousState = this.store.readState();
652
+ const preservedIds = new Set(input.preserveIds || []);
653
+ const previousLockById = new Map((previousLock?.plugins || []).map((plugin) => [plugin.id, plugin]));
654
+ const previousStateById = new Map((previousState?.plugins || []).map((plugin) => [plugin.id, plugin]));
486
655
  const resolution = resolvePluginGraph(pluginsFile.plugins, this.registry, {
487
656
  lockedPlugins: previousLock?.plugins || [],
488
657
  update: input.update,
658
+ preserveIds: [...preservedIds],
489
659
  });
660
+ const resolvedIds = new Set(resolution.graph.plugins.map(({ id }) => id));
661
+ const preservedEntries = [];
662
+ for (const id of [...preservedIds].sort(compareUtf8)) {
663
+ const previousLocked = previousLockById.get(id);
664
+ const previousEntry = previousStateById.get(id);
665
+ if (!previousLocked || !resolvedIds.has(id)) {
666
+ throw new PluginRuntimeError(`冻结 Plugin 缺少既有 lock:${id}`, {
667
+ code: PLUGIN_RUNTIME_ERROR_CODES.TARGET_DRIFT,
668
+ path: id,
669
+ });
670
+ }
671
+ if (!previousEntry) {
672
+ throw new PluginRuntimeError(`冻结 Plugin 缺少既有 state:${id}`, {
673
+ code: PLUGIN_RUNTIME_ERROR_CODES.TARGET_DRIFT,
674
+ path: id,
675
+ });
676
+ }
677
+ assertPreservedState(this.projectRoot, previousLocked, previousEntry);
678
+ preservedEntries.push(previousEntry);
679
+ }
680
+ const activeGraph = {
681
+ roots: resolution.graph.roots.filter((id) => !preservedIds.has(id)),
682
+ plugins: resolution.graph.plugins.filter(({ id }) => !preservedIds.has(id)),
683
+ };
684
+ const activeSelected = resolution.selected.filter(({ id }) => !preservedIds.has(id));
490
685
  let projection = {
491
686
  mutations: [],
492
687
  payloads: new Map(),
@@ -495,47 +690,45 @@ export class PluginApplicationService {
495
690
  directoryRemovals: [],
496
691
  };
497
692
  let platformSelection = { platforms: [], targets: [] };
498
- if (resolution.graph.plugins.length > 0) {
693
+ if (activeGraph.plugins.length > 0) {
499
694
  const platforms = input.platforms.length > 0
500
695
  ? input.platforms
501
696
  : statePlatforms(previousState);
502
697
  platformSelection = this.platformDetector(this.projectRoot, platforms);
503
698
  projection = projectPluginContent({
504
699
  projectRoot: this.projectRoot,
505
- graph: resolution.graph,
506
- selected: resolution.selected,
700
+ graph: activeGraph,
701
+ selected: activeSelected,
507
702
  registry: this.registry,
508
703
  platformSelection,
509
704
  previousState,
510
705
  });
511
706
  }
512
- const preservedIds = new Set(input.preserveIds || []);
513
707
  if (preservedIds.size > 0) {
514
- for (const id of preservedIds) {
515
- const previousEntry = previousState?.plugins.find((plugin) => plugin.id === id);
516
- const nextIndex = projection.state.plugins.findIndex((plugin) => plugin.id === id);
517
- if (!previousEntry || nextIndex < 0) {
518
- throw new PluginRuntimeError(`冻结 Plugin 缺少既有 state:${id}`, {
519
- code: PLUGIN_RUNTIME_ERROR_CODES.TARGET_DRIFT,
520
- path: id,
521
- });
522
- }
523
- projection.state.plugins[nextIndex] = structuredClone(previousEntry);
524
- const previousLocked = previousLock?.plugins.find((plugin) => plugin.id === id);
525
- const graphIndex = resolution.graph.plugins.findIndex((plugin) => plugin.id === id);
526
- if (!previousLocked || graphIndex < 0) {
527
- throw new PluginRuntimeError(`冻结 Plugin 缺少既有 lock:${id}`, {
528
- code: PLUGIN_RUNTIME_ERROR_CODES.TARGET_DRIFT,
529
- path: id,
530
- });
531
- }
532
- resolution.graph.plugins[graphIndex] = structuredClone(previousLocked);
533
- }
534
- projection.mutations = projection.mutations.filter(({ owner }) => !preservedIds.has(owner));
535
- projection.directoryClaims = projection.directoryClaims.filter(({ owner }) => !preservedIds.has(owner));
536
- projection.directoryRemovals = projection.directoryRemovals
537
- .filter(({ owner }) => !preservedIds.has(owner));
538
- if (previousState?.migration) projection.state.migration = structuredClone(previousState.migration);
708
+ const projectedStateById = new Map(projection.state.plugins.map((plugin) => [plugin.id, plugin]));
709
+ projection.state.plugins = resolution.graph.plugins.map(({ id }) => {
710
+ if (preservedIds.has(id)) return structuredClone(previousStateById.get(id));
711
+ const projected = projectedStateById.get(id);
712
+ if (!projected) throw new Error(`Resolved Plugin 缺少投影 state:${id}`);
713
+ return projected;
714
+ });
715
+ resolution.graph.plugins = resolution.graph.plugins.map((plugin) => (
716
+ preservedIds.has(plugin.id)
717
+ ? structuredClone(previousLockById.get(plugin.id))
718
+ : plugin
719
+ ));
720
+ assertNoPreservedTargetConflicts(preservedEntries, {
721
+ mutations: projection.mutations,
722
+ directoryClaims: projection.directoryClaims,
723
+ directoryRemovals: projection.directoryRemovals,
724
+ });
725
+ }
726
+ if (
727
+ preservedIds.has(SKILL_GARDEN_MIGRATION_OWNER_ID) &&
728
+ !projection.state.migration &&
729
+ previousState?.migration
730
+ ) {
731
+ projection.state.migration = structuredClone(previousState.migration);
539
732
  }
540
733
 
541
734
  const previousDirectories = new Map();
@@ -545,7 +738,11 @@ export class PluginApplicationService {
545
738
  }
546
739
  }
547
740
  const directoryClaims = [];
548
- const desiredDirectoryPaths = new Set();
741
+ const desiredDirectoryPaths = new Set(
742
+ projection.state.plugins.flatMap((plugin) => plugin.paths
743
+ .filter(({ kind }) => kind === "directory")
744
+ .map(({ path: target }) => target)),
745
+ );
549
746
  const directoryOwners = new Map();
550
747
  for (const claim of projection.directoryClaims) {
551
748
  desiredDirectoryPaths.add(claim.path);
@@ -623,6 +820,12 @@ export class PluginApplicationService {
623
820
  });
624
821
  }
625
822
  }
823
+ if (preservedIds.size > 0) {
824
+ assertNoPreservedTargetConflicts(preservedEntries, {
825
+ mutations: removalMutations,
826
+ directoryRemovals,
827
+ });
828
+ }
626
829
 
627
830
  const patchPlanner = getPluginPatchPlanner();
628
831
  let patchResult = standardContentPlan(resolution.graph);
@@ -700,6 +903,11 @@ export class PluginApplicationService {
700
903
  });
701
904
  }
702
905
  }
906
+ if (preservedIds.size > 0) {
907
+ assertNoPreservedTargetConflicts(preservedEntries, {
908
+ patchMutations: patchResult.patchMutations,
909
+ });
910
+ }
703
911
  const grants = new Map(patchResult.grants.map(({ pluginId, grant }) => [pluginId, grant]));
704
912
  for (const plugin of resolution.graph.plugins) {
705
913
  const grant = grants.get(plugin.id) || (
@@ -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)) ||