From 0d51d4c104489b761074d1e7a37104b2d418793e Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Tue, 17 Mar 2026 20:40:20 +0000 Subject: [PATCH] feat: add update-deps command for cross-repo dependency sync New command: makage update-deps --from --in Dynamically discovers all packages in the source pnpm workspace, then cross-references them against the target repo's package.json files to find: - matched packages (source workspace packages used in target) - outdated packages (version drift detected) Outputs structured JSON to stdout with: - sourcePackages: all workspace packages (name, version, path) - matchedPackages: deps found in target (name, currentVersion, availableVersion, depType, consumer, outdated) - outdatedPackages: subset with version drift - has_dep_changes: boolean signal This replaces grep-based hacks in CI with a deterministic, version-aware dependency check. Works locally and in CI. --- packages/makage/__tests__/updateDeps.test.ts | 190 ++++++++++++++++ packages/makage/src/cli.ts | 5 + packages/makage/src/commands/updateDeps.ts | 218 +++++++++++++++++++ packages/makage/src/index.ts | 1 + 4 files changed, 414 insertions(+) create mode 100644 packages/makage/__tests__/updateDeps.test.ts create mode 100644 packages/makage/src/commands/updateDeps.ts diff --git a/packages/makage/__tests__/updateDeps.test.ts b/packages/makage/__tests__/updateDeps.test.ts new file mode 100644 index 0000000..a85cab0 --- /dev/null +++ b/packages/makage/__tests__/updateDeps.test.ts @@ -0,0 +1,190 @@ +import fs from 'node:fs/promises'; +import { glob } from 'glob'; +import { runUpdateDeps } from '../src/commands/updateDeps'; + +jest.mock('node:fs/promises'); +jest.mock('glob'); + +const mockedFs = fs as jest.Mocked; +const mockedGlob = glob as jest.MockedFunction; + +// Helpers to build package.json strings +function makePkg(name: string, version: string, deps?: Record, devDeps?: Record) { + const pkg: Record = { name, version }; + if (deps) pkg.dependencies = deps; + if (devDeps) pkg.devDependencies = devDeps; + return JSON.stringify(pkg); +} + +const WORKSPACE_YAML = `packages:\n - 'packages/*'\n - 'graphile/*'\n`; + +describe('runUpdateDeps', () => { + let consoleLogSpy: jest.SpyInstance; + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + }); + + afterEach(() => { + consoleLogSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + it('should throw if --from is missing', async () => { + await expect(runUpdateDeps(['--in', '/target'])).rejects.toThrow('Missing required argument: --from'); + }); + + it('should throw if --in is missing', async () => { + await expect(runUpdateDeps(['--from', '/source'])).rejects.toThrow('Missing required argument: --in'); + }); + + it('should discover source packages and match against target', async () => { + // Source workspace + mockedFs.readFile.mockImplementation(async (filePath: any) => { + const p = filePath.toString(); + if (p.endsWith('pnpm-workspace.yaml') && p.includes('source')) { + return WORKSPACE_YAML; + } + if (p.endsWith('pnpm-workspace.yaml') && p.includes('target')) { + return `packages:\n - 'application/*'\n`; + } + // Source packages + if (p.includes('source') && p.includes('packages/foo/package.json')) { + return makePkg('@scope/foo', '2.0.0'); + } + if (p.includes('source') && p.includes('graphile/bar/package.json')) { + return makePkg('graphile-bar', '1.5.0'); + } + // Target packages + if (p.includes('target') && p.includes('package.json') && p.includes('application/myapp')) { + return makePkg('myapp', '1.0.0', { + '@scope/foo': '^1.0.0', + 'graphile-bar': '^1.5.0', + 'unrelated-pkg': '^3.0.0' + }); + } + if (p.includes('target') && p.endsWith('package.json') && !p.includes('application')) { + return makePkg('target-root', '1.0.0', { + 'graphile-bar': '^1.3.0' + }); + } + throw new Error(`ENOENT: ${p}`); + }); + + mockedGlob.mockImplementation(async (patterns: any, opts: any) => { + const cwd = opts?.cwd || ''; + if (cwd.includes('source')) { + return ['packages/foo/package.json', 'graphile/bar/package.json']; + } + if (cwd.includes('target')) { + return ['application/myapp/package.json']; + } + return []; + }); + + const result = await runUpdateDeps(['--from', '/source', '--in', '/target']); + + // Should find 2 source packages + expect(result.sourcePackages).toHaveLength(2); + expect(result.sourcePackages.map(p => p.name).sort()).toEqual(['@scope/foo', 'graphile-bar']); + + // Should match 3 deps (foo in myapp, bar in myapp, bar in root) + expect(result.matchedPackages).toHaveLength(3); + const matchedNames = result.matchedPackages.map(p => p.name); + expect(matchedNames).toContain('@scope/foo'); + expect(matchedNames).toContain('graphile-bar'); + + // @scope/foo ^1.0.0 -> 2.0.0 is outdated + const fooMatch = result.matchedPackages.find(p => p.name === '@scope/foo'); + expect(fooMatch?.outdated).toBe(true); + expect(fooMatch?.currentVersion).toBe('^1.0.0'); + expect(fooMatch?.availableVersion).toBe('2.0.0'); + + // graphile-bar ^1.5.0 -> 1.5.0 is NOT outdated (same version) + const barMatchApp = result.matchedPackages.find(p => p.name === 'graphile-bar' && p.consumer === 'myapp'); + expect(barMatchApp?.outdated).toBe(false); + + // graphile-bar ^1.3.0 -> 1.5.0 IS outdated + const barMatchRoot = result.matchedPackages.find(p => p.name === 'graphile-bar' && p.consumer === 'target-root'); + expect(barMatchRoot?.outdated).toBe(true); + + // Overall: has changes + expect(result.has_dep_changes).toBe(true); + expect(result.outdatedPackages).toHaveLength(2); + + // JSON output was written to stdout + expect(consoleLogSpy).toHaveBeenCalled(); + const output = JSON.parse(consoleLogSpy.mock.calls[0][0]); + expect(output.has_dep_changes).toBe(true); + }); + + it('should report no changes when all deps are up to date', async () => { + mockedFs.readFile.mockImplementation(async (filePath: any) => { + const p = filePath.toString(); + if (p.endsWith('pnpm-workspace.yaml') && p.includes('source')) { + return WORKSPACE_YAML; + } + if (p.endsWith('pnpm-workspace.yaml') && p.includes('target')) { + throw new Error('ENOENT'); + } + if (p.includes('source') && p.includes('packages/foo/package.json')) { + return makePkg('@scope/foo', '2.0.0'); + } + // Target root package.json + if (p.includes('target') && p.endsWith('package.json')) { + return makePkg('target', '1.0.0', { '@scope/foo': '^2.0.0' }); + } + throw new Error(`ENOENT: ${p}`); + }); + + mockedGlob.mockImplementation(async (patterns: any, opts: any) => { + const cwd = opts?.cwd || ''; + if (cwd.includes('source')) { + return ['packages/foo/package.json']; + } + return []; + }); + + const result = await runUpdateDeps(['--from', '/source', '--in', '/target']); + + expect(result.matchedPackages).toHaveLength(1); + expect(result.outdatedPackages).toHaveLength(0); + expect(result.has_dep_changes).toBe(false); + }); + + it('should handle workspace: protocol as not outdated', async () => { + mockedFs.readFile.mockImplementation(async (filePath: any) => { + const p = filePath.toString(); + if (p.endsWith('pnpm-workspace.yaml') && p.includes('source')) { + return WORKSPACE_YAML; + } + if (p.endsWith('pnpm-workspace.yaml') && p.includes('target')) { + throw new Error('ENOENT'); + } + if (p.includes('source') && p.includes('packages/foo/package.json')) { + return makePkg('@scope/foo', '5.0.0'); + } + if (p.includes('target') && p.endsWith('package.json')) { + return makePkg('target', '1.0.0', { '@scope/foo': 'workspace:*' }); + } + throw new Error(`ENOENT: ${p}`); + }); + + mockedGlob.mockImplementation(async (patterns: any, opts: any) => { + const cwd = opts?.cwd || ''; + if (cwd.includes('source')) { + return ['packages/foo/package.json']; + } + return []; + }); + + const result = await runUpdateDeps(['--from', '/source', '--in', '/target']); + + expect(result.matchedPackages).toHaveLength(1); + expect(result.matchedPackages[0].outdated).toBe(false); + expect(result.has_dep_changes).toBe(false); + }); +}); diff --git a/packages/makage/src/cli.ts b/packages/makage/src/cli.ts index 5911fbb..a766262 100644 --- a/packages/makage/src/cli.ts +++ b/packages/makage/src/cli.ts @@ -6,6 +6,7 @@ import { runAssets } from './commands/assets'; import { runBuild } from './commands/build'; import { runBuildTs } from './commands/buildTs'; import { runUpdateWorkspace } from './commands/updateWorkspace'; +import { runUpdateDeps } from './commands/updateDeps'; const [, , cmd, ...rest] = process.argv; @@ -33,6 +34,9 @@ async function main() { case 'update-workspace': await runUpdateWorkspace(rest); break; + case 'update-deps': + await runUpdateDeps(rest); + break; case '-h': case '--help': default: @@ -57,6 +61,7 @@ Usage: makage assets makage build-ts [--dev] makage update-workspace + makage update-deps --from --in `); } diff --git a/packages/makage/src/commands/updateDeps.ts b/packages/makage/src/commands/updateDeps.ts new file mode 100644 index 0000000..f4325bb --- /dev/null +++ b/packages/makage/src/commands/updateDeps.ts @@ -0,0 +1,218 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { glob } from 'glob'; +import { parse as parseYaml } from 'yaml'; + +const DEPENDENCY_TYPES = [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'optionalDependencies' +] as const; + +interface PnpmWorkspace { + packages?: string[]; +} + +interface WorkspacePackage { + name: string; + version: string; + path: string; +} + +interface MatchedDep { + name: string; + currentVersion: string; + availableVersion: string; + depType: string; + consumer: string; + outdated: boolean; +} + +interface UpdateDepsResult { + sourcePackages: WorkspacePackage[]; + matchedPackages: MatchedDep[]; + outdatedPackages: MatchedDep[]; + has_dep_changes: boolean; +} + +function parseArgs(args: string[]): { from: string; in: string } { + let from = ''; + let targetIn = ''; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--from' && args[i + 1]) { + from = args[++i]; + } else if (args[i] === '--in' && args[i + 1]) { + targetIn = args[++i]; + } + } + + if (!from) { + throw new Error('Missing required argument: --from '); + } + if (!targetIn) { + throw new Error('Missing required argument: --in '); + } + + return { from, in: targetIn }; +} + +async function getWorkspacePackages(workspaceRoot: string): Promise { + const workspaceFile = path.join(workspaceRoot, 'pnpm-workspace.yaml'); + + let workspaceConfig: PnpmWorkspace; + try { + const content = await fs.readFile(workspaceFile, 'utf-8'); + workspaceConfig = parseYaml(content) as PnpmWorkspace; + } catch { + throw new Error(`No "pnpm-workspace.yaml" found in ${workspaceRoot}`); + } + + const patterns = workspaceConfig.packages; + if (!patterns || patterns.length === 0) { + throw new Error('No package patterns found in pnpm-workspace.yaml'); + } + + const packageJsonPatterns = patterns.map(p => { + const normalized = p.replace(/\/?\*\*?$/, ''); + return `${normalized}/*/package.json`; + }); + + const packageFiles = await glob(packageJsonPatterns, { + cwd: workspaceRoot, + absolute: false, + ignore: ['**/node_modules/**'] + }); + + const packages: WorkspacePackage[] = []; + for (const file of packageFiles) { + const pkgPath = path.join(workspaceRoot, file); + const content = await fs.readFile(pkgPath, 'utf-8'); + const pkg = JSON.parse(content); + if (pkg.name) { + packages.push({ + name: pkg.name, + version: pkg.version || '0.0.0', + path: path.dirname(file) + }); + } + } + + return packages.sort((a, b) => a.name.localeCompare(b.name)); +} + +function stripVersionPrefix(version: string): string { + return version.replace(/^[\^~>=<]*/, ''); +} + +function isOutdated(currentSpec: string, availableVersion: string): boolean { + // workspace: protocol means it's managed by pnpm workspace — always in sync + if (currentSpec.startsWith('workspace:')) return false; + + const current = stripVersionPrefix(currentSpec); + if (!current || current === '*') return false; + + // Simple semver comparison: split and compare parts + const currentParts = current.split('.').map(Number); + const availableParts = availableVersion.split('.').map(Number); + + for (let i = 0; i < 3; i++) { + const c = currentParts[i] || 0; + const a = availableParts[i] || 0; + if (a > c) return true; + if (a < c) return false; + } + + return false; +} + +async function getTargetPackageFiles(targetRoot: string): Promise { + // Check if target has pnpm-workspace.yaml (monorepo) + const workspaceFile = path.join(targetRoot, 'pnpm-workspace.yaml'); + try { + const content = await fs.readFile(workspaceFile, 'utf-8'); + const config = parseYaml(content) as PnpmWorkspace; + const patterns = config.packages; + if (patterns && patterns.length > 0) { + const packageJsonPatterns = patterns.map(p => { + const normalized = p.replace(/\/?\*\*?$/, ''); + return `${normalized}/*/package.json`; + }); + // Also include the root package.json + const files = await glob(packageJsonPatterns, { + cwd: targetRoot, + absolute: false, + ignore: ['**/node_modules/**'] + }); + return ['package.json', ...files]; + } + } catch { + // Not a monorepo — fall through + } + + return ['package.json']; +} + +export async function runUpdateDeps(args: string[]): Promise { + const opts = parseArgs(args); + const fromRoot = path.resolve(opts.from); + const targetRoot = path.resolve(opts.in); + + // Step 1: Get all packages from source workspace + const sourcePackages = await getWorkspacePackages(fromRoot); + const sourceMap = new Map(sourcePackages.map(p => [p.name, p])); + + console.error(`[makage] Found ${sourcePackages.length} packages in source workspace`); + + // Step 2: Scan target repo's package.json files + const targetFiles = await getTargetPackageFiles(targetRoot); + const matchedPackages: MatchedDep[] = []; + + for (const file of targetFiles) { + const pkgPath = path.join(targetRoot, file); + let content: string; + try { + content = await fs.readFile(pkgPath, 'utf-8'); + } catch { + continue; + } + const pkg = JSON.parse(content); + const consumer = pkg.name || file; + + for (const depType of DEPENDENCY_TYPES) { + if (!pkg[depType]) continue; + for (const [depName, depVersion] of Object.entries(pkg[depType])) { + const source = sourceMap.get(depName); + if (!source) continue; + + const currentVersion = depVersion as string; + const outdated = isOutdated(currentVersion, source.version); + matchedPackages.push({ + name: depName, + currentVersion, + availableVersion: source.version, + depType, + consumer, + outdated + }); + } + } + } + + const outdatedPackages = matchedPackages.filter(p => p.outdated); + + const result: UpdateDepsResult = { + sourcePackages, + matchedPackages, + outdatedPackages, + has_dep_changes: outdatedPackages.length > 0 + }; + + // Output structured JSON to stdout (logs go to stderr) + console.log(JSON.stringify(result, null, 2)); + + console.error(`[makage] ${matchedPackages.length} matched, ${outdatedPackages.length} outdated`); + + return result; +} diff --git a/packages/makage/src/index.ts b/packages/makage/src/index.ts index 0a997c9..ed6fcd0 100644 --- a/packages/makage/src/index.ts +++ b/packages/makage/src/index.ts @@ -5,4 +5,5 @@ export { runAssets } from './commands/assets'; export { runBuild } from './commands/build'; export { runBuildTs } from './commands/buildTs'; export { runUpdateWorkspace } from './commands/updateWorkspace'; +export { runUpdateDeps } from './commands/updateDeps'; export { findWorkspaceRoot, findRootFile } from './commands/workspace';