| #!/usr/bin/env node |
|
|
| import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; |
| import { dirname, join, posix, resolve } from "node:path"; |
| import { fileURLToPath } from "node:url"; |
|
|
| const scriptDir = dirname(fileURLToPath(import.meta.url)); |
| const repoRoot = resolve(scriptDir, ".."); |
| const codingAgentDir = join(repoRoot, "packages/coding-agent"); |
| const outputDir = join(codingAgentDir, "install-lock"); |
| const rootLockfilePath = join(repoRoot, "package-lock.json"); |
| const outputPackageJsonPath = join(outputDir, "package.json"); |
| const outputLockfilePath = join(outputDir, "package-lock.json"); |
| const internalPackagePrefix = "@earendil-works/pi-"; |
| const internalPackageNames = new Set(["@earendil-works/chord"]); |
| const installPackageName = "@earendil-works/pi-coding-agent-install"; |
| const allowedInstallScriptPackages = new Map([ |
| ["@google/genai@2.21.0", "preinstall is a no-op in the published package"], |
| ["esbuild@0.28.2", "postinstall selects and verifies the platform-specific esbuild binary"], |
| ["protobufjs@7.6.6", "postinstall only warns about protobufjs version scheme mismatches"], |
| ]); |
|
|
| const args = new Set(process.argv.slice(2)); |
| const checkOnly = args.has("--check"); |
|
|
| for (const arg of args) { |
| if (arg !== "--check") { |
| console.error(`Unknown argument: ${arg}`); |
| process.exit(1); |
| } |
| } |
|
|
| function readJson(path) { |
| return JSON.parse(readFileSync(path, "utf8")); |
| } |
|
|
| function packageDependencies(entry) { |
| return { |
| ...(entry.dependencies ?? {}), |
| ...(entry.optionalDependencies ?? {}), |
| }; |
| } |
|
|
| function sortedObject(object) { |
| return Object.fromEntries(Object.entries(object).sort(([a], [b]) => a.localeCompare(b))); |
| } |
|
|
| function sortedPackageEntry(entry) { |
| const fieldOrder = [ |
| "name", |
| "version", |
| "resolved", |
| "integrity", |
| "license", |
| "dependencies", |
| "optionalDependencies", |
| "peerDependencies", |
| "peerDependenciesMeta", |
| "bin", |
| "engines", |
| "os", |
| "cpu", |
| "libc", |
| "optional", |
| "hasInstallScript", |
| "deprecated", |
| "funding", |
| ]; |
| const sorted = {}; |
|
|
| for (const field of fieldOrder) { |
| if (entry[field] !== undefined) { |
| sorted[field] = entry[field]; |
| } |
| } |
| for (const [field, value] of Object.entries(entry).sort(([a], [b]) => a.localeCompare(b))) { |
| if (sorted[field] === undefined) { |
| sorted[field] = value; |
| } |
| } |
| return sorted; |
| } |
|
|
| function copyLockEntry(entry) { |
| const copied = { ...entry }; |
| delete copied.dev; |
| delete copied.devOptional; |
| delete copied.extraneous; |
| delete copied.link; |
| return sortedPackageEntry(copied); |
| } |
|
|
| function copyPackageJsonEntry(packageJson, options) { |
| const entry = options.includeName |
| ? { name: packageJson.name, version: packageJson.version } |
| : { version: packageJson.version }; |
|
|
| for (const field of [ |
| "license", |
| "dependencies", |
| "optionalDependencies", |
| "peerDependencies", |
| "peerDependenciesMeta", |
| "bin", |
| "engines", |
| "os", |
| "cpu", |
| "libc", |
| ]) { |
| if (packageJson[field] !== undefined) { |
| entry[field] = packageJson[field]; |
| } |
| } |
|
|
| return sortedPackageEntry(entry); |
| } |
|
|
| function packageNameFromLockPath(lockPath) { |
| const marker = "node_modules/"; |
| const index = lockPath.lastIndexOf(marker); |
| if (index === -1) { |
| return undefined; |
| } |
|
|
| const parts = lockPath.slice(index + marker.length).split("/"); |
| if (parts[0]?.startsWith("@")) { |
| return `${parts[0]}/${parts[1]}`; |
| } |
| return parts[0]; |
| } |
|
|
| function registryTarballUrl(packageName, version) { |
| const tarballName = packageName.startsWith("@") ? packageName.split("/")[1] : packageName; |
| return `https://registry.npmjs.org/${packageName}/-/${tarballName}-${version}.tgz`; |
| } |
|
|
| function isExactVersionSpec(spec) { |
| return /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(spec); |
| } |
|
|
| function getInternalWorkspaces(lockPackages) { |
| const workspaces = new Map(); |
|
|
| for (const [lockPath, entry] of Object.entries(lockPackages)) { |
| if (!lockPath.startsWith("packages/") || lockPath.includes("/node_modules/") || !entry.name || !entry.version) { |
| continue; |
| } |
| if (!entry.name.startsWith(internalPackagePrefix) && !internalPackageNames.has(entry.name)) { |
| continue; |
| } |
|
|
| workspaces.set(entry.name, { |
| lockPath, |
| packageJson: readJson(join(repoRoot, lockPath, "package.json")), |
| }); |
| } |
|
|
| return workspaces; |
| } |
|
|
| function resolveExternalDependency(lockPackages, packageName, fromLockPath) { |
| const candidateDirs = []; |
| let current = fromLockPath; |
|
|
| while (current) { |
| candidateDirs.push(current); |
| const parent = posix.dirname(current); |
| if (parent === "." || parent === current) { |
| break; |
| } |
| current = parent; |
| } |
| candidateDirs.push(""); |
|
|
| const tried = new Set(); |
| for (const directory of candidateDirs) { |
| const candidate = directory ? `${directory}/node_modules/${packageName}` : `node_modules/${packageName}`; |
| if (tried.has(candidate)) { |
| continue; |
| } |
| tried.add(candidate); |
|
|
| const entry = lockPackages[candidate]; |
| if (entry && !entry.link) { |
| return candidate; |
| } |
| } |
|
|
| const suffix = `node_modules/${packageName}`; |
| const matches = Object.entries(lockPackages) |
| .filter(([lockPath, entry]) => !entry.link && (lockPath === suffix || lockPath.endsWith(`/${suffix}`))) |
| .map(([lockPath]) => lockPath); |
|
|
| if (matches.length === 1) { |
| return matches[0]; |
| } |
|
|
| throw new Error( |
| `Cannot resolve ${packageName} from ${fromLockPath || "root"}. ` + |
| (matches.length > 1 ? `Matches: ${matches.join(", ")}` : "No matching lockfile entry found."), |
| ); |
| } |
|
|
| function addInternalWorkspace(installLockPackages, addedPaths, queue, name, workspace) { |
| const packageJson = workspace.packageJson; |
| const outputPath = `node_modules/${name}`; |
| const entry = copyPackageJsonEntry(packageJson, { includeName: false }); |
| entry.resolved = registryTarballUrl(name, packageJson.version); |
|
|
| installLockPackages[outputPath] = sortedPackageEntry(entry); |
| addedPaths.add(outputPath); |
|
|
| for (const dependencyName of Object.keys(packageDependencies(packageJson))) { |
| queue.push({ |
| name: dependencyName, |
| sourceFrom: workspace.lockPath, |
| sourceBase: workspace.lockPath, |
| outputBase: outputPath, |
| }); |
| } |
| } |
|
|
| function addExternalPackage(lockPackages, installLockPackages, addedPaths, queue, item) { |
| const sourceLockPath = resolveExternalDependency(lockPackages, item.name, item.sourceFrom); |
| const outputLockPath = |
| item.sourceBase && sourceLockPath.startsWith(`${item.sourceBase}/`) |
| ? [item.outputBase, sourceLockPath.slice(item.sourceBase.length + 1)].filter(Boolean).join("/") |
| : sourceLockPath; |
| if (addedPaths.has(outputLockPath)) { |
| return; |
| } |
|
|
| const entry = lockPackages[sourceLockPath]; |
| installLockPackages[outputLockPath] = copyLockEntry(entry); |
| addedPaths.add(outputLockPath); |
|
|
| for (const dependencyName of Object.keys(packageDependencies(entry))) { |
| queue.push({ |
| name: dependencyName, |
| sourceFrom: sourceLockPath, |
| sourceBase: item.sourceBase, |
| outputBase: item.outputBase, |
| }); |
| } |
| } |
|
|
| function createInstallerPackageJson(codingAgentPackage) { |
| const packageJson = { |
| name: installPackageName, |
| version: codingAgentPackage.version, |
| private: true, |
| description: "Lockfile root used by the Pi installer and updater.", |
| dependencies: { |
| [codingAgentPackage.name]: codingAgentPackage.version, |
| }, |
| }; |
| if (codingAgentPackage.overrides) { |
| packageJson.overrides = codingAgentPackage.overrides; |
| } |
| if (codingAgentPackage.engines) { |
| packageJson.engines = codingAgentPackage.engines; |
| } |
| return packageJson; |
| } |
|
|
| function createRootLockEntry(installerPackageJson) { |
| const entry = { |
| name: installerPackageJson.name, |
| version: installerPackageJson.version, |
| dependencies: installerPackageJson.dependencies, |
| }; |
| if (installerPackageJson.engines) { |
| entry.engines = installerPackageJson.engines; |
| } |
| return sortedPackageEntry(entry); |
| } |
|
|
| function validateGeneratedFiles(installerPackageJson, installLock, internalNames) { |
| const errors = []; |
| const rootEntry = installLock.packages[""]; |
| const includedPackageNames = new Set(); |
| const seenAllowedInstallScriptPackages = new Set(); |
|
|
| if (installLock.lockfileVersion !== 3) { |
| errors.push("package-lock.json must use lockfileVersion 3"); |
| } |
| if (installLock.name !== installerPackageJson.name) { |
| errors.push(`lockfile name ${installLock.name} does not match package.json name ${installerPackageJson.name}`); |
| } |
| if (installLock.version !== installerPackageJson.version) { |
| errors.push( |
| `lockfile version ${installLock.version} does not match package.json version ${installerPackageJson.version}`, |
| ); |
| } |
| if (JSON.stringify(rootEntry?.dependencies ?? {}) !== JSON.stringify(installerPackageJson.dependencies)) { |
| errors.push("lockfile root dependencies do not match package.json dependencies"); |
| } |
|
|
| for (const [lockPath, entry] of Object.entries(installLock.packages)) { |
| const packageName = packageNameFromLockPath(lockPath); |
| if (packageName) { |
| includedPackageNames.add(packageName); |
| } |
| if (entry.link) { |
| errors.push(`${lockPath} is a link entry`); |
| } |
| if (typeof entry.resolved === "string" && /^(file:|link:|workspace:|\.\.?\/|\/)/.test(entry.resolved)) { |
| errors.push(`${lockPath} has a local resolved value: ${entry.resolved}`); |
| } |
| if (entry.dev || entry.devOptional || entry.extraneous) { |
| errors.push(`${lockPath || "root"} contains dev/extraneous metadata`); |
| } |
| if ( |
| packageName !== undefined && |
| (packageName.startsWith(internalPackagePrefix) || internalPackageNames.has(packageName)) && |
| entry.version !== installerPackageJson.version |
| ) { |
| errors.push(`${lockPath} internal package version ${entry.version} does not match ${installerPackageJson.version}`); |
| } |
| if (entry.hasInstallScript) { |
| if (!packageName || !entry.version) { |
| errors.push(`${lockPath || "root"} has install scripts but no package name/version`); |
| } else { |
| const packageId = `${packageName}@${entry.version}`; |
| if (allowedInstallScriptPackages.has(packageId)) { |
| seenAllowedInstallScriptPackages.add(packageId); |
| } else { |
| errors.push( |
| `${lockPath} has install scripts (${packageId}). Review it and add it to allowedInstallScriptPackages if intentional.`, |
| ); |
| } |
| } |
| } |
| } |
|
|
| for (const packageId of allowedInstallScriptPackages.keys()) { |
| if (!seenAllowedInstallScriptPackages.has(packageId)) { |
| errors.push(`allowed install-script package ${packageId} is no longer present; remove it from the allowlist`); |
| } |
| } |
|
|
| for (const name of internalNames) { |
| if (!includedPackageNames.has(name)) { |
| errors.push(`internal dependency ${name} is missing`); |
| } |
| } |
|
|
| for (const [lockPath, entry] of Object.entries(installLock.packages)) { |
| for (const [dependencyName, dependencySpec] of Object.entries(packageDependencies(entry))) { |
| let dependencyLockPath; |
| try { |
| dependencyLockPath = resolveExternalDependency(installLock.packages, dependencyName, lockPath); |
| } catch { |
| errors.push(`${lockPath || "root"} dependency ${dependencyName} is missing`); |
| continue; |
| } |
|
|
| const dependencyEntry = installLock.packages[dependencyLockPath]; |
| if (isExactVersionSpec(dependencySpec) && dependencyEntry.version !== dependencySpec) { |
| errors.push( |
| `${lockPath || "root"} dependency ${dependencyName}@${dependencySpec} resolves to ${dependencyEntry.version}`, |
| ); |
| } |
| } |
| } |
|
|
| const platformPackageCount = Object.values(installLock.packages).filter((entry) => entry.os || entry.cpu || entry.libc) |
| .length; |
| if (platformPackageCount === 0) { |
| errors.push("no platform-specific optional dependency entries found"); |
| } |
|
|
| if (errors.length > 0) { |
| throw new Error(`Generated installer lock failed validation:\n${errors.map((error) => ` - ${error}`).join("\n")}`); |
| } |
| } |
|
|
| function generateInstallLock() { |
| const rootLock = readJson(rootLockfilePath); |
| if (rootLock.lockfileVersion !== 3 || !rootLock.packages) { |
| throw new Error("package-lock.json must be lockfileVersion 3 and contain a packages map"); |
| } |
|
|
| const lockPackages = rootLock.packages; |
| const codingAgentPackage = readJson(join(codingAgentDir, "package.json")); |
| const installerPackageJson = createInstallerPackageJson(codingAgentPackage); |
| const internalWorkspaces = getInternalWorkspaces(lockPackages); |
| const installLockPackages = { |
| "": createRootLockEntry(installerPackageJson), |
| }; |
| const addedPaths = new Set([""]); |
| const internalNames = new Set(); |
| const queue = Object.keys(packageDependencies(installerPackageJson)).map((name) => ({ |
| name, |
| sourceFrom: "", |
| })); |
|
|
| while (queue.length > 0) { |
| const item = queue.shift(); |
| if (!item) { |
| break; |
| } |
|
|
| const workspace = internalWorkspaces.get(item.name); |
| if (workspace) { |
| const outputPath = `node_modules/${item.name}`; |
| internalNames.add(item.name); |
| if (!addedPaths.has(outputPath)) { |
| addInternalWorkspace(installLockPackages, addedPaths, queue, item.name, workspace); |
| } |
| continue; |
| } |
|
|
| addExternalPackage(lockPackages, installLockPackages, addedPaths, queue, item); |
| } |
|
|
| const installLock = { |
| name: installerPackageJson.name, |
| version: installerPackageJson.version, |
| lockfileVersion: 3, |
| requires: true, |
| packages: sortedObject(installLockPackages), |
| }; |
|
|
| validateGeneratedFiles(installerPackageJson, installLock, internalNames); |
| return { installerPackageJson, installLock }; |
| } |
|
|
| try { |
| const { installerPackageJson, installLock } = generateInstallLock(); |
| const packageJsonContent = `${JSON.stringify(installerPackageJson, null, "\t")}\n`; |
| const lockfileContent = `${JSON.stringify(installLock, null, "\t")}\n`; |
|
|
| if (checkOnly) { |
| if (!existsSync(outputPackageJsonPath) || !existsSync(outputLockfilePath)) { |
| console.error("packages/coding-agent/install-lock is missing generated files."); |
| console.error("Run: npm run install-lock:coding-agent"); |
| process.exit(1); |
| } |
| const currentPackageJson = readFileSync(outputPackageJsonPath, "utf8"); |
| const currentLockfile = readFileSync(outputLockfilePath, "utf8"); |
| if (currentPackageJson !== packageJsonContent || currentLockfile !== lockfileContent) { |
| console.error("packages/coding-agent/install-lock is out of date."); |
| console.error("Run: npm run install-lock:coding-agent"); |
| process.exit(1); |
| } |
| console.log("packages/coding-agent/install-lock is up to date."); |
| } else { |
| mkdirSync(outputDir, { recursive: true }); |
| writeFileSync(outputPackageJsonPath, packageJsonContent); |
| writeFileSync(outputLockfilePath, lockfileContent); |
| const packageCount = Object.keys(installLock.packages).length - 1; |
| const platformPackageCount = Object.values(installLock.packages).filter((entry) => entry.os || entry.cpu || entry.libc) |
| .length; |
| console.log( |
| `Wrote packages/coding-agent/install-lock/package.json and package-lock.json (${packageCount} packages, ${platformPackageCount} platform-specific).`, |
| ); |
| } |
| } catch (error) { |
| console.error(error instanceof Error ? error.message : String(error)); |
| process.exit(1); |
| } |
|
|