diff --git a/package.json b/package.json index cd0fed1a6..07a7fd3c9 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,10 @@ "scripts": { "clean": "npm run clean --workspaces", "build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build", - "check": "biome check --write --error-on-warnings . && tsgo --noEmit && npm run check:browser-smoke", + "check": "biome check --write --error-on-warnings . && npm run check:pinned-deps && npm run check:ts-imports && tsgo --noEmit && npm run check:browser-smoke", "check:browser-smoke": "node scripts/check-browser-smoke.mjs", + "check:pinned-deps": "node scripts/check-pinned-deps.mjs", + "check:ts-imports": "node scripts/check-ts-relative-imports.mjs", "profile:tui": "node scripts/profile-coding-agent-node.mjs --mode tui", "profile:rpc": "node scripts/profile-coding-agent-node.mjs --mode rpc", "test": "npm run test --workspaces --if-present", @@ -24,6 +26,7 @@ "prepublishOnly": "npm run clean && npm run build && npm run check", "publish": "npm run prepublishOnly && npm publish -ws --access public", "publish:dry": "npm run prepublishOnly && npm publish -ws --access public --dry-run", + "release:local": "node scripts/local-release.mjs", "release:patch": "node scripts/release.mjs patch", "release:minor": "node scripts/release.mjs minor", "release:major": "node scripts/release.mjs major", diff --git a/scripts/check-pinned-deps.mjs b/scripts/check-pinned-deps.mjs new file mode 100644 index 000000000..a2b389424 --- /dev/null +++ b/scripts/check-pinned-deps.mjs @@ -0,0 +1,63 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +const dependencySections = ["dependencies", "devDependencies", "optionalDependencies"]; +const exactVersionPattern = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; +const ignoredDirectories = new Set([".git", "dist", "node_modules"]); +const packageJsonFiles = []; + +function collectPackageJsonFiles(directory) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (!ignoredDirectories.has(entry.name)) { + collectPackageJsonFiles(join(directory, entry.name)); + } + continue; + } + + if (entry.isFile() && entry.name === "package.json") { + packageJsonFiles.push(join(directory, entry.name)); + } + } +} + +function isInternalWorkspaceDependency(name) { + return name.startsWith("@earendil-works/pi-"); +} + +function isNonRegistrySpecifier(specifier) { + return /^(?:workspace:|file:|link:|portal:|git\+|github:|git:|https?:|ssh:|git:\/\/)/.test(specifier); +} + +function getVersionSpecifier(specifier) { + if (!specifier.startsWith("npm:")) return specifier; + const aliasTarget = specifier.slice("npm:".length); + const versionSeparator = aliasTarget.lastIndexOf("@"); + if (versionSeparator <= 0) return specifier; + return aliasTarget.slice(versionSeparator + 1); +} + +const failures = []; + +collectPackageJsonFiles("."); + +for (const file of packageJsonFiles.sort()) { + const packageJson = JSON.parse(readFileSync(file, "utf8")); + + for (const section of dependencySections) { + const dependencies = packageJson[section]; + if (!dependencies) continue; + + for (const [name, specifier] of Object.entries(dependencies)) { + if (isInternalWorkspaceDependency(name) || isNonRegistrySpecifier(specifier)) continue; + if (exactVersionPattern.test(getVersionSpecifier(specifier))) continue; + failures.push(`${file}: ${section}.${name} must be pinned, found ${specifier}`); + } + } +} + +if (failures.length > 0) { + console.error("Direct external dependencies must use exact versions:"); + for (const failure of failures) console.error(` ${failure}`); + process.exit(1); +} diff --git a/scripts/check-ts-relative-imports.mjs b/scripts/check-ts-relative-imports.mjs new file mode 100644 index 000000000..bf21a5d0a --- /dev/null +++ b/scripts/check-ts-relative-imports.mjs @@ -0,0 +1,74 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import ts from "typescript"; + +const ignoredDirectories = new Set([".git", "coverage", "dist", "node_modules"]); +const files = []; + +function collectTypescriptFiles(directory) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (!ignoredDirectories.has(entry.name)) { + collectTypescriptFiles(join(directory, entry.name)); + } + continue; + } + + if (entry.isFile() && entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts")) { + files.push(join(directory, entry.name)); + } + } +} + +function isRelativeJavaScriptSpecifier(specifier) { + return /^\.\.?\//.test(specifier) && /\.js(?:[?#].*)?$/.test(specifier); +} + +function getImportTypeSpecifier(node) { + if (!ts.isLiteralTypeNode(node.argument)) return undefined; + if (!ts.isStringLiteralLike(node.argument.literal)) return undefined; + return node.argument.literal; +} + +const failures = []; + +collectTypescriptFiles("."); + +for (const file of files.sort()) { + const sourceText = readFileSync(file, "utf8"); + const sourceFile = ts.createSourceFile(file, sourceText, ts.ScriptTarget.Latest, true); + + function checkSpecifier(node) { + if (!isRelativeJavaScriptSpecifier(node.text)) return; + const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)); + failures.push(`${file}:${line + 1}:${character + 1}: ${node.text}`); + } + + function visit(node) { + if (ts.isImportDeclaration(node) && ts.isStringLiteralLike(node.moduleSpecifier)) { + checkSpecifier(node.moduleSpecifier); + } else if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier)) { + checkSpecifier(node.moduleSpecifier); + } else if ( + ts.isCallExpression(node) && + node.expression.kind === ts.SyntaxKind.ImportKeyword && + node.arguments[0] && + ts.isStringLiteralLike(node.arguments[0]) + ) { + checkSpecifier(node.arguments[0]); + } else if (ts.isImportTypeNode(node)) { + const specifier = getImportTypeSpecifier(node); + if (specifier) checkSpecifier(specifier); + } + + ts.forEachChild(node, visit); + } + + visit(sourceFile); +} + +if (failures.length > 0) { + console.error("Relative .js imports are not allowed in non-declaration .ts files:"); + for (const failure of failures) console.error(` ${failure}`); + process.exit(1); +} diff --git a/scripts/local-release.mjs b/scripts/local-release.mjs new file mode 100644 index 000000000..31e07901c --- /dev/null +++ b/scripts/local-release.mjs @@ -0,0 +1,187 @@ +#!/usr/bin/env node + +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; + +const packages = [ + { directory: "packages/ai", name: "@earendil-works/pi-ai" }, + { directory: "packages/tui", name: "@earendil-works/pi-tui" }, + { directory: "packages/agent", name: "@earendil-works/pi-agent-core" }, + { directory: "packages/coding-agent", name: "@earendil-works/pi-coding-agent" }, +]; + +function printUsage() { + console.log(`Usage: node scripts/local-release.mjs [options] + +Builds and packs the publishable packages, then installs the tarballs into an +isolated directory outside the repository for local release testing. + +Options: + --out Output directory. Defaults to a new directory under ${tmpdir()} + --force Remove --out first if it already exists + --skip-check Do not run npm run check before building + --skip-install Only create tarballs; do not create the isolated install + --help Show this help +`); +} + +function parseArgs() { + const options = { force: false, outDir: undefined, skipCheck: false, skipInstall: false }; + const args = process.argv.slice(2); + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--help") { + printUsage(); + process.exit(0); + } + if (arg === "--force") { + options.force = true; + continue; + } + if (arg === "--skip-check") { + options.skipCheck = true; + continue; + } + if (arg === "--skip-install") { + options.skipInstall = true; + continue; + } + if (arg === "--out") { + const value = args[++i]; + if (!value) { + throw new Error("--out requires a directory"); + } + options.outDir = value; + continue; + } + throw new Error(`Unknown option: ${arg}`); + } + + return options; +} + +function run(command, args, options = {}) { + console.log(`$ ${[command, ...args].join(" ")}`); + const result = spawnSync(command, args, { + cwd: options.cwd, + encoding: "utf8", + stdio: options.capture ? ["inherit", "pipe", "inherit"] : "inherit", + }); + + if (result.status !== 0) { + throw new Error(`Command failed: ${[command, ...args].join(" ")}`); + } + + return result.stdout ?? ""; +} + +function readPackageJson(directory) { + return JSON.parse(readFileSync(join(directory, "package.json"), "utf8")); +} + +function isInsidePath(child, parent) { + const relativePath = relative(parent, child); + return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath)); +} + +function prepareOutputDirectory(options, repoRoot) { + if (!options.outDir) { + return mkdtempSync(join(tmpdir(), "pi-local-release-")); + } + + const outDir = resolve(options.outDir); + + if (isInsidePath(outDir, repoRoot)) { + throw new Error(`Output directory must be outside the repository: ${outDir}`); + } + + if (existsSync(outDir)) { + if (!options.force) { + throw new Error(`Output directory already exists. Use --force to replace it: ${outDir}`); + } + rmSync(outDir, { force: true, recursive: true }); + } + + mkdirSync(outDir, { recursive: true }); + return outDir; +} + +function fileSpecifier(fromDirectory, file) { + const relativePath = relative(fromDirectory, file).replaceAll("\\", "/"); + return `file:${relativePath.startsWith(".") ? relativePath : `./${relativePath}`}`; +} + +function packPackage(pkg, tarballDirectory) { + const packageJson = readPackageJson(pkg.directory); + if (packageJson.name !== pkg.name) { + throw new Error(`${pkg.directory}/package.json has name ${packageJson.name}, expected ${pkg.name}`); + } + + const output = run("npm", ["pack", "--json", "--pack-destination", tarballDirectory], { + capture: true, + cwd: pkg.directory, + }); + const packed = JSON.parse(output)[0]; + return join(tarballDirectory, packed.filename); +} + +const options = parseArgs(); +const repoRoot = process.cwd(); +const rootPackageJson = readPackageJson(repoRoot); + +if (rootPackageJson.name !== "pi-monorepo") { + throw new Error("Run this script from the repository root"); +} + +const outDir = prepareOutputDirectory(options, repoRoot); +const tarballDirectory = join(outDir, "tarballs"); +const installDirectory = join(outDir, "install"); +const binDirectory = join(outDir, "bin"); +mkdirSync(tarballDirectory, { recursive: true }); + +if (!options.skipCheck) { + run("npm", ["run", "check"], { cwd: repoRoot }); +} + +for (const pkg of packages) { + run("npm", ["run", "clean"], { cwd: pkg.directory }); + run("npm", ["run", "build"], { cwd: pkg.directory }); +} + +const tarballs = new Map(); +for (const pkg of packages) { + const tarball = packPackage(pkg, tarballDirectory); + tarballs.set(pkg.name, tarball); +} + +if (!options.skipInstall) { + mkdirSync(installDirectory, { recursive: true }); + const dependencies = Object.fromEntries( + packages.map((pkg) => [pkg.name, fileSpecifier(installDirectory, tarballs.get(pkg.name))]), + ); + writeFileSync( + join(installDirectory, "package.json"), + `${JSON.stringify({ private: true, dependencies }, undefined, "\t")}\n`, + ); + + run("npm", ["install", "--omit=dev"], { cwd: installDirectory }); + mkdirSync(binDirectory, { recursive: true }); + symlinkSync(join(installDirectory, "node_modules", ".bin", "pi"), join(binDirectory, "pi")); +} + +console.log("\nLocal release artifacts created:"); +console.log(` ${outDir}`); +console.log("\nTarballs:"); +for (const tarball of tarballs.values()) { + console.log(` ${tarball}`); +} + +if (!options.skipInstall) { + console.log("\nIsolated install:"); + console.log(` ${installDirectory}`); + console.log("\nRun the locally packed CLI from outside the repository:"); + console.log(` ${join(binDirectory, "pi")} --help`); +}