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