#!/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 --write # create+push the missing vX.Y.Z plugin tag first // # (--create-tag is a WRITE: without --write it only reports) // 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 // // PUSHING NEEDS THE SAME ONE-SHOT TOKEN AS pre-push-gate.sh. That hook matches `git // push` in command text and cannot see a push this script issues via execFileSync // inside node — so `--create-tag --write` and `--push` each REFUSE unless the operator // has left the approval token first (tag-push and catalog-push share ONE token — one // publish from the operator's perspective): // mkdir -p ~/.claude/runtime/push-approvals && touch "~/.claude/runtime/push-approvals/$(pwd | sed 's|/|_|g')" // The token is consumed after the push actually succeeds, same as the gate's own. import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; import { join, dirname } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { normalizeVersion, runGate, extractCatalogStats, extractStatBadges, pickStatSource, statMismatchFindings, } 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; } // --- Pre-flight gate + write step (unit under test via injected io) ---------- // Which plugins does check-versions call ERROR right now? Catalog-wide on purpose: one red // plugin blocks every bump, because check-versions' exit code is global — a bump committed // on top of someone else's ERROR ships a catalog that cannot pass its own gate. // // Reads the ERROR set explicitly and NEVER `failed`/`hasWarn`: pre-bump, the plugin being // released is SUPPOSED to be WARN (catalog ref behind plugin.json). Gating on WARN would // brick every release. export function preflightErrors(gateResult) { return (gateResult?.results ?? []).filter(r => r.status === 'ERROR').map(r => r.name); } // --create-tag mints AND PUSHES a tag to a public remote — the one genuinely irreversible // side effect here — so it is a WRITE and must obey --write. It used to fire on the // documented dry-run entry point, publishing the tag before the plan was even printed. // // Deliberately NOT gated on the catalog-wide pre-flight: every precondition below is // local to this plugin (plugin.json == target, badge agrees, tag absent), so the minted // tag is correct by construction. A red OTHER plugin can only make the tag EARLY, never // WRONG — and `!tags.includes(newRef)` makes the retry idempotent once that plugin is // fixed. Gating on it would let plugin Y block the tagging of plugin X: the same // over-coupling that `preflightErrors` reading ERROR-only (never `failed`) exists to avoid. // // Returns 'create' (mint + push), 'dry-run' (would, but no --write), or 'skip'. export function shouldCreateTag(args, observed, target) { if (!args?.createTag || !target) return 'skip'; if (observed?.tags === null || observed?.tags === undefined) return 'skip'; if (observed.tags.includes('v' + target)) return 'skip'; if (observed.pluginVersion !== target) return 'skip'; if (observed.readmeBadge !== null && observed.readmeBadge !== observed.pluginVersion) return 'skip'; return args.write ? 'create' : 'dry-run'; } // Q3e/D1 (order 20260913T051659Z-717911204-from-.claude) — the ordinary pre-flight // (applyRelease -> check-versions) only ever inspects the OLD ref, so a stat-line drift // the release itself is about to expose (the catalog's stat line vs. the badge the NEW // ref will carry) slips straight through it, into a pushed tag + a written, uncommitted // catalog. Measured live 13.09: `repo-mailbox --version 0.34.0 --create-tag --write // --commit --push` tagged+pushed v0.34.0, wrote the ref + README label, and only THEN // (post-write) found the catalog said "868 selftest check" against the plugin's new // badge "927". // // Pure: given the catalog's own README text and the "stat source" text — the target // ref's README if that tag already exists, else the plugin's worktree README, which is // exactly what --create-tag is about to tag (see pickStatSource in check-versions.mjs, // reused here with the roles it already has: atRef wins when present) — return the // mismatch messages before anything is touched. Either side missing means "nothing to // check" (same posture as check-versions.mjs when a README is absent), not a false block. export function preflightStatMismatches({ catalogReadmeText, statSourceReadmeText, name }) { if (catalogReadmeText === null || statSourceReadmeText === null) return []; const catalogStats = extractCatalogStats(catalogReadmeText, name); const statBadges = extractStatBadges(statSourceReadmeText); return statMismatchFindings(catalogStats, statBadges).map(f => f.msg); } // Q3e/D2 — the post-write confirmation used to be a bare execFileSync call, which THROWS // on a non-zero exit: an unhandled exception, over a release that had already tagged, // pushed, and written files but never committed ("half done", operator had to finish it // by hand). Turns any failure — a real ERROR that slipped past pre-flight, or the // subprocess dying for its own reasons — into one precise, actionable message: what is // done, what remains, and (when found) the specific check-versions finding. `runCheckVersions` // is injected so this is testable without a real subprocess. // check-versions.mjs prints one status line per plugin ("✗ ERROR demo-plugin") followed // by its finding lines indented underneath, until the next status line. Grab the whole // block for the named plugin, not just the status line — the finding is the actionable // part (which axis, which numbers). function extractPluginBlock(stdout, name) { const lines = String(stdout || '').split('\n'); const idx = lines.findIndex(l => l.includes(name)); if (idx === -1) return ''; const out = [lines[idx]]; for (let i = idx + 1; i < lines.length; i++) { const l = lines[i]; if (l.trim() === '' || /^[✓⚠✗–]/.test(l.trim())) break; out.push(l); } return out.join('\n').trim(); } export function reportPostWriteCheck({ name, applied, tagged, willPush }, runCheckVersions) { let out; try { out = runCheckVersions(); } catch (err) { const stdout = String(err.stdout || err.message || ''); const block = extractPluginBlock(stdout, name); const lines = [ ' ✗ post-write check-versions FAILED — the release is HALF DONE, nothing further ran automatically:', ` tag pushed: ${tagged ? 'yes' : 'no'}`, ` catalog files written: yes (${applied.writes.join(', ')}${applied.readme === 'written' ? ' + README label' : ''})`, ` NOT done: commit${willPush ? ', push' : ''}`, block ? ` check-versions:\n ${block.split('\n').join('\n ')}` : (stdout.trim() ? stdout.trim().split('\n').map(l => ` ${l}`).join('\n') : ' (no output captured)'), ' Fix the reported issue, then finish manually: git add .claude-plugin/marketplace.json README.md && git commit ... (add --push if needed).', ]; return { ok: false, message: lines.join('\n'), exitCode: (typeof err.status === 'number' && err.status !== 0) ? err.status : 1 }; } const line = out.split('\n').find(l => l.includes(name)) ?? ''; return { ok: true, message: ` check-versions: ${line.trim() || '(no line)'}` }; } // --- push-token gate --------------------------------------------------------- // // pre-push-gate.sh is a PreToolUse hook that matches `git push` in COMMAND TEXT — it // cannot see a push issued via execFileSync inside this script's own process (pinned // as GAP in the gate's header, and this script is the concretely-named example there). // This script mints+pushes a plugin tag (--create-tag) and pushes the catalog itself // (--push), both invisible to that gate. So it must require the SAME one-shot approval // token the gate checks (`hooks/lib/cmd-parse.sh` token_path()) before either push, and // consume it itself after a push succeeds — post-push-consume.sh (PostToolUse) never // fires for a call the gate never saw. Tag-push and catalog-push share ONE token: one // publish from the operator's perspective. // Computes the token path EXACTLY like token_path(): `sed 's|/|_|g'` on $PWD — only // '/' is rewritten, every other character (including '-' and '.') is left alone. export function pushAuthorisation({ cwd, home, exists }) { const tokenPath = join(home, '.claude', 'runtime', 'push-approvals', cwd.split('/').join('_')); return { tokenPath, authorised: exists(tokenPath) }; } export function requirePushAuthorisation({ cwd, home, exists }) { const { tokenPath, authorised } = pushAuthorisation({ cwd, home, exists }); if (authorised) return { authorised: true, tokenPath }; const message = [ 'BLOCKED: this run would push — release-plugin.mjs pushes a tag and/or the catalog itself', "via execFileSync inside node, invisible to pre-push-gate.sh's command-text match.", "Tag-push and catalog-push share ONE token: one publish from the operator's perspective.", '', 'To approve exactly one publish from this run, the OPERATOR runs:', ` mkdir -p ${dirname(tokenPath)} && touch "${tokenPath}"`, ].join('\n'); return { authorised: false, tokenPath, message }; } // Deletes the token if present. A no-op if it is already gone (idempotent across the // two push sites that share it). export function consumeToken({ tokenPath, exists, unlink }) { if (exists(tokenPath)) unlink(tokenPath); } // A run-scoped push-token gate (Q3b, order 20260912T213049Z-5772222747 — fixes two // defects in Q3's per-call pushWithToken): // // D1: --create-tag --write --commit --push does TWO pushes (tag, then catalog) in ONE // run. pushWithToken checked-and-consumed per call, so the tag push spent the operator's // one-shot token and the catalog push right after always saw blocked:true. ensure() // checks the token ONCE per run and every later call reuses that same result — one // token covers every push the run makes. // // D2: `git tag -a` used to run before any token check at all, so a blocked run left a // local annotated tag behind (a retry after the operator drops the token then fails // with "tag already exists", exit 128). Callers must call ensure() BEFORE the first // write this run intends to push toward — including a local tag meant to precede a // later push — not only immediately before the `git push` itself. // // consume() deletes the token once, after the run's LAST push has succeeded; it is a // no-op if ensure() was never authorised (nothing pushed) or already consumed. export function createPushGate({ cwd, home, exists, unlink }) { let auth = null; let consumed = false; return { // Set by a caller (runRelease) right after a `git push` it issued actually succeeds. // Q3c/D3: consume() must fire whenever this is true, on EVERY exit from the run, not // only the run's final line — a run can push a tag and then resolve to BLOCKED/NOOP/ // dry-run/red-pre-flight afterwards, and each of those used to skip consume() entirely. pushed: false, ensure() { if (auth === null) auth = requirePushAuthorisation({ cwd, home, exists }); return auth; }, consume() { if (consumed) return; if (auth && auth.authorised) consumeToken({ tokenPath: auth.tokenPath, exists, unlink }); consumed = true; }, }; } // Single-push convenience wrapper over createPushGate: checks, pushes, consumes for // exactly one push. Consumes the token only after `push()` returns without throwing — // if it throws (a real push failure), the exception propagates and the token is left // intact for the retry. export function pushWithToken({ cwd, home, exists, unlink, push }) { const gate = createPushGate({ cwd, home, exists, unlink }); const auth = gate.ensure(); if (!auth.authorised) return { pushed: false, blocked: true, message: auth.message, tokenPath: auth.tokenPath }; push(); gate.consume(); return { pushed: true, blocked: false, tokenPath: auth.tokenPath }; } // Run the gate FIRST, then write. The old order wrote both files and only then ran the gate // (which throws on exit 1), leaving a half-applied release in the working tree for a parallel // session to carry to the public remote. `io` is injected so the ORDER is testable. export function applyRelease({ plan, catalogDir, mktPath, readmePath }, io) { const errors = preflightErrors(io.runGate(catalogDir)); if (errors.length > 0) return { verdict: 'BLOCKED', preflightErrors: errors, writes: [], readme: null }; const writes = []; io.writeFileSync(mktPath, JSON.stringify(plan.newMarketplace, null, 2) + '\n', 'utf8'); writes.push(mktPath); // Keep the human-facing catalog README label in lock-step with the ref (gated by check-versions). // The `try` covers the READ only: a catalog without a README is a tolerated state, but a README // that cannot be WRITTEN is a real failure and must surface. The wider try reported EACCES/ENOSPC // on the write as readme:'missing' ("no catalog README to update") with verdict WROTE and exit 0 — // a bumped ref with a stale label, announced as success. let readme; let readmeText; try { readmeText = io.readFileSync(readmePath, 'utf8'); } catch { readmeText = null; } if (readmeText === null) { readme = 'missing'; } else { const newReadme = reconcileReadmeLabel(readmeText, plan.name, plan.newRef); if (newReadme !== null) { io.writeFileSync(readmePath, newReadme, 'utf8'); writes.push(readmePath); readme = 'written'; } else { readme = 'unchanged'; } } return { verdict: 'WROTE', preflightErrors: [], writes, readme }; } // --- 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; } } // Q3e/D1 I/O helpers — read-only, both null-safe (missing file/tag reads as "not checked"). function readFileSafe(path) { try { return readFileSync(path, 'utf8'); } catch { return null; } } function readGitShow(repoDir, ref, path) { if (!ref) return null; try { return execFileSync('git', ['-C', repoDir, 'show', `${ref}:${path}`], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); } 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; } // The body of main(), minus the top-level plumbing (read marketplace.json off disk, // build the pushGate) and minus the final process.exit call — pulled out so it is // testable against a real temp git repo (Q3c/S1) and so it can return an exit code // instead of calling process.exit at each branch (Q3c/D3, see main() below for why). export function runRelease({ args, catalogDir, mktPath, marketplace, pushGate, runCheckVersions }) { const checkVersionsRunner = runCheckVersions || (() => execFileSync('node', [join(catalogDir, 'scripts', 'check-versions.mjs')], { cwd: catalogDir, encoding: 'utf8' })); let obs = observePlugin(catalogDir, args.name); const target = normalizeVersion(args.version ?? obs.pluginVersion ?? ''); // Q3e/D1: check the catalog's stat line against what this release is ABOUT TO MAKE // current — BEFORE any tag or write, uncoupled from whether the ref is already // consistent (the point is to catch it before --create-tag ever touches a remote). if (target) { const mismatches = preflightStatMismatches({ catalogReadmeText: readFileSafe(join(catalogDir, 'README.md')), statSourceReadmeText: pickStatSource({ atRef: readGitShow(obs.repoDir, 'v' + target, 'README.md'), atWorktree: readFileSafe(join(obs.repoDir, 'README.md')), }), name: args.name, }); if (mismatches.length > 0) { console.log(`\nrelease-plugin: ${args.name} — BLOCKED before any tag or write (stale catalog stat line):`); for (const m of mismatches) console.log(` ✗ ${m}`); console.log(' Fix the catalog stat line (or the plugin badge) first, then re-run.'); return 1; } } // --create-tag: if the only thing missing is the tag, mint + push it first — but only // under --write. Without it this is a dry-run and must publish nothing. // pushGate is shared across BOTH push sites in this run (tag push, catalog push) — one // operator token authorises the whole publish, not each push individually (D1). ensure() // is called before the FIRST write this run intends to push toward, including the local // `git tag -a` below, which must not run before the check passes (D2). const tagStep = shouldCreateTag(args, obs, target); if (tagStep === 'create') { const auth = pushGate.ensure(); if (!auth.authorised) { console.error(auth.message); return 1; } 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' }); // Q3d/S: if the push itself fails (network, permissions, remote gone), delete the // local tag we just made rather than leave an orphan behind — a retry after fixing // the underlying problem must go through shouldCreateTag's tag-absent check again, // not hit git's own "tag already exists" (exit 128). try { execFileSync('git', ['-C', obs.repoDir, 'push', 'origin', tag], { stdio: 'inherit' }); } catch (err) { execFileSync('git', ['-C', obs.repoDir, 'tag', '-d', tag], { stdio: 'inherit' }); throw err; } pushGate.pushed = true; 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}`); } // Printed AFTER the blockers: the missing-tag blocker points at --create-tag, and this is // the answer to "I did pass it" — the flag is a write, so it waited for --write. if (tagStep === 'dry-run') { console.log(` (dry-run) --create-tag would mint + push v${target} in ${obs.repoDir} — re-run with --write.`); } // Q3c/D3: every branch below returns instead of calling process.exit. A tag push above // may already have set pushGate.pushed = true, and process.exit() called from inside a // try does not run its finally (verified live) — so main() must be the only place that // calls process.exit, after its finally has had a chance to consume the token. if (plan.verdict === 'BLOCKED') return 1; if (plan.verdict === 'NOOP') { console.log(' ✓ catalog already pins this version — nothing to do.'); return 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.'); return 0; } const readmePath = join(catalogDir, 'README.md'); const applied = applyRelease({ plan, catalogDir, mktPath, readmePath }, { readFileSync, writeFileSync, runGate }); if (applied.verdict === 'BLOCKED') { console.log(' ✗ pre-flight check-versions is RED — nothing written.'); for (const n of applied.preflightErrors) console.log(` ERROR: ${n}`); console.log(' Fix every ERROR (any plugin — the gate exit code is catalog-wide), then re-run.'); return 1; } console.log(` ✓ wrote ${mktPath} (ref ${plan.currentRef} -> ${plan.newRef})`); if (applied.readme === 'written') console.log(` ✓ updated README label (${plan.name} -> ${plan.newRef})`); else if (applied.readme === 'unchanged') console.log(` · README label already ${plan.newRef} (or no heading found)`); else console.log(' · no catalog README to update'); // Confirm the gate is green for this plugin AFTER the write — the pre-flight validated the // old state, this validates the new one. Different jobs; the redundancy is only apparent. // Q3e/D2: any failure here (a real ERROR, or the subprocess itself dying) becomes one // precise message instead of an unhandled execFileSync exception over an already // half-applied release (tag pushed + files written, nothing committed). const confirm = reportPostWriteCheck({ name: args.name, applied, tagged: tagStep === 'create', willPush: args.push }, checkVersionsRunner); console.log(confirm.message); if (!confirm.ok) return confirm.exitCode; 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) { const auth = pushGate.ensure(); if (!auth.authorised) { console.error(auth.message); return 1; } console.log(' → pushing'); execFileSync('git', ['-C', catalogDir, 'push', 'origin', 'HEAD'], { stdio: 'inherit' }); pushGate.pushed = true; console.log(' ✓ pushed'); } } return 0; } 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')); const pushGate = createPushGate({ cwd: catalogDir, home: process.env.HOME, exists: existsSync, unlink: unlinkSync }); let exitCode; try { exitCode = runRelease({ args, catalogDir, mktPath, marketplace, pushGate }); } finally { // Q3c/D3 (order 20260912T220453Z-5021715764): consume the shared token on ANY exit // from this run once at least one push has succeeded — not just the final line, which // four earlier exits (BLOCKED plan, NOOP, dry-run, red pre-flight) used to bypass // entirely. This is the only place consume() is called from. if (pushGate.pushed) pushGate.consume(); } process.exit(exitCode); } if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { main(); }