mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
* Add native Cursor marketplace plugin packaging AI assistance: Codex, under maintainer direction. * Document verified Cursor plugin installation and smoke tests AI assistance: Codex, under maintainer direction. * Fix Cursor plugin sync for license changes AI assistance: Codex, under maintainer direction.
159 lines
6.0 KiB
JavaScript
159 lines
6.0 KiB
JavaScript
/**
|
|
* Plugin/skill version-drift detection (issue #274).
|
|
*
|
|
* The Claude Code marketplace installs from the committed `./plugin` subtree,
|
|
* so any version disagreement between the hand-edited manifests and the
|
|
* generated subtree ships stale content reporting a wrong version. Root
|
|
* `.claude-plugin/plugin.json` is the single source of truth (build() reads
|
|
* skillsVersion from it). Every other version-bearing file must match it:
|
|
*
|
|
* - `.claude-plugin/marketplace.json` plugins[0].version — hand-edited
|
|
* alongside plugin.json; the post-merge sync workflow can't repair a
|
|
* mismatch here because it never bumps versions.
|
|
* - `plugin/.claude-plugin/plugin.json` version — generated, derived from
|
|
* root at build:release; checked so a bump that forgets to regenerate the
|
|
* subtree fails loudly instead of merging a drift window onto main.
|
|
* - `plugin/skills/impeccable/SKILL.md` frontmatter version — generated;
|
|
* same rationale.
|
|
* - `dist/openai/impeccable/.codex-plugin/plugin.json` version — generated
|
|
* for public OpenAI submission and checked when that build output exists.
|
|
*
|
|
* The collector is pure (filesystem-in, data-out) so it can be unit-tested
|
|
* against fixtures; build.js owns the logging and the non-zero exit.
|
|
*/
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
/**
|
|
* Pull the version value out of a SKILL.md leading frontmatter block.
|
|
* CRLF-tolerant (`\r?\n`) to match the shared parseFrontmatter in
|
|
* scripts/lib/utils.js — a bundle saved with CRLF line endings must not read
|
|
* as a null version and trip a false mismatch. Codex builds carry the version
|
|
* under `metadata.version`; legacy provider builds keep the top-level key.
|
|
*/
|
|
export function readSkillFrontmatterVersion(content) {
|
|
const fm = String(content).match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---(?:[ \t]*\r?\n|[ \t]*$)/);
|
|
if (!fm) return null;
|
|
|
|
let metadataVersion = null;
|
|
let topLevelVersion = null;
|
|
let inMetadata = false;
|
|
let metadataIndent = null;
|
|
|
|
for (const line of fm[1].split(/\r?\n/)) {
|
|
if (!line.trim() || line.trimStart().startsWith('#')) continue;
|
|
const indentText = line.match(/^[ \t]*/)[0];
|
|
const indent = indentText.replace(/\t/g, ' ').length;
|
|
|
|
if (indent === 0) {
|
|
inMetadata = /^metadata:\s*(?:#.*)?$/.test(line);
|
|
metadataIndent = null;
|
|
const version = line.match(/^version:\s*(.+?)\s*$/);
|
|
if (version) topLevelVersion = version[1];
|
|
continue;
|
|
}
|
|
|
|
if (!inMetadata) continue;
|
|
if (metadataIndent === null) metadataIndent = indent;
|
|
if (indent !== metadataIndent) continue;
|
|
const version = line.trim().match(/^version:\s*(.+?)\s*$/);
|
|
if (version) metadataVersion = version[1];
|
|
}
|
|
|
|
const value = metadataVersion || topLevelVersion;
|
|
return value ? value.trim().replace(/^(["'])(.*)\1$/, '$2') : null;
|
|
}
|
|
|
|
/**
|
|
* Read a file and extract a value, turning read/parse failures into a clean
|
|
* sentinel instead of a raw throw. A version bump is exactly the moment a
|
|
* manifest is half-edited, so a malformed file must produce an actionable
|
|
* diagnostic naming the file, not a stack trace out of build().
|
|
*
|
|
* @returns {{ value: any } | { error: string }}
|
|
*/
|
|
function extractFromFile(absPath, extract) {
|
|
let raw;
|
|
try {
|
|
raw = fs.readFileSync(absPath, 'utf-8');
|
|
} catch (err) {
|
|
return { error: `could not read file (${err.code || err.message})` };
|
|
}
|
|
try {
|
|
return { value: extract(raw) };
|
|
} catch (err) {
|
|
return { error: `could not parse (${err.message})` };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compare every version-bearing plugin/skill file against root plugin.json.
|
|
*
|
|
* @param {string} rootDir repository root
|
|
* @returns {{
|
|
* source: string|null,
|
|
* checked: Array<{relPath:string, found:any}>,
|
|
* mismatches: Array<{relPath:string, found:any, expected:string}>,
|
|
* errors: Array<{relPath:string, reason:string}>,
|
|
* }}
|
|
* `source` is null only when root plugin.json is absent (nothing to check).
|
|
* A present-but-malformed root, or one missing its `version` field, instead
|
|
* reports an entry in `errors` so the build fails loudly rather than passing.
|
|
*/
|
|
export function collectPluginVersions(rootDir) {
|
|
const rootRel = '.claude-plugin/plugin.json';
|
|
const rootManifestPath = path.join(rootDir, rootRel);
|
|
const empty = { source: null, checked: [], mismatches: [], errors: [] };
|
|
if (!fs.existsSync(rootManifestPath)) return empty;
|
|
|
|
const rootResult = extractFromFile(rootManifestPath, (raw) => JSON.parse(raw).version);
|
|
if (rootResult.error) {
|
|
return { ...empty, errors: [{ relPath: rootRel, reason: rootResult.error }] };
|
|
}
|
|
const source = rootResult.value;
|
|
if (source == null) {
|
|
return { ...empty, errors: [{ relPath: rootRel, reason: 'missing "version" field' }] };
|
|
}
|
|
|
|
const checks = [
|
|
{
|
|
relPath: '.claude-plugin/marketplace.json',
|
|
read: (raw) => JSON.parse(raw).plugins?.[0]?.version,
|
|
},
|
|
{
|
|
relPath: 'plugin/.claude-plugin/plugin.json',
|
|
read: (raw) => JSON.parse(raw).version,
|
|
},
|
|
{
|
|
relPath: 'dist/openai/impeccable/.codex-plugin/plugin.json',
|
|
read: (raw) => JSON.parse(raw).version,
|
|
},
|
|
...['cursor-plugin', 'dist/cursor-plugin'].flatMap((prefix) => [
|
|
{ relPath: `${prefix}/.cursor-plugin/plugin.json`, read: (raw) => JSON.parse(raw).version },
|
|
{ relPath: `${prefix}/skills/impeccable/SKILL.md`, read: readSkillFrontmatterVersion },
|
|
]),
|
|
{
|
|
relPath: 'plugin/skills/impeccable/SKILL.md',
|
|
read: (raw) => readSkillFrontmatterVersion(raw),
|
|
},
|
|
];
|
|
|
|
const checked = [];
|
|
const mismatches = [];
|
|
const errors = [];
|
|
for (const { relPath, read } of checks) {
|
|
const absPath = path.join(rootDir, relPath);
|
|
if (!fs.existsSync(absPath)) continue;
|
|
const result = extractFromFile(absPath, read);
|
|
if (result.error) {
|
|
errors.push({ relPath, reason: result.error });
|
|
continue;
|
|
}
|
|
const found = result.value;
|
|
checked.push({ relPath, found });
|
|
if (found !== source) mismatches.push({ relPath, found, expected: source });
|
|
}
|
|
|
|
return { source, checked, mismatches, errors };
|
|
}
|