From 9d264f664dee2a933d34a20e1ea2298fa90f2136 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Wed, 17 Jun 2026 13:32:03 +0200 Subject: [PATCH] chore(migration): mixed-source marketplace.json rewriter (HTTPS+ref) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 60-rewrite-marketplace.mjs: flips a named subset (--only) or all (--all) plugin entries from local './plugins/' to external {source:'url', url, ref:v} read from plugin-map.json. Validates output (name+source+description; external => https url under Forgejo /open/ + ref). Writes to a required --out path only — never the live .claude-plugin/marketplace.json (D8). Enables the mixed-source live states (SC3/SC8). Verify: --only voyage => 9 local + 1 external (https + ref:v5.1.1), 0 ssh. Test 4/4 (mixed-source, --all all-external, refuses --out-less run, rejects unknown name). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../migration/60-rewrite-marketplace.mjs | 125 ++++++++++++++++++ .../migration/60-rewrite-marketplace.test.mjs | 78 +++++++++++ 2 files changed, 203 insertions(+) create mode 100644 docs/marketplace-polyrepo-migration/migration/60-rewrite-marketplace.mjs create mode 100644 docs/marketplace-polyrepo-migration/migration/60-rewrite-marketplace.test.mjs diff --git a/docs/marketplace-polyrepo-migration/migration/60-rewrite-marketplace.mjs b/docs/marketplace-polyrepo-migration/migration/60-rewrite-marketplace.mjs new file mode 100644 index 0000000..cd79c96 --- /dev/null +++ b/docs/marketplace-polyrepo-migration/migration/60-rewrite-marketplace.mjs @@ -0,0 +1,125 @@ +#!/usr/bin/env node +// Step 8 — marketplace.json rewriter (mixed-source aware, HTTPS + ref pin). +// +// Rewrites a marketplace.json so a NAMED SUBSET of plugin entries become external git sources +// { "name", "source": "url", "url": "https://git.fromaitochitta.com/open/.git", "ref": "v", "description" } +// (F2 — HTTPS + discriminator `source`; D4 — ref pinned to the plugin's release tag), while the rest +// stay local `"source": "./plugins/"`. This is what makes the mixed-source intermediate states +// possible (SC3/SC8): the operator can flip plugins one batch at a time and keep the marketplace live. +// +// --only flip just these (comma/space-separated) e.g. --only "voyage,llm-security" +// --all flip every plugin (the final, fully-external state) +// --in input marketplace.json (default: /.claude-plugin/marketplace.json) +// --out REQUIRED output path. The rewriter NEVER writes the live file (D8 — NULL push / +// no live mutation outside the operator window). Refuses to run without --out. +// +// Versions + repo URLs are read from plugin-map.json (the single source of truth, verified from each +// plugin.json). Output is validated: every entry has name+source+description; every external entry has +// a valid https url under the Forgejo /open/ namespace plus a ref. Pure, idempotent transform. +import { readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(HERE, '..', '..', '..'); +const DEFAULT_IN = path.join(REPO_ROOT, '.claude-plugin', 'marketplace.json'); +const PLUGIN_MAP = path.join(HERE, 'plugin-map.json'); +const FORGEJO_PREFIX = 'https://git.fromaitochitta.com/open/'; + +function parseArgs(argv) { + const args = { in: DEFAULT_IN, out: null, only: null, all: false }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--all') args.all = true; + else if (a === '--only') args.only = String(argv[++i] || '').split(/[,\s]+/).filter(Boolean); + else if (a === '--in') args.in = argv[++i]; + else if (a === '--out') args.out = argv[++i]; + else throw new Error(`unknown argument: ${a}`); + } + return args; +} + +function externalSource(name, map) { + const t = map.targets && map.targets[name]; + if (!t) throw new Error(`no plugin-map entry for '${name}' — cannot flip to external`); + if (typeof t.repo_url !== 'string' || !t.repo_url.startsWith(FORGEJO_PREFIX)) { + throw new Error(`plugin-map repo_url for '${name}' is not under ${FORGEJO_PREFIX}: ${t.repo_url}`); + } + if (typeof t.tag !== 'string' || !t.tag) throw new Error(`plugin-map has no tag for '${name}'`); + return { url: t.repo_url, ref: t.tag }; +} + +function validate(mp) { + if (!Array.isArray(mp.plugins)) throw new Error('marketplace.json has no plugins array'); + for (const p of mp.plugins) { + if (typeof p.name !== 'string' || !p.name) throw new Error('an entry is missing name'); + if (typeof p.source !== 'string' || !p.source) throw new Error(`entry ${p.name} missing source`); + if (typeof p.description !== 'string' || !p.description) throw new Error(`entry ${p.name} missing description`); + if (p.source === 'url') { + if (typeof p.url !== 'string' || !p.url.startsWith('https://')) { + throw new Error(`entry ${p.name} external url is not https: ${p.url}`); + } + if (p.url.startsWith('ssh://')) throw new Error(`entry ${p.name} url is ssh (must be https)`); + if (typeof p.ref !== 'string' || !p.ref) throw new Error(`entry ${p.name} missing ref`); + } + } +} + +export function rewrite({ inPath = DEFAULT_IN, only = null, all = false } = {}) { + const mp = JSON.parse(readFileSync(inPath, 'utf8')); + const map = JSON.parse(readFileSync(PLUGIN_MAP, 'utf8')); + if (!Array.isArray(mp.plugins)) throw new Error('marketplace.json has no plugins array'); + + const names = new Set(mp.plugins.map((p) => p.name)); + if (!all && only) { + for (const n of only) { + if (!names.has(n)) throw new Error(`--only name not in marketplace.json: ${n}`); + } + } + const flip = all ? names : new Set(only || []); + + let flipped = 0; + let local = 0; + mp.plugins = mp.plugins.map((p) => { + if (flip.has(p.name)) { + const { url, ref } = externalSource(p.name, map); + flipped++; + return { name: p.name, source: 'url', url, ref, description: p.description }; + } + local++; + return p; + }); + + validate(mp); + return { mp, flipped, local }; +} + +function main() { + let args; + try { + args = parseArgs(process.argv.slice(2)); + } catch (e) { + console.error(`error: ${e.message}`); + process.exit(2); + } + if (!args.all && (!args.only || args.only.length === 0)) { + console.error('error: specify --only or --all'); + process.exit(2); + } + if (!args.out) { + console.error('error: --out is required — the rewriter never writes the live marketplace.json (D8)'); + process.exit(2); + } + try { + const { mp, flipped, local } = rewrite({ inPath: args.in, only: args.only, all: args.all }); + writeFileSync(args.out, JSON.stringify(mp, null, 2) + '\n'); + console.log(`marketplace rewrite: ${flipped} external (HTTPS+ref), ${local} local (./plugins/), out=${args.out}`); + } catch (e) { + console.error(`error: ${e.message}`); + process.exit(1); + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/docs/marketplace-polyrepo-migration/migration/60-rewrite-marketplace.test.mjs b/docs/marketplace-polyrepo-migration/migration/60-rewrite-marketplace.test.mjs new file mode 100644 index 0000000..e246c06 --- /dev/null +++ b/docs/marketplace-polyrepo-migration/migration/60-rewrite-marketplace.test.mjs @@ -0,0 +1,78 @@ +// Step 8 test — the rewriter must prove the mixed-source intermediate (the live-marketplace guarantee): +// --only voyage -> voyage external (https url + ref:v5.1.1), the other 9 stay ./plugins/, no ssh:// +// --all -> zero ./plugins/ sources, every entry external https+ref, schema-valid +// safety -> refuses to run without --out; rejects an unknown --only name +// Pattern: plugins/voyage/tests/parsers/*.test.mjs (CLI exercised exactly as the plan's Verify does). +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync, readFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const CLI = path.join(here, '60-rewrite-marketplace.mjs'); + +function run(args) { + return spawnSync('node', [CLI, ...args], { encoding: 'utf8' }); +} +function readJson(p) { + return JSON.parse(readFileSync(p, 'utf8')); +} + +test('--only voyage flips just voyage to external https+ref:v5.1.1, leaves 9 local', () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'mp-only-')); + try { + const out = path.join(tmp, 'mp.json'); + const r = run(['--only', 'voyage', '--out', out]); + assert.equal(r.status, 0, `expected exit 0:\n${r.stdout}\n${r.stderr}`); + const mp = readJson(out); + const voyage = mp.plugins.find((p) => p.name === 'voyage'); + assert.equal(voyage.source, 'url'); + assert.ok(voyage.url.startsWith('https://git.fromaitochitta.com/open/'), `voyage url: ${voyage.url}`); + assert.equal(voyage.ref, 'v5.1.1'); + const local = mp.plugins.filter((p) => typeof p.source === 'string' && p.source.startsWith('./plugins/')); + assert.equal(local.length, 9, 'exactly 9 entries must stay local (mixed-source proven)'); + assert.ok(!JSON.stringify(mp).includes('ssh://'), 'no ssh:// anywhere'); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test('--all leaves zero ./plugins/ sources; every entry external https+ref', () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'mp-all-')); + try { + const out = path.join(tmp, 'mp.json'); + const r = run(['--all', '--out', out]); + assert.equal(r.status, 0, `expected exit 0:\n${r.stdout}\n${r.stderr}`); + const mp = readJson(out); + const local = mp.plugins.filter((p) => typeof p.source === 'string' && p.source.startsWith('./plugins/')); + assert.equal(local.length, 0, '--all must leave zero local sources'); + for (const p of mp.plugins) { + assert.equal(p.source, 'url', `${p.name} should be external`); + assert.ok(p.url.startsWith('https://'), `${p.name} url must be https: ${p.url}`); + assert.ok(typeof p.ref === 'string' && p.ref.length > 0, `${p.name} must have a ref`); + assert.ok(typeof p.description === 'string' && p.description.length > 0, `${p.name} must keep its description`); + } + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test('refuses to run without --out (never writes the live file — D8)', () => { + const r = run(['--all']); + assert.notEqual(r.status, 0, 'must refuse without --out'); + assert.match(r.stderr, /--out .* required/); +}); + +test('rejects an unknown --only name', () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'mp-bad-')); + try { + const r = run(['--only', 'does-not-exist', '--out', path.join(tmp, 'mp.json')]); + assert.notEqual(r.status, 0, 'must reject an unknown plugin name'); + assert.match(r.stderr, /not in marketplace\.json/); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +});