From f704067b5863af493d298bb6d7d3a159eb2c678a Mon Sep 17 00:00:00 2001 From: "extro.rook.host" Date: Mon, 27 Jul 2026 17:34:50 -0600 Subject: [PATCH] feat(beacon): canonicalize vit: project identity at every write and query boundary Normalize configured, explicit, and remote project beacons through one shared primitive so stored, compared, and published identities use the canonical vit: form. Keep init repair paths able to replace corrupt config and make doctor report invalid identities without aborting its remaining checks. vit skim --skills now deliberately fails fast on a corrupt project config where it previously continued. --- src/cmd/adopt.js | 6 +- src/cmd/beacon.js | 7 +- src/cmd/doctor.js | 27 +- src/cmd/explore.js | 13 +- src/cmd/init.js | 43 +- src/cmd/remix.js | 3 +- src/cmd/scan.js | 10 +- src/cmd/ship.js | 13 +- src/cmd/skim.js | 3 +- src/cmd/vet.js | 3 +- src/cmd/vouch.js | 7 +- src/lib/beacon.js | 24 +- src/lib/vit-dir.js | 27 +- ...nonical-beacon-remote-suite-260727.test.js | 405 ++++++++++++++++++ test/adopt.test.js | 2 +- test/beacon.test.js | 117 +++-- test/canonical-beacon-cli-260727.test.js | 283 ++++++++++++ test/canonical-beacon-remote-260727.test.js | 28 ++ test/vit-dir.test.js | 72 +++- 19 files changed, 1017 insertions(+), 76 deletions(-) create mode 100644 test/.fixtures/canonical-beacon-remote-suite-260727.test.js create mode 100644 test/canonical-beacon-cli-260727.test.js create mode 100644 test/canonical-beacon-remote-260727.test.js diff --git a/src/cmd/adopt.js b/src/cmd/adopt.js index c893b96..2f5321e 100644 --- a/src/cmd/adopt.js +++ b/src/cmd/adopt.js @@ -4,7 +4,7 @@ import { existsSync } from 'node:fs'; import { resolve } from 'node:path'; import { execFileSync } from 'node:child_process'; -import { parseGitUrl, toBeacon, beaconToHttps } from '../lib/beacon.js'; +import { parseGitUrl, normalizeBeacon, beaconToHttps } from '../lib/beacon.js'; import { requireNotAgent } from '../lib/agent.js'; import { which } from '../lib/compat.js'; import { mark, name } from '../lib/brand.js'; @@ -30,9 +30,9 @@ export default function register(program) { // resolve beacon if (verbose) console.log(`[verbose] resolving beacon: ${beacon}`); - const httpsUrl = beaconToHttps(beacon); + const beaconUri = normalizeBeacon(beacon, 'vit adopt '); + const httpsUrl = beaconToHttps(beaconUri); const parsed = parseGitUrl(httpsUrl); - const beaconUri = 'vit:' + toBeacon(httpsUrl); if (verbose) console.log(`[verbose] beacon: ${beaconUri}`); if (verbose) console.log(`[verbose] https: ${httpsUrl}`); diff --git a/src/cmd/beacon.js b/src/cmd/beacon.js index 3ac6e63..fd28277 100644 --- a/src/cmd/beacon.js +++ b/src/cmd/beacon.js @@ -4,7 +4,7 @@ import git from 'isomorphic-git'; import http from 'isomorphic-git/http/node'; import { memfs } from 'memfs'; -import { beaconToHttps } from '../lib/beacon.js'; +import { beaconToHttps, normalizeBeacon, tryNormalizeBeacon } from '../lib/beacon.js'; import { mark } from '../lib/brand.js'; import { errorMessage, formatError } from '../lib/error-format.js'; @@ -31,7 +31,8 @@ export default function register(program) { .action(async (target, opts) => { try { const { verbose } = opts; - const url = beaconToHttps(target); + const canonicalTarget = normalizeBeacon(target, 'vit beacon '); + const url = beaconToHttps(canonicalTarget); if (verbose) console.log(`[verbose] Resolved URL: ${url}`); const { fs } = memfs(); const dir = '/'; @@ -47,7 +48,7 @@ export default function register(program) { let beacon; try { - beacon = content && JSON.parse(content).beacon; + beacon = tryNormalizeBeacon(content && JSON.parse(content).beacon); } catch (err) { console.warn(`warning: failed to parse .vit/config.json from ${url}: ${errorMessage(err)}`); } diff --git a/src/cmd/doctor.js b/src/cmd/doctor.js index a6e3b0b..72d42d0 100644 --- a/src/cmd/doctor.js +++ b/src/cmd/doctor.js @@ -65,6 +65,7 @@ export default function register(program) { let vitPath = which(name); let installPath = vitPath || null; let beacon = null; + let beaconError = null; let skillInstalled = false; let projectSkills = []; let userSkills = []; @@ -93,12 +94,19 @@ export default function register(program) { } } - const projConfig = readProjectConfig(); - beacon = projConfig.beacon || null; - if (projConfig.beacon) { - if (!opts.json) console.log(`${mark} beacon: ${projConfig.beacon}`); - } else { - if (!opts.json) console.log(`${mark} beacon: not set (run vit init)`); + try { + const projConfig = readProjectConfig(); + beacon = projConfig.beacon || null; + if (projConfig.beacon) { + if (!opts.json) console.log(`${mark} beacon: ${projConfig.beacon}`); + } else { + if (!opts.json) console.log(`${mark} beacon: not set (run vit init)`); + } + } catch (err) { + beaconError = errorMessage(err); + if (!opts.json) { + console.log(`${mark} beacon: invalid project identity in .vit/config.json (run vit init --beacon )`); + } } const skillResult = ensureSkill(); @@ -184,7 +192,7 @@ export default function register(program) { } if (opts.json) { - jsonOk({ + const result = { install: { type: installType, path: installPath }, beacon, skill: skillInstalled, @@ -192,8 +200,11 @@ export default function register(program) { projectSkills, userSkills, bluesky: { ok: blueskyOk, did: effectiveDid || null, pds, source: identitySource, authType }, - }); + }; + if (beaconError) result.beaconError = beaconError; + jsonOk(result); } + if (beaconError) process.exitCode = 1; } catch (err) { if (opts.json) { jsonError(err); diff --git a/src/cmd/explore.js b/src/cmd/explore.js index 9409e98..425b072 100644 --- a/src/cmd/explore.js +++ b/src/cmd/explore.js @@ -6,6 +6,7 @@ import { readProjectConfig } from '../lib/vit-dir.js'; import { brand } from '../lib/brand.js'; import { jsonOk, jsonError } from '../lib/json-output.js'; import { errorMessage, formatError } from '../lib/error-format.js'; +import { normalizeBeacon } from '../lib/beacon.js'; function timeAgo(isoString) { const seconds = Math.floor((Date.now() - new Date(isoString).getTime()) / 1000); @@ -104,9 +105,12 @@ export default function register(program) { const baseUrl = resolveUrl(opts); try { + const beacon = opts.beacon + ? normalizeBeacon(opts.beacon, '--beacon') + : null; const url = new URL('/api/cap', baseUrl); url.searchParams.set('ref', ref); - if (opts.beacon) url.searchParams.set('beacon', opts.beacon); + if (beacon) url.searchParams.set('beacon', beacon); const data = await fetchExploreJson(url); @@ -236,6 +240,8 @@ export default function register(program) { return; } beacon = beacons.join(','); + } else if (beacon) { + beacon = normalizeBeacon(beacon, '--beacon'); } const url = new URL('/api/caps', baseUrl); @@ -389,10 +395,13 @@ export default function register(program) { return; } + const beacon = opts.beacon + ? normalizeBeacon(opts.beacon, '--beacon') + : null; let capUri = opts.cap; if (opts.ref) { const capsUrl = new URL('/api/caps', baseUrl); - if (opts.beacon) capsUrl.searchParams.set('beacon', opts.beacon); + if (beacon) capsUrl.searchParams.set('beacon', beacon); const capsData = await fetchExploreJson(capsUrl); const match = capsData.caps?.find((cap) => cap.ref === opts.ref); diff --git a/src/cmd/init.js b/src/cmd/init.js index f058724..33807c9 100644 --- a/src/cmd/init.js +++ b/src/cmd/init.js @@ -4,13 +4,15 @@ import { existsSync, writeFileSync } from 'node:fs'; import { execSync } from 'node:child_process'; import { join } from 'node:path'; -import { toBeacon } from '../lib/beacon.js'; -import { vitDir, readProjectConfig, writeProjectConfig } from '../lib/vit-dir.js'; +import { normalizeBeacon, tryNormalizeBeacon } from '../lib/beacon.js'; +import { vitDir, readProjectConfig, readRawProjectConfig, writeProjectConfig } from '../lib/vit-dir.js'; import { requireAgent } from '../lib/agent.js'; import { mark, name, DOT_VIT_README } from '../lib/brand.js'; import { jsonOk, jsonError } from '../lib/json-output.js'; import { errorMessage, formatError } from '../lib/error-format.js'; +const BEACON_REPAIR_HINT = 'run vit init --beacon to repair it'; + export default function register(program) { program .command('init') @@ -39,7 +41,13 @@ export default function register(program) { if (verbose) vlog(`[verbose] .vit dir: ${dir}`); if (!opts.beacon && !opts.secondary) { - const config = readProjectConfig(); + let config; + try { + config = readProjectConfig(); + } catch (err) { + err.hint = BEACON_REPAIR_HINT; + throw err; + } if (config.beacon) { if (opts.json) { const out = { beacon: config.beacon }; @@ -149,7 +157,7 @@ export default function register(program) { } if (opts.secondary && !opts.beacon) { - const config = readProjectConfig(); + const config = readRawProjectConfig(); if (!config.beacon) { if (opts.json) { jsonError("no primary beacon set — run 'vit init --beacon ' first"); @@ -160,8 +168,15 @@ export default function register(program) { return; } - const secondary = 'vit:' + toBeacon(opts.secondary); - const merged = { ...config, secondaryBeacon: secondary }; + let primary; + try { + primary = normalizeBeacon(config.beacon, '.vit/config.json "beacon"'); + } catch (err) { + err.hint = BEACON_REPAIR_HINT; + throw err; + } + const secondary = normalizeBeacon(opts.secondary, '--secondary'); + const merged = { ...config, beacon: primary, secondaryBeacon: secondary }; writeProjectConfig(merged); if (opts.json) { jsonOk({ beacon: merged.beacon, secondaryBeacon: merged.secondaryBeacon }); @@ -210,12 +225,20 @@ export default function register(program) { if (verbose) vlog(`[verbose] Read git remote ${usedRemote}: ${gitUrl}`); } - const beacon = 'vit:' + toBeacon(gitUrl); + const beacon = normalizeBeacon(gitUrl, '--beacon'); if (verbose) vlog(`[verbose] Computed beacon: ${beacon}`); - const existing = readProjectConfig(); + const existing = readRawProjectConfig(); const merged = { ...existing, beacon }; if (opts.secondary) { - merged.secondaryBeacon = 'vit:' + toBeacon(opts.secondary); + merged.secondaryBeacon = normalizeBeacon(opts.secondary, '--secondary'); + } else if (Object.hasOwn(existing, 'secondaryBeacon')) { + const secondary = tryNormalizeBeacon(existing.secondaryBeacon); + if (secondary) { + merged.secondaryBeacon = secondary; + } else { + delete merged.secondaryBeacon; + console.warn('warning: dropping invalid .vit/config.json "secondaryBeacon" while repairing beacon'); + } } writeProjectConfig(merged); if (verbose) vlog(`[verbose] Wrote config.json`); @@ -239,7 +262,7 @@ export default function register(program) { jsonError(err); return; } - console.error(formatError(err, { verbose: opts.verbose })); + console.error(formatError(err, { hint: err?.hint, verbose: opts.verbose })); process.exitCode = 1; } }); diff --git a/src/cmd/remix.js b/src/cmd/remix.js index 919bb2f..58f3945 100644 --- a/src/cmd/remix.js +++ b/src/cmd/remix.js @@ -13,6 +13,7 @@ import { resolvePds, listRecordsFromPds, batchQuery } from '../lib/pds.js'; import { loadConfig } from '../lib/config.js'; import { jsonOk, jsonError } from '../lib/json-output.js'; import { formatError } from '../lib/error-format.js'; +import { tryNormalizeBeacon } from '../lib/beacon.js'; export default function register(program) { program @@ -114,7 +115,7 @@ export default function register(program) { let match = null; for (const records of allRecords) { for (const rec of records) { - if (!beaconSet.has(rec.value.beacon)) continue; + if (!beaconSet.has(tryNormalizeBeacon(rec.value.beacon))) continue; const recRef = resolveRef(rec.value, rec.cid); if (recRef === ref) { if (!match || (rec.value.createdAt || '') > (match.value.createdAt || '')) { diff --git a/src/cmd/scan.js b/src/cmd/scan.js index e54d198..140ae5e 100644 --- a/src/cmd/scan.js +++ b/src/cmd/scan.js @@ -8,6 +8,7 @@ import { brand } from '../lib/brand.js'; import { jsonOk, jsonError } from '../lib/json-output.js'; import { readBeaconSet } from '../lib/vit-dir.js'; import { formatError } from '../lib/error-format.js'; +import { normalizeBeacon, tryNormalizeBeacon } from '../lib/beacon.js'; export default function register(program) { program @@ -53,7 +54,7 @@ export default function register(program) { return; } } else { - beaconSet = new Set([opts.beacon]); + beaconSet = new Set([normalizeBeacon(opts.beacon, '--beacon')]); } } @@ -111,7 +112,8 @@ export default function register(program) { if (!isCapEvent && !isSkillEvent) return; // Apply filters - if (isCapEvent && beaconSet && !beaconSet.has(record.beacon)) return; + const recordBeacon = isCapEvent ? tryNormalizeBeacon(record.beacon) : null; + if (isCapEvent && beaconSet && !beaconSet.has(recordBeacon)) return; if (isSkillEvent && opts.tag) { const tags = record.tags || []; if (!tags.some(t => t.toLowerCase() === opts.tag.toLowerCase())) return; @@ -125,7 +127,7 @@ export default function register(program) { if (isCapEvent) { const title = record.title || ''; const refPart = ref ? ` (${ref})` : ''; - vlog(` ${didShort}: [cap] ${title}${refPart} [${record.beacon || 'no beacon'}]`); + vlog(` ${didShort}: [cap] ${title}${refPart} [${recordBeacon || 'no beacon'}]`); } else { const skillName = record.name || ''; const tags = record.tags ? ` [${record.tags.join(', ')}]` : ''; @@ -139,7 +141,7 @@ export default function register(program) { const entry = publishers.get(did); if (isCapEvent) { entry.capCount++; - if (record.beacon) entry.beacons.add(record.beacon); + if (recordBeacon) entry.beacons.add(recordBeacon); } else { entry.skillCount++; if (record.tags) { diff --git a/src/cmd/ship.js b/src/cmd/ship.js index 52ee861..40643ff 100644 --- a/src/cmd/ship.js +++ b/src/cmd/ship.js @@ -14,7 +14,7 @@ import { isValidSkillName, skillRefFromName } from '../lib/skill-ref.js'; import { name } from '../lib/brand.js'; import { resolvePds, listRecordsFromPds, batchQuery } from '../lib/pds.js'; import { jsonOk, jsonError } from '../lib/json-output.js'; -import { toBeacon } from '../lib/beacon.js'; +import { normalizeBeacon } from '../lib/beacon.js'; import { hashTo3Words } from '../lib/cap-ref.js'; import { formatError } from '../lib/error-format.js'; import { publishCap } from '../lib/cap.js'; @@ -49,11 +49,6 @@ function generateRef(title, existingRefs) { return null; } -function normalizeBeacon(input) { - if (input.startsWith('vit:')) return input; - return 'vit:' + toBeacon(input); -} - function parseFrontmatter(text) { const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/); if (!match) return { frontmatter: {}, body: text }; @@ -381,13 +376,13 @@ export async function shipCap(opts) { // Request caps: --beacon flag or project config (in that order) if (opts.beacon) { try { - beacon = normalizeBeacon(opts.beacon); + beacon = normalizeBeacon(opts.beacon, '--beacon'); } catch (err) { if (opts.json) { - jsonError(`invalid --beacon: ${err.message}`); + jsonError(err); return; } - console.error(`error: invalid --beacon: ${err.message}`); + console.error(formatError(err, { verbose })); process.exitCode = 1; return; } diff --git a/src/cmd/skim.js b/src/cmd/skim.js index 715f7c3..c613f24 100644 --- a/src/cmd/skim.js +++ b/src/cmd/skim.js @@ -12,6 +12,7 @@ import { name } from '../lib/brand.js'; import { resolvePds, listRecordsFromPds, batchQuery } from '../lib/pds.js'; import { jsonError } from '../lib/json-output.js'; import { formatError } from '../lib/error-format.js'; +import { tryNormalizeBeacon } from '../lib/beacon.js'; export default function register(program) { program @@ -95,7 +96,7 @@ export default function register(program) { // Fetch caps (filtered by beacon) if (wantCaps && beaconSet.size > 0) { const res = await listRecordsFromPds(pds, repoDid, CAP_COLLECTION, 50); - let caps = res.records.filter(r => beaconSet.has(r.value.beacon)); + let caps = res.records.filter(r => beaconSet.has(tryNormalizeBeacon(r.value.beacon))); if (opts.kind) { caps = caps.filter(r => r.value.kind === opts.kind); } diff --git a/src/cmd/vet.js b/src/cmd/vet.js index 93db0a1..e456a0a 100644 --- a/src/cmd/vet.js +++ b/src/cmd/vet.js @@ -18,6 +18,7 @@ import { loadConfig } from '../lib/config.js'; import { jsonOk, jsonError } from '../lib/json-output.js'; import { sandboxArgs } from '../lib/sandbox.js'; import { formatError } from '../lib/error-format.js'; +import { tryNormalizeBeacon } from '../lib/beacon.js'; const execFileAsync = promisify(execFile); @@ -290,7 +291,7 @@ export default function register(program) { let match = null; for (const records of allRecords) { for (const rec of records) { - if (!beaconSet.has(rec.value.beacon)) continue; + if (!beaconSet.has(tryNormalizeBeacon(rec.value.beacon))) continue; const recRef = resolveRef(rec.value, rec.cid); if (recRef === ref) { if (!match || (rec.value.createdAt || '') > (match.value.createdAt || '')) { diff --git a/src/cmd/vouch.js b/src/cmd/vouch.js index 6d50401..5a4a390 100644 --- a/src/cmd/vouch.js +++ b/src/cmd/vouch.js @@ -13,6 +13,7 @@ import { resolvePds, listRecordsFromPds, batchQuery } from '../lib/pds.js'; import { loadConfig } from '../lib/config.js'; import { jsonOk, jsonError } from '../lib/json-output.js'; import { formatError } from '../lib/error-format.js'; +import { tryNormalizeBeacon } from '../lib/beacon.js'; export default function register(program) { program @@ -229,7 +230,7 @@ export default function register(program) { let match = null; for (const records of allRecords) { for (const rec of records) { - if (!isWant && !beaconSet.has(rec.value.beacon)) continue; + if (!isWant && !beaconSet.has(tryNormalizeBeacon(rec.value.beacon))) continue; const recRef = resolveRef(rec.value, rec.cid); if (recRef === ref) { if (!match || (rec.value.createdAt || '') > (match.value.createdAt || '')) { @@ -254,7 +255,9 @@ export default function register(program) { } const now = new Date().toISOString(); - const projBeacon = beaconSet.size > 0 ? [...beaconSet][0] : (match.value.beacon || null); + const projBeacon = beaconSet.size > 0 + ? [...beaconSet][0] + : tryNormalizeBeacon(match.value.beacon); const vouchRecord = { $type: VOUCH_COLLECTION, subject: { diff --git a/src/lib/beacon.js b/src/lib/beacon.js index 2df88dd..53682e5 100644 --- a/src/lib/beacon.js +++ b/src/lib/beacon.js @@ -81,11 +81,33 @@ export function parseGitUrl(input) { return { host, org, repo }; } -export function toBeacon(input) { +function toBeacon(input) { const { host, org, repo } = parseGitUrl(input); return org ? `${host}/${org}/${repo}` : `${host}//${repo}`; } +export const BEACON_ACCEPTED_FORMS = 'vit:host/owner/repo or a git URL (a scheme URL such as https://host/owner/repo, ssh://git@host/owner/repo, or git://host/owner/repo; SCP-style git@host:owner/repo; or host/owner/repo)'; + +export function normalizeBeacon(input, source) { + try { + const trimmed = typeof input === 'string' ? input.trim() : input; + const gitUrl = typeof trimmed === 'string' && trimmed.startsWith('vit:') + ? trimmed.slice(4) + : trimmed; + return `vit:${toBeacon(gitUrl)}`; + } catch { + throw new Error(`Invalid beacon from ${source}: expected ${BEACON_ACCEPTED_FORMS}.`); + } +} + +export function tryNormalizeBeacon(input) { + try { + return normalizeBeacon(input, 'untrusted value'); + } catch { + return null; + } +} + export function beaconToHttps(input) { if (typeof input === 'string' && input.startsWith('vit:')) { const path = input.slice(4); diff --git a/src/lib/vit-dir.js b/src/lib/vit-dir.js index 0abeb1c..a691423 100644 --- a/src/lib/vit-dir.js +++ b/src/lib/vit-dir.js @@ -3,13 +3,16 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs'; import { join } from 'node:path'; +import { normalizeBeacon } from './beacon.js'; import { errorMessage } from './error-format.js'; export function vitDir(dir) { return join(dir || process.cwd(), '.vit'); } -export function readProjectConfig(dir) { +// Exported only so vit init can replace corrupt beacon fields while preserving +// unrelated config. Other command paths must use readProjectConfig. +export function readRawProjectConfig(dir) { const p = join(vitDir(dir), 'config.json'); if (!existsSync(p)) return {}; try { @@ -20,6 +23,25 @@ export function readProjectConfig(dir) { } } +function normalizeProjectConfig(config) { + const normalized = { ...config }; + if (Object.hasOwn(normalized, 'beacon')) { + normalized.beacon = normalizeBeacon(normalized.beacon, '.vit/config.json "beacon"'); + } + if (Object.hasOwn(normalized, 'secondaryBeacon')) { + normalized.secondaryBeacon = normalizeBeacon( + normalized.secondaryBeacon, + '.vit/config.json "secondaryBeacon"', + ); + } + return normalized; +} + +export function readProjectConfig(dir) { + const config = readRawProjectConfig(dir); + return normalizeProjectConfig(config); +} + export function readBeaconSet(dir) { const config = readProjectConfig(dir); const set = new Set(); @@ -30,8 +52,9 @@ export function readBeaconSet(dir) { export function writeProjectConfig(obj, baseDir) { const dir = baseDir ? join(baseDir, '.vit') : vitDir(); + const normalized = normalizeProjectConfig(obj); mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, 'config.json'), JSON.stringify(obj, null, 2) + '\n'); + writeFileSync(join(dir, 'config.json'), JSON.stringify(normalized, null, 2) + '\n'); } export function appendLog(filename, record, dir) { diff --git a/test/.fixtures/canonical-beacon-remote-suite-260727.test.js b/test/.fixtures/canonical-beacon-remote-suite-260727.test.js new file mode 100644 index 0000000..e3a17b9 --- /dev/null +++ b/test/.fixtures/canonical-beacon-remote-suite-260727.test.js @@ -0,0 +1,405 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 sol pbc + +// This lives under test/.fixtures/ so top-level `bun test` does not auto-discover +// it in the shared process; its sibling wrapper executes it explicitly. +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from 'bun:test'; +import { Command } from 'commander'; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const CAP_URI = 'at://did:plc:author/org.v-it.cap/record'; +const CAP_CID = 'bafycanonical'; +const FAKE_HEAD = 'head'; +const FAKE_TREE = 'tree'; +const FAKE_VIT_TREE = 'vit-tree'; +const FAKE_CONFIG_BLOB = 'config-blob'; + +let publishCalls = []; +let putRecordCalls = []; +let remoteRecords = []; +let remoteRepoConfig = {}; + +const fakeAgent = { + resolveHandle: async () => ({ data: { did: 'did:plc:test' } }), + com: { + atproto: { + repo: { + describeRepo: async () => ({ data: { handle: 'test.example' } }), + putRecord: async (args) => { + putRecordCalls.push(args); + return { data: { uri: 'at://did:plc:test/org.v-it.vouch/record', cid: 'bafyvouch' } }; + }, + }, + }, + }, +}; + +mock.module('../../src/lib/cap.js', () => ({ + publishCap: async (_agent, input) => { + publishCalls.push(input); + return { + uri: CAP_URI, + cid: CAP_CID, + record: input, + response: { uri: CAP_URI, cid: CAP_CID }, + }; + }, +})); + +mock.module('../../src/lib/oauth.js', () => ({ + restoreAgent: async () => ({ + agent: fakeAgent, + session: { serverMetadata: { issuer: 'https://pds.example' } }, + }), +})); + +mock.module('../../src/lib/pds.js', () => ({ + resolvePds: async () => 'https://pds.example', + resolveHandleFromDid: async () => 'test.example', + listRecordsFromPds: async () => ({ records: remoteRecords }), + batchQuery: async (items, fn) => Promise.all(items.map(fn)), +})); + +mock.module('isomorphic-git', () => ({ + default: { + clone: async () => {}, + resolveRef: async () => FAKE_HEAD, + readObject: async ({ oid }) => { + if (oid === FAKE_HEAD) return { object: { tree: FAKE_TREE } }; + if (oid === FAKE_TREE) return { object: [{ path: '.vit', oid: FAKE_VIT_TREE }] }; + if (oid === FAKE_VIT_TREE) { + return { object: [{ path: 'config.json', oid: FAKE_CONFIG_BLOB }] }; + } + if (oid === FAKE_CONFIG_BLOB) { + return { object: new TextEncoder().encode(JSON.stringify(remoteRepoConfig)) }; + } + return { object: [] }; + }, + }, +})); + +const [ + { shipCap }, + { default: registerBeacon }, + { default: registerRemix }, + { default: registerScan }, + { default: registerSkim }, + { default: registerVet }, + { default: registerVouch }, +] = await Promise.all([ + import('../../src/cmd/ship.js'), + import('../../src/cmd/beacon.js'), + import('../../src/cmd/remix.js'), + import('../../src/cmd/scan.js'), + import('../../src/cmd/skim.js'), + import('../../src/cmd/vet.js'), + import('../../src/cmd/vouch.js'), +]); + +function writeRawConfig(dir, config) { + mkdirSync(join(dir, '.vit'), { recursive: true }); + writeFileSync(join(dir, '.vit', 'config.json'), JSON.stringify(config, null, 2) + '\n'); +} + +function capRecord(beacon, overrides = {}) { + return { + uri: CAP_URI, + cid: CAP_CID, + value: { + $type: 'org.v-it.cap', + ref: 'canonical-beacon-test', + title: 'Canonical Beacon', + description: 'Canonical beacon test cap', + text: 'test body', + createdAt: '2026-07-27T00:00:00.000Z', + beacon, + ...overrides, + }, + }; +} + +async function runRegistered(register, args) { + const program = new Command(); + program.exitOverride(); + register(program); + await program.parseAsync(args, { from: 'user' }); +} + +describe('canonical remote beacon behavior', () => { + let testDir; + let cwdSpy; + let logSpy; + let errorSpy; + let warnSpy; + let savedEnv; + let savedWebSocket; + + beforeEach(() => { + testDir = join(tmpdir(), '.test-canonical-beacon-remote-' + Math.random().toString(36).slice(2)); + mkdirSync(testDir, { recursive: true }); + cwdSpy = spyOn(process, 'cwd').mockReturnValue(testDir); + logSpy = spyOn(console, 'log').mockImplementation(() => {}); + errorSpy = spyOn(console, 'error').mockImplementation(() => {}); + warnSpy = spyOn(console, 'warn').mockImplementation(() => {}); + savedEnv = { + CLAUDECODE: process.env.CLAUDECODE, + GEMINI_CLI: process.env.GEMINI_CLI, + CODEX_CI: process.env.CODEX_CI, + OPENCODE: process.env.OPENCODE, + }; + savedWebSocket = globalThis.WebSocket; + process.env.CLAUDECODE = ''; + process.env.GEMINI_CLI = ''; + process.env.CODEX_CI = ''; + process.env.OPENCODE = ''; + process.exitCode = 0; + publishCalls = []; + putRecordCalls = []; + remoteRecords = []; + remoteRepoConfig = {}; + }); + + afterEach(() => { + cwdSpy.mockRestore(); + logSpy.mockRestore(); + errorSpy.mockRestore(); + warnSpy.mockRestore(); + globalThis.WebSocket = savedWebSocket; + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + process.exitCode = 0; + rmSync(testDir, { recursive: true, force: true }); + }); + + afterAll(() => { + mock.restore(); + }); + + function logs() { + return logSpy.mock.calls.map(call => call.join(' ')).join('\n'); + } + + function enableAgent() { + process.env.CODEX_CI = '1'; + } + + test('cap publication uses canonical primary config and explicit option values', async () => { + enableAgent(); + writeRawConfig(testDir, { beacon: 'https://github.com/solpbc/thermals' }); + + await shipCap({ + did: 'did:plc:test', + kind: 'request', + title: 'Canonical Beacon', + description: 'Canonical config publication', + ref: 'canonical-config-publication', + json: true, + }); + expect(publishCalls[0].beacon).toBe('vit:github.com/solpbc/thermals'); + + rmSync(join(testDir, '.vit'), { recursive: true, force: true }); + await shipCap({ + did: 'did:plc:test', + kind: 'request', + title: 'Canonical Beacon', + description: 'Canonical option publication', + ref: 'canonical-option-publication', + beacon: 'https://tangled.org/solpbc.org/rookery', + json: true, + }); + expect(publishCalls[1].beacon).toBe('vit:tangled.org/solpbc.org/rookery'); + }); + + test('invalid explicit cap beacon invokes no publish collaborator', async () => { + enableAgent(); + + await shipCap({ + did: 'did:plc:test', + kind: 'request', + title: 'Canonical Beacon', + description: 'Rejected publication', + ref: 'rejected-beacon-publication', + beacon: 'not a url', + json: true, + }); + + expect(process.exitCode).toBe(1); + expect(publishCalls).toHaveLength(0); + expect(logs()).toContain('Invalid beacon from --beacon'); + }); + + test('skim matches an HTTPS remote beacon against canonical local config and skips garbage', async () => { + enableAgent(); + writeRawConfig(testDir, { beacon: 'https://github.com/solpbc/thermals' }); + remoteRecords = [ + capRecord('not a url', { ref: 'garbage-beacon-record' }), + capRecord('https://github.com/solpbc/thermals'), + ]; + + await runRegistered(registerSkim, ['skim', '--did', 'did:plc:test', '--caps', '--json']); + + const output = JSON.parse(logSpy.mock.calls.at(-1)[0]); + expect(output).toHaveLength(1); + expect(output[0].value.ref).toBe('canonical-beacon-test'); + }); + + test('remix matches an HTTPS remote beacon through a canonical secondary config', async () => { + enableAgent(); + writeRawConfig(testDir, { + beacon: 'vit:knot.commonscomputer.com//did:plc:mfquhie7kthb4ig453glwgdk', + secondaryBeacon: 'https://tangled.org/solpbc.org/rookery', + }); + writeFileSync( + join(testDir, '.vit', 'trusted.jsonl'), + JSON.stringify({ ref: 'canonical-beacon-test', uri: CAP_URI }) + '\n', + ); + remoteRecords = [capRecord('https://tangled.org/solpbc.org/rookery')]; + + await runRegistered(registerRemix, [ + 'remix', 'canonical-beacon-test', '--did', 'did:plc:test', '--json', + ]); + + const output = JSON.parse(logSpy.mock.calls.at(-1)[0]); + expect(output.ok).toBe(true); + expect(output.ref).toBe('canonical-beacon-test'); + }); + + test('vet matches an HTTPS remote beacon and silently skips malformed remote values', async () => { + writeRawConfig(testDir, { beacon: 'https://github.com/solpbc/thermals' }); + remoteRecords = [ + capRecord('not a url', { ref: 'garbage-beacon-record' }), + capRecord('https://github.com/solpbc/thermals'), + ]; + + await runRegistered(registerVet, [ + 'vet', 'canonical-beacon-test', '--did', 'did:plc:test', '--json', + ]); + + const output = JSON.parse(logSpy.mock.calls.at(-1)[0]); + expect(output.ok).toBe(true); + expect(output.ref).toBe('canonical-beacon-test'); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test('vouch publishes and logs one canonical project beacon after remote alias matching', async () => { + writeRawConfig(testDir, { beacon: 'https://github.com/solpbc/thermals' }); + writeFileSync( + join(testDir, '.vit', 'trusted.jsonl'), + JSON.stringify({ ref: 'canonical-beacon-test', uri: CAP_URI }) + '\n', + ); + remoteRecords = [capRecord('https://github.com/solpbc/thermals')]; + + await runRegistered(registerVouch, [ + 'vouch', 'canonical-beacon-test', '--did', 'did:plc:test', '--json', + ]); + + expect(putRecordCalls).toHaveLength(1); + expect(putRecordCalls[0].record.beacon).toBe('vit:github.com/solpbc/thermals'); + const local = JSON.parse( + readFileSync(join(testDir, '.vit', 'vouched.jsonl'), 'utf-8').trim(), + ); + expect(local.beacon).toBe('vit:github.com/solpbc/thermals'); + }); + + test('want vouch tolerant-normalizes the matched ownerless beacon for record and log', async () => { + const ownerless = 'vit:knot.commonscomputer.com//did:plc:mfquhie7kthb4ig453glwgdk'; + remoteRecords = [capRecord(ownerless)]; + + await runRegistered(registerVouch, [ + 'vouch', 'canonical-beacon-test', '--kind', 'want', '--did', 'did:plc:test', '--json', + ]); + + expect(putRecordCalls[0].record.beacon).toBe(ownerless); + const local = JSON.parse( + readFileSync(join(testDir, '.vit', 'vouched.jsonl'), 'utf-8').trim(), + ); + expect(local.beacon).toBe(ownerless); + }); + + test('a malformed want-vouch fallback is omitted from both published record and log', async () => { + remoteRecords = [capRecord('not a url')]; + + await runRegistered(registerVouch, [ + 'vouch', 'canonical-beacon-test', '--kind', 'want', '--did', 'did:plc:test', '--json', + ]); + + expect(putRecordCalls[0].record).not.toHaveProperty('beacon'); + const local = JSON.parse( + readFileSync(join(testDir, '.vit', 'vouched.jsonl'), 'utf-8').trim(), + ); + expect(local.beacon).toBeNull(); + }); + + test('scan matches a remote HTTPS alias and emits its canonical beacon', async () => { + writeRawConfig(testDir, { beacon: 'https://github.com/solpbc/thermals' }); + const messages = [ + { + kind: 'commit', + did: 'did:plc:author', + commit: { + operation: 'create', + collection: 'org.v-it.cap', + cid: CAP_CID, + record: capRecord('https://github.com/solpbc/thermals').value, + }, + }, + { + kind: 'commit', + did: 'did:plc:garbage', + commit: { + operation: 'create', + collection: 'org.v-it.cap', + cid: 'bafygarbage', + record: capRecord('not a url').value, + }, + }, + ]; + globalThis.WebSocket = class FakeWebSocket { + constructor() { + queueMicrotask(() => { + for (const message of messages) { + this.onmessage?.({ data: JSON.stringify(message) }); + } + this.onclose?.(); + }); + } + + close() { + this.onclose?.(); + } + }; + + await runRegistered(registerScan, [ + 'scan', '--beacon', '.', '--caps', '--days', '1', '--json', + ]); + + const output = JSON.parse(logSpy.mock.calls.at(-1)[0]); + expect(output.publishers).toHaveLength(1); + expect(output.publishers[0].beacons).toEqual(['vit:github.com/solpbc/thermals']); + }); + + test('remote beacon display canonicalizes aliases and uses existing unlit wording for garbage', async () => { + remoteRepoConfig = { beacon: 'https://tangled.org/solpbc.org/rookery' }; + await runRegistered(registerBeacon, ['beacon', 'vit:github.com/solpbc/vit']); + expect(logs()).toContain('beacon: lit vit:tangled.org/solpbc.org/rookery'); + + logSpy.mockClear(); + remoteRepoConfig = { beacon: 'not a url' }; + await runRegistered(registerBeacon, ['beacon', 'vit:github.com/solpbc/vit']); + expect(logs()).toContain('beacon: unlit'); + expect(logs()).not.toContain('not a url'); + }); +}); diff --git a/test/adopt.test.js b/test/adopt.test.js index a631367..1545d5b 100644 --- a/test/adopt.test.js +++ b/test/adopt.test.js @@ -35,7 +35,7 @@ describe('vit adopt', () => { test('fails with invalid beacon', () => { const result = run('adopt notaurl', tmpDir, NON_AGENT_ENV); expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain('Invalid git URL'); + expect(result.stderr).toContain('Invalid beacon from vit adopt '); }); test('fails if directory already exists', () => { diff --git a/test/beacon.test.js b/test/beacon.test.js index 11efb1a..fdd2e46 100644 --- a/test/beacon.test.js +++ b/test/beacon.test.js @@ -2,43 +2,106 @@ // Copyright (c) 2026 sol pbc import { describe, test, expect } from 'bun:test'; -import { toBeacon, parseGitUrl, beaconToHttps } from '../src/lib/beacon.js'; +import { + BEACON_ACCEPTED_FORMS, + beaconToHttps, + normalizeBeacon, + parseGitUrl, + tryNormalizeBeacon, +} from '../src/lib/beacon.js'; -describe('toBeacon', () => { - test('SCP SSH with .git', () => expect(toBeacon('git@github.com:org/repo.git')).toBe('github.com/org/repo')); - test('SCP SSH without .git', () => expect(toBeacon('git@github.com:org/repo')).toBe('github.com/org/repo')); +describe('normalizeBeacon', () => { + const canonical = 'vit:github.com/owner/repo'; - test('SSH URL', () => expect(toBeacon('ssh://git@github.com/org/repo.git')).toBe('github.com/org/repo')); - test('SSH URL with port', () => expect(toBeacon('ssh://git@github.com:22/org/repo.git')).toBe('github.com/org/repo')); + test.each([ + 'vit:github.com/Owner/Repo', + 'https://github.com/Owner/Repo.git', + 'ssh://git@github.com/Owner/Repo.git', + 'git@github.com:Owner/Repo.git', + 'github.com/Owner/Repo', + ])('normalizes supported form %s', (input) => { + expect(normalizeBeacon(input, '--beacon')).toBe(canonical); + }); - test('HTTPS with .git', () => expect(toBeacon('https://github.com/org/repo.git')).toBe('github.com/org/repo')); - test('HTTPS without .git', () => expect(toBeacon('https://github.com/org/repo')).toBe('github.com/org/repo')); - test('HTTPS trailing slash', () => expect(toBeacon('https://github.com/org/repo/')).toBe('github.com/org/repo')); + test('is idempotent', () => { + expect(normalizeBeacon(normalizeBeacon(canonical, '--beacon'), '--beacon')).toBe(canonical); + }); - test('git protocol', () => expect(toBeacon('git://github.com/org/repo.git')).toBe('github.com/org/repo')); + test('SCP SSH without .git', () => + expect(normalizeBeacon('git@github.com:org/repo', '--beacon')).toBe('vit:github.com/org/repo')); + test('SSH URL with port', () => + expect(normalizeBeacon('ssh://git@github.com:22/org/repo.git', '--beacon')).toBe('vit:github.com/org/repo')); + test('HTTPS without .git', () => + expect(normalizeBeacon('https://github.com/org/repo', '--beacon')).toBe('vit:github.com/org/repo')); + test('HTTPS trailing slash', () => + expect(normalizeBeacon('https://github.com/org/repo/', '--beacon')).toBe('vit:github.com/org/repo')); + test('git protocol', () => + expect(normalizeBeacon('git://github.com/org/repo.git', '--beacon')).toBe('vit:github.com/org/repo')); + test('bare slug with .git', () => + expect(normalizeBeacon('github.com/org/repo.git', '--beacon')).toBe('vit:github.com/org/repo')); - test('bare slug', () => expect(toBeacon('github.com/org/repo')).toBe('github.com/org/repo')); - test('bare slug with .git', () => expect(toBeacon('github.com/org/repo.git')).toBe('github.com/org/repo')); + test('trims whitespace', () => + expect(normalizeBeacon(' \n https://GitHub.Com/Owner/Repo.git \t', '--beacon')).toBe(canonical)); + test('ignores query and fragment text without changing the path grammar', () => { + expect(normalizeBeacon('https://github.com/Owner/Repo.git?tab=readme#usage', '--beacon')).toBe(canonical); + }); - test('case normalization', () => expect(toBeacon('GitHub.Com/Org/Repo.git')).toBe('github.com/org/repo')); test('SCP case normalization', () => - expect(toBeacon('git@GitHub.Com:Org/Repo.git')).toBe('github.com/org/repo')); - test('HTTPS case normalization', () => - expect(toBeacon('https://GitHub.Com/Org/Repo')).toBe('github.com/org/repo')); + expect(normalizeBeacon('git@GitHub.Com:Owner/Repo.git', '--beacon')).toBe(canonical)); - test('no-org SCP (tilde)', () => expect(toBeacon('git@sr.ht:~user/repo.git')).toBe('sr.ht/~user/repo')); - test('no-org SCP single segment', () => expect(toBeacon('git@myhost.com:repo.git')).toBe('myhost.com//repo')); - test('no-org HTTPS', () => expect(toBeacon('https://myhost.com/repo.git')).toBe('myhost.com//repo')); - test('no-org bare slug', () => expect(toBeacon('myhost.com/repo')).toBe('myhost.com//repo')); + test('no-org SCP (tilde)', () => + expect(normalizeBeacon('git@sr.ht:~user/repo.git', '--beacon')).toBe('vit:sr.ht/~user/repo')); + test('no-org SCP single segment', () => + expect(normalizeBeacon('git@myhost.com:repo.git', '--beacon')).toBe('vit:myhost.com//repo')); + test('no-org HTTPS', () => + expect(normalizeBeacon('https://myhost.com/repo.git', '--beacon')).toBe('vit:myhost.com//repo')); + test('no-org bare slug', () => + expect(normalizeBeacon('myhost.com/repo', '--beacon')).toBe('vit:myhost.com//repo')); + test('ownerless vit URI double slash round-trips', () => { + const beacon = 'vit:knot.commonscomputer.com//did:plc:mfquhie7kthb4ig453glwgdk'; + expect(normalizeBeacon(beacon, '--beacon')).toBe(beacon); + }); - test('this repo (solpbc/vit)', () => expect(toBeacon('git@github.com:solpbc/vit.git')).toBe('github.com/solpbc/vit')); + test('dotted owner is preserved', () => { + expect(normalizeBeacon('https://tangled.org/solpbc.org/rookery', '--beacon')) + .toBe('vit:tangled.org/solpbc.org/rookery'); + }); - test('empty string throws', () => expect(() => toBeacon('')).toThrow('Invalid git URL')); - test('null throws', () => expect(() => toBeacon(null)).toThrow('Invalid git URL')); - test('undefined throws', () => expect(() => toBeacon(undefined)).toThrow('Invalid git URL')); - test('number throws', () => expect(() => toBeacon(123)).toThrow('Invalid git URL')); - test('bare word throws', () => expect(() => toBeacon('repo')).toThrow('Invalid git URL')); - test('too many segments throws', () => expect(() => toBeacon('github.com/a/b/c')).toThrow('Invalid git URL')); + test.each([ + '', + ' ', + 'vit:', + 'repo', + 'https://github.com/', + 'github.com/a/b/c', + null, + undefined, + 123, + {}, + ])('rejects malformed or non-string input %#', (input) => { + expect(() => normalizeBeacon(input, '--beacon')).toThrow( + `Invalid beacon from --beacon: expected ${BEACON_ACCEPTED_FORMS}.`, + ); + }); + + test('strict error names a config field without echoing its value', () => { + const bad = 'secret-not-a-url'; + expect(() => normalizeBeacon(bad, '.vit/config.json "beacon"')).toThrow( + `Invalid beacon from .vit/config.json "beacon": expected ${BEACON_ACCEPTED_FORMS}.`, + ); + try { + normalizeBeacon(bad, '.vit/config.json "beacon"'); + } catch (err) { + expect(err.message).not.toContain(bad); + } + }); + + test('tolerant form returns canonical values or null', () => { + expect(tryNormalizeBeacon('https://github.com/Owner/Repo.git')).toBe(canonical); + expect(tryNormalizeBeacon(canonical)).toBe(canonical); + expect(tryNormalizeBeacon('not a url')).toBeNull(); + expect(tryNormalizeBeacon(null)).toBeNull(); + }); }); describe('parseGitUrl', () => { diff --git a/test/canonical-beacon-cli-260727.test.js b/test/canonical-beacon-cli-260727.test.js new file mode 100644 index 0000000..87e91a5 --- /dev/null +++ b/test/canonical-beacon-cli-260727.test.js @@ -0,0 +1,283 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 sol pbc + +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const vitBin = join(import.meta.dir, '..', 'bin', 'vit.js'); +const cleanAgentEnv = { CLAUDECODE: '', GEMINI_CLI: '', CODEX_CI: '', OPENCODE: '' }; + +async function runVit(args, cwd, env = {}) { + const proc = Bun.spawn(['bun', vitBin, ...args], { + cwd, + env: { ...process.env, ...cleanAgentEnv, ...env }, + stdin: 'ignore', + stdout: 'pipe', + stderr: 'pipe', + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }; +} + +function writeRawConfig(dir, config) { + mkdirSync(join(dir, '.vit'), { recursive: true }); + writeFileSync(join(dir, '.vit', 'config.json'), JSON.stringify(config, null, 2) + '\n'); +} + +describe('canonical beacon CLI behavior', () => { + let testDir; + let testHome; + let server; + let requests; + + beforeEach(() => { + testDir = join(tmpdir(), '.test-canonical-beacon-cli-' + Math.random().toString(36).slice(2)); + testHome = join(tmpdir(), '.test-canonical-beacon-home-' + Math.random().toString(36).slice(2)); + mkdirSync(testDir, { recursive: true }); + mkdirSync(testHome, { recursive: true }); + requests = []; + }); + + afterEach(() => { + server?.stop(true); + server = null; + rmSync(testDir, { recursive: true, force: true }); + rmSync(testHome, { recursive: true, force: true }); + }); + + function isolatedEnv(extra = {}) { + return { + HOME: testHome, + XDG_CONFIG_HOME: join(testHome, '.config'), + ...extra, + }; + } + + function startExploreServer() { + server = Bun.serve({ + port: 0, + fetch(request) { + requests.push(new URL(request.url)); + return Response.json({ caps: [], cursor: null }); + }, + }); + return `http://127.0.0.1:${server.port}`; + } + + test('primary repair canonicalizes the replacement and preserves unrelated fields', async () => { + writeRawConfig(testDir, { + beacon: 'not a url', + secondaryBeacon: 'https://tangled.org/solpbc.org/rookery', + untouched: { keep: true }, + }); + + const result = await runVit( + ['init', '--beacon', 'https://github.com/solpbc/thermals'], + testDir, + isolatedEnv({ CODEX_CI: '1' }), + ); + + expect(result.exitCode).toBe(0); + expect(JSON.parse(readFileSync(join(testDir, '.vit', 'config.json'), 'utf-8'))).toEqual({ + beacon: 'vit:github.com/solpbc/thermals', + secondaryBeacon: 'vit:tangled.org/solpbc.org/rookery', + untouched: { keep: true }, + }); + }); + + test('primary repair drops only a corrupt preserved secondary', async () => { + writeRawConfig(testDir, { + beacon: 'not a url', + secondaryBeacon: 'also not a url', + untouched: 42, + }); + + const result = await runVit( + ['init', '--beacon', 'https://github.com/solpbc/thermals'], + testDir, + isolatedEnv({ CODEX_CI: '1' }), + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain('dropping invalid .vit/config.json "secondaryBeacon"'); + expect(JSON.parse(readFileSync(join(testDir, '.vit', 'config.json'), 'utf-8'))).toEqual({ + beacon: 'vit:github.com/solpbc/thermals', + untouched: 42, + }); + }); + + test('secondary repair replaces corrupt secondary and canonicalizes the preserved primary', async () => { + writeRawConfig(testDir, { + beacon: 'https://github.com/solpbc/thermals', + secondaryBeacon: 'not a url', + untouched: true, + }); + + const result = await runVit( + ['init', '--secondary', 'https://tangled.org/solpbc.org/rookery'], + testDir, + isolatedEnv({ CODEX_CI: '1' }), + ); + + expect(result.exitCode).toBe(0); + expect(JSON.parse(readFileSync(join(testDir, '.vit', 'config.json'), 'utf-8'))).toEqual({ + beacon: 'vit:github.com/solpbc/thermals', + secondaryBeacon: 'vit:tangled.org/solpbc.org/rookery', + untouched: true, + }); + }); + + test('secondary-only repair refuses a corrupt primary with a repair hint and no write', async () => { + const original = { beacon: 'not a url', secondaryBeacon: 'old invalid', untouched: true }; + writeRawConfig(testDir, original); + + const result = await runVit( + ['init', '--secondary', 'https://tangled.org/solpbc.org/rookery'], + testDir, + isolatedEnv({ CODEX_CI: '1' }), + ); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('.vit/config.json "beacon"'); + expect(result.stderr).toContain('run vit init --beacon to repair it'); + expect(JSON.parse(readFileSync(join(testDir, '.vit', 'config.json'), 'utf-8'))).toEqual(original); + }); + + test('doctor reports an invalid beacon, completes checks, and exposes beaconError in JSON', async () => { + writeRawConfig(testDir, { beacon: 'not a url' }); + const env = isolatedEnv(); + + const human = await runVit(['doctor'], testDir, env); + expect(human.exitCode).toBe(1); + expect(human.stdout).toContain('beacon: invalid project identity in .vit/config.json'); + expect(human.stdout).toContain('skill:'); + expect(human.stdout).toContain('bluesky:'); + + const json = await runVit(['doctor', '--json'], testDir, env); + const parsed = JSON.parse(json.stdout); + expect(json.exitCode).toBe(1); + expect(parsed.ok).toBe(true); + expect(parsed.beacon).toBeNull(); + expect(parsed.beaconError).toContain('.vit/config.json "beacon"'); + expect(parsed).toHaveProperty('skillInstall'); + expect(parsed).toHaveProperty('bluesky'); + }); + + test('init status reports the corrupt config field with the repair hint', async () => { + writeRawConfig(testDir, { beacon: 'not a url' }); + + const result = await runVit( + ['init'], + testDir, + isolatedEnv({ CODEX_CI: '1' }), + ); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('.vit/config.json "beacon"'); + expect(result.stderr).toContain('run vit init --beacon to repair it'); + }); + + test('explore dot filter sends canonical primary and secondary values', async () => { + writeRawConfig(testDir, { + beacon: 'https://github.com/solpbc/thermals', + secondaryBeacon: 'https://tangled.org/solpbc.org/rookery', + }); + const baseUrl = startExploreServer(); + + const result = await runVit( + ['explore', 'caps', '--beacon', '.', '--json', '--explore-url', baseUrl], + testDir, + isolatedEnv(), + ); + + expect(result.exitCode).toBe(0); + expect(requests).toHaveLength(1); + expect(requests[0].searchParams.get('beacon')).toBe( + 'vit:github.com/solpbc/thermals,vit:tangled.org/solpbc.org/rookery', + ); + }); + + test('explicit explore filters normalize before the HTTP request', async () => { + const baseUrl = startExploreServer(); + + const result = await runVit( + [ + 'explore', 'caps', + '--beacon', 'https://github.com/solpbc/thermals', + '--json', + '--explore-url', baseUrl, + ], + testDir, + isolatedEnv(), + ); + + expect(result.exitCode).toBe(0); + expect(requests).toHaveLength(1); + expect(requests[0].searchParams.get('beacon')).toBe('vit:github.com/solpbc/thermals'); + }); + + test('invalid explicit explore filter makes no HTTP request', async () => { + const baseUrl = startExploreServer(); + + const result = await runVit( + ['explore', 'caps', '--beacon', 'not a url', '--json', '--explore-url', baseUrl], + testDir, + isolatedEnv(), + ); + + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stdout).error).toContain('--beacon'); + expect(requests).toHaveLength(0); + }); + + test('invalid init option names the option and accepted forms without writing config', async () => { + const result = await runVit( + ['init', '--beacon', 'not a url'], + testDir, + isolatedEnv({ CODEX_CI: '1' }), + ); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Invalid beacon from --beacon'); + expect(result.stderr).toContain('vit:host/owner/repo or a git URL'); + expect(existsSync(join(testDir, '.vit', 'config.json'))).toBe(false); + }); + + test('all eleven config consumers surface a corrupt primary before external work', async () => { + writeRawConfig(testDir, { beacon: 'not a url' }); + const cases = [ + { args: ['init'], env: { CODEX_CI: '1' } }, + { args: ['init', '--secondary', 'https://github.com/org/repo'], env: { CODEX_CI: '1' } }, + { args: ['doctor'] }, + { args: ['explore', 'caps', '--beacon', '.', '--json', '--explore-url', 'http://127.0.0.1:1'] }, + { + args: [ + 'ship', '--did', 'did:plc:test', '--kind', 'request', + '--title', 'Canonical Beacon', '--description', 'Test request', + '--ref', 'canonical-beacon-test', + ], + env: { CODEX_CI: '1' }, + }, + { args: ['skim', '--did', 'did:plc:test', '--skills'], env: { CODEX_CI: '1' } }, + { args: ['remix', 'canonical-beacon-test', '--did', 'did:plc:test'], env: { CODEX_CI: '1' } }, + { args: ['vet', 'canonical-beacon-test', '--did', 'did:plc:test'] }, + { args: ['vouch', 'canonical-beacon-test', '--did', 'did:plc:test', '--kind', 'want'] }, + { args: ['scan', '--beacon', '.', '--days', '1', '--json'] }, + { args: ['inbox', '--json', '--explore-url', 'http://127.0.0.1:1'] }, + ]; + + for (const entry of cases) { + const result = await runVit(entry.args, testDir, isolatedEnv(entry.env)); + expect(result.exitCode).toBe(1); + expect(`${result.stdout}\n${result.stderr}`).toContain( + entry.args[0] === 'doctor' ? 'invalid project identity' : '.vit/config.json', + ); + } + }); +}); diff --git a/test/canonical-beacon-remote-260727.test.js b/test/canonical-beacon-remote-260727.test.js new file mode 100644 index 0000000..5c05440 --- /dev/null +++ b/test/canonical-beacon-remote-260727.test.js @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 sol pbc + +import { expect, test } from 'bun:test'; +import { join } from 'node:path'; + +// Run the real suite in a subprocess because Bun's mock.module() is process-global +// and persists across test files; inlining it here contaminates unrelated suites. +test('canonical remote beacon behavior passes in an isolated process', async () => { + const suite = join( + import.meta.dir, + '.fixtures', + 'canonical-beacon-remote-suite-260727.test.js', + ); + const proc = Bun.spawn(['bun', 'test', suite], { + cwd: join(import.meta.dir, '..'), + stdin: 'ignore', + stdout: 'pipe', + stderr: 'pipe', + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + + expect(exitCode, `${stdout}\n${stderr}`).toBe(0); +}, 30_000); diff --git a/test/vit-dir.test.js b/test/vit-dir.test.js index 8cd7018..98b01ae 100644 --- a/test/vit-dir.test.js +++ b/test/vit-dir.test.js @@ -2,7 +2,7 @@ // Copyright (c) 2026 sol pbc import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; -import { mkdirSync, rmSync, readFileSync, existsSync } from 'node:fs'; +import { mkdirSync, rmSync, readFileSync, existsSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -34,6 +34,64 @@ describe('vit-dir', () => { expect(config.beacon).toBe('vit:github.com/org/repo'); }); + test('readProjectConfig normalizes primary and secondary aliases without rewriting', async () => { + const vitPath = join(tmpDir, '.vit'); + mkdirSync(vitPath); + const raw = { + beacon: 'https://github.com/solpbc/thermals', + secondaryBeacon: 'https://tangled.org/solpbc.org/rookery', + untouched: true, + }; + writeFileSync(join(vitPath, 'config.json'), JSON.stringify(raw)); + const { readProjectConfig } = await import('../src/lib/vit-dir.js'); + + expect(readProjectConfig(tmpDir)).toEqual({ + beacon: 'vit:github.com/solpbc/thermals', + secondaryBeacon: 'vit:tangled.org/solpbc.org/rookery', + untouched: true, + }); + expect(JSON.parse(readFileSync(join(vitPath, 'config.json'), 'utf-8'))).toEqual(raw); + }); + + test('readRawProjectConfig preserves corrupt beacon fields for init repair', async () => { + const vitPath = join(tmpDir, '.vit'); + mkdirSync(vitPath); + writeFileSync(join(vitPath, 'config.json'), JSON.stringify({ + beacon: 'not a url', + secondaryBeacon: null, + untouched: 'yes', + })); + const { readRawProjectConfig } = await import('../src/lib/vit-dir.js'); + + expect(readRawProjectConfig(tmpDir)).toEqual({ + beacon: 'not a url', + secondaryBeacon: null, + untouched: 'yes', + }); + }); + + test('readProjectConfig raises corrupt beacon outside the JSON and IO fallback', async () => { + const vitPath = join(tmpDir, '.vit'); + mkdirSync(vitPath); + writeFileSync(join(vitPath, 'config.json'), JSON.stringify({ beacon: 'not a url' })); + const { readProjectConfig } = await import('../src/lib/vit-dir.js'); + + expect(() => readProjectConfig(tmpDir)).toThrow('.vit/config.json "beacon"'); + }); + + test('writeProjectConfig stores canonical primary and secondary values', async () => { + const { writeProjectConfig } = await import('../src/lib/vit-dir.js'); + writeProjectConfig({ + beacon: 'https://github.com/solpbc/thermals', + secondaryBeacon: 'https://tangled.org/solpbc.org/rookery', + }, tmpDir); + + expect(JSON.parse(readFileSync(join(tmpDir, '.vit', 'config.json'), 'utf-8'))).toEqual({ + beacon: 'vit:github.com/solpbc/thermals', + secondaryBeacon: 'vit:tangled.org/solpbc.org/rookery', + }); + }); + test('readProjectConfig returns {} when file missing', async () => { const { readProjectConfig } = await import('../src/lib/vit-dir.js'); const config = readProjectConfig(tmpDir); @@ -64,6 +122,18 @@ describe('vit-dir', () => { expect(set.has('vit:github.com/upstream/repo')).toBe(true); }); + test('readBeaconSet canonicalizes and deduplicates aliases', async () => { + const vitPath = join(tmpDir, '.vit'); + mkdirSync(vitPath); + writeFileSync(join(vitPath, 'config.json'), JSON.stringify({ + beacon: 'https://github.com/solpbc/thermals', + secondaryBeacon: 'vit:github.com/solpbc/thermals', + })); + const { readBeaconSet } = await import('../src/lib/vit-dir.js'); + + expect([...readBeaconSet(tmpDir)]).toEqual(['vit:github.com/solpbc/thermals']); + }); + test('appendLog creates file and appends JSONL line', async () => { const { appendLog } = await import('../src/lib/vit-dir.js'); appendLog('caps.jsonl', { ts: '2026-01-01T00:00:00Z', did: 'did:plc:test' }, tmpDir); -- 2.51.2