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
@@ -12,6 +12,9 @@ export const PLUGIN_ID_PATTERN = "^[a-z0-9]+(?:-[a-z0-9]+)*$";
12
12
  export const CANONICAL_PLUGIN_ID_PATTERN =
13
13
  "^[a-z0-9]+(?:-[a-z0-9]+)*/[a-z0-9]+(?:-[a-z0-9]+)*$";
14
14
 
15
+ /** contentSelection.skills 与 manifest Skill 条目名称的正则源。 */
16
+ export const CONTENT_SKILL_NAME_PATTERN = "^(?!\\.{1,2}$)[^/\\\\]+$";
17
+
15
18
  /** SHA-256 摘要的正则源。 */
16
19
  export const SHA256_DIGEST_PATTERN = "^sha256:[a-f0-9]{64}$";
17
20
 
@@ -180,6 +183,13 @@ export function isGitHubRepository(value) {
180
183
  /** 可复用的安全相对路径 JSON Schema。 */
181
184
  export const SAFE_PATH_SCHEMA = Object.freeze({ type: "string", format: "posix-relative-path" });
182
185
 
186
+ /** 可复用的内容 Skill 名称 JSON Schema。 */
187
+ export const CONTENT_SKILL_NAME_SCHEMA = Object.freeze({
188
+ type: "string",
189
+ minLength: 1,
190
+ pattern: CONTENT_SKILL_NAME_PATTERN,
191
+ });
192
+
183
193
  /** 可复用的 capability 名称 JSON Schema。 */
184
194
  export const CAPABILITY_NAME_SCHEMA = Object.freeze({
185
195
  type: "string",
@@ -433,7 +433,7 @@ export class GitHubSourceProvider {
433
433
  committedAt: resolved.committedAt,
434
434
  subdir,
435
435
  format: "flower",
436
- entryPath: null,
436
+ entryPath: entry.source.manifestPath || null,
437
437
  });
438
438
  if (prepared.packages.length !== 1) {
439
439
  throw new PluginRuntimeError(`Flower Marketplace 条目不是单一 Plugin:${entry.id}`, {
@@ -12,7 +12,7 @@ import {
12
12
  parseCanonicalPluginId,
13
13
  } from "../schemas/shared.js";
14
14
  import { compareUtf8 } from "../stable-order.js";
15
- import { verifyPluginPackage } from "./package-reader.js";
15
+ import { PLUGIN_MANIFEST_FILE, verifyPluginPackage } from "./package-reader.js";
16
16
  import {
17
17
  copyOrdinaryDirectory,
18
18
  extractRemoteArchive,
@@ -100,38 +100,11 @@ export class GitLabSourceProvider {
100
100
  const candidates = [];
101
101
  for (const version of entry.versions) {
102
102
  const root = await this.#preparePackage(entry.source, version);
103
- const manifest = validatePluginManifest(JSON.parse(fs.readFileSync(path.join(root, "plugin.json"), "utf8")));
104
- const id = composeCanonicalPluginId(this.id, manifest.id);
105
- if (id !== canonicalId || manifest.version !== version.version) {
106
- throw new PluginRuntimeError(`远程 Plugin 身份与索引不一致:${canonicalId}@${version.version}`, {
107
- code: PLUGIN_RUNTIME_ERROR_CODES.TARGET_DRIFT,
108
- path: canonicalId,
109
- });
110
- }
111
- if (PROFILE_RANK[manifest.capabilities.profile] > PROFILE_RANK[entry.trust.maxProfile]) {
112
- throw new PluginRuntimeError(`远程 Plugin 超出 Marketplace trust 上限:${canonicalId}`, {
113
- code: PLUGIN_RUNTIME_ERROR_CODES.SOURCE_CONFIG_INVALID,
114
- path: canonicalId,
115
- });
116
- }
117
- const candidate = {
118
- id,
119
- version: manifest.version,
120
- source: {
121
- id: this.id,
122
- type: "gitlab",
123
- reference: this.#sourceReference(entry.source),
124
- indexCommit: this.indexCommit,
125
- },
126
- commit: version.commit.toLowerCase(),
127
- integrity: version.integrity,
128
- manifest,
129
- marketplaceMaxProfile: entry.trust.maxProfile,
130
- };
103
+ const manifest = this.#readPackageManifest(root);
104
+ const candidate = this.#candidateFromMarketplaceVersion(canonicalId, entry, version, manifest);
131
105
  candidates.push(candidate);
132
- this.packageRoots.set(this.#key(candidate), root);
106
+ this.#registerCandidate(candidate, root);
133
107
  }
134
- this.candidates.set(canonicalId, candidates);
135
108
  this.preparedIds.add(canonicalId);
136
109
  const dependencies = new Set(candidates.flatMap((candidate) => Object.keys(candidate.manifest.dependencies || {})));
137
110
  for (const dependency of [...dependencies].sort(compareUtf8)) await this.prepare(dependency);
@@ -140,6 +113,76 @@ export class GitLabSourceProvider {
140
113
  }
141
114
  }
142
115
 
116
+ /**
117
+ * 只准备指定版本的固定包。
118
+ *
119
+ * TUI 读取 Skill 清单只需要一个已选版本;完整 add/update 生命周期仍调用 `prepare()`,
120
+ * 继续准备全部候选和依赖闭包。
121
+ *
122
+ * @param {string} canonicalId canonical Plugin ID
123
+ * @param {string} requestedVersion 需要读取的 Plugin 版本
124
+ * @returns {Promise<void>} 准备完成
125
+ */
126
+ async prepareVersion(canonicalId, requestedVersion) {
127
+ const { sourceId, pluginId } = parseCanonicalPluginId(canonicalId);
128
+ if (sourceId !== this.id) return;
129
+ const existing = this.candidates.get(canonicalId)
130
+ ?.find((candidate) => candidate.version === requestedVersion);
131
+ if (existing && this.packageRoots.has(this.#key(existing))) return;
132
+
133
+ const marketplace = await this.prepareIndex();
134
+ const entry = marketplace.plugins.find((plugin) => plugin.id === pluginId);
135
+ const version = entry?.versions.find((candidate) => candidate.version === requestedVersion);
136
+ if (!entry || !version) {
137
+ throw new PluginRuntimeError(`Marketplace Plugin 版本不存在:${canonicalId}@${requestedVersion}`, {
138
+ code: PLUGIN_RUNTIME_ERROR_CODES.SOURCE_NOT_FOUND,
139
+ path: canonicalId,
140
+ });
141
+ }
142
+ const root = await this.#preparePackage(entry.source, version);
143
+ const manifest = this.#readPackageManifest(root);
144
+ const candidate = this.#candidateFromMarketplaceVersion(canonicalId, entry, version, manifest);
145
+ this.#registerCandidate(candidate, root);
146
+ }
147
+
148
+ /**
149
+ * 为 TUI inspection 只读取指定版本的 manifest。
150
+ *
151
+ * @param {string} canonicalId canonical Plugin ID
152
+ * @param {{version?:string|null,lockedPlugin?:import("../contracts.js").ResolvedPlugin|null}} [options] inspection 选项
153
+ * @returns {Promise<import("../contracts.js").PluginCandidate|null>} manifest 元数据候选
154
+ */
155
+ async inspectContentManifest(canonicalId, options = {}) {
156
+ const { sourceId, pluginId } = parseCanonicalPluginId(canonicalId);
157
+ if (sourceId !== this.id) return null;
158
+ const lockedPlugin = options.lockedPlugin || null;
159
+ const requestedVersion = options.version || lockedPlugin?.version;
160
+ if (!requestedVersion) {
161
+ throw new PluginRuntimeError(`读取 GitLab Plugin manifest 需要版本:${canonicalId}`, {
162
+ code: PLUGIN_RUNTIME_ERROR_CODES.USAGE_ERROR,
163
+ path: canonicalId,
164
+ });
165
+ }
166
+
167
+ const inspected = lockedPlugin
168
+ ? await this.#lockedMarketplaceVersionForInspection(pluginId, lockedPlugin)
169
+ : await this.#marketplaceVersionForInspection(pluginId, requestedVersion);
170
+ const manifest = await this.#readRepositoryManifest(
171
+ inspected.entry.source,
172
+ inspected.version.commit.toLowerCase(),
173
+ canonicalId,
174
+ );
175
+ const candidate = this.#candidateFromMarketplaceVersion(
176
+ canonicalId,
177
+ inspected.entry,
178
+ inspected.version,
179
+ manifest,
180
+ inspected.indexCommit,
181
+ );
182
+ if (lockedPlugin) this.#assertLockedInspectionMatches(lockedPlugin, candidate);
183
+ return candidate;
184
+ }
185
+
143
186
  /**
144
187
  * 从 lock 恢复固定缓存;缓存缺失时再回退到远程准备。
145
188
  *
@@ -187,7 +230,7 @@ export class GitLabSourceProvider {
187
230
  path: plugin.id,
188
231
  });
189
232
  }
190
- const expectedProject = entry.source.type === "path" ? this.source.project : entry.source.project;
233
+ const expectedProject = this.#sourceProject(entry.source);
191
234
  if (plugin.source.reference !== expectedProject) {
192
235
  throw new PluginRuntimeError(`GitLab lock project 与 Marketplace 不一致:${plugin.id}`, {
193
236
  code: PLUGIN_RUNTIME_ERROR_CODES.TARGET_DRIFT,
@@ -197,7 +240,7 @@ export class GitLabSourceProvider {
197
240
  const root = await this.#preparePackage(entry.source, version);
198
241
  let manifest;
199
242
  try {
200
- manifest = validatePluginManifest(JSON.parse(fs.readFileSync(path.join(root, "plugin.json"), "utf8")));
243
+ manifest = this.#readPackageManifest(root);
201
244
  if (composeCanonicalPluginId(this.id, manifest.id) !== plugin.id || manifest.version !== plugin.version) {
202
245
  throw new PluginIntegrityError("GitLab Plugin 缓存身份不匹配", { path: this.id });
203
246
  }
@@ -277,17 +320,18 @@ export class GitLabSourceProvider {
277
320
 
278
321
  /** @param {object} source @param {object} version @returns {Promise<string>} */
279
322
  async #preparePackage(source, version) {
280
- const project = source.type === "path" ? this.source.project : source.project;
281
- const subdir = source.type === "path" ? source.path : (source.subdir || null);
323
+ const project = this.#sourceProject(source);
324
+ const subdir = this.#sourceSubdir(source);
325
+ const manifestPath = this.#manifestPath(source);
282
326
  const commit = version.commit.toLowerCase();
283
- const target = this.#cacheTarget(project, commit, subdir, version.integrity);
327
+ const target = this.#cacheTarget(project, commit, subdir, manifestPath, version.integrity);
284
328
  const key = path.basename(target);
285
329
  if (fs.existsSync(target)) {
286
330
  try {
287
331
  if (hashCanonicalTree(target) !== version.integrity) {
288
332
  throw new PluginIntegrityError("GitLab Plugin 缓存摘要不匹配", { path: this.id });
289
333
  }
290
- this.#writeCacheMetadata(target, { project, commit, subdir, integrity: version.integrity });
334
+ this.#writeCacheMetadata(target, { project, commit, subdir, manifestPath, integrity: version.integrity });
291
335
  return target;
292
336
  } catch (error) {
293
337
  // 缓存只按不可变哈希键寻址,损坏后删除并重建不会影响 lock 或项目目标。
@@ -299,6 +343,7 @@ export class GitLabSourceProvider {
299
343
  const staging = fs.mkdtempSync(path.join(this.cacheRoot, `.staging-${key.slice(0, 12)}-`));
300
344
  const archiveFile = path.join(staging, "archive.tar.gz");
301
345
  const extractRoot = path.join(staging, "extract");
346
+ const snapshotRoot = path.join(staging, "snapshot");
302
347
  const packageRoot = path.join(staging, "package");
303
348
  try {
304
349
  fs.mkdirSync(extractRoot);
@@ -312,12 +357,12 @@ export class GitLabSourceProvider {
312
357
  sourceId: this.id,
313
358
  extractArchive: this.extractArchive,
314
359
  });
315
- copyOrdinaryDirectory(selectedRoot, packageRoot, "GitLab Plugin");
360
+ copyOrdinaryDirectory(selectedRoot, snapshotRoot, "GitLab Plugin");
316
361
  } catch (error) {
317
362
  if (!this.#canFallbackToRepositoryTree(error)) throw error;
318
- await this.#materializeRepositoryTree(project, commit, subdir, packageRoot);
363
+ await this.#materializeRepositoryTree(project, commit, subdir, snapshotRoot);
319
364
  }
320
- validatePluginManifest(JSON.parse(fs.readFileSync(path.join(packageRoot, "plugin.json"), "utf8")));
365
+ this.#buildRuntimePackage(snapshotRoot, packageRoot, source);
321
366
  const integrity = hashCanonicalTree(packageRoot);
322
367
  if (integrity !== version.integrity) {
323
368
  throw new PluginIntegrityError(`GitLab Plugin 摘要不匹配:${version.version}`, { path: this.id });
@@ -333,7 +378,7 @@ export class GitLabSourceProvider {
333
378
  fs.renameSync(packageRoot, target);
334
379
  }
335
380
  }
336
- this.#writeCacheMetadata(target, { project, commit, subdir, integrity: version.integrity });
381
+ this.#writeCacheMetadata(target, { project, commit, subdir, manifestPath, integrity: version.integrity });
337
382
  return target;
338
383
  } catch (error) {
339
384
  if (error instanceof PluginRuntimeError) throw error;
@@ -438,7 +483,270 @@ export class GitLabSourceProvider {
438
483
 
439
484
  /** @param {object} source @returns {string} */
440
485
  #sourceReference(source) {
441
- return source.type === "path" ? this.source.project : source.project;
486
+ return this.#sourceProject(source);
487
+ }
488
+
489
+ /** @param {object} source @returns {string} */
490
+ #sourceProject(source) {
491
+ if (source.type === "path") return this.source.project;
492
+ if (source.type === "gitlab") return source.project;
493
+ throw new PluginRuntimeError(`GitLab Marketplace 暂不支持跨接来源:${source.type}`, {
494
+ code: PLUGIN_RUNTIME_ERROR_CODES.FORMAT_UNSUPPORTED,
495
+ path: this.id,
496
+ });
497
+ }
498
+
499
+ /** @param {object} source @returns {string|null} */
500
+ #sourceSubdir(source) {
501
+ if (source.type === "path") return source.path || null;
502
+ return source.subdir || null;
503
+ }
504
+
505
+ /** @param {object} source @returns {string} */
506
+ #manifestPath(source) {
507
+ return assertSafePosixRelativePath(
508
+ source.manifestPath || PLUGIN_MANIFEST_FILE,
509
+ "GitLab Plugin manifest 路径",
510
+ );
511
+ }
512
+
513
+ /** @param {object} source @returns {string} */
514
+ #repositoryManifestPath(source) {
515
+ const manifestPath = this.#manifestPath(source);
516
+ const subdir = this.#sourceSubdir(source);
517
+ if (!subdir) return manifestPath;
518
+ return `${assertSafePosixRelativePath(subdir, "GitLab Plugin subdir")}/${manifestPath}`;
519
+ }
520
+
521
+ /** @param {string} root @returns {import("../contracts.js").PluginManifest} */
522
+ #readPackageManifest(root) {
523
+ return validatePluginManifest(JSON.parse(fs.readFileSync(path.join(root, PLUGIN_MANIFEST_FILE), "utf8")));
524
+ }
525
+
526
+ /** @param {string} root @param {object} source @returns {{manifest:import("../contracts.js").PluginManifest,raw:string}} */
527
+ #readSourceManifest(root, source) {
528
+ const manifestPath = this.#manifestPath(source);
529
+ const raw = fs.readFileSync(path.join(root, ...manifestPath.split("/")), "utf8");
530
+ return { manifest: validatePluginManifest(JSON.parse(raw)), raw };
531
+ }
532
+
533
+ /** @param {import("../contracts.js").PluginManifest} manifest @returns {string[]} */
534
+ #declaredPackagePaths(manifest) {
535
+ const content = manifest.content || {};
536
+ const skills = (content.skills || []).map(({ path: skillPath }) => skillPath);
537
+ const passive = ["specs", "assets", "scripts", "tests"].flatMap((kind) => content[kind] || []);
538
+ const patches = [
539
+ manifest.patches?.catalog,
540
+ manifest.patches?.bundles,
541
+ ].filter(Boolean);
542
+ return [...new Set([...skills, ...passive, ...patches])].sort(compareUtf8);
543
+ }
544
+
545
+ /** @param {string} sourceRoot @param {string} targetRoot @param {string} relative @returns {void} */
546
+ #copyDeclaredPath(sourceRoot, targetRoot, relative) {
547
+ const safeRelative = assertSafePosixRelativePath(relative, "GitLab Plugin content 路径");
548
+ const source = path.join(sourceRoot, ...safeRelative.split("/"));
549
+ const target = path.join(targetRoot, ...safeRelative.split("/"));
550
+ let stat;
551
+ try {
552
+ stat = fs.lstatSync(source);
553
+ } catch (error) {
554
+ throw new PluginIoError(`GitLab Plugin 声明内容不存在:${safeRelative}`, {
555
+ path: safeRelative,
556
+ cause: error,
557
+ });
558
+ }
559
+ if (stat.isSymbolicLink()) {
560
+ throw new PluginPathError(`GitLab Plugin 声明内容不能是软链:${safeRelative}`, { path: safeRelative });
561
+ }
562
+ if (stat.isDirectory()) {
563
+ copyOrdinaryDirectory(source, target, "GitLab Plugin content");
564
+ return;
565
+ }
566
+ if (!stat.isFile()) {
567
+ throw new PluginPathError(`GitLab Plugin 声明内容必须是普通文件或目录:${safeRelative}`, {
568
+ path: safeRelative,
569
+ });
570
+ }
571
+ fs.mkdirSync(path.dirname(target), { recursive: true });
572
+ fs.copyFileSync(source, target);
573
+ }
574
+
575
+ /** @param {string} sourceRoot @param {string} targetRoot @param {import("../contracts.js").PluginManifest} manifest @returns {void} */
576
+ #copyPlatformOverrides(sourceRoot, targetRoot, manifest) {
577
+ const platformsRoot = path.join(sourceRoot, "platforms");
578
+ if (!fs.existsSync(platformsRoot)) return;
579
+ for (const platform of fs.readdirSync(platformsRoot, { withFileTypes: true }).sort((left, right) => compareUtf8(left.name, right.name))) {
580
+ if (!platform.isDirectory()) continue;
581
+ for (const relative of this.#declaredPackagePaths(manifest)) {
582
+ const override = `platforms/${platform.name}/${relative}`;
583
+ if (fs.existsSync(path.join(sourceRoot, ...override.split("/")))) {
584
+ this.#copyDeclaredPath(sourceRoot, targetRoot, override);
585
+ }
586
+ }
587
+ }
588
+ }
589
+
590
+ /** @param {string} sourceRoot @param {string} targetRoot @param {object} source @returns {import("../contracts.js").PluginManifest} */
591
+ #buildRuntimePackage(sourceRoot, targetRoot, source) {
592
+ const { manifest, raw } = this.#readSourceManifest(sourceRoot, source);
593
+ fs.mkdirSync(targetRoot, { recursive: true });
594
+ fs.writeFileSync(path.join(targetRoot, PLUGIN_MANIFEST_FILE), raw);
595
+ for (const relative of this.#declaredPackagePaths(manifest)) {
596
+ this.#copyDeclaredPath(sourceRoot, targetRoot, relative);
597
+ }
598
+ this.#copyPlatformOverrides(sourceRoot, targetRoot, manifest);
599
+ return manifest;
600
+ }
601
+
602
+ /**
603
+ * 读取固定 commit 上的原始 manifest,不准备运行时包。
604
+ *
605
+ * @param {object} source Marketplace source 条目
606
+ * @param {string} commit 固定 Plugin commit
607
+ * @param {string} canonicalId 诊断用 canonical Plugin ID
608
+ * @returns {Promise<import("../contracts.js").PluginManifest>} 已校验 manifest
609
+ */
610
+ async #readRepositoryManifest(source, commit, canonicalId) {
611
+ const project = this.#sourceProject(source);
612
+ const manifestPath = this.#repositoryManifestPath(source);
613
+ const raw = await this.client.readRawFile(project, manifestPath, commit);
614
+ try {
615
+ return validatePluginManifest(JSON.parse(raw));
616
+ } catch (error) {
617
+ throw new PluginRuntimeError(`GitLab Plugin manifest 无效:${canonicalId}`, {
618
+ code: error?.code || PLUGIN_RUNTIME_ERROR_CODES.SOURCE_CONFIG_INVALID,
619
+ path: canonicalId,
620
+ cause: error,
621
+ });
622
+ }
623
+ }
624
+
625
+ /**
626
+ * 从当前 Marketplace 中定位 inspection 版本。
627
+ *
628
+ * @param {string} pluginId Plugin 本地 ID
629
+ * @param {string} requestedVersion 请求版本
630
+ * @returns {Promise<{entry:object,version:object,indexCommit:string}>} Marketplace 版本上下文
631
+ */
632
+ async #marketplaceVersionForInspection(pluginId, requestedVersion) {
633
+ const marketplace = await this.prepareIndex();
634
+ const entry = marketplace.plugins.find((plugin) => plugin.id === pluginId);
635
+ const version = entry?.versions.find((candidate) => candidate.version === requestedVersion);
636
+ if (!entry || !version) {
637
+ throw new PluginRuntimeError(`Marketplace Plugin 版本不存在:${this.id}/${pluginId}@${requestedVersion}`, {
638
+ code: PLUGIN_RUNTIME_ERROR_CODES.SOURCE_NOT_FOUND,
639
+ path: `${this.id}/${pluginId}`,
640
+ });
641
+ }
642
+ return { entry, version, indexCommit: this.indexCommit };
643
+ }
644
+
645
+ /**
646
+ * 从锁定 Marketplace 中定位 inspection 版本。
647
+ *
648
+ * @param {string} pluginId Plugin 本地 ID
649
+ * @param {import("../contracts.js").ResolvedPlugin} plugin 已锁定 Plugin
650
+ * @returns {Promise<{entry:object,version:object,indexCommit:string}>} Marketplace 版本上下文
651
+ */
652
+ async #lockedMarketplaceVersionForInspection(pluginId, plugin) {
653
+ const raw = await this.client.readRawFile(
654
+ this.source.project,
655
+ this.source.marketplacePath,
656
+ plugin.source.indexCommit,
657
+ );
658
+ let marketplace;
659
+ try {
660
+ marketplace = validateMarketplaceManifest(JSON.parse(raw));
661
+ } catch (error) {
662
+ throw new PluginRuntimeError(`锁定 Marketplace index 无效:${plugin.id}`, {
663
+ code: PLUGIN_RUNTIME_ERROR_CODES.SOURCE_CONFIG_INVALID,
664
+ path: plugin.id,
665
+ cause: error,
666
+ });
667
+ }
668
+ if (marketplace.id !== this.id) {
669
+ throw new PluginRuntimeError(`锁定 Marketplace ID 与 source 不一致:${plugin.id}`, {
670
+ code: PLUGIN_RUNTIME_ERROR_CODES.SOURCE_CONFIG_INVALID,
671
+ path: plugin.id,
672
+ });
673
+ }
674
+ const entry = marketplace.plugins.find((candidate) => candidate.id === pluginId);
675
+ const version = entry?.versions.find((candidate) => (
676
+ candidate.version === plugin.version &&
677
+ candidate.commit.toLowerCase() === plugin.commit.toLowerCase() &&
678
+ candidate.integrity === plugin.integrity
679
+ ));
680
+ if (!entry || !version) {
681
+ throw new PluginRuntimeError(`锁定 Marketplace index 不包含 Plugin:${plugin.id}@${plugin.version}`, {
682
+ code: PLUGIN_RUNTIME_ERROR_CODES.SOURCE_NOT_FOUND,
683
+ path: plugin.id,
684
+ });
685
+ }
686
+ return { entry, version, indexCommit: plugin.source.indexCommit };
687
+ }
688
+
689
+ /**
690
+ * 确认 manifest-only 读取到的锁定身份没有漂移。
691
+ *
692
+ * @param {import("../contracts.js").ResolvedPlugin} lockedPlugin 已锁定 Plugin
693
+ * @param {import("../contracts.js").PluginCandidate} candidate inspection 候选
694
+ * @returns {void}
695
+ */
696
+ #assertLockedInspectionMatches(lockedPlugin, candidate) {
697
+ if (
698
+ lockedPlugin.id !== candidate.id ||
699
+ lockedPlugin.version !== candidate.version ||
700
+ lockedPlugin.commit.toLowerCase() !== candidate.commit ||
701
+ lockedPlugin.integrity !== candidate.integrity ||
702
+ lockedPlugin.source.reference !== candidate.source.reference ||
703
+ lockedPlugin.source.indexCommit !== candidate.source.indexCommit
704
+ ) {
705
+ throw new PluginRuntimeError(`GitLab lock 与 Marketplace 不一致:${lockedPlugin.id}`, {
706
+ code: PLUGIN_RUNTIME_ERROR_CODES.TARGET_DRIFT,
707
+ path: lockedPlugin.id,
708
+ });
709
+ }
710
+ }
711
+
712
+ /**
713
+ * 从 Marketplace version 和固定包 manifest 构造候选。
714
+ *
715
+ * @param {string} canonicalId canonical Plugin ID
716
+ * @param {object} entry Marketplace Plugin 条目
717
+ * @param {object} version Marketplace 版本条目
718
+ * @param {object} manifest 已校验 Plugin manifest
719
+ * @param {string} [indexCommit] Marketplace index commit
720
+ * @returns {import("../contracts.js").PluginCandidate} Provider 候选
721
+ */
722
+ #candidateFromMarketplaceVersion(canonicalId, entry, version, manifest, indexCommit = this.indexCommit) {
723
+ const id = composeCanonicalPluginId(this.id, manifest.id);
724
+ if (id !== canonicalId || manifest.version !== version.version) {
725
+ throw new PluginRuntimeError(`远程 Plugin 身份与索引不一致:${canonicalId}@${version.version}`, {
726
+ code: PLUGIN_RUNTIME_ERROR_CODES.TARGET_DRIFT,
727
+ path: canonicalId,
728
+ });
729
+ }
730
+ if (PROFILE_RANK[manifest.capabilities.profile] > PROFILE_RANK[entry.trust.maxProfile]) {
731
+ throw new PluginRuntimeError(`远程 Plugin 超出 Marketplace trust 上限:${canonicalId}`, {
732
+ code: PLUGIN_RUNTIME_ERROR_CODES.SOURCE_CONFIG_INVALID,
733
+ path: canonicalId,
734
+ });
735
+ }
736
+ return {
737
+ id,
738
+ version: manifest.version,
739
+ source: {
740
+ id: this.id,
741
+ type: "gitlab",
742
+ reference: this.#sourceReference(entry.source),
743
+ indexCommit,
744
+ },
745
+ commit: version.commit.toLowerCase(),
746
+ integrity: version.integrity,
747
+ manifest,
748
+ marketplaceMaxProfile: entry.trust.maxProfile,
749
+ };
442
750
  }
443
751
 
444
752
  /** @param {import("../contracts.js").ResolvedPlugin} plugin @returns {{root:string,manifest:object}|null} */
@@ -464,7 +772,7 @@ export class GitLabSourceProvider {
464
772
  metadata.integrity !== plugin.integrity
465
773
  ) continue;
466
774
  if (hashCanonicalTree(root) !== plugin.integrity) continue;
467
- const manifest = validatePluginManifest(JSON.parse(fs.readFileSync(path.join(root, "plugin.json"), "utf8")));
775
+ const manifest = this.#readPackageManifest(root);
468
776
  if (composeCanonicalPluginId(this.id, manifest.id) === plugin.id && manifest.version === plugin.version) {
469
777
  return { root, manifest };
470
778
  }
@@ -480,7 +788,7 @@ export class GitLabSourceProvider {
480
788
  return `${target}.metadata.json`;
481
789
  }
482
790
 
483
- /** @param {string} target @param {{project:string,commit:string,subdir:string|null,integrity:string}} metadata */
791
+ /** @param {string} target @param {{project:string,commit:string,subdir:string|null,manifestPath:string,integrity:string}} metadata */
484
792
  #writeCacheMetadata(target, metadata) {
485
793
  const metadataPath = this.#cacheMetadataPath(target);
486
794
  const temporary = `${metadataPath}.${process.pid}.tmp`;
@@ -492,6 +800,7 @@ export class GitLabSourceProvider {
492
800
  project: metadata.project,
493
801
  commit: metadata.commit,
494
802
  subdir: metadata.subdir,
803
+ manifestPath: metadata.manifestPath,
495
804
  integrity: metadata.integrity,
496
805
  }, null, 2)}\n`, { mode: 0o600 });
497
806
  fs.renameSync(temporary, metadataPath);
@@ -522,16 +831,33 @@ export class GitLabSourceProvider {
522
831
  manifest,
523
832
  marketplaceMaxProfile,
524
833
  };
525
- this.packageRoots.set(this.#key(plugin), root);
526
- const candidates = this.candidates.get(plugin.id) || [];
527
- if (!candidates.some(({ version }) => version === plugin.version)) candidates.push(candidate);
528
- this.candidates.set(plugin.id, candidates);
834
+ this.#registerCandidate(candidate, root);
835
+ }
836
+
837
+ /**
838
+ * 登记已准备固定包候选。
839
+ *
840
+ * @param {import("../contracts.js").PluginCandidate} candidate Provider 候选
841
+ * @param {string} root 固定包根
842
+ * @returns {void}
843
+ */
844
+ #registerCandidate(candidate, root) {
845
+ this.packageRoots.set(this.#key(candidate), root);
846
+ const candidates = this.candidates.get(candidate.id) || [];
847
+ if (!candidates.some(({ version, commit }) => (
848
+ version === candidate.version &&
849
+ commit === candidate.commit
850
+ ))) {
851
+ candidates.push(candidate);
852
+ candidates.sort((left, right) => compareUtf8(left.version, right.version));
853
+ }
854
+ this.candidates.set(candidate.id, candidates);
529
855
  }
530
856
 
531
- /** @param {string} project @param {string} commit @param {string|null} subdir @param {string} integrity @returns {string} */
532
- #cacheTarget(project, commit, subdir, integrity) {
857
+ /** @param {string} project @param {string} commit @param {string|null} subdir @param {string} manifestPath @param {string} integrity @returns {string} */
858
+ #cacheTarget(project, commit, subdir, manifestPath, integrity) {
533
859
  const key = crypto.createHash("sha256")
534
- .update(`${this.source.baseUrl}\0${project}\0${commit}\0${subdir || ""}\0${integrity}`)
860
+ .update(`${this.source.baseUrl}\0${project}\0${commit}\0${subdir || ""}\0${manifestPath}\0${integrity}`)
535
861
  .digest("hex");
536
862
  return path.join(this.cacheRoot, key);
537
863
  }
@@ -2,6 +2,10 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ import {
6
+ GITLAB_OAUTH_LEGACY_SCOPES,
7
+ GITLAB_OAUTH_REQUEST_SCOPES,
8
+ } from "../auth/credential-store.js";
5
9
  import { PluginIoError } from "../errors.js";
6
10
  import { PLUGIN_RUNTIME_ERROR_CODES, PluginRuntimeError } from "../runtime-errors.js";
7
11
  import {
@@ -27,6 +31,18 @@ const SECRET_FIELDS = new Set([
27
31
  "applicationSecret",
28
32
  ]);
29
33
 
34
+ /**
35
+ * 判断 GitLab source descriptor 的 OAuth scopes 是否属于兼容集合。
36
+ *
37
+ * @param {string[]} scopes 已排序 scope
38
+ * @returns {boolean} 是否支持
39
+ */
40
+ function isSupportedGitLabSourceScopes(scopes) {
41
+ const serialized = scopes.join(" ");
42
+ return serialized === [...GITLAB_OAUTH_LEGACY_SCOPES].sort(compareUtf8).join(" ") ||
43
+ serialized === [...GITLAB_OAUTH_REQUEST_SCOPES].sort(compareUtf8).join(" ");
44
+ }
45
+
30
46
  /**
31
47
  * 返回当前平台的 Flower 用户配置目录。
32
48
  *
@@ -91,7 +107,7 @@ export function validateGitLabSourceDescriptor(value) {
91
107
  typeof source.ref !== "string" || !source.ref ||
92
108
  typeof source.marketplacePath !== "string" || !source.marketplacePath ||
93
109
  typeof oauth?.applicationId !== "string" || !oauth.applicationId ||
94
- scopes.join(" ") !== "read_api read_repository"
110
+ !isSupportedGitLabSourceScopes(scopes)
95
111
  ) {
96
112
  throw new PluginRuntimeError(`GitLab source 配置无效:${String(source?.id || "")}`, {
97
113
  code: PLUGIN_RUNTIME_ERROR_CODES.SOURCE_CONFIG_INVALID,