@vetta-org/plugin-cli 0.1.1 → 0.1.2
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/CHANGELOG.md +6 -0
- package/dist/cli.js +9 -2
- package/dist/index.js +9 -2
- package/dist/sync.d.ts.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@vetta-org/plugin-cli` are documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.1.2] — 2026-09-14
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- `sync` preserves the marketplace index's existing indentation (and whether it ended with a newline) instead of rewriting the whole file with tabs. Reformatting turned a two-line change into a whole-file diff, fought with other scripts that write the same file, and escalated any concurrent commit into a full-file conflict. A reconciliation tool should only touch the fields it reconciles.
|
|
10
|
+
|
|
5
11
|
## [0.1.1] — 2026-09-14
|
|
6
12
|
|
|
7
13
|
### Fixed
|
package/dist/cli.js
CHANGED
|
@@ -19250,6 +19250,10 @@ import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync2,
|
|
|
19250
19250
|
import { join as join4, relative as relative2, resolve as resolve3, sep } from "node:path";
|
|
19251
19251
|
var SCAN_IGNORED = new Set(["node_modules", ".git", "dist", "release", ".vetta", "assets", "test", "src"]);
|
|
19252
19252
|
var SCAN_MAX_DEPTH = 5;
|
|
19253
|
+
function detectJsonIndent(source) {
|
|
19254
|
+
const match2 = /\n([ \t]+)\S/.exec(source);
|
|
19255
|
+
return match2?.[1] ?? "\t";
|
|
19256
|
+
}
|
|
19253
19257
|
function readJsonFile(path) {
|
|
19254
19258
|
try {
|
|
19255
19259
|
const parsed = JSON.parse(readFileSync2(path, "utf8"));
|
|
@@ -19352,8 +19356,11 @@ function syncMarketplaceIndex(input) {
|
|
|
19352
19356
|
message: "content changed but marketplaceVersion could not be bumped automatically (expected semver or an integer). Set it manually — clients skip the update when it does not change."
|
|
19353
19357
|
});
|
|
19354
19358
|
}
|
|
19355
|
-
|
|
19356
|
-
|
|
19359
|
+
const source = readFileSync2(input.manifestPath, "utf8");
|
|
19360
|
+
const serialized = JSON.stringify(manifest, null, detectJsonIndent(source));
|
|
19361
|
+
writeFileSync2(input.manifestPath, source.endsWith(`
|
|
19362
|
+
`) ? `${serialized}
|
|
19363
|
+
` : serialized, "utf8");
|
|
19357
19364
|
written = true;
|
|
19358
19365
|
}
|
|
19359
19366
|
return { manifestPath: input.manifestPath, changes, problems, unlisted, written };
|
package/dist/index.js
CHANGED
|
@@ -19249,6 +19249,10 @@ import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync2,
|
|
|
19249
19249
|
import { join as join4, relative as relative2, resolve as resolve3, sep } from "node:path";
|
|
19250
19250
|
var SCAN_IGNORED = new Set(["node_modules", ".git", "dist", "release", ".vetta", "assets", "test", "src"]);
|
|
19251
19251
|
var SCAN_MAX_DEPTH = 5;
|
|
19252
|
+
function detectJsonIndent(source) {
|
|
19253
|
+
const match2 = /\n([ \t]+)\S/.exec(source);
|
|
19254
|
+
return match2?.[1] ?? "\t";
|
|
19255
|
+
}
|
|
19252
19256
|
function readJsonFile(path) {
|
|
19253
19257
|
try {
|
|
19254
19258
|
const parsed = JSON.parse(readFileSync2(path, "utf8"));
|
|
@@ -19351,8 +19355,11 @@ function syncMarketplaceIndex(input) {
|
|
|
19351
19355
|
message: "content changed but marketplaceVersion could not be bumped automatically (expected semver or an integer). Set it manually — clients skip the update when it does not change."
|
|
19352
19356
|
});
|
|
19353
19357
|
}
|
|
19354
|
-
|
|
19355
|
-
|
|
19358
|
+
const source = readFileSync2(input.manifestPath, "utf8");
|
|
19359
|
+
const serialized = JSON.stringify(manifest, null, detectJsonIndent(source));
|
|
19360
|
+
writeFileSync2(input.manifestPath, source.endsWith(`
|
|
19361
|
+
`) ? `${serialized}
|
|
19362
|
+
` : serialized, "utf8");
|
|
19356
19363
|
written = true;
|
|
19357
19364
|
}
|
|
19358
19365
|
return { manifestPath: input.manifestPath, changes, problems, unlisted, written };
|
package/dist/sync.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sync.d.ts","sourceRoot":"","sources":["../src/sync.ts"],"names":[],"mappings":"AAGA;;;;;;;;;GASG;AAEH,MAAM,MAAM,cAAc,GAAG,SAAS,GAAG,aAAa,GAAG,aAAa,GAAG,UAAU,GAAG,oBAAoB,CAAC;AAE3G,MAAM,WAAW,UAAU;IAC1B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,cAAc,CAAC;IAC/B,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IACvB,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAC;CACrB;AAED,MAAM,WAAW,WAAW;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,UAAU;IAC1B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,OAAO,EAAE,SAAS,UAAU,EAAE,CAAC;IACxC,QAAQ,CAAC,QAAQ,EAAE,SAAS,WAAW,EAAE,CAAC;IAC1C,8FAAoC;IACpC,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC,wCAAsB;IACtB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;CAC1B;AAED,MAAM,WAAW,SAAS;IACzB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,oDAAgC;IAChC,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;CACxB;AAuDD,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,SAAS,GAAG,UAAU,CA6EjE;AAiHD;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE;IACzC,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CAChB,GAAG,MAAM,GAAG,SAAS,CAUrB","sourcesContent":["import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from \"node:fs\";\nimport { join, relative, resolve, sep } from \"node:path\";\n\n/**\n * 对账能力市场索引(`.vetta/marketplace.json`)与各能力目录。\n *\n * 索引本来就是派生数据,却有一组会咬人的硬约束:条目的 slug/version 必须与能力目录里的身份\n * 文件完全相等,否则宿主同步**直接失败**;`entry`/`styles` 指向的文件必须真实存在,否则本地\n * 能装、市场上装不了;而改了任何内容却没换 `marketplaceVersion` 时,客户端按它判缓存——\n * 既不报错也不更新,用户只是永远收不到新版本。\n *\n * 三种失败里有两种不在作者机器上复现,一种压根不报错。所以这件事必须是工具做的。\n */\n\nexport type SyncChangeKind = \"version\" | \"api_version\" | \"permissions\" | \"commands\" | \"marketplaceVersion\";\n\nexport interface SyncChange {\n\treadonly slug: string;\n\treadonly field: SyncChangeKind;\n\treadonly from: unknown;\n\treadonly to: unknown;\n}\n\nexport interface SyncProblem {\n\treadonly slug: string;\n\treadonly message: string;\n}\n\nexport interface SyncResult {\n\treadonly manifestPath: string;\n\treadonly changes: readonly SyncChange[];\n\treadonly problems: readonly SyncProblem[];\n\t/** 目录里有身份文件、索引里却没有的能力。只报告,不擅自上架。 */\n\treadonly unlisted: readonly string[];\n\t/** apply 时是否真的写了盘。 */\n\treadonly written: boolean;\n}\n\nexport interface SyncInput {\n\treadonly hubRoot: string;\n\treadonly manifestPath: string;\n\t/** false 时只报告不写盘(`--check`)。 */\n\treadonly apply: boolean;\n}\n\nconst SCAN_IGNORED = new Set([\"node_modules\", \".git\", \"dist\", \"release\", \".vetta\", \"assets\", \"test\", \"src\"]);\nconst SCAN_MAX_DEPTH = 5;\n\nfunction readJsonFile(path: string): Record<string, unknown> | undefined {\n\ttry {\n\t\tconst parsed: unknown = JSON.parse(readFileSync(path, \"utf8\"));\n\t\tif (typeof parsed !== \"object\" || parsed === null || Array.isArray(parsed)) return undefined;\n\t\treturn parsed as Record<string, unknown>;\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\nfunction stringArray(value: unknown): string[] | undefined {\n\tif (!Array.isArray(value)) return undefined;\n\treturn value.every((item) => typeof item === \"string\") ? [...(value as string[])] : undefined;\n}\n\nfunction sameStringArray(left: unknown, right: readonly string[]): boolean {\n\tconst current = stringArray(left) ?? [];\n\treturn current.length === right.length && current.every((item, index) => item === right[index]);\n}\n\n/** `source.path` 必须留在仓库内;索引是仓库里的文件,不该能指到仓库外面去。 */\nfunction resolveAbilityDir(hubRoot: string, sourcePath: string): string | undefined {\n\tconst target = resolve(hubRoot, sourcePath);\n\tconst fromRoot = relative(hubRoot, target);\n\tif (fromRoot === \"\" || fromRoot === \"..\" || fromRoot.startsWith(`..${sep}`)) return undefined;\n\treturn existsSync(target) && statSync(target).isDirectory() ? target : undefined;\n}\n\n/** 能力目录内的相对路径同理,且必须指向真实存在的文件。 */\nfunction packagedFileExists(abilityDir: string, path: string): boolean {\n\tconst target = resolve(abilityDir, path);\n\tconst fromDir = relative(abilityDir, target);\n\tif (fromDir === \"\" || fromDir === \"..\" || fromDir.startsWith(`..${sep}`)) return false;\n\treturn existsSync(target) && statSync(target).isFile();\n}\n\n/**\n * 推进 `marketplaceVersion`。\n *\n * 只认得两种写法:semver 和纯整数。其它写法(日期、commit 短号……)由作者自己决定下一个是\n * 什么,工具猜一个反而更危险——这个值一旦回退或重复,客户端就不会拉新快照。\n */\nfunction nextMarketplaceVersion(current: unknown): string | undefined {\n\tif (typeof current !== \"string\") return undefined;\n\tconst semver = /^(\\d+)\\.(\\d+)\\.(\\d+)$/.exec(current.trim());\n\tif (semver) return `${semver[1]}.${semver[2]}.${Number(semver[3]) + 1}`;\n\tif (/^\\d+$/.test(current.trim())) return String(Number(current.trim()) + 1);\n\treturn undefined;\n}\n\nexport function syncMarketplaceIndex(input: SyncInput): SyncResult {\n\tconst manifest = readJsonFile(input.manifestPath);\n\tif (!manifest) throw new Error(`Malformed marketplace manifest: ${input.manifestPath}`);\n\tconst abilities = Array.isArray(manifest.abilities) ? (manifest.abilities as unknown[]) : [];\n\n\tconst changes: SyncChange[] = [];\n\tconst problems: SyncProblem[] = [];\n\tconst listedDirs = new Set<string>();\n\n\tfor (const raw of abilities) {\n\t\tif (typeof raw !== \"object\" || raw === null || Array.isArray(raw)) continue;\n\t\tconst entry = raw as Record<string, unknown>;\n\t\tconst slug = typeof entry.slug === \"string\" ? entry.slug : \"(unnamed)\";\n\t\tconst type = typeof entry.type === \"string\" ? entry.type : \"\";\n\t\tif (type === \"bundle\") {\n\t\t\t// bundle 成员刻意不独立上架(索引的 abilities 是「独立上架条目」)。把它们的目录\n\t\t\t// 记为已登记,否则会被当成漏登记的能力报出来。\n\t\t\tconst bundleConfig =\n\t\t\t\ttypeof entry.config === \"object\" && entry.config !== null && !Array.isArray(entry.config)\n\t\t\t\t\t? (entry.config as Record<string, unknown>)\n\t\t\t\t\t: undefined;\n\t\t\tfor (const member of Array.isArray(bundleConfig?.members) ? (bundleConfig.members as unknown[]) : []) {\n\t\t\t\tif (typeof member !== \"object\" || member === null || Array.isArray(member)) continue;\n\t\t\t\tconst memberSource = (member as Record<string, unknown>).source;\n\t\t\t\tconst memberPath =\n\t\t\t\t\ttypeof memberSource === \"object\" && memberSource !== null && !Array.isArray(memberSource)\n\t\t\t\t\t\t? (memberSource as Record<string, unknown>).path\n\t\t\t\t\t\t: undefined;\n\t\t\t\tif (typeof memberPath !== \"string\") continue;\n\t\t\t\tconst dir = resolveAbilityDir(input.hubRoot, memberPath);\n\t\t\t\tif (dir) listedDirs.add(dir);\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst source = typeof entry.source === \"object\" && entry.source !== null ? (entry.source as Record<string, unknown>) : undefined;\n\t\tconst sourcePath = typeof source?.path === \"string\" ? source.path : undefined;\n\t\tif (!sourcePath) {\n\t\t\tproblems.push({ slug, message: \"entry has no source.path\" });\n\t\t\tcontinue;\n\t\t}\n\t\tconst abilityDir = resolveAbilityDir(input.hubRoot, sourcePath);\n\t\tif (!abilityDir) {\n\t\t\tproblems.push({ slug, message: `source.path does not resolve to a directory inside the repository: ${sourcePath}` });\n\t\t\tcontinue;\n\t\t}\n\t\tlistedDirs.add(abilityDir);\n\n\t\tif (type === \"plugin\") reconcilePlugin({ entry, slug, abilityDir, changes, problems });\n\t\telse if (type === \"mcp\") reconcileIdentityFile({ entry, slug, abilityDir, fileName: \"mcp.json\", changes, problems });\n\t\t// skill / scene 目录里没有身份文件,目录存在即算通过。\n\t}\n\n\t// 未登记的目录只报告、不作为 problem:作者可能正在开发一个还不打算上架的东西。\n\tconst unlisted = findAbilityDirectories(input.hubRoot)\n\t\t.filter((dir) => !listedDirs.has(dir))\n\t\t.map((dir) => relative(input.hubRoot, dir).split(sep).join(\"/\"))\n\t\t.sort();\n\n\tlet written = false;\n\tif (changes.length > 0 && input.apply) {\n\t\tconst bumped = nextMarketplaceVersion(manifest.marketplaceVersion);\n\t\tif (bumped) {\n\t\t\tchanges.push({ slug: \"(manifest)\", field: \"marketplaceVersion\", from: manifest.marketplaceVersion, to: bumped });\n\t\t\tmanifest.marketplaceVersion = bumped;\n\t\t} else {\n\t\t\tproblems.push({\n\t\t\t\tslug: \"(manifest)\",\n\t\t\t\tmessage:\n\t\t\t\t\t\"content changed but marketplaceVersion could not be bumped automatically (expected semver or an integer). Set it manually — clients skip the update when it does not change.\",\n\t\t\t});\n\t\t}\n\t\twriteFileSync(input.manifestPath, `${JSON.stringify(manifest, null, \"\\t\")}\\n`, \"utf8\");\n\t\twritten = true;\n\t}\n\n\treturn { manifestPath: input.manifestPath, changes, problems, unlisted, written };\n}\n\nfunction reconcilePlugin(context: {\n\tentry: Record<string, unknown>;\n\tslug: string;\n\tabilityDir: string;\n\tchanges: SyncChange[];\n\tproblems: SyncProblem[];\n}): void {\n\tconst { entry, slug, abilityDir, changes, problems } = context;\n\tconst manifest = readJsonFile(join(abilityDir, \"plugin.json\"));\n\tif (!manifest) {\n\t\tproblems.push({ slug, message: \"plugin.json is missing or malformed\" });\n\t\treturn;\n\t}\n\tif (manifest.id !== slug) {\n\t\t// id 由作者决定,slug 是上架身份;改哪个都有副作用,不擅自动手。\n\t\tproblems.push({ slug, message: `plugin.json id ${JSON.stringify(manifest.id)} does not match the ability slug` });\n\t\treturn;\n\t}\n\tif (typeof manifest.version === \"string\" && entry.version !== manifest.version) {\n\t\tchanges.push({ slug, field: \"version\", from: entry.version, to: manifest.version });\n\t\tentry.version = manifest.version;\n\t}\n\n\t// api_version / permissions / commands 刻意不回填:宿主在建目录时用 plugin.json 推导的值\n\t// 整个覆盖 config,索引里写什么都会被重算掉。写进去只会多一份会漂移的副本,所以只在它\n\t// 已经存在且与真源不符时提醒作者删掉或改对。\n\tconst config =\n\t\ttypeof entry.config === \"object\" && entry.config !== null && !Array.isArray(entry.config)\n\t\t\t? (entry.config as Record<string, unknown>)\n\t\t\t: undefined;\n\tif (config) {\n\t\tif (typeof config.api_version === \"string\" && config.api_version !== manifest.pluginApiVersion) {\n\t\t\tproblems.push({\n\t\t\t\tslug,\n\t\t\t\tmessage: `config.api_version ${JSON.stringify(config.api_version)} disagrees with plugin.json (${JSON.stringify(manifest.pluginApiVersion)}); the host derives this field, so drop the copy or fix it`,\n\t\t\t});\n\t\t}\n\t\tconst declared = stringArray(manifest.permissions) ?? [];\n\t\tif (Array.isArray(config.permissions) && !sameStringArray(config.permissions, declared)) {\n\t\t\tproblems.push({\n\t\t\t\tslug,\n\t\t\t\tmessage: \"config.permissions disagrees with plugin.json; the host derives this field, so drop the copy or fix it\",\n\t\t\t});\n\t\t}\n\t}\n\n\t// 宿主按 plugin.json 直接读目录,不会替作者构建:产物不在仓库里就是装不上。\n\tconst entryFile = typeof manifest.entry === \"string\" ? manifest.entry : undefined;\n\tif (!entryFile || !packagedFileExists(abilityDir, entryFile)) {\n\t\tproblems.push({ slug, message: `built entry is missing from the published directory: ${entryFile ?? \"(entry not declared)\"}` });\n\t}\n\tfor (const style of stringArray(manifest.styles) ?? []) {\n\t\tif (!packagedFileExists(abilityDir, style)) {\n\t\t\tproblems.push({ slug, message: `declared style is missing from the published directory: ${style}` });\n\t\t}\n\t}\n}\n\nfunction reconcileIdentityFile(context: {\n\tentry: Record<string, unknown>;\n\tslug: string;\n\tabilityDir: string;\n\tfileName: string;\n\tchanges: SyncChange[];\n\tproblems: SyncProblem[];\n}): void {\n\tconst { entry, slug, abilityDir, fileName, changes, problems } = context;\n\tconst identity = readJsonFile(join(abilityDir, fileName));\n\tif (!identity) {\n\t\tproblems.push({ slug, message: `${fileName} is missing or malformed` });\n\t\treturn;\n\t}\n\tif (typeof identity.slug === \"string\" && identity.slug !== slug) {\n\t\tproblems.push({ slug, message: `${fileName} slug ${JSON.stringify(identity.slug)} does not match the ability slug` });\n\t\treturn;\n\t}\n\tif (typeof identity.version === \"string\" && entry.version !== identity.version) {\n\t\tchanges.push({ slug, field: \"version\", from: entry.version, to: identity.version });\n\t\tentry.version = identity.version;\n\t}\n}\n\n/** 扫描仓库里带身份文件的能力目录。深度与忽略名单是为了不爬进依赖和构建产物。 */\nfunction findAbilityDirectories(hubRoot: string): string[] {\n\tconst found: string[] = [];\n\tconst walk = (dir: string, depth: number): void => {\n\t\tif (depth > SCAN_MAX_DEPTH) return;\n\t\tlet entries: string[];\n\t\ttry {\n\t\t\tentries = readdirSync(dir);\n\t\t} catch {\n\t\t\treturn;\n\t\t}\n\t\tif (entries.includes(\"plugin.json\") || entries.includes(\"mcp.json\")) {\n\t\t\tfound.push(dir);\n\t\t\treturn; // 能力目录内部不再深挖。\n\t\t}\n\t\tfor (const name of entries) {\n\t\t\tif (name.startsWith(\".\") || SCAN_IGNORED.has(name)) continue;\n\t\t\tconst child = join(dir, name);\n\t\t\ttry {\n\t\t\t\tif (statSync(child).isDirectory()) walk(child, depth + 1);\n\t\t\t} catch {\n\t\t\t\t// 断链或权限不足:跳过即可,扫描不该因为一个目录中断。\n\t\t\t}\n\t\t}\n\t};\n\twalk(hubRoot, 0);\n\treturn found.sort();\n}\n\n/**\n * 单个能力的索引漂移,用于在开发命令里顺手提醒。\n *\n * 完整的 `sync` 是仓库根的事,但作者改完 version 往往立刻就装一次——那一刻提醒,比等他\n * 某天想起来跑 CI 要早得多,也是 Agent 唯一能可靠接收到这件事的时机。\n */\nexport function describeIndexDrift(input: {\n\thubRoot: string;\n\tmanifestPath: string;\n\tslug: string;\n\tversion: string;\n}): string | undefined {\n\tconst manifest = readJsonFile(input.manifestPath);\n\tif (!manifest || !Array.isArray(manifest.abilities)) return undefined;\n\tconst entry = (manifest.abilities as unknown[]).find(\n\t\t(item) =>\n\t\t\ttypeof item === \"object\" && item !== null && !Array.isArray(item) && (item as Record<string, unknown>).slug === input.slug,\n\t) as Record<string, unknown> | undefined;\n\tif (!entry) return undefined;\n\tif (entry.version === input.version) return undefined;\n\treturn `Marketplace index still lists ${input.slug} ${JSON.stringify(entry.version)} (this project is ${JSON.stringify(input.version)}). Run \\`vetta-plugin-cli sync\\` at ${input.hubRoot} — the host refuses to sync an entry whose version does not match.`;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"sync.d.ts","sourceRoot":"","sources":["../src/sync.ts"],"names":[],"mappings":"AAGA;;;;;;;;;GASG;AAEH,MAAM,MAAM,cAAc,GAAG,SAAS,GAAG,aAAa,GAAG,aAAa,GAAG,UAAU,GAAG,oBAAoB,CAAC;AAE3G,MAAM,WAAW,UAAU;IAC1B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,cAAc,CAAC;IAC/B,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IACvB,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAC;CACrB;AAED,MAAM,WAAW,WAAW;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,UAAU;IAC1B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,OAAO,EAAE,SAAS,UAAU,EAAE,CAAC;IACxC,QAAQ,CAAC,QAAQ,EAAE,SAAS,WAAW,EAAE,CAAC;IAC1C,8FAAoC;IACpC,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC,wCAAsB;IACtB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;CAC1B;AAED,MAAM,WAAW,SAAS;IACzB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,oDAAgC;IAChC,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;CACxB;AAmED,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,SAAS,GAAG,UAAU,CA+EjE;AAiHD;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE;IACzC,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CAChB,GAAG,MAAM,GAAG,SAAS,CAUrB","sourcesContent":["import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from \"node:fs\";\nimport { join, relative, resolve, sep } from \"node:path\";\n\n/**\n * 对账能力市场索引(`.vetta/marketplace.json`)与各能力目录。\n *\n * 索引本来就是派生数据,却有一组会咬人的硬约束:条目的 slug/version 必须与能力目录里的身份\n * 文件完全相等,否则宿主同步**直接失败**;`entry`/`styles` 指向的文件必须真实存在,否则本地\n * 能装、市场上装不了;而改了任何内容却没换 `marketplaceVersion` 时,客户端按它判缓存——\n * 既不报错也不更新,用户只是永远收不到新版本。\n *\n * 三种失败里有两种不在作者机器上复现,一种压根不报错。所以这件事必须是工具做的。\n */\n\nexport type SyncChangeKind = \"version\" | \"api_version\" | \"permissions\" | \"commands\" | \"marketplaceVersion\";\n\nexport interface SyncChange {\n\treadonly slug: string;\n\treadonly field: SyncChangeKind;\n\treadonly from: unknown;\n\treadonly to: unknown;\n}\n\nexport interface SyncProblem {\n\treadonly slug: string;\n\treadonly message: string;\n}\n\nexport interface SyncResult {\n\treadonly manifestPath: string;\n\treadonly changes: readonly SyncChange[];\n\treadonly problems: readonly SyncProblem[];\n\t/** 目录里有身份文件、索引里却没有的能力。只报告,不擅自上架。 */\n\treadonly unlisted: readonly string[];\n\t/** apply 时是否真的写了盘。 */\n\treadonly written: boolean;\n}\n\nexport interface SyncInput {\n\treadonly hubRoot: string;\n\treadonly manifestPath: string;\n\t/** false 时只报告不写盘(`--check`)。 */\n\treadonly apply: boolean;\n}\n\nconst SCAN_IGNORED = new Set([\"node_modules\", \".git\", \"dist\", \"release\", \".vetta\", \"assets\", \"test\", \"src\"]);\nconst SCAN_MAX_DEPTH = 5;\n\n/**\n * 探测 JSON 文件用的缩进,回写时沿用。\n *\n * 不这么做的代价很具体:索引文件本是 2 空格,工具按 Tab 重排就会把整份文件写成一个大 diff,\n * 与仓库里其它写这份文件的脚本来回拉锯,任何并发提交都升级成整文件冲突。对账工具只该改它\n * 要改的那几个字段。\n */\nfunction detectJsonIndent(source: string): string {\n\tconst match = /\\n([ \\t]+)\\S/.exec(source);\n\treturn match?.[1] ?? \"\\t\";\n}\n\nfunction readJsonFile(path: string): Record<string, unknown> | undefined {\n\ttry {\n\t\tconst parsed: unknown = JSON.parse(readFileSync(path, \"utf8\"));\n\t\tif (typeof parsed !== \"object\" || parsed === null || Array.isArray(parsed)) return undefined;\n\t\treturn parsed as Record<string, unknown>;\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\nfunction stringArray(value: unknown): string[] | undefined {\n\tif (!Array.isArray(value)) return undefined;\n\treturn value.every((item) => typeof item === \"string\") ? [...(value as string[])] : undefined;\n}\n\nfunction sameStringArray(left: unknown, right: readonly string[]): boolean {\n\tconst current = stringArray(left) ?? [];\n\treturn current.length === right.length && current.every((item, index) => item === right[index]);\n}\n\n/** `source.path` 必须留在仓库内;索引是仓库里的文件,不该能指到仓库外面去。 */\nfunction resolveAbilityDir(hubRoot: string, sourcePath: string): string | undefined {\n\tconst target = resolve(hubRoot, sourcePath);\n\tconst fromRoot = relative(hubRoot, target);\n\tif (fromRoot === \"\" || fromRoot === \"..\" || fromRoot.startsWith(`..${sep}`)) return undefined;\n\treturn existsSync(target) && statSync(target).isDirectory() ? target : undefined;\n}\n\n/** 能力目录内的相对路径同理,且必须指向真实存在的文件。 */\nfunction packagedFileExists(abilityDir: string, path: string): boolean {\n\tconst target = resolve(abilityDir, path);\n\tconst fromDir = relative(abilityDir, target);\n\tif (fromDir === \"\" || fromDir === \"..\" || fromDir.startsWith(`..${sep}`)) return false;\n\treturn existsSync(target) && statSync(target).isFile();\n}\n\n/**\n * 推进 `marketplaceVersion`。\n *\n * 只认得两种写法:semver 和纯整数。其它写法(日期、commit 短号……)由作者自己决定下一个是\n * 什么,工具猜一个反而更危险——这个值一旦回退或重复,客户端就不会拉新快照。\n */\nfunction nextMarketplaceVersion(current: unknown): string | undefined {\n\tif (typeof current !== \"string\") return undefined;\n\tconst semver = /^(\\d+)\\.(\\d+)\\.(\\d+)$/.exec(current.trim());\n\tif (semver) return `${semver[1]}.${semver[2]}.${Number(semver[3]) + 1}`;\n\tif (/^\\d+$/.test(current.trim())) return String(Number(current.trim()) + 1);\n\treturn undefined;\n}\n\nexport function syncMarketplaceIndex(input: SyncInput): SyncResult {\n\tconst manifest = readJsonFile(input.manifestPath);\n\tif (!manifest) throw new Error(`Malformed marketplace manifest: ${input.manifestPath}`);\n\tconst abilities = Array.isArray(manifest.abilities) ? (manifest.abilities as unknown[]) : [];\n\n\tconst changes: SyncChange[] = [];\n\tconst problems: SyncProblem[] = [];\n\tconst listedDirs = new Set<string>();\n\n\tfor (const raw of abilities) {\n\t\tif (typeof raw !== \"object\" || raw === null || Array.isArray(raw)) continue;\n\t\tconst entry = raw as Record<string, unknown>;\n\t\tconst slug = typeof entry.slug === \"string\" ? entry.slug : \"(unnamed)\";\n\t\tconst type = typeof entry.type === \"string\" ? entry.type : \"\";\n\t\tif (type === \"bundle\") {\n\t\t\t// bundle 成员刻意不独立上架(索引的 abilities 是「独立上架条目」)。把它们的目录\n\t\t\t// 记为已登记,否则会被当成漏登记的能力报出来。\n\t\t\tconst bundleConfig =\n\t\t\t\ttypeof entry.config === \"object\" && entry.config !== null && !Array.isArray(entry.config)\n\t\t\t\t\t? (entry.config as Record<string, unknown>)\n\t\t\t\t\t: undefined;\n\t\t\tfor (const member of Array.isArray(bundleConfig?.members) ? (bundleConfig.members as unknown[]) : []) {\n\t\t\t\tif (typeof member !== \"object\" || member === null || Array.isArray(member)) continue;\n\t\t\t\tconst memberSource = (member as Record<string, unknown>).source;\n\t\t\t\tconst memberPath =\n\t\t\t\t\ttypeof memberSource === \"object\" && memberSource !== null && !Array.isArray(memberSource)\n\t\t\t\t\t\t? (memberSource as Record<string, unknown>).path\n\t\t\t\t\t\t: undefined;\n\t\t\t\tif (typeof memberPath !== \"string\") continue;\n\t\t\t\tconst dir = resolveAbilityDir(input.hubRoot, memberPath);\n\t\t\t\tif (dir) listedDirs.add(dir);\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst source = typeof entry.source === \"object\" && entry.source !== null ? (entry.source as Record<string, unknown>) : undefined;\n\t\tconst sourcePath = typeof source?.path === \"string\" ? source.path : undefined;\n\t\tif (!sourcePath) {\n\t\t\tproblems.push({ slug, message: \"entry has no source.path\" });\n\t\t\tcontinue;\n\t\t}\n\t\tconst abilityDir = resolveAbilityDir(input.hubRoot, sourcePath);\n\t\tif (!abilityDir) {\n\t\t\tproblems.push({ slug, message: `source.path does not resolve to a directory inside the repository: ${sourcePath}` });\n\t\t\tcontinue;\n\t\t}\n\t\tlistedDirs.add(abilityDir);\n\n\t\tif (type === \"plugin\") reconcilePlugin({ entry, slug, abilityDir, changes, problems });\n\t\telse if (type === \"mcp\") reconcileIdentityFile({ entry, slug, abilityDir, fileName: \"mcp.json\", changes, problems });\n\t\t// skill / scene 目录里没有身份文件,目录存在即算通过。\n\t}\n\n\t// 未登记的目录只报告、不作为 problem:作者可能正在开发一个还不打算上架的东西。\n\tconst unlisted = findAbilityDirectories(input.hubRoot)\n\t\t.filter((dir) => !listedDirs.has(dir))\n\t\t.map((dir) => relative(input.hubRoot, dir).split(sep).join(\"/\"))\n\t\t.sort();\n\n\tlet written = false;\n\tif (changes.length > 0 && input.apply) {\n\t\tconst bumped = nextMarketplaceVersion(manifest.marketplaceVersion);\n\t\tif (bumped) {\n\t\t\tchanges.push({ slug: \"(manifest)\", field: \"marketplaceVersion\", from: manifest.marketplaceVersion, to: bumped });\n\t\t\tmanifest.marketplaceVersion = bumped;\n\t\t} else {\n\t\t\tproblems.push({\n\t\t\t\tslug: \"(manifest)\",\n\t\t\t\tmessage:\n\t\t\t\t\t\"content changed but marketplaceVersion could not be bumped automatically (expected semver or an integer). Set it manually — clients skip the update when it does not change.\",\n\t\t\t});\n\t\t}\n\t\tconst source = readFileSync(input.manifestPath, \"utf8\");\n\t\tconst serialized = JSON.stringify(manifest, null, detectJsonIndent(source));\n\t\twriteFileSync(input.manifestPath, source.endsWith(\"\\n\") ? `${serialized}\\n` : serialized, \"utf8\");\n\t\twritten = true;\n\t}\n\n\treturn { manifestPath: input.manifestPath, changes, problems, unlisted, written };\n}\n\nfunction reconcilePlugin(context: {\n\tentry: Record<string, unknown>;\n\tslug: string;\n\tabilityDir: string;\n\tchanges: SyncChange[];\n\tproblems: SyncProblem[];\n}): void {\n\tconst { entry, slug, abilityDir, changes, problems } = context;\n\tconst manifest = readJsonFile(join(abilityDir, \"plugin.json\"));\n\tif (!manifest) {\n\t\tproblems.push({ slug, message: \"plugin.json is missing or malformed\" });\n\t\treturn;\n\t}\n\tif (manifest.id !== slug) {\n\t\t// id 由作者决定,slug 是上架身份;改哪个都有副作用,不擅自动手。\n\t\tproblems.push({ slug, message: `plugin.json id ${JSON.stringify(manifest.id)} does not match the ability slug` });\n\t\treturn;\n\t}\n\tif (typeof manifest.version === \"string\" && entry.version !== manifest.version) {\n\t\tchanges.push({ slug, field: \"version\", from: entry.version, to: manifest.version });\n\t\tentry.version = manifest.version;\n\t}\n\n\t// api_version / permissions / commands 刻意不回填:宿主在建目录时用 plugin.json 推导的值\n\t// 整个覆盖 config,索引里写什么都会被重算掉。写进去只会多一份会漂移的副本,所以只在它\n\t// 已经存在且与真源不符时提醒作者删掉或改对。\n\tconst config =\n\t\ttypeof entry.config === \"object\" && entry.config !== null && !Array.isArray(entry.config)\n\t\t\t? (entry.config as Record<string, unknown>)\n\t\t\t: undefined;\n\tif (config) {\n\t\tif (typeof config.api_version === \"string\" && config.api_version !== manifest.pluginApiVersion) {\n\t\t\tproblems.push({\n\t\t\t\tslug,\n\t\t\t\tmessage: `config.api_version ${JSON.stringify(config.api_version)} disagrees with plugin.json (${JSON.stringify(manifest.pluginApiVersion)}); the host derives this field, so drop the copy or fix it`,\n\t\t\t});\n\t\t}\n\t\tconst declared = stringArray(manifest.permissions) ?? [];\n\t\tif (Array.isArray(config.permissions) && !sameStringArray(config.permissions, declared)) {\n\t\t\tproblems.push({\n\t\t\t\tslug,\n\t\t\t\tmessage: \"config.permissions disagrees with plugin.json; the host derives this field, so drop the copy or fix it\",\n\t\t\t});\n\t\t}\n\t}\n\n\t// 宿主按 plugin.json 直接读目录,不会替作者构建:产物不在仓库里就是装不上。\n\tconst entryFile = typeof manifest.entry === \"string\" ? manifest.entry : undefined;\n\tif (!entryFile || !packagedFileExists(abilityDir, entryFile)) {\n\t\tproblems.push({ slug, message: `built entry is missing from the published directory: ${entryFile ?? \"(entry not declared)\"}` });\n\t}\n\tfor (const style of stringArray(manifest.styles) ?? []) {\n\t\tif (!packagedFileExists(abilityDir, style)) {\n\t\t\tproblems.push({ slug, message: `declared style is missing from the published directory: ${style}` });\n\t\t}\n\t}\n}\n\nfunction reconcileIdentityFile(context: {\n\tentry: Record<string, unknown>;\n\tslug: string;\n\tabilityDir: string;\n\tfileName: string;\n\tchanges: SyncChange[];\n\tproblems: SyncProblem[];\n}): void {\n\tconst { entry, slug, abilityDir, fileName, changes, problems } = context;\n\tconst identity = readJsonFile(join(abilityDir, fileName));\n\tif (!identity) {\n\t\tproblems.push({ slug, message: `${fileName} is missing or malformed` });\n\t\treturn;\n\t}\n\tif (typeof identity.slug === \"string\" && identity.slug !== slug) {\n\t\tproblems.push({ slug, message: `${fileName} slug ${JSON.stringify(identity.slug)} does not match the ability slug` });\n\t\treturn;\n\t}\n\tif (typeof identity.version === \"string\" && entry.version !== identity.version) {\n\t\tchanges.push({ slug, field: \"version\", from: entry.version, to: identity.version });\n\t\tentry.version = identity.version;\n\t}\n}\n\n/** 扫描仓库里带身份文件的能力目录。深度与忽略名单是为了不爬进依赖和构建产物。 */\nfunction findAbilityDirectories(hubRoot: string): string[] {\n\tconst found: string[] = [];\n\tconst walk = (dir: string, depth: number): void => {\n\t\tif (depth > SCAN_MAX_DEPTH) return;\n\t\tlet entries: string[];\n\t\ttry {\n\t\t\tentries = readdirSync(dir);\n\t\t} catch {\n\t\t\treturn;\n\t\t}\n\t\tif (entries.includes(\"plugin.json\") || entries.includes(\"mcp.json\")) {\n\t\t\tfound.push(dir);\n\t\t\treturn; // 能力目录内部不再深挖。\n\t\t}\n\t\tfor (const name of entries) {\n\t\t\tif (name.startsWith(\".\") || SCAN_IGNORED.has(name)) continue;\n\t\t\tconst child = join(dir, name);\n\t\t\ttry {\n\t\t\t\tif (statSync(child).isDirectory()) walk(child, depth + 1);\n\t\t\t} catch {\n\t\t\t\t// 断链或权限不足:跳过即可,扫描不该因为一个目录中断。\n\t\t\t}\n\t\t}\n\t};\n\twalk(hubRoot, 0);\n\treturn found.sort();\n}\n\n/**\n * 单个能力的索引漂移,用于在开发命令里顺手提醒。\n *\n * 完整的 `sync` 是仓库根的事,但作者改完 version 往往立刻就装一次——那一刻提醒,比等他\n * 某天想起来跑 CI 要早得多,也是 Agent 唯一能可靠接收到这件事的时机。\n */\nexport function describeIndexDrift(input: {\n\thubRoot: string;\n\tmanifestPath: string;\n\tslug: string;\n\tversion: string;\n}): string | undefined {\n\tconst manifest = readJsonFile(input.manifestPath);\n\tif (!manifest || !Array.isArray(manifest.abilities)) return undefined;\n\tconst entry = (manifest.abilities as unknown[]).find(\n\t\t(item) =>\n\t\t\ttypeof item === \"object\" && item !== null && !Array.isArray(item) && (item as Record<string, unknown>).slug === input.slug,\n\t) as Record<string, unknown> | undefined;\n\tif (!entry) return undefined;\n\tif (entry.version === input.version) return undefined;\n\treturn `Marketplace index still lists ${input.slug} ${JSON.stringify(entry.version)} (this project is ${JSON.stringify(input.version)}). Run \\`vetta-plugin-cli sync\\` at ${input.hubRoot} — the host refuses to sync an entry whose version does not match.`;\n}\n"]}
|