Luigit
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);
		});