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>
This commit is contained in:
Kjell Tore Guttormsen 2026-06-17 16:02:35 +02:00
commit 86208dab6c
2 changed files with 19 additions and 14 deletions

View file

@ -2,7 +2,8 @@
// 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/<name>.git", "ref": "v<version>", "description" }
// { "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.
@ -53,14 +54,18 @@ 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.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.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`);
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`);
}
}
}
@ -84,7 +89,7 @@ export function rewrite({ inPath = DEFAULT_IN, only = null, all = false } = {})
if (flip.has(p.name)) {
const { url, ref } = externalSource(p.name, map);
flipped++;
return { name: p.name, source: 'url', url, ref, description: p.description };
return { name: p.name, source: { source: 'url', url, ref }, description: p.description };
}
local++;
return p;

View file

@ -29,9 +29,9 @@ test('--only voyage flips just voyage to external https+ref:v5.1.1, leaves 9 loc
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');
assert.equal(voyage.source.source, 'url', 'nested source-object discriminator must be url');
assert.ok(voyage.source.url.startsWith('https://git.fromaitochitta.com/open/'), `voyage url: ${voyage.source.url}`);
assert.equal(voyage.source.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');
@ -50,9 +50,9 @@ test('--all leaves zero ./plugins/ sources; every entry external https+ref', ()
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.equal(p.source.source, 'url', `${p.name} should be external (nested source-object)`);
assert.ok(p.source.url.startsWith('https://'), `${p.name} url must be https: ${p.source.url}`);
assert.ok(typeof p.source.ref === 'string' && p.source.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 {