#!/usr/bin/env node import { spawnSync } from "node:child_process"; import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, relative, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import ts from "typescript"; import { readBuildSpec } from "./build/spec.ts"; import { CONFIG_PATH, checkGenerated } from "./codegen.mjs"; import { git, ROOT, readJson, rel, run, stripAnsi, writeJson, } from "./lib/common.mjs"; export const EXT_ROOT = join(ROOT, "extensions"); export const PI_PEERS = new Set([ "@earendil-works/pi-ai", "@earendil-works/pi-agent-core", "@earendil-works/pi-coding-agent", "@earendil-works/pi-tui", "typebox", ]); export const HARNESS = "@marcfargas/pi-test-harness"; const IMPORT_RE = /(?:import|export)\s+(?:[^'"]+?\s+from\s+)?['"]([^'"]+)['"]|import\(['"]([^'"]+)['"]\)/g; const SECRET_RE = /(api[_-]?key|secret|token|password)\s*[:=]\s*['"][^'"]{12,}/i; const LOCAL_ARTIFACT_DIRS = new Set([ ".test-home", ".test-state", ".test-config", ".test-cache", ".test-data", ]); const MOVED_PI_AI_ROOT_EXPORTS = new Set([ "complete", "completeSimple", "getModel", "getModels", "getProviders", "registerApiProvider", "getEnvApiKey", "stream", "streamSimple", "streamOpenAICodexResponses", "streamSimpleOpenAICodexResponses", ]); const SECRET_SCAN_IGNORE_KEY = "secretScanIgnore"; const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/; const FLAT_PROMPT_ERROR = "flat before_agent_start system prompt override is forbidden; mutate event.systemPromptOptions.sections"; const FORBIDDEN_EXTENSION_CONFIG_PATTERNS = [ { re: /(?:path\.)?join\(\s*(?:process\.cwd\(\)|\bcwd\b)\s*,\s*["']settings\.json["']\s*\)/, message: "workspace-root settings.json is not a Pi settings file; use merged Pi settings", }, { re: /\.settingsManager(?:\?\.)?\.?getSettings/, message: "private settingsManager access is forbidden; declare settings through src/pi-ext-settings.ts", }, ]; // Settings and flags go through the generated extension-local helper (extensions/SETTINGS.md). const HELPER_OWNED_CALLS = new Map([ ["getGlobalSettings", "read settings through resolveSetting"], ["getProjectSettings", "read settings through resolveSetting"], ["registerFlag", "register flags through registerSettingFlag"], ["getFlag", "read flags through resolveSetting"], ]); const CHANGELOG_STYLE = `Voice: - terse, technical, human - brutal minimalism: no marketing, no excitement, no filler - concrete nouns/verbs; name actual commands, tools, hooks, files, API - no corporate adjectives: enhanced, improved, streamlined, robust, seamless - also ban: powerful, comprehensive, leverage - no vague "various fixes" - write like a maintainer note, not an ad - short fragments beat full sentences Hard format: ## {version} - bullet - bullet Hard limits: - 3-5 bullets total - max 100 chars per bullet; rewrite until every bullet fits - each bullet must start with a verb, command name, or concrete noun - combine details; do not enumerate implementation internals separately - omit internal function names unless they are the change users maintain `; const README_STYLE = `Return the full root README.md content for bugabinga/pi-ext. Use only the context in this prompt. Do not edit files. Do not say done. Do not summarize. Output the complete markdown file only. Task: - If root README.md exists: update it; preserve useful manual text; refresh stale facts. - If root README.md is missing: create it. Voice: - terse, technical, human - no marketing slime - funny is allowed; cute and mean is allowed; bloat is not - concrete install commands and examples Required content. For missing README, use these sections. For existing README, keep equivalent existing headings/placement when present: # pi-ext one-line description ## Install Use exact Pi CLI syntax. The command is \`pi install\`, never \`pi ext install\`. - all extensions via git: \`pi install git:github.com/bugabinga/pi-ext@v{version}\` - default branch install: \`pi install git:github.com/bugabinga/pi-ext\` - local checkout via absolute path: \`pi install /home/me/Workspace/pi-ext\` - local checkout via ~ path: \`pi install ~/Workspace/pi-ext\` - single-extension package filter JSON for \`~/.pi/agent/settings.json\` - explain extension and extension-owned skill filters briefly ## Extensions Markdown table: extension slug, short explanation, link to sub README. One row per extension under extensions/*. ## Resources Mention the root package exports extension entrypoints and extension-owned skills only. Personal skills, prompts, and themes live in global Pi config. ## Release model Single repo version. Tag format vX.Y.Z. Per-extension package versions intentionally absent. --- A cute, mean, dark haiku about these extensions and Pi philosophy. `; const EXT_README_STYLE = `Return the full README.md content for one bugabinga/pi-ext extension. Use only the context in this prompt. Do not edit files. Do not say done. Do not summarize. Output the complete markdown file only. Task: - If README.md exists: update it; preserve useful manual text; refresh stale facts. - If README.md is missing: create it. - Do not invent commands, tools, settings, env vars, or install paths. Voice: - terse, technical, human - no marketing slime - concrete behavior over adjectives Required content. For missing README, use these sections. For existing README, keep equivalent existing headings/placement when present: # extension-slug one-line purpose ## Install / load Briefly say this extension is loaded through the root pi-ext package. Link root README relatively as ../../README.md. ## Commands / tools / settings List actual commands/tools/hooks/settings. Say none where none exist. ## Behavior Short operational notes from source. `; function format(template, vars) { return template.replaceAll(/\{(\w+)\}/g, (_, key) => String(vars[key] ?? `{${key}}`), ); } export function rootPkg() { return readJson(join(ROOT, "package.json")); } export function rootVersion() { return rootPkg().version ?? "0.0.0"; } export function parseVersion(version) { const m = SEMVER_RE.exec(version); if (!m) throw new Error( `root package.json version must be X.Y.Z, got ${JSON.stringify(version)}`, ); return m.slice(1).map(Number); } export function bumpPatch(version) { const [a, b, c] = parseVersion(version); return `${a}.${b}.${c + 1}`; } export function setRootVersion(version) { parseVersion(version); const pkg = rootPkg(); pkg.version = version; writeJson(join(ROOT, "package.json"), pkg); const lockPath = join(ROOT, "package-lock.json"); const lock = readJson(lockPath); lock.version = version; if (lock.packages?.[""]) lock.packages[""].version = version; writeJson(lockPath, lock); console.log(`OK version v${version}`); } export function pkgName(spec) { return spec.startsWith("@") ? spec.split("/").slice(0, 2).join("/") : spec.split("/")[0]; } export function readPkg(ext) { return readJson(join(ext, "package.json")); } export function extDirs(selected = []) { const dirs = readdirSync(EXT_ROOT, { withFileTypes: true }) .filter( (e) => e.isDirectory() && existsSync(join(EXT_ROOT, e.name, "package.json")), ) .map((e) => join(EXT_ROOT, e.name)) .sort(); if (!selected.length) return dirs; const wanted = new Set(selected); const result = []; for (const ext of dirs) { const pkg = readPkg(ext); if (wanted.has(ext.split(/[\\/]/).at(-1)) || wanted.has(pkg.name)) result.push(ext); } const found = new Set( result.flatMap((ext) => [ext.split(/[\\/]/).at(-1), readPkg(ext).name]), ); const missing = [...wanted].filter((x) => !found.has(x)); if (missing.length) throw new Error(`unknown extension(s): ${missing.sort().join(", ")}`); return result; } function walk(dir, out = []) { if (!existsSync(dir)) return out; for (const ent of readdirSync(dir, { withFileTypes: true })) { const p = join(dir, ent.name); if (ent.isDirectory()) { if (ent.name !== "node_modules") walk(p, out); } else out.push(p); } return out; } export function tsFiles(ext) { return walk(ext) .filter((p) => p.endsWith(".ts")) .filter((p) => !relative(ext, p).split(/[\\/]/).includes("skills")) .sort(); } export function importSpecs(ext) { const specs = new Set(); for (const file of tsFiles(ext)) { const text = readFileSync(file, "utf8"); IMPORT_RE.lastIndex = 0; for (const m of text.matchAll(IMPORT_RE)) specs.add(m[1] || m[2]); } return specs; } function movedPiAiRootImports(text) { const moved = []; const re = /import\s+(?!type\b)([\s\S]*?)\s+from\s+["']@earendil-works\/pi-ai["']/g; for (const match of text.matchAll(re)) { const named = match[1].match(/\{([\s\S]*?)\}/)?.[1]; if (!named) continue; for (const part of named.split(",")) { const name = part .trim() .split(/\s+as\s+/i)[0] ?.replace(/^type\s+/, ""); if (MOVED_PI_AI_ROOT_EXPORTS.has(name)) moved.push(name); } } return moved; } function pathInside(child, parent) { const r = relative(resolve(parent), resolve(child)); return ( r === "" || (!r.startsWith("..") && !resolve(r).startsWith(resolve(r).split(/[\\/]/)[0] + ":")) ); } export function isAllowedTestImport(file, resolved, root = ROOT) { return ( pathInside(resolved, join(root, "test")) && (file.split(/[\\/]/).includes("__tests__") || file.split(/[\\/]/).at(-1) === "playwright.config.ts") ); } function err(errors, ext, msg) { errors.push(`${ext.split(/[\\/]/).at(-1)}: ${msg}`); } export function secretScanIgnorePaths(root = ROOT) { try { const paths = readJson(join(root, "scripts", "extensions.json"))[ SECRET_SCAN_IGNORE_KEY ]; return new Set( Array.isArray(paths) ? paths.filter((p) => typeof p === "string" && p) : [], ); } catch { return new Set(); } } export function isSecretScanIgnored(file, ignores, root = ROOT) { const r = rel(resolve(file), resolve(root)); return !r.startsWith("..") && ignores.has(r); } function nodeName(node) { if (ts.isIdentifier(node) || ts.isStringLiteral(node)) return node.text; return undefined; } function isBeforeAgentStartRegistration(node) { return ( ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "on" && node.arguments.length >= 2 && ts.isStringLiteral(node.arguments[0]) && node.arguments[0].text === "before_agent_start" ); } function hasFlatPromptOverride(file, text) { const source = ts.createSourceFile( file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS, ); let found = false; const inspectHandler = (node) => { if ( (ts.isPropertyAssignment(node) || ts.isShorthandPropertyAssignment(node)) && nodeName(node.name) === "systemPrompt" ) found = true; if ( ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isPropertyAccessExpression(node.left) && node.left.name.text === "forceSystemPrompt" ) found = true; ts.forEachChild(node, inspectHandler); }; const visit = (node) => { if (isBeforeAgentStartRegistration(node)) { const handler = node.arguments[1]; if ( handler && (ts.isArrowFunction(handler) || ts.isFunctionExpression(handler)) ) inspectHandler(handler.body); } ts.forEachChild(node, visit); }; visit(source); return found; } function isProductionTypeScript(file, ext) { return !relative(ext, file) .split(/[\\/]/) .some((part) => part.startsWith("__")); } function debugModule(ext, file, spec) { if (!spec.startsWith(".")) return ""; return relative(ext, resolve(dirname(file), spec)).replaceAll("\\", "/"); } function hasDebugSchemaBinding(path) { const source = ts.createSourceFile( path, readFileSync(path, "utf8"), ts.ScriptTarget.Latest, true, ); const imported = source.statements.some( (node) => ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && debugModule(dirname(dirname(path)), path, node.moduleSpecifier.text) === "src/pi-ext-debug.ts" && ts.isNamedImports(node.importClause?.namedBindings) && node.importClause.namedBindings.elements.some( (element) => element.name.text === "defineDebug", ), ); if (!imported) return false; return source.statements.some( (node) => ts.isVariableStatement(node) && node.modifiers?.some( (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword, ) && node.declarationList.declarations.some((declaration) => { if ( !ts.isObjectBindingPattern(declaration.name) || !ts.isCallExpression(declaration.initializer) || !ts.isIdentifier(declaration.initializer.expression) || declaration.initializer.expression.text !== "defineDebug" ) return false; const keys = new Set( declaration.name.elements.map((element) => (element.propertyName ?? element.name).getText(source), ), ); return ["dbg", "closeDebug"].every((key) => keys.has(key)); }), ); } export function staticCheck(ext, options = {}) { const root = options.root ?? ROOT; const errors = []; let pkg; try { pkg = readPkg(ext); } catch (e) { return [`${ext.split(/[\\/]/).at(-1)}: invalid package.json: ${e.message}`]; } const name = ext.split(/[\\/]/).at(-1); const expected = `@bugabinga/pi-ext-${name}`; if (pkg.name !== expected) err(errors, ext, `package name must be ${JSON.stringify(expected)}`); if (pkg.version !== undefined) err( errors, ext, "per-extension version must be absent; root package.json owns repo version", ); if (pkg.private !== true) err(errors, ext, "package must have private:true"); if (pkg.type !== "module") err(errors, ext, "package.json type must be 'module'"); if (pkg.main !== "index.ts") err(errors, ext, "package.json main must be 'index.ts'"); if (pkg.scripts !== undefined) err( errors, ext, "package scripts are prohibited; use extension-local Mise tasks", ); if (!existsSync(join(ext, "index.ts"))) err(errors, ext, "missing index.ts"); if (pkg.license !== "MIT") err(errors, ext, "package license must be MIT"); if (!existsSync(join(ext, "README.md"))) err(errors, ext, "missing README.md"); try { readBuildSpec(ext); } catch (e) { err(errors, ext, `invalid package build declaration: ${e.message}`); } errors.push(...checkGenerated(ext, root)); const debugPath = join(ext, "src", "debug.ts"); if (!existsSync(debugPath)) err(errors, ext, "missing handwritten src/debug.ts schema"); else if (!hasDebugSchemaBinding(debugPath)) err( errors, ext, "src/debug.ts must export dbg and closeDebug bound by defineDebug", ); const deps = pkg.dependencies ?? {}, peers = pkg.peerDependencies ?? {}, dev = pkg.devDependencies ?? {}, optional = pkg.optionalDependencies ?? {}; if (!dev[HARNESS]) err(errors, ext, `missing devDependency ${HARNESS}`); if (!existsSync(join(ext, "__tests__", "harness.test.ts"))) err(errors, ext, "missing __tests__/harness.test.ts"); for (const [section, declared] of [ ["dependencies", deps], ["devDependencies", dev], ["optionalDependencies", optional], ]) { for (const dep of Object.keys(declared)) { if (PI_PEERS.has(dep)) err( errors, ext, `Pi package ${dep} belongs only in peerDependencies, not ${section}`, ); if (dep.startsWith("@bugabinga/pi-ext-")) err(errors, ext, `must not depend on sibling extension ${dep}`); } } for (const [dep, version] of Object.entries(peers)) { if (PI_PEERS.has(dep) && version !== "*") err(errors, ext, `Pi package ${dep} peerDependency range must be '*'`); if (dep.startsWith("@bugabinga/pi-ext-")) err(errors, ext, `must not peer-depend on sibling extension ${dep}`); } const specs = importSpecs(ext); const packages = new Set( [...specs] .filter( (s) => !(s.startsWith(".") || s.startsWith("/") || s.startsWith("node:")), ) .map(pkgName), ); for (const spec of specs) if (spec.startsWith("@bugabinga/pi-ext-")) err(errors, ext, `must not import sibling extension ${spec}`); const ignores = secretScanIgnorePaths(root); let hasRuntimeDebugImport = false; for (const file of tsFiles(ext)) { const text = readFileSync(file, "utf8"); if (isProductionTypeScript(file, ext) && file !== debugPath) { const source = ts.createSourceFile( file, text, ts.ScriptTarget.Latest, true, ); const generated = /^src\/pi-ext-[^/]+\.ts$/.test( rel(file, ext).replaceAll("\\", "/"), ); const visit = (node) => { if ( !generated && ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && HELPER_OWNED_CALLS.has(node.expression.name.text) ) { const name = node.expression.name.text; err( errors, ext, `direct ${name}() in ${rel(file, ext)}; ${HELPER_OWNED_CALLS.get(name)} from src/pi-ext-settings.ts`, ); } const moduleSpecifier = ts.isImportDeclaration(node) || ts.isExportDeclaration(node) ? node.moduleSpecifier : ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword ? node.arguments[0] : undefined; if (moduleSpecifier && ts.isStringLiteral(moduleSpecifier)) { const target = debugModule(ext, file, moduleSpecifier.text); if (["src/pi-ext-debug.ts", "src/pi-ext-debug.js"].includes(target)) err( errors, ext, `runtime import of generated debug module in ${rel(file, ext)}; import src/debug.ts instead`, ); if ( ["src/debug.ts", "src/debug.js"].includes(target) && ts.isImportDeclaration(node) && node.importClause && !node.importClause.isTypeOnly && (node.importClause.name || (node.importClause.namedBindings && (ts.isNamespaceImport(node.importClause.namedBindings) || node.importClause.namedBindings.elements.some( (element) => !element.isTypeOnly, )))) ) hasRuntimeDebugImport = true; } ts.forEachChild(node, visit); }; visit(source); } if (isProductionTypeScript(file, ext) && hasFlatPromptOverride(file, text)) err(errors, ext, `${FLAT_PROMPT_ERROR} in ${rel(file, ext)}`); for (const moved of movedPiAiRootImports(text)) { err( errors, ext, `moved pi-ai root API import ${moved} in ${rel(file, ext)}; use @earendil-works/pi-ai/compat or a specific API entrypoint`, ); } for (const pattern of FORBIDDEN_EXTENSION_CONFIG_PATTERNS) { if (pattern.re.test(text)) err(errors, ext, `${pattern.message} in ${rel(file, ext)}`); } IMPORT_RE.lastIndex = 0; for (const m of text.matchAll(IMPORT_RE)) { const spec = m[1] || m[2]; if (!spec?.startsWith("../")) continue; const resolved = resolve(dirname(file), spec); if ( !pathInside(resolved, ext) && !isAllowedTestImport(file, resolved, root) ) err( errors, ext, `cross-extension relative import ${spec} in ${rel(file, ext)}`, ); } if (SECRET_RE.test(text) && !isSecretScanIgnored(file, ignores, root)) err(errors, ext, `possible secret in ${rel(file, ext)}`); } if (!hasRuntimeDebugImport) err( errors, ext, "runtime code must import its handwritten src/debug.ts schema", ); for (const dep of [...packages].filter((p) => PI_PEERS.has(p)).sort()) if (!(dep in peers)) err(errors, ext, `imported ${dep} but missing peerDependency`); for (const dep of [...packages].filter((p) => !PI_PEERS.has(p)).sort()) if (!(dep in deps) && !(dep in peers) && !(dep in dev)) err( errors, ext, `imported external package ${dep} but package.json does not declare it`, ); return errors; } function gitCapture(args, check = true) { return git(args, { capture: true, check }).stdout; } function gitChanges(path, since) { const args = ["log", "--format=%h %s"]; if (since) args.push(`${since}..HEAD`); args.push("--", rel(path)); const r = git(args, { capture: true, check: false }); return r.status === 0 ? r.stdout .split(/\r?\n/) .map((l) => l.trim()) .filter(Boolean) : []; } function latestReleaseTag() { const tags = gitCapture(["tag", "--list", "v*", "--sort=-creatordate"]) .split(/\r?\n/) .map((l) => l.trim()) .filter(Boolean); return tags[0]; } function changedSinceRelease(path) { if (gitCapture(["status", "--porcelain", "--", rel(path)]).trim()) return true; const tag = latestReleaseTag(); return ( !tag || git(["diff", "--quiet", tag, "--", rel(path)], { check: false }).status !== 0 ); } function anyChangedSinceRelease(paths) { const tag = latestReleaseTag(); return !tag || paths.some(changedSinceRelease); } function piGenerate(prompt, model, label = "agent") { const pi = spawnSync("pi", ["--help"], { stdio: "ignore" }).error ? "pi.cmd" : "pi"; const args = [ "--no-session", "--no-tools", "--no-extensions", "--no-skills", "--no-prompt-templates", "--no-context-files", "-p", ]; if (model) args.push("--model", model); const dir = mkdtempSync(join(tmpdir(), "pi-ext-prompt-")); try { const path = join(dir, "prompt.md"); writeFileSync(path, prompt, "utf8"); console.error(`RUN ${label}`); const r = spawnSync(pi, [...args, `@${path}`], { cwd: ROOT, encoding: "utf8", maxBuffer: 64 * 1024 * 1024, }); if (r.status !== 0) { if (r.stderr) console.error(r.stderr); throw new Error(`${label} failed`); } return stripAnsi(r.stdout); } finally { rmSync(dir, { recursive: true, force: true }); } } function changelogStyle() { return format(CHANGELOG_STYLE, { version: rootVersion() }); } function changelogPrompt(ext) { const pkg = readPkg(ext), tag = latestReleaseTag(), commits = gitChanges(ext, tag) .map((l) => `- ${l}`) .join("\n") || "- no commits since previous tag"; return `You are writing one changelog section for one Pi extension.\n\nOutput markdown only. No preamble. No code fence.\n\n${changelogStyle()}\n\nTask:\nExtension: ${ext.split(/[\\/]/).at(-1)}\nPackage: ${pkg.name}\nPath: ${rel(ext)}\nRepo version: ${rootVersion()}\nPrevious repo tag: ${tag || "none"}\n\nUse only the context in this prompt. Do not edit files.\n\nGit commits touching this extension:\n${commits}\n`; } function firstHeadingMarkdown(text) { const lines = text.split(/\r?\n/); const i = lines.findIndex((l) => l.startsWith("# ")); return (i > 0 ? lines.slice(i).join("\n") : text).trimEnd() + "\n"; } function wrapGeneratedMarkdown(text) { return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trimEnd() + "\n"; } function writeRepoText(path, text) { writeFileSync(path, wrapGeneratedMarkdown(text), "utf8"); } function generateChangelogEntry(ext, model) { const out = piGenerate( changelogPrompt(ext), model, `changelog ${ext.split(/[\\/]/).at(-1)}`, ); if (!out.startsWith("## ")) throw new Error(`bad changelog output: ${out.slice(0, 200)}`); return out.trimEnd() + "\n"; } function updateChangelog(ext, model) { const path = join(ext, "CHANGELOG.md"), entry = generateChangelogEntry(ext, model), heading = `## ${rootVersion()}`; if (!existsSync(path)) { writeRepoText(path, `# Changelog\n\n${entry}`); console.log(`OK changelog ${ext.split(/[\\/]/).at(-1)}`); return entry; } const text = readFileSync(path, "utf8"); let next; if (text.includes(heading)) { const lines = text.split(/(?<=\n)/); const start = lines.findIndex((l) => l.startsWith(heading)); const end = lines.findIndex((l, i) => i > start && l.startsWith("## ")); next = lines.slice(0, start).join("") + entry + "\n" + lines .slice(end === -1 ? lines.length : end) .join("") .replace(/^\n+/, ""); } else if (text.startsWith("# Changelog")) next = `# Changelog\n\n${entry}\n${text.split("\n").slice(1).join("\n").replace(/^\n+/, "")}`; else next = `# Changelog\n\n${entry}\n${text}`; writeRepoText(path, next); console.log(`OK changelog ${ext.split(/[\\/]/).at(-1)}`); return entry; } function repoChangelogPrompt(exts) { const tag = latestReleaseTag(), commits = gitChanges(ROOT, tag) .map((l) => `- ${l}`) .join("\n") || "- no commits since previous tag"; return `Write one repo-level CHANGELOG.md section for bugabinga/pi-ext.\n\nOutput markdown only. No preamble. No code fence.\n\n${changelogStyle()}\n\nScope: ${exts.map((e) => e.split(/[\\/]/).at(-1)).join(", ")}\nRepo version: ${rootVersion()}\nPrevious repo tag: ${tag || "none"}\n\nUse only the context in this prompt. Do not edit files.\nSummarize cross-extension release value, not every tiny file.\n\nGit commits:\n${commits}\n`; } function generateRepoChangelogEntry(exts, model) { const out = piGenerate(repoChangelogPrompt(exts), model, "changelog root").trimEnd() + "\n"; if (!out.startsWith("## ")) throw new Error(`bad repo changelog output: ${out.slice(0, 200)}`); return out; } function updateRepoChangelog(exts, model) { const entry = generateRepoChangelogEntry(exts, model), path = join(ROOT, "CHANGELOG.md"), heading = `## ${rootVersion()}`; let next; if (!existsSync(path)) next = `# Changelog\n\n${entry}`; else { const text = readFileSync(path, "utf8"); if (text.includes(heading)) { const lines = text.split(/(?<=\n)/); const start = lines.findIndex((l) => l.startsWith(heading)); const end = lines.findIndex((l, i) => i > start && l.startsWith("## ")); next = lines.slice(0, start).join("") + entry + "\n" + lines .slice(end === -1 ? lines.length : end) .join("") .replace(/^\n+/, ""); } else if (text.startsWith("# Changelog")) next = `# Changelog\n\n${entry}\n${text.split("\n").slice(1).join("\n").replace(/^\n+/, "")}`; else next = `# Changelog\n\n${entry}\n${text}`; } writeRepoText(path, next); console.log("OK changelog root"); return entry; } function readmePrompt() { const exts = extDirs([]) .map( (ext) => `- ${ext.split(/[\\/]/).at(-1)}: package=${readPkg(ext).name}\nREADME excerpt:\n${existsSync(join(ext, "README.md")) ? readFileSync(join(ext, "README.md"), "utf8").slice(0, 1200) : ""}`, ) .join("\n"); const current = existsSync(join(ROOT, "README.md")) ? readFileSync(join(ROOT, "README.md"), "utf8") : ""; return `${format(README_STYLE, { version: rootVersion() })}\n\nExisting root README.md:\n${current || ""}\n\nRoot package.json:\n${JSON.stringify(rootPkg(), null, 2)}\n\nExtensions:\n${exts}\n`; } function generateReadme(model) { const out = firstHeadingMarkdown( piGenerate(readmePrompt(), model, "README root"), ); if (!out.startsWith("# pi-ext")) throw new Error(`bad README output: ${out.slice(0, 200)}`); return out.trimEnd() + "\n"; } function updateReadme(model) { const text = generateReadme(model); writeRepoText(join(ROOT, "README.md"), text); console.log("OK README.md"); return text; } function extReadmePrompt(ext) { const sources = tsFiles(ext) .slice(0, 8) .map( (f) => `--- ${rel(f, ext)} ---\n${readFileSync(f, "utf8").slice(0, 5000)}`, ) .join("\n"); return `${EXT_README_STYLE}\n\nExtension: ${ext.split(/[\\/]/).at(-1)}\nPackage.json:\n${JSON.stringify(readPkg(ext), null, 2)}\n\nExisting README.md:\n${existsSync(join(ext, "README.md")) ? readFileSync(join(ext, "README.md"), "utf8") : ""}\n\nSource excerpts:\n${sources}\n`; } function generateExtReadme(ext, model) { let prompt = extReadmePrompt(ext), last = ""; for (let i = 0; i < 2; i++) { const out = firstHeadingMarkdown( piGenerate( prompt, model, `README ${ext.split(/[\\/]/).at(-1)}${i ? " retry" : ""}`, ), ); last = out; if (out.startsWith("# ")) return out.trimEnd() + "\n"; prompt += `\nPrevious output invalid; retry with H1 first.\n${out.slice(0, 500)}`; } throw new Error(`bad README output: ${last.slice(0, 200)}`); } function updateExtReadme(ext, model) { const text = generateExtReadme(ext, model); writeRepoText(join(ext, "README.md"), text); console.log(`OK README ${ext.split(/[\\/]/).at(-1)}`); return text; } function updateAllReadmes(exts, model) { const tag = latestReleaseTag(), errors = []; if ( anyChangedSinceRelease([ join(ROOT, "README.md"), join(ROOT, "package.json"), EXT_ROOT, ]) ) { try { updateReadme(model); } catch (e) { errors.push(`root: ${e.message}`); console.error(`ERROR README root: ${e.message}`); } } else console.log(`OK README root skipped (unchanged since ${tag})`); for (const ext of exts) { if (!changedSinceRelease(ext)) { console.log( `OK README ${ext.split(/[\\/]/).at(-1)} skipped (unchanged since ${tag})`, ); continue; } try { updateExtReadme(ext, model); } catch (e) { errors.push(`${ext.split(/[\\/]/).at(-1)}: ${e.message}`); console.error(`ERROR README ${ext.split(/[\\/]/).at(-1)}: ${e.message}`); } } if (errors.length) throw new Error(`README generation failed: ${errors.join("; ")}`); } function changedPaths() { const out = `${gitCapture(["diff", "--name-only"])}\n${gitCapture(["diff", "--cached", "--name-only"])}`; return [...new Set(out.split(/\r?\n/).filter(Boolean))].sort(); } function allowedChange(path, roots) { return roots.some((root) => { const r = rel(root); return path === r || path.startsWith(`${r}/`); }); } export function assertOnlyReleaseChanges(pathsToAdd) { const roots = pathsToAdd.filter(existsSync), extra = changedPaths().filter((p) => !allowedChange(p, roots)); if (extra.length) throw new Error( `refusing to publish unrelated change(s): ${extra.join(", ")}`, ); } function releaseTagFor(version) { parseVersion(version); return `v${version}`; } function releaseTag() { return releaseTagFor(rootVersion()); } function tagExists(tag) { return ( git(["rev-parse", "--verify", "--quiet", `refs/tags/${tag}`], { check: false, }).status === 0 ); } function remoteTagExists(tag) { return ( git(["ls-remote", "--exit-code", "--tags", "origin", `refs/tags/${tag}`], { capture: true, check: false, }).status === 0 ); } function ensureUnreleasedVersion() { git(["fetch", "--prune", "--tags", "origin"]); let v = rootVersion(), bumped = false; while (tagExists(releaseTagFor(v)) || remoteTagExists(releaseTagFor(v))) { v = bumpPatch(v); bumped = true; } if (bumped) setRootVersion(v); return v; } function createTag() { const tag = releaseTag(); if (tagExists(tag)) console.log(`OK tag ${tag} exists`); else { git(["tag", tag, "HEAD"]); console.log(`OK tag ${tag}`); } return tag; } export function releasePaths(dirs) { return [ ...new Set([ join(ROOT, "README.md"), join(ROOT, "CHANGELOG.md"), join(ROOT, "package.json"), join(ROOT, "package-lock.json"), ...dirs.flatMap((e) => [join(e, "README.md"), join(e, "CHANGELOG.md")]), ]), ].sort(); } function formatReleaseArtifacts(dirs) { const paths = [ join(ROOT, "README.md"), join(ROOT, "CHANGELOG.md"), ...dirs.flatMap((e) => [join(e, "README.md"), join(e, "CHANGELOG.md")]), ] .filter(existsSync) .map((p) => rel(p)); if (paths.length) run("rumdl", ["fmt", ...paths], { cwd: ROOT }); } function commitRelease(pathsToAdd) { assertOnlyReleaseChanges(pathsToAdd); const paths = [ ...new Set(pathsToAdd.filter(existsSync).map((p) => rel(p))), ].sort(); git(["add", ...paths]); if ( git(["diff", "--cached", "--quiet", "--", ...paths], { check: false }) .status === 0 ) { console.log("OK commit none"); return false; } git(["commit", "-m", `release: v${rootVersion()}`, "--", ...paths]); console.log(`OK commit v${rootVersion()}`); return true; } function pushRelease(tag) { git(["push"]); git(["push", "origin", tag]); console.log("OK push"); } export function isLocalTestArtifact(path) { const parts = path.split("/"); if (parts[0] === ".pi" && parts[1] === "tmp") return true; return parts .slice(0, -1) .some( (part) => LOCAL_ARTIFACT_DIRS.has(part) || part.startsWith(".debug-") || part.startsWith(".pfui-") || /^\..+-debug-.+/.test(part), ); } export function checkStatic(dirs) { const errors = dirs.flatMap(staticCheck), pkg = rootPkg(); try { const targets = readJson(join(ROOT, CONFIG_PATH)).targets; if ( !Array.isArray(targets) || !targets.some( (target) => target.name === "pi-ext-debug" && target.template === "scripts/codegen/templates/pi-ext-debug.ts" && target.extensions === "all" && target.output === "extensions/{{extension-slug}}/src/pi-ext-debug.ts", ) ) errors.push( "root: mandatory per-extension debug codegen target is missing", ); } catch { errors.push( "root: mandatory per-extension debug codegen config is missing or invalid", ); } const gitRoot = git(["rev-parse", "--show-toplevel"], { capture: true, check: false, }); if (gitRoot.status === 0 && gitRoot.stdout.trim() === ROOT) { const tracked = git(["ls-files", "--cached", "-z"], { capture: true }); for (const path of tracked.stdout.split("\0")) if (isLocalTestArtifact(path)) errors.push(`root: tracked test artifact: ${path}`); } if (pkg.private !== true) errors.push("root: package must have private:true"); if (JSON.stringify(pkg.workspaces) !== JSON.stringify(["extensions/*"])) errors.push("root: workspaces must be ['extensions/*']"); // The root is a development workspace, never a Pi package: an empty manifest // stops Pi from discovering `extensions/` by convention. Pi loads generated // packages from dist/ instead. if (JSON.stringify(pkg.pi) !== "{}") errors.push( "root: pi must be an empty manifest; Pi loads generated dist/ packages, not the workspace", ); if (errors.length) { for (const e of errors) console.error(`ERROR ${e}`); return 1; } console.log(`OK static ${dirs.length} extension package(s)`); return 0; } export async function main(argv = process.argv.slice(2)) { const [cmd, ...rest] = argv; if (!cmd) throw new Error("missing command"); const takeModel = (args) => { const i = args.indexOf("--model"); if (i === -1) return [undefined, args]; const model = args[i + 1]; return [model, args.toSpliced(i, 2)]; }; if (cmd === "list") { for (const ext of extDirs(rest)) console.log(`${ext.split(/[\\/]/).at(-1)}\t${readPkg(ext).name}`); return 0; } if (cmd === "check") return checkStatic(extDirs(rest)); if (cmd === "test") { const dirs = extDirs(rest).map((d) => rel(d)); run("npm", ["run", "test", "--", ...dirs], { cwd: ROOT }); return 0; } if (cmd === "changelog") { const write = rest.includes("--write"), [model, args] = takeModel(rest.filter((a) => a !== "--write")); for (const ext of extDirs(args)) console.log( (write ? updateChangelog(ext, model) : generateChangelogEntry(ext, model) ).trimEnd(), ); return 0; } if (cmd === "readme") { const write = rest.includes("--write"), [model, args] = takeModel(rest.filter((a) => a !== "--write")); const dirs = extDirs(args); if (write) { updateAllReadmes(dirs, model); return 0; } console.log(generateReadme(model).trimEnd()); for (const ext of dirs) console.log(`\n${generateExtReadme(ext, model).trimEnd()}`); return 0; } if (cmd === "release-plan") { const [model, args] = takeModel(rest); const dirs = extDirs(args); console.log("RELEASE PLAN"); console.log(`version: v${rootVersion()}`); console.log("README:"); console.log(generateReadme(model).trimEnd()); console.log("repo changelog:"); console.log(generateRepoChangelogEntry(dirs, model).trimEnd()); for (const ext of dirs) console.log(generateChangelogEntry(ext, model).trimEnd()); console.log("skip write/commit/tag/push"); return 0; } if (cmd === "release-prepare") { const [model, args] = takeModel(rest); const dirs = extDirs(args); updateAllReadmes(dirs, model); updateRepoChangelog(dirs, model); for (const ext of dirs) updateChangelog(ext, model); formatReleaseArtifacts(dirs); console.log(`OK release prepared v${rootVersion()}`); return 0; } if (cmd === "publish") { const dirs = extDirs(rest), paths = releasePaths(dirs); assertOnlyReleaseChanges(paths); ensureUnreleasedVersion(); await main(["release-prepare", ...rest]); commitRelease(paths); pushRelease(createTag()); return 0; } throw new Error(`unknown command: ${cmd}`); } if (import.meta.url === pathToFileURL(resolve(process.argv[1])).href) main() .then((code) => process.exit(code)) .catch((e) => { console.error(e.message); process.exit(1); });