repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
scripts/extensions.mjs
Raw#!/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 || "<missing>"}\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") : "<missing>"}\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/<name> instead.
if (JSON.stringify(pkg.pi) !== "{}")
errors.push(
"root: pi must be an empty manifest; Pi loads generated dist/<name> 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);
});