#!/usr/bin/env node import { accessSync, constants, existsSync, readFileSync, realpathSync, } from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; const PACKAGE = "@earendil-works/pi-coding-agent"; const REGISTRY_URL = "https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/latest"; const MIGRATION_SECTIONS = new Set([ "breaking changes", "changed", "deprecated", "removed", ]); const MIGRATION_WORDS = /\b(breaking|deprecat\w*|migrat\w*|remov\w*|renam\w*|replac\w*|no longer|instead of|must now)\b/i; function versionParts(value) { const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(value.trim()); if (!match) throw new Error(`invalid semantic version: ${value}`); return match.slice(1, 4).map(Number); } export function compareVersions(left, right) { const a = versionParts(left); const b = versionParts(right); for (let index = 0; index < 3; index++) { if (a[index] !== b[index]) return a[index] > b[index] ? 1 : -1; } return 0; } export function assertLatest(localVersion, latestVersion) { if (compareVersions(localVersion, latestVersion) !== 0) { throw new Error( `installed Pi ${localVersion} is not latest ${latestVersion}; update Pi before modernizing`, ); } } export function parseChangelog(markdown) { const releases = []; let release; let section; let bullet; const flushBullet = () => { if (bullet && section) section.bullets.push(bullet.trim()); bullet = undefined; }; for (const line of markdown.split(/\r?\n/)) { const releaseMatch = /^## \[(\d+\.\d+\.\d+(?:[-+][^\]]+)?)\]/.exec(line); if (releaseMatch) { flushBullet(); release = { version: releaseMatch[1], sections: [] }; releases.push(release); section = undefined; continue; } if (/^## /.test(line)) { flushBullet(); release = undefined; section = undefined; continue; } const sectionMatch = /^### (.+?)\s*$/.exec(line); if (sectionMatch && release) { flushBullet(); section = { name: sectionMatch[1], bullets: [] }; release.sections.push(section); continue; } const bulletMatch = /^- (.+)$/.exec(line); if (bulletMatch && section) { flushBullet(); bullet = bulletMatch[1]; continue; } if (bullet && line.trim() && !/^#{1,6} /.test(line)) { bullet += ` ${line.trim()}`; } } flushBullet(); if (releases.length === 0) throw new Error("changelog contains no release sections"); return releases; } export function extractCandidates(markdown, { afterVersion, throughVersion }) { const candidates = []; for (const release of parseChangelog(markdown)) { if (compareVersions(release.version, afterVersion) <= 0) continue; if (compareVersions(release.version, throughVersion) > 0) continue; for (const section of release.sections) { const migrationSection = MIGRATION_SECTIONS.has( section.name.toLowerCase(), ); for (const text of section.bullets) { if (!migrationSection && !MIGRATION_WORDS.test(text)) continue; candidates.push({ version: release.version, section: section.name, text, }); } } } return candidates; } export function collectPackageVersions(lock) { const packages = lock && typeof lock === "object" && lock.packages ? lock.packages : {}; const found = new Map(); for (const [location, value] of Object.entries(packages)) { const match = /(?:^|\/)node_modules\/(@earendil-works\/pi-[^/]+)$/.exec( location, ); if (!match || !value || typeof value !== "object") continue; if (typeof value.version !== "string") continue; const item = { name: match[1], version: value.version }; found.set(`${item.name}@${item.version}`, item); } return [...found.values()].sort( (a, b) => a.name.localeCompare(b.name) || compareVersions(a.version, b.version), ); } export function executableOnPath(name) { const extensions = process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""]; for (const directory of (process.env.PATH ?? "").split(path.delimiter)) { if (!directory) continue; for (const extension of extensions) { const candidate = path.join(directory, `${name}${extension}`); try { accessSync(candidate, constants.X_OK); return realpathSync(candidate); } catch { // Try the next PATH candidate. } } } throw new Error(`${name} executable not found on PATH`); } function packageAt(directory) { const manifestPath = path.join(directory, "package.json"); if (!existsSync(manifestPath)) return undefined; const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); return manifest?.name === PACKAGE ? manifest : undefined; } function findPiPackage(executable) { let directory = path.dirname(realpathSync(executable)); while (true) { for (const packageRoot of [ directory, path.join(directory, "node_modules", ...PACKAGE.split("/")), ]) { const manifest = packageAt(packageRoot); if (manifest) return { packageRoot, manifest }; } const parent = path.dirname(directory); if (parent === directory) throw new Error( `installed ${PACKAGE} package not found near ${executable}`, ); directory = parent; } } export function resolvePiInstallation(executable) { const { packageRoot, manifest } = findPiPackage(executable); const bin = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.pi; if (typeof bin !== "string") throw new Error(`installed ${PACKAGE} package has no pi bin entry`); const cli = path.resolve(packageRoot, bin); const relativeCli = path.relative(packageRoot, cli); if (relativeCli.startsWith("..") || path.isAbsolute(relativeCli)) throw new Error(`installed ${PACKAGE} pi bin points outside its package`); accessSync(cli, constants.R_OK); const changelog = path.join(packageRoot, "CHANGELOG.md"); accessSync(changelog, constants.R_OK); if (typeof manifest.version !== "string") throw new Error(`installed ${PACKAGE} package has no version`); return { cli, changelog, version: manifest.version }; } async function fetchJson(url) { const response = await fetch(url, { headers: { "user-agent": "pi-ext-modernize" }, signal: AbortSignal.timeout(30_000), }); if (!response.ok) throw new Error(`fetch ${url} failed: HTTP ${response.status}`); return response.json(); } async function fetchText(url) { const response = await fetch(url, { headers: { "user-agent": "pi-ext-modernize" }, signal: AbortSignal.timeout(30_000), }); if (!response.ok) throw new Error(`fetch ${url} failed: HTTP ${response.status}`); return response.text(); } function oldestVersion(packages, fallback) { return packages.reduce( (oldest, item) => compareVersions(item.version, oldest) < 0 ? item.version : oldest, fallback, ); } function mergeCandidates(groups) { const merged = new Map(); for (const { source: sourceName, candidates } of groups) { for (const candidate of candidates) { const key = `${candidate.version}\0${candidate.section}\0${candidate.text}`; const current = merged.get(key); if (current) current.sources.push(sourceName); else merged.set(key, { ...candidate, sources: [sourceName] }); } } return [...merged.values()].sort((a, b) => compareVersions(a.version, b.version), ); } export function createReport({ piVersion, candidatesAfterVersion, localChangelogPath, remoteChangelogUrl, candidates, }) { return { schemaVersion: 2, status: "ok", piVersion, candidatesAfterVersion, changelogs: { installed: localChangelogPath, remote: remoteChangelogUrl, }, candidates, }; } async function main() { const repo = process.cwd(); const installation = resolvePiInstallation(executableOnPath("pi")); // Package resolution already reads the authoritative installed manifest; // spawning another Node process only to print the same version costs ~770 ms. const localVersion = installation.version; const metadata = await fetchJson(REGISTRY_URL); if (!metadata || typeof metadata.version !== "string") throw new Error(`registry response for ${PACKAGE} has no version`); const latestVersion = metadata.version; assertLatest(localVersion, latestVersion); const localChangelogPath = installation.changelog; const localChangelog = readFileSync(localChangelogPath, "utf8"); const remoteChangelogUrl = `https://raw.githubusercontent.com/earendil-works/pi/v${latestVersion}/packages/coding-agent/CHANGELOG.md`; const remoteChangelog = await fetchText(remoteChangelogUrl); parseChangelog(localChangelog); parseChangelog(remoteChangelog); const lockPath = path.join(repo, "package-lock.json"); const packages = existsSync(lockPath) ? collectPackageVersions(JSON.parse(readFileSync(lockPath, "utf8"))) : []; const afterVersion = oldestVersion(packages, localVersion); const interval = { afterVersion, throughVersion: latestVersion }; const candidates = mergeCandidates([ { source: "installed", candidates: extractCandidates(localChangelog, interval), }, { source: "remote", candidates: extractCandidates(remoteChangelog, interval), }, ]); process.stdout.write( `${JSON.stringify( createReport({ piVersion: latestVersion, candidatesAfterVersion: afterVersion, localChangelogPath, remoteChangelogUrl, candidates, }), )}\n`, ); } const invoked = process.argv[1] ? import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href : false; if (invoked) { main().catch((error) => { process.stderr.write( `${JSON.stringify({ schemaVersion: 2, status: "failed", reason: error instanceof Error ? error.message : String(error), })}\n`, ); process.exitCode = 1; }); }