#!/usr/bin/env node // Atomic plugin-release helper for the polyrepo marketplace. // // In the monorepo, marketplace.json used a relative `source` path, so a plugin's // version was read straight from its plugin.json and could never drift. In the // polyrepo, each plugin's `source` pins a release tag (`ref`), so a release is a // TWO-repo act: tag the plugin repo AND bump the catalog ref. The second step is // manual and easily forgotten — that drift is exactly what stranded a plugin on // an old version while its plugin.json moved ahead. // // This helper makes the catalog side impossible to do wrong: it REFUSES unless // plugin.json == README badge == the target version AND the vX.Y.Z tag exists, // then bumps the ref AND the catalog README's per-plugin label together so // `check-versions.mjs` is green by construction (it now also gates label == ref). // The pure planner (planRelease) and label reconciler (reconcileReadmeLabel) are // fully tested; the I/O shell reads the tree and, under explicit flags, // writes/commits/pushes. Dry-run by default — it changes nothing until you pass --write. // // Usage: // node scripts/release-plugin.mjs [--version X.Y.Z] # dry-run: print the plan // node scripts/release-plugin.mjs --create-tag # create+push the missing vX.Y.Z plugin tag first // node scripts/release-plugin.mjs --write # write the bumped catalog ref // node scripts/release-plugin.mjs --write --commit # + git commit the catalog // node scripts/release-plugin.mjs --write --commit --push # + push (you own the push window) import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; import { join, dirname } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { normalizeVersion } from './check-versions.mjs'; // --- Pure planner (unit under test) ----------------------------------------- export function planRelease({ marketplace, name, observed, targetVersion }) { const plugins = marketplace?.plugins ?? []; const entry = plugins.find(p => p.name === name); if (!entry) { return { name, verdict: 'BLOCKED', targetVersion: targetVersion ? normalizeVersion(targetVersion) : null, currentRef: null, newRef: null, blockers: [`plugin "${name}" is not in the catalog`], newMarketplace: null, commitSubject: null, }; } const currentRef = entry.source?.ref ?? null; const raw = targetVersion ?? observed.pluginVersion ?? null; const target = raw === null ? null : normalizeVersion(raw); if (target === null) { return { name, verdict: 'BLOCKED', targetVersion: null, currentRef, newRef: null, blockers: ['cannot resolve a target version (no --version and no plugin.json version)'], newMarketplace: null, commitSubject: null, }; } const newRef = 'v' + target; const blockers = []; // Release preconditions — each must hold, else the catalog must not move. if (observed.pluginVersion !== null && observed.pluginVersion !== target) { blockers.push(`plugin.json version is ${observed.pluginVersion}, asked to release ${target} — bump plugin.json first`); } if (observed.readmeBadge !== null && observed.pluginVersion !== null && observed.readmeBadge !== observed.pluginVersion) { blockers.push(`README version-badge ${observed.readmeBadge} != plugin.json ${observed.pluginVersion} — fix internal consistency first`); } if (observed.tags !== null && !observed.tags.includes(newRef)) { blockers.push(`tag ${newRef} not found in the plugin repo — tag the plugin (and push the tag) first, or pass --create-tag`); } if (blockers.length > 0) { return { name, verdict: 'BLOCKED', targetVersion: target, currentRef, newRef, blockers, newMarketplace: null, commitSubject: null }; } if (currentRef === newRef) { return { name, verdict: 'NOOP', targetVersion: target, currentRef, newRef, blockers: [], newMarketplace: null, commitSubject: null }; } // READY — deep-clone the marketplace, bump only this plugin's ref. const newMarketplace = JSON.parse(JSON.stringify(marketplace)); newMarketplace.plugins.find(p => p.name === name).source.ref = newRef; return { name, verdict: 'READY', targetVersion: target, currentRef, newRef, blockers: [], newMarketplace, commitSubject: `chore(catalog): bump ${name} ${currentRef} -> ${newRef}`, }; } // Bump the catalog README's per-plugin label so the human-facing doc matches the new ref. // Replaces the FIRST `vX.Y.Z` token on the plugin's `/open/)` heading line, leaving any // trailing lang/flag badge untouched. Returns the new text, or null if nothing changed // (label already correct, or the plugin has no heading). export function reconcileReadmeLabel(readmeText, name, newRef) { let changed = false; const out = String(readmeText || '').split('\n').map(line => { if (!changed && line.includes(`/open/${name})`)) { const replaced = line.replace(/`v\d+\.\d+\.\d+`/, '`' + newRef + '`'); if (replaced !== line) changed = true; return replaced; } return line; }); return changed ? out.join('\n') : null; } // --- I/O shell -------------------------------------------------------------- function gitTags(repoDir) { try { return execFileSync('git', ['-C', repoDir, 'tag', '--list', 'v*'], { encoding: 'utf8' }) .split('\n').map(s => s.trim()).filter(Boolean); } catch { return null; } } function extractBadge(readmeText) { const m = /badge\/version-(\d+\.\d+\.\d+)/.exec(readmeText || ''); return m ? m[1] : null; } function observePlugin(catalogDir, name) { const repoDir = join(catalogDir, '..', name); if (!existsSync(repoDir)) return { repoDir, pluginVersion: null, readmeBadge: null, tags: null }; let pluginVersion = null; try { pluginVersion = JSON.parse(readFileSync(join(repoDir, '.claude-plugin', 'plugin.json'), 'utf8')).version ?? null; } catch { /* null */ } let readmeBadge = null; try { readmeBadge = extractBadge(readFileSync(join(repoDir, 'README.md'), 'utf8')); } catch { /* null */ } return { repoDir, pluginVersion, readmeBadge, tags: gitTags(repoDir) }; } function parseArgs(argv) { const out = { name: null, version: null, write: false, commit: false, push: false, createTag: false }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === '--version') out.version = argv[++i]; else if (a === '--write') out.write = true; else if (a === '--commit') out.commit = true; else if (a === '--push') out.push = true; else if (a === '--create-tag') out.createTag = true; else if (!a.startsWith('--') && out.name === null) out.name = a; } return out; } function main() { const args = parseArgs(process.argv.slice(2)); if (!args.name) { console.error('usage: release-plugin.mjs [--version X.Y.Z] [--create-tag] [--write] [--commit] [--push]'); process.exit(2); } const catalogDir = join(dirname(fileURLToPath(import.meta.url)), '..'); const mktPath = join(catalogDir, '.claude-plugin', 'marketplace.json'); const marketplace = JSON.parse(readFileSync(mktPath, 'utf8')); let obs = observePlugin(catalogDir, args.name); const target = normalizeVersion(args.version ?? obs.pluginVersion ?? ''); // --create-tag: if the only thing missing is the tag, mint + push it first. if (args.createTag && target && obs.tags !== null && !obs.tags.includes('v' + target) && obs.pluginVersion === target && (obs.readmeBadge === null || obs.readmeBadge === obs.pluginVersion)) { const tag = 'v' + target; console.log(`→ creating annotated tag ${tag} in ${obs.repoDir}`); execFileSync('git', ['-C', obs.repoDir, 'tag', '-a', tag, '-m', `${args.name} ${tag}`], { stdio: 'inherit' }); execFileSync('git', ['-C', obs.repoDir, 'push', 'origin', tag], { stdio: 'inherit' }); obs = observePlugin(catalogDir, args.name); } const plan = planRelease({ marketplace, name: args.name, observed: obs, targetVersion: args.version ?? undefined }); console.log(`\nrelease-plugin: ${plan.name} ${plan.currentRef ?? '?'} -> ${plan.newRef ?? '?'} [${plan.verdict}]`); if (plan.blockers.length) { for (const b of plan.blockers) console.log(` ✗ ${b}`); } if (plan.verdict === 'BLOCKED') process.exit(1); if (plan.verdict === 'NOOP') { console.log(' ✓ catalog already pins this version — nothing to do.'); process.exit(0); } // READY if (!args.write) { console.log(` ✓ ready — would bump catalog ref + README label and commit:\n ${plan.commitSubject}`); console.log(' (dry-run) re-run with --write [--commit] [--push] to apply.'); process.exit(0); } writeFileSync(mktPath, JSON.stringify(plan.newMarketplace, null, 2) + '\n', 'utf8'); console.log(` ✓ wrote ${mktPath} (ref ${plan.currentRef} -> ${plan.newRef})`); // Keep the human-facing catalog README label in lock-step with the ref (gated by check-versions). const readmePath = join(catalogDir, 'README.md'); try { const newReadme = reconcileReadmeLabel(readFileSync(readmePath, 'utf8'), plan.name, plan.newRef); if (newReadme !== null) { writeFileSync(readmePath, newReadme, 'utf8'); console.log(` ✓ updated README label (${plan.name} -> ${plan.newRef})`); } else { console.log(` · README label already ${plan.newRef} (or no heading found)`); } } catch { console.log(' · no catalog README to update'); } // Confirm the gate is green for this plugin after the write. const gate = execFileSync('node', [join(catalogDir, 'scripts', 'check-versions.mjs')], { cwd: catalogDir, encoding: 'utf8' }); const line = gate.split('\n').find(l => l.includes(args.name)) ?? ''; console.log(` check-versions: ${line.trim() || '(no line)'}`); if (args.commit) { const body = `${plan.name} ${plan.newRef} — release. Catalog ref now pins the ${plan.newRef} tag so \`claude plugin update\` resolves the release.`; const msg = `${plan.commitSubject}\n\n${body}\n\nCo-Authored-By: Claude Opus 4.8 (1M context) \n`; execFileSync('git', ['-C', catalogDir, 'add', '.claude-plugin/marketplace.json', 'README.md'], { stdio: 'inherit' }); execFileSync('git', ['-C', catalogDir, 'commit', '-m', msg], { stdio: 'inherit' }); console.log(' ✓ committed the catalog'); if (args.push) { console.log(' → pushing (you own the push window: weekday 20–23, weekend/holiday anytime)'); execFileSync('git', ['-C', catalogDir, 'push', 'origin', 'HEAD'], { stdio: 'inherit' }); console.log(' ✓ pushed'); } } process.exit(0); } if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { main(); }