import { run } from './auxilary.mjs' import { readFileSync, writeFileSync } from 'node:fs' import { join, resolve } from 'path' import { select, input } from '@inquirer/prompts' import { cwd } from 'node:process' import chalk from 'chalk' const invocationDir = resolve(process.env.INIT_CWD) const workspaceDir = resolve(cwd()) const action = await select({ message: 'What do you want to do?', choices: [ { name: 'List dependencies', value: 'list', description: 'List all direct dependencies', }, { name: 'Check dependencies', value: 'check', description: 'Check for version inconsistencies', }, { name: 'Fix dependencies', value: 'fix', description: 'Resolve inconsistencies for a dependency', }, { name: 'Add dependency', value: 'add', description: 'Add a new dependency', }, { name: 'Remove dependency', value: 'remove', description: 'Remove a dependency', }, { name: 'Change dependency type', value: 'change', description: 'Change the type of a dependency', }, ], }) const roots = findPackageRoots() const jsons = readPackageJsons(roots) const summary = summarizePackageJsons(jsons) const problems = identifyProblems(jsons, summary) if (action === 'list') { const good = sortMap(filterMap(summary, ([n]) => !problems.has(n))) const bad = sortMap(filterMap(summary, ([n]) => problems.has(n))) if (good.size) { console.log(chalk.green(`\n${good.size} good dependencies:`)) for (const [name, versions] of good) { const [version, users] = versions.entries().next().value const packages = [...users] .filter(([r, ts]) => !(r === workspaceDir && ts.length === 1 && ts[0] === 'overrides')) .map(([r]) => jsons.get(r).name) console.log(` ${chalk.green(name)}@${version}: ${packages.length}x (${packages.join(', ')})`) } } if (bad.size) { console.log(chalk.red(`\n${bad.size} bad dependencies:`)) printProblems(problems, ' ') } } else if (action === 'check') { if (problems.size) { printProblems(problems) process.exit(1) } } else if (action === 'fix') { for (const [name, ps] of problems) { console.log(`Fixing ${name}:`) for (const p of ps) { process.stdout.write(` ${p.title}: `) if (p.fix) { p.fix() console.log(chalk.green('Fixed')) } else { console.log(chalk.red('Skipped')) } } } } else if (action === 'add') { const name = (await input({ message: `Name of the package:` })).trim() let version //if (roots.map(r => jsons.get(r).name)) if ([...jsons].map(([r, j]) => j.name).includes(name)) { version = 'workspace:*' console.log('Using version workspace:* for local package') } else if (summary.get(name)?.size === 1) { version = [...summary.get(name)][0][0] console.log(`Reusing currently used version ${version}`) } else { version = (await input({ message: `Version of the package:` })).trim() } const type = await select({ message: 'Which type is the dependency?', choices: getDependencyTypeChoices(), }) const flag = invocationDir === workspaceDir ? '-w' : '' const identifier = version ? `${name}@${version}` : name run(`cd ${invocationDir} && pnpm add ${flag} ${type} ${identifier}`) const packageSummary = summarizePackageJson(readPackageJson(invocationDir)) const versions = packageSummary.get(name) if ((versions?.size ?? 2) > 1) { console.error( `Zero or more than 1 versions specified in ${join(invocationDir, 'package.json')} after pnpm add` ) process.exit(2) } const workspaceJson = readPackageJson(workspaceDir) updatePackageJsonValue(workspaceJson, `pnpm.overrides.${name}`, [...versions][0][0]) writePackageJson(workspaceDir, workspaceJson) run(`cd ${workspaceDir} && pnpm install --no-frozen-lockfile`) } else if (action === 'remove') { const name = await select({ message: 'Which dependency do you want to remove?', choices: getDependencyChoices(), }) const flag = invocationDir === workspaceDir ? '-w' : '' run(`cd ${invocationDir} && pnpm remove ${flag} ${name}`) const newRoots = findPackageRoots() const newJsons = readPackageJsons(newRoots) const newSummary = summarizePackageJsons(newJsons) const newProblems = identifyProblems(newJsons, newSummary).get(name) ?? [] const overrideRedundant = newProblems.find((p) => p.id === 'override-redundant') overrideRedundant?.fix() } else if (action === 'change') { const name = await select({ message: 'Which dependency do you want to change?', choices: getDependencyChoices(), }) const type = await select({ message: 'What should the new dependency type be?', choices: getDependencyTypeChoices(), }) const flag = invocationDir === workspaceDir ? '-w' : '' run(`cd ${invocationDir} && pnpm install ${flag} ${type} ${name}`) if (type !== '--save-peer') { const newJson = readPackageJson(invocationDir) updatePackageJsonValue(newJson, `peerDependencies.${name}`, undefined) writePackageJson(invocationDir, newJson) // no need to run pnpm install afterwards for peer dependencies } } function findPackageRoots() { const output = run('pnpm list --recursive --depth -1 --json') return JSON.parse(output).map((p) => resolve(p.path)) } function readPackageJson(root) { return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) } function readPackageJsons(roots) { return new Map(roots.map((r) => [r, readPackageJson(r)])) } function writePackageJson(root, json) { writeFileSync(join(root, 'package.json'), JSON.stringify(json, null, 2), 'utf8') } function updatePackageJsonValue(json, name, value) { const parents = name.split('.') const target = parents.pop() for (const parent of parents) { if (!(parent in json)) { if (value === undefined) return json[parent] = {} } json = json[parent] } if (value === undefined && json !== undefined) { delete json[target] } else if (value !== undefined) { json[target] = value } } function summarizePackageJson(json) { const summary = new Map() const getters = { dependencies: (p) => p.dependencies, devDependencies: (p) => p.devDependencies, peerDependencies: (p) => p.peerDependencies, overrides: (p) => p.pnpm?.overrides, } for (const [type, getter] of Object.entries(getters)) { for (const [name, version] of Object.entries(getter(json) ?? {})) { const outer = summary.get(name) ?? new Map() const inner = outer.get(version) ?? [] inner.push(type) outer.set(version, inner) summary.set(name, outer) } } return summary } function summarizePackageJsons(roots) { const summary = new Map() for (const [root, json] of roots) { for (const [name, versions] of summarizePackageJson(json)) { for (const [version, types] of versions) { const outer = summary.get(name) ?? new Map() const inner = outer.get(version) ?? new Map() inner.set(root, types) outer.set(version, inner) summary.set(name, outer) } } } return summary } // TODO: all versions should be exact, app should have parent peer deps as deps (how to find parents?) function identifyProblems(roots, summary) { const problems = new Map() function summarize(users) { return [...users].map(([r, ts]) => `${roots.get(r).name}: (${ts.join(', ')})`).join(', ') } for (const [name, versions] of summary.entries()) { if (versions.size > 1) { const list = problems.get(name) ?? [] list.push({ id: 'version-mismatch', name: chalk.red(name), title: 'Version mismatch detected', lines: [...versions].map(([v, us]) => `${chalk.red(v)}: ${summarize(us)}`), }) problems.set(name, list) } for (const [version, users] of versions.entries()) { const workspace = roots.get(workspaceDir).name const list = problems.get(name) ?? [] for (const [root, types] of users) { if (types.includes('peerDependencies') && !types.includes('devDependencies')) { list.push({ id: 'peer-missing-dev', name: chalk.red(name), title: 'Peer dependency missing development dependency', lines: [ `Entry ${name}@${version} required in devDependencies for ${roots.get(root).name}`, ], fix: () => { const flag = invocationDir === workspaceDir ? '-w' : '' const identifier = version ? `${name}@${version}` : name run(`cd ${invocationDir} && pnpm add ${flag} --save-peer ${identifier}`) }, }) } } const override = [...users].find(([u, ts]) => workspaceDir === u && ts.includes('overrides')) if (!override) { list.push({ id: 'override-missing', name: chalk.red(name), title: 'No override specified', lines: [`Entry ${name}@${version} required in pnpm.overrides for ${workspace}`], fix: () => { const json = roots.get(workspaceDir) updatePackageJsonValue(json, `pnpm.overrides.${name}`, version) writePackageJson(workspaceDir, json) run(`cd ${workspaceDir} && pnpm install --no-frozen-lockfile`) }, }) } else if (users.size === 1 && [...users][0][1].length === 1) { list.push({ id: 'override-redundant', name: chalk.red(name), title: 'Override specified without use', lines: [`Entry ${name}@${version} in pnpm.overrides for ${workspace} but never used`], fix: () => { const json = roots.get(workspaceDir) updatePackageJsonValue(json, `pnpm.overrides.${name}`, undefined) writePackageJson(workspaceDir, json) run(`cd ${workspaceDir} && pnpm install --no-frozen-lockfile`) }, }) } if (list.length) { problems.set(name, list) } } } return problems } function printProblems(problems, indent = '') { for (const [name, versions] of sortMap(problems)) { console.log(`${indent}${chalk.red(name)}:`) for (const problem of problems.get(name)) { const lines = [`${indent} ${chalk.red(problem.title)}`, ...problem.lines] console.log(lines.join(`\n${indent} `)) } } } function filterMap(map, predicate) { return new Map([...map].filter(predicate)) } function sortMap(map) { return new Map([...map].sort(([a], [b]) => a.localeCompare(b))) } function getDependencyChoices() { const json = readPackageJson(invocationDir) const summary = summarizePackageJson(json) const choices = [...summary] .filter(([n, vs]) => { const types = new Set([...vs].flatMap(([v, ts]) => ts)) return types.size > 1 || types.values().next().value !== 'overrides' }) .map(([n, vs]) => ({ name: n, value: n, description: `The ${n} package` })) choices.sort((a, b) => a.name.localeCompare(b.name)) return choices } function getDependencyTypeChoices() { return [ { name: 'Production', value: '--save-prod', description: 'Required in production', }, { name: 'Development', value: '--save-dev', description: 'For development only', }, { name: 'Peer', value: '--save-peer', description: 'A peer dependency', }, ] }