#!/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 { readBuildSpec } from "./build/spec.ts"; import { CONFIG_PATH, checkGenerated } from "./codegen.ts"; import { git, ROOT, readJson, rel, run, stripAnsi, writeJson, } from "./lib/common.ts"; import { type EsNode, literalString, parseModule, walkEs, } from "./lib/esast.ts"; import { extractSurface, injectSurface, renderSurface, } from "./readme/config.ts"; type PkgJson = { name?: string; version?: string; private?: boolean; type?: string; main?: string; license?: string; scripts?: Record; dependencies?: Record; peerDependencies?: Record; devDependencies?: Record; optionalDependencies?: Record; workspaces?: unknown; pi?: unknown; }; type LockJson = { version?: string; packages?: Record; }; export const EXT_ROOT: string = join(ROOT, "extensions"); export const PI_PEERS: Set = 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. - Never author content between \`\` and \`\`; that block is generated after this response. Follow the README authoring contract and template supplied below. `; function format(template: string, vars: Record): string { return template.replaceAll(/\{(\w+)\}/g, (_: string, key: string) => String(vars[key] ?? `{${key}}`), ); } export function rootPkg(): PkgJson { return readJson(join(ROOT, "package.json")) as PkgJson; } export function rootVersion(): string { return rootPkg().version ?? "0.0.0"; } export function parseVersion(version: string): number[] { 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: string) { const [a, b, c] = parseVersion(version); if (a === undefined || b === undefined || c === undefined) throw new Error(`version must be X.Y.Z, got ${JSON.stringify(version)}`); return `${a}.${b}.${c + 1}`; } export function setRootVersion(version: string): void { 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) as LockJson; lock.version = version; if (lock.packages?.[""]) lock.packages[""].version = version; writeJson(lockPath, lock); console.log(`OK version v${version}`); } export function pkgName(spec: string): string { return spec.startsWith("@") ? spec.split("/").slice(0, 2).join("/") : (spec.split("/")[0] ?? spec); } export function readPkg(ext: string): PkgJson { return readJson(join(ext, "package.json")) as PkgJson; } export function extDirs(selected: string[] = []): string[] { 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: string[] = []; for (const ext of dirs) { const pkg = readPkg(ext); if ( wanted.has(ext.split(/[\\/]/).at(-1) ?? "") || (pkg.name !== undefined && 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: string, out: string[] = []) { 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: string): string[] { return walk(ext) .filter((p) => p.endsWith(".ts")) .filter((p) => !relative(ext, p).split(/[\\/]/).includes("skills")) .sort(); } export function importSpecs(ext: string): Set { 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)) { const spec = m[1] || m[2]; if (spec !== undefined) specs.add(spec); } } return specs; } function movedPiAiRootImports(text: string) { const moved: string[] = []; 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 (name && MOVED_PI_AI_ROOT_EXPORTS.has(name)) moved.push(name); } } return moved; } function pathInside(child: string, parent: string) { const r = relative(resolve(parent), resolve(child)); return ( r === "" || (!r.startsWith("..") && !resolve(r).startsWith(resolve(r).split(/[\\/]/)[0] + ":")) ); } export function isAllowedTestImport( file: string, resolved: string, root: string = ROOT, ): boolean { const segments = file.split(/[\\/]/); const inTestDir = segments.includes("__tests__") || segments.includes("__serial__") || segments.includes("__bench__") || segments.includes("__e2e__") || segments.includes("__fuzz__") || segments.at(-1) === "playwright.config.ts"; if (!inTestDir) return false; if (pathInside(resolved, join(root, "test"))) return true; // A suite may import its own built package from `dist/` (browser E2E // against the generated artifact); sibling dist stays forbidden. const extIndex = segments.lastIndexOf("extensions"); const ownName = extIndex >= 0 ? segments[extIndex + 1] : undefined; return ownName ? pathInside(resolved, join(root, "dist", ownName)) : false; } function err(errors: string[], ext: string, msg: string) { errors.push(`${ext.split(/[\\/]/).at(-1)}: ${msg}`); } export function secretScanIgnorePaths(root: string = ROOT): Set { try { const paths = ( readJson(join(root, "scripts", "extensions.json")) as Record< string, unknown > )[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: string, ignores: Set, root: string = ROOT, ): boolean { const r = rel(resolve(file), resolve(root)); return !r.startsWith("..") && ignores.has(r); } function keyName(node: EsNode | undefined): string | undefined { if (!node) return undefined; if (node.type === "Identifier") return node["name"] as string; if (node.type === "Literal" && typeof node["value"] === "string") return node["value"]; return undefined; } function isMemberNamed(node: unknown, name: string): node is EsNode { return ( !!node && typeof node === "object" && (node as EsNode).type === "MemberExpression" && ((node as EsNode)["property"] as EsNode | undefined)?.type === "Identifier" && ((node as EsNode)["property"] as EsNode)["name"] === name ); } function isBeforeAgentStartRegistration(node: EsNode): boolean { const args = node["arguments"] as EsNode[] | undefined; return ( node.type === "CallExpression" && isMemberNamed(node["callee"], "on") && !!args && args.length >= 2 && args[0]?.type === "Literal" && args[0]["value"] === "before_agent_start" ); } function hasFlatPromptOverride(file: string, text: string): boolean { let found = false; const inspectHandler = (node: unknown) => walkEs(node, (n) => { if ( n.type === "Property" && keyName(n["key"] as EsNode) === "systemPrompt" ) found = true; if ( n.type === "AssignmentExpression" && n["operator"] === "=" && isMemberNamed(n["left"], "forceSystemPrompt") ) found = true; }); walkEs(parseModule(file, text), (node) => { if (isBeforeAgentStartRegistration(node)) { const handler = (node["arguments"] as EsNode[])[1]; if ( handler?.type === "ArrowFunctionExpression" || handler?.type === "FunctionExpression" ) inspectHandler(handler["body"]); } }); return found; } function isProductionTypeScript(file: string, ext: string) { return !relative(ext, file) .split(/[\\/]/) .some((part) => part.startsWith("__")); } function debugModule(ext: string, file: string, spec: string) { if (!spec.startsWith(".")) return ""; return relative(ext, resolve(dirname(file), spec)).replaceAll("\\", "/"); } function hasDebugSchemaBinding(path: string): boolean { const body = parseModule(path, readFileSync(path, "utf8"))[ "body" ] as EsNode[]; const imported = body.some( (node) => node.type === "ImportDeclaration" && (node["source"] as EsNode | undefined)?.type === "Literal" && debugModule( dirname(dirname(path)), path, (node["source"] as EsNode)["value"] as string, ) === "src/pi-ext-debug.ts" && ((node["specifiers"] as EsNode[] | undefined) ?? []).some( (specifier) => specifier.type === "ImportSpecifier" && (specifier["imported"] as EsNode | undefined)?.["name"] === "defineDebug", ), ); if (!imported) return false; return body.some((node) => { if (node.type !== "ExportNamedDeclaration") return false; const declaration = node["declaration"] as EsNode | null; if (declaration?.type !== "VariableDeclaration") return false; return (declaration["declarations"] as EsNode[]).some((declarator) => { const id = declarator["id"] as EsNode; const init = declarator["init"] as EsNode | null; if ( id?.type !== "ObjectPattern" || init?.type !== "CallExpression" || (init["callee"] as EsNode | undefined)?.type !== "Identifier" || (init["callee"] as EsNode)["name"] !== "defineDebug" ) return false; const keys = new Set( (id["properties"] as EsNode[]) .map((property) => keyName(property["key"] as EsNode | undefined)) .filter((key): key is string => key !== undefined), ); return ["dbg", "closeDebug"].every((key) => keys.has(key)); }); }); } export function staticCheck( ext: string, options: { root?: string } = {}, ): string[] { const root = options.root ?? ROOT; const errors: string[] = []; let pkg: PkgJson; try { pkg = readPkg(ext); } catch (e) { return [ `${ext.split(/[\\/]/).at(-1)}: invalid package.json: ${(e as Error).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 as Error).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 (!existsSync(join(ext, "__tests__", "contract.test.ts"))) err(errors, ext, "missing __tests__/contract.test.ts"); // The owned test fixture replaced the patched harness; the devDependency must // not reappear. if (dev[HARNESS]) err( errors, ext, `remove devDependency ${HARNESS}; tests use test/fixture/create-pi-session.ts`, ); for (const [section, declared] of [ ["dependencies", deps], ["devDependencies", dev], ["optionalDependencies", optional], ] as [string, Record][]) { 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"); // Tests in `__tests__/` run in the shared, non-isolated `unit` pool, where a // `vi.mock` persists in the module registry across files. Module-mocking // tests must live in `__serial__/`. The owned-fixture `contract.test.ts` is // its own isolated tier and is exempt. (Global/env stubs are fine when // restored in cleanup; real subprocess spawning is a parallelism concern.) const relPath = rel(file, ext).replaceAll("\\", "/"); if ( /(?:^|\/)__tests__\/[^/]+\.test\.ts$/.test(relPath) && !relPath.endsWith("/contract.test.ts") && /\bvi\.mock\s*\(/.test(text) ) err( errors, ext, `${relPath} uses vi.mock in the shared unit pool (module mocks leak across files); move it to __serial__/`, ); if (isProductionTypeScript(file, ext) && file !== debugPath) { const generated = /^src\/pi-ext-[^/]+\.ts$/.test( rel(file, ext).replaceAll("\\", "/"), ); walkEs(parseModule(file, text), (node) => { const callee = node["callee"] as EsNode | undefined; const calleeProperty = callee?.type === "MemberExpression" ? (callee["property"] as EsNode | undefined) : undefined; if ( !generated && node.type === "CallExpression" && calleeProperty?.type === "Identifier" && HELPER_OWNED_CALLS.has(calleeProperty["name"] as string) ) { const name = calleeProperty["name"] as string; err( errors, ext, `direct ${name}() in ${rel(file, ext)}; ${HELPER_OWNED_CALLS.get(name)} from src/pi-ext-settings.ts`, ); } const sourceSpec = node.type === "ImportDeclaration" || node.type === "ExportNamedDeclaration" || node.type === "ExportAllDeclaration" || node.type === "ImportExpression" ? literalString(node["source"] as EsNode | null) : undefined; if (sourceSpec !== undefined) { const target = debugModule(ext, file, sourceSpec); 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) && node.type === "ImportDeclaration" && node["importKind"] !== "type" && ((node["specifiers"] as EsNode[] | undefined) ?? []).some( (specifier) => specifier.type === "ImportDefaultSpecifier" || specifier.type === "ImportNamespaceSpecifier" || (specifier.type === "ImportSpecifier" && specifier["importKind"] !== "type"), ) ) hasRuntimeDebugImport = true; } }); } 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: string[], check = true) { return git(args, { capture: true, check }).stdout; } function gitChanges(path: string, since?: string) { 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: string) { 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: string[]) { const tag = latestReleaseTag(); return !tag || paths.some(changedSinceRelease); } function piGenerate(prompt: string, model?: string, 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: string) { 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: string) { 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: string) { return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trimEnd() + "\n"; } function writeRepoText(path: string, text: string) { writeFileSync(path, wrapGeneratedMarkdown(text), "utf8"); } function generateChangelogEntry(ext: string, model?: string) { 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: string, model?: string) { 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: string; 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: string[]) { 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: string[], model?: string) { 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: string[], model?: string) { const entry = generateRepoChangelogEntry(exts, model), path = join(ROOT, "CHANGELOG.md"), heading = `## ${rootVersion()}`; let next: string; 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?: string) { 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?: string) { const text = generateReadme(model); writeRepoText(join(ROOT, "README.md"), text); console.log("OK README.md"); return text; } function extReadmeAuthoringContext() { const skill = join(ROOT, ".pi/skills/pi-ext-readme/SKILL.md"); const template = join( ROOT, ".pi/skills/pi-ext-readme/references/template.md", ); return [skill, template] .map((path) => `--- ${rel(path)} ---\n${readFileSync(path, "utf8")}`) .join("\n"); } export function readmePreamble(text: string): string { const lines = text.split(/\r?\n/); const heading = lines.findIndex((line) => line.startsWith("# ")); return heading > 0 ? `${lines.slice(0, heading).join("\n").trimEnd()}\n\n` : ""; } function readmeDemo(text: string) { return text .split(/\r?\n/) .filter((line) => line.includes("assets/demo.")) .join("\n"); } export function preserveReadmeDemo(text: string, demo: string): string { if (!demo || text.includes("assets/demo.gif")) return text; const lines = text.split(/\r?\n/); const heading = lines.findIndex((line) => line.startsWith("# ")); return heading < 0 ? text : `${lines.slice(0, heading + 1).join("\n")}\n\n${demo}\n${lines .slice(heading + 1) .join("\n")}`; } function renderReadmeSurface(ext: string, text: string) { const block = renderSurface(extractSurface(ext)); return injectSurface(text, block) ?? `${text.trimEnd()}\n\n${block}\n`; } function extReadmePrompt(ext: string) { const sources = tsFiles(ext) .slice(0, 8) .map( (f) => `--- ${rel(f, ext)} ---\n${readFileSync(f, "utf8").slice(0, 5000)}`, ) .join("\n"); const authoringContext = extReadmeAuthoringContext(); return `${EXT_README_STYLE}\n${authoringContext ? `\nREADME authoring contract:\n${authoringContext}\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: string, model?: string) { const existing = existsSync(join(ext, "README.md")) ? readFileSync(join(ext, "README.md"), "utf8") : ""; const preamble = readmePreamble(existing) || `${ext.split(/[\\/]/).at(-1)}\n\n`; const demo = readmeDemo(existing); 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 renderReadmeSurface( ext, preserveReadmeDemo(`${preamble}${out}`, demo), ); 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: string, model?: string) { const text = generateExtReadme(ext, model); writeRepoText(join(ext, "README.md"), text); console.log(`OK README ${ext.split(/[\\/]/).at(-1)}`); return text; } function updateAllReadmes(exts: string[], model?: string) { 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 as Error).message}`); console.error(`ERROR README root: ${(e as Error).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 as Error).message}`); console.error( `ERROR README ${ext.split(/[\\/]/).at(-1)}: ${(e as Error).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: string, roots: string[]) { return roots.some((root) => { const r = rel(root); return path === r || path.startsWith(`${r}/`); }); } export function assertOnlyReleaseChanges(pathsToAdd: string[]): void { 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: string) { parseVersion(version); return `v${version}`; } function releaseTag() { return releaseTagFor(rootVersion()); } function tagExists(tag: string) { return ( git(["rev-parse", "--verify", "--quiet", `refs/tags/${tag}`], { check: false, }).status === 0 ); } function remoteTagExists(tag: string) { 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: string[]): string[] { 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: string[]) { 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: string[]) { 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: string) { git(["push"]); git(["push", "origin", tag]); console.log("OK push"); } export function isLocalTestArtifact(path: string): boolean { 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: string[]): number { const errors = dirs.flatMap((ext) => staticCheck(ext)), pkg = rootPkg(); try { const targets = (readJson(join(ROOT, CONFIG_PATH)) as { targets?: unknown }) .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: string[] = process.argv.slice(2), ): Promise { const [cmd, ...rest] = argv; if (!cmd) throw new Error("missing command"); const takeModel = (args: string[]): [string | undefined, string[]] => { 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}`); } const entryPoint = process.argv[1]; if (entryPoint && import.meta.url === pathToFileURL(resolve(entryPoint)).href) main() .then((code) => process.exit(code)) .catch((e) => { console.error(e.message); process.exit(1); });