ktg-plugin-marketplace/docs/marketplace-polyrepo-migration/migration/60-rewrite-marketplace.mjs
Kjell Tore Guttormsen 86208dab6c fix(migration): BLOCKER 94b83ad — nested external source-object per official CC schema
60-rewrite-marketplace.mjs:87 emitted a flat { source: 'url', url, ref }
shape. The official Claude Code marketplace schema (verified at
code.claude.com/docs/en/plugin-marketplaces) and brief §6 both require the
nested form { source: { source: 'url', url, ref } } — a flat shape would not
resolve at install, breaking SC1/SC3/SC8 for every externalised entry.

- l.87: emit nested source-object
- validate(): branch on object (external) vs string (local ./plugins/) source
- 60-rewrite-marketplace.test.mjs: assert nested voyage.source.source / .url / .ref

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:02:35 +02:00

130 lines
5.9 KiB
JavaScript

#!/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": { "source": "url", "url": "https://git.fromaitochitta.com/open/<name>.git", "ref": "v<version>" }, "description" }
// (nested source-object per the official Claude Code marketplace schema — code.claude.com/docs/en/plugin-marketplaces)
// (F2 — HTTPS + discriminator `source`; D4 — ref pinned to the plugin's release tag), while the rest
// stay local `"source": "./plugins/<name>"`. 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 <names> flip just these (comma/space-separated) e.g. --only "voyage,llm-security"
// --all flip every plugin (the final, fully-external state)
// --in <path> input marketplace.json (default: <repo>/.claude-plugin/marketplace.json)
// --out <path> 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.description !== 'string' || !p.description) throw new Error(`entry ${p.name} missing description`);
if (p.source && typeof p.source === 'object') {
// external entry: nested source-object { source: 'url', url, ref } (official CC schema)
if (p.source.source !== 'url') throw new Error(`entry ${p.name} external source.source must be 'url': ${p.source.source}`);
if (typeof p.source.url !== 'string' || !p.source.url.startsWith('https://')) {
throw new Error(`entry ${p.name} external url is not https: ${p.source.url}`);
}
if (p.source.url.startsWith('ssh://')) throw new Error(`entry ${p.name} url is ssh (must be https)`);
if (typeof p.source.ref !== 'string' || !p.source.ref) throw new Error(`entry ${p.name} missing ref`);
} else if (typeof p.source !== 'string' || !p.source) {
// local entry must be a non-empty "./plugins/<name>" string
throw new Error(`entry ${p.name} missing source`);
}
}
}
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: { 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 <names> or --all');
process.exit(2);
}
if (!args.out) {
console.error('error: --out <path> 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();
}