UNPKG

@jakehamilton/titan

Version:

A little tool for big (monorepo) projects.

605 lines (499 loc) 16.1 kB
const { execSync } = require("child_process"); const semver = require("semver"); const fs = require("./fs"); const log = require("./log"); const path = require("./path"); let PROJECT_ROOT_CONFIG = null; let PROJECT_ROOT = null; const getProjectRoot = () => { if (PROJECT_ROOT === null) { const parts = process.cwd().split(path.sep); for (let i = parts.length; i > 0; i--) { const currentPath = parts.slice(0, i).join(path.sep); if (currentPath === "") { continue; } const files = fs.readDir(currentPath); if (files.find((file) => file === "package.json")) { let pkg; try { pkg = JSON.parse( fs.read(path.resolve(currentPath, "package.json"), { encoding: "utf8", }) ); } catch (error) { log.debug( `Could not import file "${path.resolve( currentPath, "package.json" )}".` ); } if (pkg && "titan" in pkg) { PROJECT_ROOT = currentPath; PROJECT_ROOT_CONFIG = pkg; return PROJECT_ROOT; } } } log.error("Unable to find project root. Are you in a Titan project?"); process.exit(1); } else { return PROJECT_ROOT; } }; const getProjectRootConfig = () => { if (PROJECT_ROOT_CONFIG === null) { getProjectRoot(); } return PROJECT_ROOT_CONFIG; }; let ALL_PACKAGES_CACHE = null; const getAllPackages = (cache = false) => { if (cache && ALL_PACKAGES_CACHE !== null) { return ALL_PACKAGES_CACHE; } const root = getProjectRoot(); const config = JSON.parse( fs.read(path.resolve(root, "package.json"), { encoding: "utf8", }) ); const pkgs = new Map(); for (const location of config.titan.packages) { const pkgsDir = path.resolveRelative(location, root); if (fs.exists(pkgsDir)) { log.debug(`Loading packages in "${pkgsDir}".`); for (const pkgDir of fs.readDir(pkgsDir)) { const fullPath = path.resolve(pkgsDir, pkgDir); if (fs.isDir(fullPath)) { const pkg = JSON.parse( fs.read(path.resolve(fullPath, "package.json"), { encoding: "utf8", }) ); log.trace(`Loaded config for package "${pkg.name}".`); pkgs.set(pkg.name, { path: fullPath, config: pkg, }); } } } else { log.error(`Packages directory "${pkgsDir}" does not exist.`); } } ALL_PACKAGES_CACHE = pkgs; return pkgs; }; const withLinkedLocals = async (pkgs, fn) => { for (const pkg of pkgs.values()) { const dependencies = patchDependenciesWithLocals(pkg, pkgs, { ...(pkg.config.dependencies || {}), }); const devDependencies = patchDependenciesWithLocals(pkg, pkgs, { ...(pkg.config.devDependencies || {}), }); const optionalDependencies = patchDependenciesWithLocals(pkg, pkgs, { ...(pkg.config.optionalDependencies || {}), }); const peerDependencies = patchDependenciesWithLocals(pkg, pkgs, { ...(pkg.config.peerDependencies || {}), }); const newPkg = { ...pkg, config: { ...pkg.config, dependencies, devDependencies, optionalDependencies, peerDependencies, }, }; writePackageInfo(newPkg); } try { await fn(); } catch (error) { throw error; } finally { for (const pkg of pkgs.values()) { writePackageInfo(pkg); } } }; const ensureLocals = (pkg, locals) => { for (const local of locals.values()) { const dir = path.resolve(pkg.path, "node_modules", local.config.name); // @NOTE(jakehamilton): This fixes an issue with symlinks on macOS. // More analysis can be done to understand exactly why macOS // thinks the directory both does and does not exist at the same time. fs.rm(dir); if (!fs.exists(dir)) { fs.mkdir(dir); } } }; const getLocalDependencies = ( pkg, pkgs, recursive = false, ensure = true, known = new Map() ) => { const locals = new Map(); const allDependencies = { ...(pkg.config.dependencies || {}), ...(pkg.config.devDependencies || {}), ...(pkg.config.optionalDependencies || {}), ...(pkg.config.peerDependencies || {}), }; for (const [name, version] of Object.entries(allDependencies)) { if ( pkgs.has(name) && semver.satisfies(pkgs.get(name).config.version, version) ) { locals.set(name, pkgs.get(name)); } } if (ensure) { ensureLocals(pkg, locals); } if (recursive) { const currentLocals = [...locals.values()]; for (const local of currentLocals) { if (known.has(local.config.name)) { continue; } const transitiveLocals = getLocalDependencies( local, pkgs, true, true, locals ); for (const [name, local] of transitiveLocals.entries()) { locals.set(name, local); } } } return locals; }; const recurseDetectCycles = (pkg, pkgs, known = new Set([pkg])) => { const locals = getLocalDependencies(pkg, pkgs); for (const local of locals.values()) { if (known.has(local)) { return [...known, local].map((pkg) => pkg.config.name); } else { const result = recurseDetectCycles( local, pkgs, new Set([...known, local]) ); if (result.length > 0) { return result; } } } return []; }; const detectCycles = (pkgs) => { const cycles = []; for (const pkg of pkgs.values()) { const result = recurseDetectCycles(pkg, pkgs); if (result.length > 0) { cycles.push(result); } } return cycles; }; const writePackageInfo = (pkg) => { if (fs.exists(pkg.path)) { fs.write( path.resolve(pkg.path, "package.json"), JSON.stringify(pkg.config, null, 4) + "\n" ); } else { log.fatal( `Could not write package "${pkg.config.name}" to "${path.resolve( pkg.path, "package.json" )}".` ); process.exit(1); } }; const install = (root = getProjectRoot(), args = []) => { execSync(`npm install ${args.join(" ")}`, { cwd: root, stdio: "pipe", }); }; const parseNameWithVersion = (name) => { const match = /(?<name>@?[^@]+)(?:@(?<version>.+))?/.exec(name); if (match) { return { name: match.groups.name, version: match.groups.version || "latest", }; } else { throw new Error(`Unable to parse package name with version "${name}".`); } }; const dedupe = (tags) => { const pkgs = new Map(); for (const tag of tags) { const { name, version } = parseNameWithVersion(tag); if (!pkgs.has(name)) { pkgs.set(name, version); } else { const otherVersion = pkgs.get(name); if (semver.gt(version, otherVersion)) { pkgs.set(name, version); } } } return [...pkgs.entries()].map(([name, version]) => `${name}@${version}`); }; const publish = (pkg) => { log.info( `Publishing package "${pkg.config.name}" with version "${pkg.config.version}".` ); execSync("npm publish", { cwd: pkg.path, encoding: "utf8", stdio: "inherit", }); }; const ALIAS_PROTOCOL = "npm:"; const patchDependenciesWithLocals = (pkg, pkgsMap, dependencies) => { for (const [name, version] of Object.entries(dependencies)) { if (version.startsWith(ALIAS_PROTOCOL)) { const alias = parseNameWithVersion( version.slice(ALIAS_PROTOCOL.length) ); if (pkgsMap.has(alias.name)) { const local = pkgsMap.get(alias.name); if (semver.satisfies(local.config.version, alias.version)) { dependencies[name] = `file:${path.relative( pkg.path, local.path )}`; } } } else if (pkgsMap.has(name)) { const local = pkgsMap.get(name); if (semver.satisfies(local.config.version, version)) { dependencies[name] = `file:${path.relative( pkg.path, local.path )}`; } } } return dependencies; }; const pkgsToDependencyMap = (pkgs, known = new Map()) => { for (const pkg of pkgs) { if (known.has(pkg.config.name)) { continue; } const locals = [ ...getLocalDependencies( pkg, getAllPackages(true), false, false ).values(), ]; const localKnown = new Map(); if (locals.length > 0) { pkgsToDependencyMap(locals, localKnown); } for (const [key, value] of localKnown) { known.set(key, value); } known.set(pkg.config.name, { pkg, dependencies: localKnown, useCache: false, }); } return known; }; const traverseOrdered = async (pkgs, cb, known = new Map()) => { for (const pkg of pkgs) { if (known.has(pkg.config.name)) { continue; } const locals = [ ...getLocalDependencies(pkg, getAllPackages(true)).values(), ]; if (locals.length > 0) { await traverseOrdered(locals, cb, known); } await cb(pkg); known.set(pkg.config.name, pkg); } }; const upgradeLocalDependents = (pkgs, upgrade) => { const { name } = upgrade.pkg.config; for (const pkg of pkgs.values()) { if (pkg.config.dependencies && pkg.config.dependencies[name]) { pkg.config.dependencies[name] = (pkg.config.dependencies[name].startsWith("^") ? "^" : "") + upgrade.newVersion; } if (pkg.config.devDependencies && pkg.config.devDependencies[name]) { pkg.config.devDependencies[name] = (pkg.config.devDependencies[name].startsWith("^") ? "^" : "") + upgrade.newVersion; } if ( pkg.config.optionalDependencies && pkg.config.optionalDependencies[name] ) { pkg.config.optionalDependencies[name] = (pkg.config.optionalDependencies[name].startsWith("^") ? "^" : "") + upgrade.newVersion; } if (pkg.config.peerDependencies && pkg.config.peerDependencies[name]) { pkg.config.peerDependencies[name] = (pkg.config.peerDependencies[name].startsWith("^") ? "^" : "") + upgrade.newVersion; } } }; const getAllDependencies = (pkg) => { const deps = {}; const { config } = pkg; if (config.dependencies) { Object.assign(deps, config.dependencies); } if (config.optionalDependencies) { Object.assign(deps, config.optionalDependencies); } if (config.peerDependencies) { Object.assign(deps, config.peerDependencies); } if (config.devDependencies) { Object.assign(deps, config.devDependencies); } return deps; }; const getDownstreamPackages = (upstreamPkgs, downstreamPkgs = new Map()) => { const all = getAllPackages(true); for (const pkg of all.values()) { for (const upstreamPkg of upstreamPkgs) { const deps = getAllDependencies(pkg); if ( !downstreamPkgs.has(pkg.config.name) && deps[upstreamPkg.config.name] ) { downstreamPkgs.set(pkg.config.name, pkg); getDownstreamPackages([pkg], downstreamPkgs); } } } return downstreamPkgs; }; const pkgsArrayToMap = (pkgs) => { const map = new Map(); for (const pkg of pkgs) { map.set(pkg.config.name, pkg); } return map; }; const upgradeDownstreamPackages = (pkgs) => { const upstreamPkgs = pkgsArrayToMap(pkgs); const downstreamPkgs = getDownstreamPackages(pkgs); for (const pkg of downstreamPkgs.values()) { const { config } = pkg; const isUpstream = upstreamPkgs.has(config.name); if (isUpstream) { log.trace( `Package "${config.name}" is both upstream and downstream of changes.` ); } else { const newVersion = semver.inc(config.version, "patch"); log.debug( `Setting new version "${newVersion}" for downstream package "${config.name}".` ); config.version = newVersion; } } const getPackage = (name) => { if (upstreamPkgs.has(name)) { return upstreamPkgs.get(name); } else if (downstreamPkgs.has(name)) { return downstreamPkgs.get(name); } }; for (const pkg of downstreamPkgs.values()) { const { config } = pkg; if (config.dependencies) { for (const [name, version] of Object.entries(config.dependencies)) { const upstreamPkg = getPackage(name); if (upstreamPkg) { config.dependencies[name] = upstreamPkg.config.version; } } } if (config.peerDependencies) { for (const [name, version] of Object.entries( config.peerDependencies )) { const upstreamPkg = getPackage(name); if (upstreamPkg) { config.peerDependencies[name] = upstreamPkg.config.version; } } } if (config.optionalDependencies) { for (const [name, version] of Object.entries( config.optionalDependencies )) { const upstreamPkg = getPackage(name); if (upstreamPkg) { config.optionalDependencies[name] = upstreamPkg.config.version; } } } if (config.devDependencies) { for (const [name, version] of Object.entries( config.devDependencies )) { const upstreamPkg = getPackage(name); if (upstreamPkg) { config.devDependencies[name] = upstreamPkg.config.version; } } } } return { upstream: upstreamPkgs, downstream: downstreamPkgs, }; }; module.exports = { getProjectRoot, getProjectRootConfig, getAllPackages, getLocalDependencies, pkgsToDependencyMap, withLinkedLocals, detectCycles, writePackageInfo, parseNameWithVersion, dedupe, install, publish, traverseOrdered, upgradeLocalDependents, getAllDependencies, getDownstreamPackages, upgradeDownstreamPackages, };