#!/usr/bin/env tsx import { existsSync } from "node:fs"; import { type ExtensionPackage, type PackageJson, extensionsDir, findExtensionPackages, lockfilePath, readJson, rootPackagePath, writeJson, } from "./shared.ts"; type DependencyMap = Record; const dependencyFields = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"] as const; type DependencyField = (typeof dependencyFields)[number]; type FullPackageJson = PackageJson & { license?: string; dependencies?: DependencyMap; devDependencies?: DependencyMap; peerDependencies?: DependencyMap; optionalDependencies?: DependencyMap; tdder?: { generatedRootDependencies?: boolean; generatedRootDependenciesNote?: string; }; }; type Lockfile = { packages?: { ""?: { dependencies?: DependencyMap; devDependencies?: DependencyMap; peerDependencies?: DependencyMap; optionalDependencies?: DependencyMap; }; }; }; const GENERATED_NOTE = "Root dependency sections (dependencies, devDependencies, peerDependencies, optionalDependencies) are generated from extensions/*/package.json by npm run sync-root-deps. Do not edit those sections in the root package.json manually."; try { main(); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); } function main(): void { const rootPackage = readJson(rootPackagePath); assertGeneratedMetadata(rootPackage); const extensionPackages = findExtensionPackages(); assertLicenseConsistency(rootPackage, extensionPackages); const generatedByField = Object.fromEntries( dependencyFields.map((field) => [field, collectExtensionDependencies(extensionPackages, field)]), ) as Record; const packageJsonChanged = dependencyFields.some((field) => { const current = normalizeDependencies(rootPackage[field], `root package.json ${field}`); const generated = generatedByField[field]; return !sameDependencies(current, generated); }); if (packageJsonChanged) { for (const field of dependencyFields) { const generated = generatedByField[field]; if (Object.keys(generated).length === 0) { delete rootPackage[field]; } else { rootPackage[field] = generated; } } writeJson(rootPackagePath, rootPackage); console.error("Updated generated dependency sections in root package.json. Run npm install to refresh package-lock.json."); } if (!existsSync(lockfilePath)) { fail("Missing package-lock.json. Run npm install."); } const lockfile = readJson(lockfilePath); for (const field of dependencyFields) { const lockfileDependencies = normalizeDependencies(lockfile.packages?.[""]?.[field], `package-lock.json ${field}`); const generated = generatedByField[field]; if (!sameDependencies(lockfileDependencies, generated)) { fail(`package-lock.json is out of sync for ${field}. Run npm install.`); } } if (packageJsonChanged) { fail("Root package.json was updated; package-lock.json must be refreshed before continuing."); } console.log(`Root dependency sections are in sync across ${extensionPackages.length} extension package(s).`); } function collectExtensionDependencies( extensionPackages: ExtensionPackage[], fieldName: DependencyField, ): DependencyMap { const ownersByDependency = new Map(); const versionByDependency = new Map(); for (const extensionPackage of extensionPackages) { const dependencies = readDependencyBlock( extensionPackage.packageJson as FullPackageJson, extensionPackage.packagePath, fieldName, ); for (const [name, version] of Object.entries(dependencies)) { const owners = ownersByDependency.get(name) ?? []; const existingVersion = versionByDependency.get(name); if (existingVersion && existingVersion !== version) { const allOwners = [...owners, extensionPackage.dirName].sort().join(", "); fail( `${fieldName}: version mismatch for '${name}': '${existingVersion}' vs '${version}' across extensions ${allOwners}`, ); } owners.push(extensionPackage.dirName); ownersByDependency.set(name, owners); versionByDependency.set(name, version); } } return Object.fromEntries([...versionByDependency.entries()].sort(([a], [b]) => a.localeCompare(b))); } function assertGeneratedMetadata(rootPackage: FullPackageJson): void { if (rootPackage.tdder?.generatedRootDependencies !== true) { fail("package.json must declare tdder.generatedRootDependencies=true"); } if (rootPackage.tdder?.generatedRootDependenciesNote !== GENERATED_NOTE) { fail( "package.json tdder.generatedRootDependenciesNote does not match the expected generated-dependencies note", ); } } function assertLicenseConsistency(rootPackage: FullPackageJson, extensionPackages: ExtensionPackage[]): void { const rootLicense = normalizeLicense(rootPackage.license, rootPackagePath); for (const extensionPackage of extensionPackages) { const extensionLicense = normalizeLicense( (extensionPackage.packageJson as FullPackageJson).license, extensionPackage.packagePath, ); if (extensionLicense !== rootLicense) { fail( `${extensionPackage.packagePath}: license '${extensionLicense}' does not match root package license '${rootLicense}'`, ); } } } function normalizeLicense(license: unknown, sourcePath: string): string { if (typeof license !== "string" || license.trim() === "") { fail(`${sourcePath}: license must be a non-empty string`); } return license; } function readDependencyBlock( packageJson: FullPackageJson, packagePath: string, fieldName: DependencyField, ): DependencyMap { const block = packageJson[fieldName]; if (block === undefined) return {}; if (!isDependencyMap(block)) { fail(`${packagePath}: '${fieldName}' must be an object with non-empty string values when present`); } return Object.fromEntries(Object.entries(block).sort(([a], [b]) => a.localeCompare(b))); } function normalizeDependencies(block: unknown, source: string): DependencyMap { if (block === undefined) return {}; if (!isDependencyMap(block)) { fail(`${source} must be an object with non-empty string values when present`); } return Object.fromEntries(Object.entries(block).sort(([a], [b]) => a.localeCompare(b))); } function sameDependencies(left: DependencyMap, right: DependencyMap): boolean { const leftEntries = Object.entries(left); const rightEntries = Object.entries(right); if (leftEntries.length !== rightEntries.length) return false; return leftEntries.every(([name, version], index) => { const [otherName, otherVersion] = rightEntries[index] ?? []; return name === otherName && version === otherVersion; }); } function isDependencyMap(value: unknown): value is DependencyMap { if (!isPlainObject(value)) return false; return Object.entries(value).every( ([name, version]) => name.trim() !== "" && typeof version === "string" && version.trim() !== "", ); } function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function fail(message: string): never { throw new Error(message); }