diff --git a/depsynky/package.json b/depsynky/package.json index b3d91c9dc..918f6c15c 100644 --- a/depsynky/package.json +++ b/depsynky/package.json @@ -1,6 +1,6 @@ { "name": "@bpinternal/depsynky", - "version": "0.3.0", + "version": "0.3.1", "description": "CLI to synchronize dependencies across a pnpm mono-repo", "main": "dist/index.js", "repository": { @@ -29,10 +29,12 @@ }, "devDependencies": { "@types/node": "^22.16.4", + "@types/picomatch": "^4.0.2", "@types/prettier": "^2.7.3", "@types/prompts": "^2.0.14", "@types/semver": "^7.3.11", "esbuild": "^0.25.0", + "picomatch": "^4.0.4", "ts-node": "^10.9.1", "typescript": "^4.9.4", "vitest": "^3.0.7" diff --git a/depsynky/pnpm-lock.yaml b/depsynky/pnpm-lock.yaml index cd7dc4757..11dfbc914 100644 --- a/depsynky/pnpm-lock.yaml +++ b/depsynky/pnpm-lock.yaml @@ -31,6 +31,9 @@ devDependencies: '@types/node': specifier: ^22.16.4 version: 22.16.4 + '@types/picomatch': + specifier: ^4.0.2 + version: 4.0.2 '@types/prettier': specifier: ^2.7.3 version: 2.7.3 @@ -43,6 +46,9 @@ devDependencies: esbuild: specifier: ^0.25.0 version: 0.25.2 + picomatch: + specifier: ^4.0.4 + version: 4.0.4 ts-node: specifier: ^10.9.1 version: 10.9.2(@types/node@22.16.4)(typescript@4.9.4) @@ -501,6 +507,10 @@ packages: undici-types: 6.21.0 dev: true + /@types/picomatch@4.0.2: + resolution: {integrity: sha512-qHHxQ+P9PysNEGbALT8f8YOSHW0KJu6l2xU8DYY0fu/EmGxXdVnuTLvFUvBgPJMSqXq29SYHveejeAha+4AYgA==} + dev: true + /@types/prettier@2.7.3: resolution: {integrity: sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==} dev: true @@ -883,6 +893,11 @@ packages: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} dev: true + /picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + dev: true + /postcss@8.5.3: resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==} engines: {node: ^10 || ^12 || >=14} diff --git a/depsynky/src/__tests__/bump.test.ts b/depsynky/src/__tests__/bump.test.ts new file mode 100644 index 000000000..1ccb9e6e4 --- /dev/null +++ b/depsynky/src/__tests__/bump.test.ts @@ -0,0 +1,163 @@ +import { test, expect, describe } from 'vitest' +import { buildApp } from './utils/test-setup' +import { DepSynkyError } from '../errors' + +describe('bumpVersion', () => { + test('bumps a package version with patch', async () => { + const { app, pkg } = buildApp( + { + packages: [ + { name: 'pkg-a', version: '1.0.0' }, + { name: 'pkg-b', version: '1.0.0' } + ] + }, + async () => 'patch' + ) + + await app.bumpVersion({ pkgName: 'pkg-a', sync: false }) + + const pkgA = await pkg.read('pkg-a') + expect(pkgA.version).toBe('1.0.1') + }) + + test('bumps a package version with minor', async () => { + const { app, pkg } = buildApp( + { + packages: [{ name: 'pkg-a', version: '1.0.0' }] + }, + async () => 'minor' + ) + + await app.bumpVersion({ pkgName: 'pkg-a', sync: false }) + + const pkgA = await pkg.read('pkg-a') + expect(pkgA.version).toBe('1.1.0') + }) + + test('bumps a package version with major', async () => { + const { app, pkg } = buildApp( + { + packages: [{ name: 'pkg-a', version: '1.0.0' }] + }, + async () => 'major' + ) + + await app.bumpVersion({ pkgName: 'pkg-a', sync: false }) + + const pkgA = await pkg.read('pkg-a') + expect(pkgA.version).toBe('2.0.0') + }) + + test('skips bump when "none" is selected', async () => { + const { app, pkg } = buildApp( + { + packages: [{ name: 'pkg-a', version: '1.0.0' }] + }, + async () => 'none' + ) + + await app.bumpVersion({ pkgName: 'pkg-a', sync: false }) + + const pkgA = await pkg.read('pkg-a') + expect(pkgA.version).toBe('1.0.0') + }) + + test('skips private packages during bump', async () => { + const { app, pkg } = buildApp( + { + packages: [ + { name: 'pkg-a', version: '1.0.0' }, + { name: 'pkg-b', version: '1.0.0', private: true, dependencies: { 'pkg-a': '^1.0.0' } } + ] + }, + async () => 'patch' // only one prompt because pkg-b is private and skipped + ) + + await app.bumpVersion({ pkgName: 'pkg-a', sync: false }) + + const pkgA = await pkg.read('pkg-a') + expect(pkgA.version).toBe('1.0.1') + + const pkgB = await pkg.read('pkg-b') + expect(pkgB.version).toBe('1.0.0') // not bumped + }) + + test('bumps dependents recursively', async () => { + const { app, pkg } = buildApp( + { + packages: [ + { name: 'pkg-a', version: '1.0.0' }, + { name: 'pkg-b', version: '1.0.0', dependencies: { 'pkg-a': '^1.0.0' } }, + { name: 'pkg-c', version: '1.0.0', dependencies: { 'pkg-b': '^1.0.0' } } + ] + }, + async ({ pkgName }) => { + if (pkgName === 'pkg-a') return 'patch' + if (pkgName === 'pkg-b') return 'minor' + if (pkgName === 'pkg-c') return 'patch' + return 'none' + } + ) + + await app.bumpVersion({ pkgName: 'pkg-a', sync: false }) + + const pkgA = await pkg.read('pkg-a') + expect(pkgA.version).toBe('1.0.1') + + const pkgB = await pkg.read('pkg-b') + expect(pkgB.version).toBe('1.1.0') + + const pkgC = await pkg.read('pkg-c') + expect(pkgC.version).toBe('1.0.1') + }) + + test('calls syncVersions when sync is true', async () => { + const { app, pkg } = buildApp( + { + packages: [ + { name: 'pkg-a', version: '1.0.0' }, + { name: 'pkg-b', version: '1.0.0', dependencies: { 'pkg-a': '^1.0.0' } } + ] + }, + async ({ pkgName }) => (pkgName === 'pkg-a' ? 'patch' : 'none') + ) + + await app.bumpVersion({ pkgName: 'pkg-a', sync: true }) + + const pkgA = await pkg.read('pkg-a') + expect(pkgA.version).toBe('1.0.1') + + // NOTE: syncVersions is called but not awaited inside bumpVersion, + // so we allow a microtask tick for the fire-and-forget sync to complete + await new Promise((r) => setTimeout(r, 10)) + + const pkgB = await pkg.read('pkg-b') + expect(pkgB.dependencies?.['pkg-a']).toBe('^1.0.1') + }) + + test('throws when package is not found', async () => { + const { app } = buildApp({ + packages: [{ name: 'pkg-a', version: '1.0.0' }] + }) + + await expect(app.bumpVersion({ pkgName: 'nonexistent', sync: false })).rejects.toThrow(DepSynkyError) + }) + + test('does not sync when sync is false', async () => { + const { app, pkg } = buildApp( + { + packages: [ + { name: 'pkg-a', version: '1.0.0' }, + { name: 'pkg-b', version: '1.0.0', dependencies: { 'pkg-a': '^1.0.0' } } + ] + }, + async ({ pkgName }) => (pkgName === 'pkg-a' ? 'major' : 'none') + ) + + await app.bumpVersion({ pkgName: 'pkg-a', sync: false }) + + const pkgB = await pkg.read('pkg-b') + // dependency should NOT be updated because sync is false + expect(pkgB.dependencies?.['pkg-a']).toBe('^1.0.0') + }) +}) diff --git a/depsynky/src/__tests__/check.test.ts b/depsynky/src/__tests__/check.test.ts new file mode 100644 index 000000000..0abb691ef --- /dev/null +++ b/depsynky/src/__tests__/check.test.ts @@ -0,0 +1,147 @@ +import { test, expect, describe } from 'vitest' +import { buildApp } from './utils/test-setup' +import { DepSynkyError } from '../errors' + +describe('checkVersions', () => { + test('passes when all dependencies are in sync', async () => { + const { app } = buildApp({ + packages: [ + { name: 'pkg-a', version: '1.0.0' }, + { name: 'pkg-b', version: '2.0.0', dependencies: { 'pkg-a': '^1.0.0' } } + ] + }) + + await expect(app.checkVersions({})).resolves.not.toThrow() + }) + + test('throws when a dependency is out of sync', async () => { + const { app } = buildApp({ + packages: [ + { name: 'pkg-a', version: '2.0.0' }, + { name: 'pkg-b', version: '1.0.0', dependencies: { 'pkg-a': '^1.0.0' } } + ] + }) + + await expect(app.checkVersions({})).rejects.toThrow(DepSynkyError) + await expect(app.checkVersions({})).rejects.toThrow('out of sync') + }) + + test('throws when a devDependency is out of sync', async () => { + const { app } = buildApp({ + packages: [ + { name: 'pkg-a', version: '2.0.0' }, + { name: 'pkg-b', version: '1.0.0', devDependencies: { 'pkg-a': '^1.0.0' } } + ] + }) + + await expect(app.checkVersions({})).rejects.toThrow(DepSynkyError) + }) + + test('throws when a peerDependency is out of sync', async () => { + const { app } = buildApp({ + packages: [ + { name: 'pkg-a', version: '2.0.0' }, + { name: 'pkg-b', version: '1.0.0', peerDependencies: { 'pkg-a': '^1.0.0' } } + ] + }) + + await expect(app.checkVersions({})).rejects.toThrow(DepSynkyError) + }) + + test('ignores out-of-sync peerDependencies when ignorePeers is true', async () => { + const { app } = buildApp({ + packages: [ + { name: 'pkg-a', version: '2.0.0' }, + { name: 'pkg-b', version: '1.0.0', peerDependencies: { 'pkg-a': '^1.0.0' } } + ] + }) + + await expect(app.checkVersions({ ignorePeers: true })).resolves.not.toThrow() + }) + + test('ignores out-of-sync devDependencies when ignoreDev is true', async () => { + const { app } = buildApp({ + packages: [ + { name: 'pkg-a', version: '2.0.0' }, + { name: 'pkg-b', version: '1.0.0', devDependencies: { 'pkg-a': '^1.0.0' } } + ] + }) + + await expect(app.checkVersions({ ignoreDev: true })).resolves.not.toThrow() + }) + + test('still checks dependencies when ignorePeers and ignoreDev are true', async () => { + const { app } = buildApp({ + packages: [ + { name: 'pkg-a', version: '2.0.0' }, + { name: 'pkg-b', version: '1.0.0', dependencies: { 'pkg-a': '^1.0.0' } } + ] + }) + + await expect(app.checkVersions({ ignorePeers: true, ignoreDev: true })).rejects.toThrow(DepSynkyError) + }) + + test('skips local (workspace:) versions for private packages', async () => { + const { app } = buildApp({ + packages: [ + { name: 'pkg-a', version: '1.0.0' }, + { name: 'pkg-b', version: '1.0.0', private: true, dependencies: { 'pkg-a': 'workspace:*' } } + ] + }) + + await expect(app.checkVersions({})).resolves.not.toThrow() + }) + + test('throws for local (workspace:) versions on public packages', async () => { + const { app } = buildApp({ + packages: [ + { name: 'pkg-a', version: '1.0.0' }, + { name: 'pkg-b', version: '1.0.0', dependencies: { 'pkg-a': 'workspace:*' } } + ] + }) + + await expect(app.checkVersions({})).rejects.toThrow(DepSynkyError) + await expect(app.checkVersions({})).rejects.toThrow('public and cannot depend on local package') + }) + + test('passes with custom targetVersions', async () => { + const { app } = buildApp({ + packages: [ + { name: 'pkg-a', version: '1.0.0' }, + { name: 'pkg-b', version: '1.0.0', dependencies: { 'pkg-a': '^1.0.0' } } + ] + }) + + await expect(app.checkVersions({ targetVersions: { 'pkg-a': '1.0.0' } })).resolves.not.toThrow() + }) + + test('fails with custom targetVersions that are not satisfied', async () => { + const { app } = buildApp({ + packages: [ + { name: 'pkg-a', version: '2.0.0' }, + { name: 'pkg-b', version: '1.0.0', dependencies: { 'pkg-a': '^1.0.0' } } + ] + }) + + await expect(app.checkVersions({ targetVersions: { 'pkg-a': '2.0.0' } })).rejects.toThrow(DepSynkyError) + }) + + test('passes when packages have no dependencies', async () => { + const { app } = buildApp({ + packages: [ + { name: 'pkg-a', version: '1.0.0' }, + { name: 'pkg-b', version: '2.0.0' } + ] + }) + + await expect(app.checkVersions({})).resolves.not.toThrow() + }) + + test('ignores dependencies on packages not in the monorepo', async () => { + const { app } = buildApp({ + packages: [{ name: 'pkg-a', version: '1.0.0', dependencies: { lodash: '^4.0.0' } }] + }) + + await expect(app.checkVersions({})).resolves.not.toThrow() + }) +}) diff --git a/depsynky/src/__tests__/ls.test.ts b/depsynky/src/__tests__/ls.test.ts new file mode 100644 index 000000000..db28db80c --- /dev/null +++ b/depsynky/src/__tests__/ls.test.ts @@ -0,0 +1,77 @@ +import { test, expect, describe } from 'vitest' +import { buildApp } from './utils/test-setup' + +describe('listVersions', () => { + test('returns versions of all public packages', async () => { + const { app } = buildApp({ + packages: [ + { name: 'pkg-a', version: '1.0.0' }, + { name: 'pkg-b', version: '2.0.0' }, + { name: 'pkg-c', version: '3.0.0' } + ] + }) + + const result = await app.listVersions() + + expect(result).toEqual({ + 'pkg-a': '1.0.0', + 'pkg-b': '2.0.0', + 'pkg-c': '3.0.0' + }) + }) + + test('excludes private packages', async () => { + const { app } = buildApp({ + packages: [ + { name: 'pkg-a', version: '1.0.0' }, + { name: 'pkg-private', version: '0.0.1', private: true }, + { name: 'pkg-b', version: '2.0.0' } + ] + }) + + const result = await app.listVersions() + + expect(result).toEqual({ + 'pkg-a': '1.0.0', + 'pkg-b': '2.0.0' + }) + expect(result).not.toHaveProperty('pkg-private') + }) + + test('returns empty object when all packages are private', async () => { + const { app } = buildApp({ + packages: [ + { name: 'pkg-a', version: '1.0.0', private: true }, + { name: 'pkg-b', version: '2.0.0', private: true } + ] + }) + + const result = await app.listVersions() + + expect(result).toEqual({}) + }) + + test('returns empty object when no packages exist', async () => { + const { app } = buildApp({ packages: [] }) + + const result = await app.listVersions() + + expect(result).toEqual({}) + }) + + test('handles scoped package names', async () => { + const { app } = buildApp({ + packages: [ + { name: '@scope/pkg-a', version: '1.0.0' }, + { name: '@scope/pkg-b', version: '2.5.0' } + ] + }) + + const result = await app.listVersions() + + expect(result).toEqual({ + '@scope/pkg-a': '1.0.0', + '@scope/pkg-b': '2.5.0' + }) + }) +}) diff --git a/depsynky/src/__tests__/sync.test.ts b/depsynky/src/__tests__/sync.test.ts new file mode 100644 index 000000000..d8f8cae4d --- /dev/null +++ b/depsynky/src/__tests__/sync.test.ts @@ -0,0 +1,160 @@ +import { test, expect, describe } from 'vitest' +import { buildApp } from './utils/test-setup' + +describe('syncVersions', () => { + test('updates dependencies to match target versions', async () => { + const { app, pkg } = buildApp({ + packages: [ + { name: 'pkg-a', version: '2.0.0' }, + { name: 'pkg-b', version: '1.0.0', dependencies: { 'pkg-a': '^1.0.0' } } + ] + }) + + await app.syncVersions({}) + + const pkgB = await pkg.read('pkg-b') + expect(pkgB.dependencies?.['pkg-a']).toBe('^2.0.0') + }) + + test('updates devDependencies to match target versions', async () => { + const { app, pkg } = buildApp({ + packages: [ + { name: 'pkg-a', version: '2.0.0' }, + { name: 'pkg-b', version: '1.0.0', devDependencies: { 'pkg-a': '^1.0.0' } } + ] + }) + + await app.syncVersions({}) + + const pkgB = await pkg.read('pkg-b') + expect(pkgB.devDependencies?.['pkg-a']).toBe('^2.0.0') + }) + + test('updates peerDependencies to match target versions', async () => { + const { app, pkg } = buildApp({ + packages: [ + { name: 'pkg-a', version: '2.0.0' }, + { name: 'pkg-b', version: '1.0.0', peerDependencies: { 'pkg-a': '^1.0.0' } } + ] + }) + + await app.syncVersions({}) + + const pkgB = await pkg.read('pkg-b') + expect(pkgB.peerDependencies?.['pkg-a']).toBe('^2.0.0') + }) + + test('skips peerDependencies when ignorePeers is true', async () => { + const { app, pkg } = buildApp({ + packages: [ + { name: 'pkg-a', version: '2.0.0' }, + { name: 'pkg-b', version: '1.0.0', peerDependencies: { 'pkg-a': '^1.0.0' } } + ] + }) + + await app.syncVersions({ ignorePeers: true }) + + const pkgB = await pkg.read('pkg-b') + expect(pkgB.peerDependencies?.['pkg-a']).toBe('^1.0.0') + }) + + test('skips devDependencies when ignoreDev is true', async () => { + const { app, pkg } = buildApp({ + packages: [ + { name: 'pkg-a', version: '2.0.0' }, + { name: 'pkg-b', version: '1.0.0', devDependencies: { 'pkg-a': '^1.0.0' } } + ] + }) + + await app.syncVersions({ ignoreDev: true }) + + const pkgB = await pkg.read('pkg-b') + expect(pkgB.devDependencies?.['pkg-a']).toBe('^1.0.0') + }) + + test('preserves workspace: references for private packages', async () => { + const { app, pkg } = buildApp({ + packages: [ + { name: 'pkg-a', version: '2.0.0' }, + { name: 'pkg-b', version: '1.0.0', private: true, dependencies: { 'pkg-a': 'workspace:*' } } + ] + }) + + await app.syncVersions({}) + + const pkgB = await pkg.read('pkg-b') + expect(pkgB.dependencies?.['pkg-a']).toBe('workspace:*') + }) + + test('uses custom targetVersions when provided', async () => { + const { app, pkg } = buildApp({ + packages: [ + { name: 'pkg-a', version: '1.0.0' }, + { name: 'pkg-b', version: '1.0.0', dependencies: { 'pkg-a': '^1.0.0' } } + ] + }) + + await app.syncVersions({ targetVersions: { 'pkg-a': '1.5.0' } }) + + const pkgB = await pkg.read('pkg-b') + expect(pkgB.dependencies?.['pkg-a']).toBe('^1.5.0') + }) + + test('preserves tilde ranges when syncing', async () => { + const { app, pkg } = buildApp({ + packages: [ + { name: 'pkg-a', version: '2.0.0' }, + { name: 'pkg-b', version: '1.0.0', dependencies: { 'pkg-a': '~1.0.0' } } + ] + }) + + await app.syncVersions({}) + + const pkgB = await pkg.read('pkg-b') + expect(pkgB.dependencies?.['pkg-a']).toBe('~2.0.0') + }) + + test('does not modify packages with no matching dependencies', async () => { + const { app, pkg } = buildApp({ + packages: [ + { name: 'pkg-a', version: '2.0.0' }, + { name: 'pkg-b', version: '1.0.0', dependencies: { lodash: '^4.0.0' } } + ] + }) + + await app.syncVersions({}) + + const pkgB = await pkg.read('pkg-b') + expect(pkgB.dependencies?.['lodash']).toBe('^4.0.0') + }) + + test('syncs multiple packages at once', async () => { + const { app, pkg } = buildApp({ + packages: [ + { name: 'pkg-a', version: '2.0.0' }, + { name: 'pkg-b', version: '3.0.0' }, + { name: 'pkg-c', version: '1.0.0', dependencies: { 'pkg-a': '^1.0.0', 'pkg-b': '^1.0.0' } } + ] + }) + + await app.syncVersions({}) + + const pkgC = await pkg.read('pkg-c') + expect(pkgC.dependencies?.['pkg-a']).toBe('^2.0.0') + expect(pkgC.dependencies?.['pkg-b']).toBe('^3.0.0') + }) + + test('does not touch packages that are already in sync', async () => { + const { app, pkg } = buildApp({ + packages: [ + { name: 'pkg-a', version: '1.0.0' }, + { name: 'pkg-b', version: '1.0.0', dependencies: { 'pkg-a': '^1.0.0' } } + ] + }) + + await app.syncVersions({}) + + const pkgB = await pkg.read('pkg-b') + expect(pkgB.dependencies?.['pkg-a']).toBe('^1.0.0') + }) +}) diff --git a/depsynky/src/__tests__/utils/mem-fs.ts b/depsynky/src/__tests__/utils/mem-fs.ts new file mode 100644 index 000000000..1ad945905 --- /dev/null +++ b/depsynky/src/__tests__/utils/mem-fs.ts @@ -0,0 +1,27 @@ +import picomatch from 'picomatch' +import * as types from '../../types' + +export class InMemoryFileSystem implements types.FsRepository { + public constructor(private _files: Record) {} + + public existsSync = (path: string): boolean => { + return path in this._files + } + + public readFile = async (path: string): Promise => { + const file = this._files[path] + if (!file) { + throw new Error(`File not found: ${path}`) + } + return file + } + + public writeFile = async (path: string, content: string): Promise => { + this._files[path] = content + } + + public globSync = (pattern: string): string[] => { + const matcher = picomatch(pattern) + return Object.keys(this._files).filter((f) => matcher(f)) + } +} diff --git a/depsynky/src/__tests__/utils/test-setup.ts b/depsynky/src/__tests__/utils/test-setup.ts new file mode 100644 index 000000000..5208f7754 --- /dev/null +++ b/depsynky/src/__tests__/utils/test-setup.ts @@ -0,0 +1,65 @@ +import * as types from '../../types' +import { InMemoryFileSystem } from './mem-fs' +import { PackageJsonService } from '../../application/pkgjson-service' +import { PNPM_WORKSPACE_FILE, PnpmWorkspaceService } from '../../application/pnpm-service' +import { DepSynkyApplication } from '../../application/application' + +export type Monorepo = { + packages: (types.PackageJson & { name: Name })[] +} + +export type PkgReader = { + read: (pkgName: Name) => Promise +} + +const ROOT_DIR = '/repo' + +const _kebabCase = (str: string) => { + const tokens = str.match(/[A-Za-z0-9]+/g) + if (!tokens) { + return str + } + return tokens.map((t) => t.toLowerCase()).join('-') +} + +const _buildFs = (monorepo: Monorepo): InMemoryFileSystem => { + const files: Record = {} + + const workspacePatterns = ['packages/*'] + files[`${ROOT_DIR}/${PNPM_WORKSPACE_FILE}`] = [ + // + 'packages:', + ...workspacePatterns.map((p) => ` - "${p}"`), + '' + ].join('\n') + + for (const pkg of monorepo.packages) { + const dirName = _kebabCase(pkg.name) + files[`packages/${dirName}`] = '' + const pkgJsonPath = `${ROOT_DIR}/packages/${dirName}/package.json` + files[pkgJsonPath] = JSON.stringify(pkg) + } + + return new InMemoryFileSystem(files) +} + +export const buildApp = ( + monorepo: Monorepo, + bumpFn: types.BumpService['promptJump'] = async () => 'none' +) => { + const fs = _buildFs(monorepo) + const pkg = new PackageJsonService(fs) + const pnpm = new PnpmWorkspaceService(pkg, fs, ROOT_DIR) + const bump: types.BumpService = { promptJump: bumpFn } + const app = new DepSynkyApplication(pnpm, pkg, bump) + return { + app, + pkg: { + read: async (pkgName: Name) => { + const dirName = _kebabCase(pkgName) + const pkgJsonPath = `${ROOT_DIR}/packages/${dirName}/package.json` + return pkg.read(pkgJsonPath) + } + } + } +} diff --git a/depsynky/src/application/application.ts b/depsynky/src/application/application.ts new file mode 100644 index 000000000..137d9b5ad --- /dev/null +++ b/depsynky/src/application/application.ts @@ -0,0 +1,185 @@ +import * as types from '../types' +import * as semver from 'semver' +import * as errors from '../errors' +import * as utils from '../utils' + +const { logger } = utils.logging + +export type BumpVersionArgs = { + pkgName: string + sync: boolean +} + +export type CheckVersionsArgs = { + ignorePeers?: boolean + ignoreDev?: boolean + targetVersions?: Record +} + +export type ListVersionsResult = Record + +export type SyncVersionsArgs = { + ignorePeers?: boolean + ignoreDev?: boolean + targetVersions?: Record +} + +export class DepSynkyApplication { + public constructor( + private readonly _pnpm: types.PnpmService, + private readonly _pkgJson: types.PackageJsonService, + private readonly _bump: types.BumpService + ) {} + + public async bumpVersion(args: BumpVersionArgs) { + let pkgName = args.pkgName + + const { dependency, dependents } = await this._pnpm.findRecursiveReferences(pkgName) + const targetPackages = [dependency, ...dependents] + + const currentVersions = this._pnpmVersions(targetPackages) + const targetVersions = { ...currentVersions } + + for (const { path: pkgPath, content } of targetPackages) { + if (content.private) { + continue // no need to bump the version of private packages + } + + const jump = await this._bump.promptJump({ + pkgName: content.name, + currentVersion: content.version + }) + + if (jump === 'none') { + continue + } + + const next = semver.inc(content.version, jump) + if (!next) { + throw new errors.DepSynkyError(`Invalid version jump: ${jump}`) + } + + targetVersions[content.name] = next + await this._pkgJson.update(pkgPath, { version: next }) + } + + await this.listVersions() + if (args.sync) { + logger.info('Syncing versions...') + this.syncVersions({ ...args, targetVersions }) + } + } + + public async checkVersions(args: CheckVersionsArgs) { + const allPackages = await this._pnpm.searchWorkspaces() + const targetVersions = args.targetVersions ?? this._pnpmVersions(allPackages) + + for (const { content } of allPackages) { + const { dependencies, devDependencies, peerDependencies } = content + + const check = this._makePackageChecker(content) + check(dependencies, targetVersions) + if (!args.ignorePeers) check(peerDependencies, targetVersions) + if (!args.ignoreDev) check(devDependencies, targetVersions) + } + + logger.info('All versions are in sync') + } + + public async listVersions(): Promise { + const allPackages = await this._pnpm.searchWorkspaces() + + const versions: Record = {} + + for (const { content } of allPackages) { + if (content.private) { + continue + } + versions[content.name] = content.version + } + + return versions + } + + public async syncVersions(args: SyncVersionsArgs) { + const allPackages = await this._pnpm.searchWorkspaces() + const targetVersions = args.targetVersions ?? this._pnpmVersions(allPackages) + + for (const { path: pkgPath, content } of allPackages) { + const { dependencies, devDependencies, peerDependencies } = content + + const update = this._makeUpdater(content) + + const updatedDeps = update(dependencies, targetVersions) + const updatedPeerDeps = args.ignorePeers ? peerDependencies : update(peerDependencies, targetVersions) + const updatedDevDeps = args.ignoreDev ? devDependencies : update(devDependencies, targetVersions) + + await this._pkgJson.update(pkgPath, { + dependencies: updatedDeps, + devDependencies: updatedDevDeps, + peerDependencies: updatedPeerDeps + }) + } + } + + private _makeUpdater = + (pkg: types.PackageJson) => (current: Record | undefined, target: Record) => { + if (!current) { + return current + } + + for (const [name, version] of utils.objects.entries(target)) { + const currentVersion = current[name] + if (!currentVersion) { + continue + } + const isLocal = this._pnpm.isLocalVersion(currentVersion) + const isPublic = !pkg.private + if (isLocal) { + if (isPublic) { + utils.logging.logger.warn( + `Package ${pkg.name} is public and cannot depend on local package ${name}. To keep reference on local package, make ${pkg.name} private.` + ) + } + current[name] = currentVersion + continue + } + current[name] = utils.semver.attemptBumpLowerbound(currentVersion, version) + } + return current + } + + private _makePackageChecker = + (pkg: types.PackageJson) => (current: Record | undefined, target: Record) => { + if (!current) { + return + } + + for (const [name, targetVersion] of utils.objects.entries(target)) { + const currentVersion = current[name] + if (!currentVersion) { + continue + } + const isLocal = this._pnpm.isLocalVersion(currentVersion) + const isPublic = !pkg.private + if (isLocal) { + if (isPublic) { + throw new errors.DepSynkyError( + `Package ${pkg.name} is public and cannot depend on local package ${name}. To keep reference on local package, make ${pkg.name} private.` + ) + } + continue + } + + if (!semver.satisfies(targetVersion, currentVersion)) { + throw new errors.DepSynkyError( + `Dependency ${name} is out of sync in ${pkg.name}: ${currentVersion} < ${targetVersion}` + ) + } + } + } + + private _pnpmVersions = (workspaces: types.PnpmWorkspace[]): Record => { + return utils.objects.fromEntries(workspaces.map(({ content: { name, version } }) => [name, version])) + } +} diff --git a/depsynky/src/application/bump-service.ts b/depsynky/src/application/bump-service.ts new file mode 100644 index 000000000..a143b7e04 --- /dev/null +++ b/depsynky/src/application/bump-service.ts @@ -0,0 +1,19 @@ +import * as types from '../types' + +export class BumpService implements types.BumpService { + public constructor(private _promptRepo: types.PromptRepository) {} + + public promptJump = async (args: { pkgName: string; currentVersion: string }) => { + const { pkgName, currentVersion } = args + const jump = await this._promptRepo.promptChoices({ + message: `Bump ${pkgName} version from ${currentVersion}`, + choices: [ + { name: 'Patch', value: 'patch' }, + { name: 'Minor', value: 'minor' }, + { name: 'Major', value: 'major' }, + { name: 'None', value: 'none' } + ] + }) + return jump + } +} diff --git a/depsynky/src/application/index.ts b/depsynky/src/application/index.ts new file mode 100644 index 000000000..f036daf6f --- /dev/null +++ b/depsynky/src/application/index.ts @@ -0,0 +1,4 @@ +export * from './application' +export * from './pkgjson-service' +export * from './pnpm-service' +export * from './bump-service' diff --git a/depsynky/src/application/pkgjson-service.ts b/depsynky/src/application/pkgjson-service.ts new file mode 100644 index 000000000..92f8c5859 --- /dev/null +++ b/depsynky/src/application/pkgjson-service.ts @@ -0,0 +1,33 @@ +import * as prettier from 'prettier' +import * as objects from '../utils/objects' +import * as types from '../types' + +export class PackageJsonService implements types.PackageJsonService { + public constructor(private _fs: types.FsRepository) {} + + public read = async (filePath: string): Promise => { + const strContent = await this._fs.readFile(filePath) + const content = JSON.parse(strContent) + return content + } + + public write = async (filePath: string, content: types.PackageJson): Promise => { + let strContent = JSON.stringify(content, null, 2) + strContent = prettier.format(strContent, { parser: 'json' }) + await this._fs.writeFile(filePath, strContent) + } + + public update = async (filePath: string, content: Partial) => { + const currentPackage = await this.read(filePath) + + // this preserves the order of the keys + const newPackage = objects.keys(currentPackage).reduce((acc, key) => { + if (key in content) { + return { ...acc, [key]: content[key] } + } + return acc + }, currentPackage) + + await this.write(filePath, newPackage) + } +} diff --git a/depsynky/src/application/pnpm-service.ts b/depsynky/src/application/pnpm-service.ts new file mode 100644 index 000000000..f0457fa27 --- /dev/null +++ b/depsynky/src/application/pnpm-service.ts @@ -0,0 +1,93 @@ +import * as types from '../types' +import * as pathlib from 'path' +import * as yaml from 'yaml' +import * as errors from '../errors' +import * as utils from '../utils' + +export const PNPM_WORKSPACE_FILE = 'pnpm-workspace.yaml' +export const LOCAL_VERSION_PREFIX = 'workspace:' + +export class PnpmWorkspaceService implements types.PnpmService { + public constructor( + private _pkgJsonService: types.PackageJsonService, + private _fs: types.FsRepository, + private _rootDir: string + ) {} + public searchWorkspaces = async () => { + const pnpmWorkspacesFile = pathlib.join(this._rootDir, PNPM_WORKSPACE_FILE) + if (!this._fs.existsSync(pnpmWorkspacesFile)) { + throw new errors.DepSynkyError(`Could not find ${PNPM_WORKSPACE_FILE} at "${this._rootDir}"`) + } + const pnpmWorkspacesContent = await this._fs.readFile(pnpmWorkspacesFile) + const pnpmWorkspaces: string[] = yaml.parse(pnpmWorkspacesContent).packages + const globMatches = pnpmWorkspaces.flatMap((ws) => this._fs.globSync(ws, { absolute: false, cwd: this._rootDir })) + const absGlobMatches = globMatches.map(this._abs) + const packageJsonPaths = absGlobMatches.map((p) => pathlib.join(p, 'package.json')) + const actualPackages = packageJsonPaths.filter((f) => this._fs.existsSync(f)) + const absolutePaths = actualPackages.map(this._abs) + const workspaces = await Promise.all( + absolutePaths.map(async (p) => ({ path: p, content: await this._pkgJsonService.read(p) })) + ) + return workspaces + } + + public findDirectReferences = async ( + pkgName: string + ): Promise<{ dependency: types.PnpmWorkspace; dependents: types.PnpmWorkspace[] }> => { + const workspaces = await this.searchWorkspaces() + const dependency = workspaces.find((w) => w.content.name === pkgName) + if (!dependency) { + throw new errors.DepSynkyError(`Could not find package "${pkgName}"`) + } + const dependents = this._findDirectDependents(workspaces, pkgName) + return { dependency, dependents } + } + + public findRecursiveReferences = async (pkgName: string) => { + const workspaces = await this.searchWorkspaces() + const dependency = workspaces.find((w) => w.content.name === pkgName) + if (!dependency) { + throw new errors.DepSynkyError(`Could not find package "${pkgName}"`) + } + + const visited = new utils.sets.SetBy([], (s) => s.content.name) + const queued = new utils.sets.SetBy([dependency], (s) => s.content.name) + + while (queued.length > 0) { + const currentPkg = queued.shift()! + if (visited.hasKey(currentPkg.content.name)) { + continue + } + + visited.add(currentPkg) + + const dependents = this._findDirectDependents(workspaces, currentPkg.content.name) + for (const dependent of dependents) { + if (!visited.hasKey(dependent.content.name) && !queued.hasKey(dependent.content.name)) { + queued.add(dependent) + } + } + } + + const dependents = visited.values.filter((w) => w.content.name !== pkgName) + return { dependency, dependents } + } + + public isLocalVersion = (version: string) => version.startsWith(LOCAL_VERSION_PREFIX) + + private _findDirectDependents = (workspaces: types.PnpmWorkspace[], pkgName: string): types.PnpmWorkspace[] => { + return workspaces.filter( + (w) => + w.content.dependencies?.[pkgName] || + w.content.devDependencies?.[pkgName] || + w.content.peerDependencies?.[pkgName] + ) + } + + public listPublicPackages = async (): Promise => { + const workspaces = await this.searchWorkspaces() + return workspaces.filter((w) => !w.content.private).map((w) => w.content.name) + } + + private _abs = (p: string) => pathlib.resolve(this._rootDir, p) +} diff --git a/depsynky/src/bootstrap.ts b/depsynky/src/bootstrap.ts new file mode 100644 index 000000000..bc41812f2 --- /dev/null +++ b/depsynky/src/bootstrap.ts @@ -0,0 +1,19 @@ +import * as app from './application' +import * as repo from './infrastructure' +import * as config from './config' + +export const bootstrap = async (argv: config.CommonConfig) => { + const promptRepo = new repo.PromptStdinRepo() + const fs = new repo.FsRepo() + + const pkgJsonService = new app.PackageJsonService(fs) + const pnpmWorkspaceService = new app.PnpmWorkspaceService(pkgJsonService, fs, argv.rootDir) + const bumpService = new app.BumpService(promptRepo) + + const application = new app.DepSynkyApplication(pnpmWorkspaceService, pkgJsonService, bumpService) + return { + app: application, + pnpm: pnpmWorkspaceService, + pkgJson: pkgJsonService + } +} diff --git a/depsynky/src/commands/bump-versions.ts b/depsynky/src/commands/bump-versions.ts index 212506dd8..b011369ce 100644 --- a/depsynky/src/commands/bump-versions.ts +++ b/depsynky/src/commands/bump-versions.ts @@ -1,30 +1,8 @@ import { YargsConfig } from '@bpinternal/yargs-extra' import * as prompts from 'prompts' -import * as semver from 'semver' import * as config from '../config' import * as errors from '../errors' -import * as utils from '../utils' -import { listVersions } from './list-versions' -import { syncVersions } from './sync-versions' - -const { logger } = utils.logging - -type VersionJump = 'major' | 'minor' | 'patch' | 'none' - -const promptJump = async (pkgName: string, pkgVersion: string): Promise => { - const { jump: promptedJump } = await prompts.prompt({ - type: 'select', - name: 'jump', - message: `Bump ${pkgName} version from ${pkgVersion}`, - choices: [ - { title: 'Patch', value: 'patch' }, - { title: 'Minor', value: 'minor' }, - { title: 'Major', value: 'major' }, - { title: 'None', value: 'none' } - ] - }) - return promptedJump -} +import { bootstrap } from '../bootstrap' const promptPackage = async (publicPkgs: string[]): Promise => { if (publicPkgs.length === 0) { @@ -46,40 +24,13 @@ const promptPackage = async (publicPkgs: string[]): Promise => { } export const bumpVersion = async (argv: YargsConfig & { pkgName?: string }) => { + const { app, pnpm } = await bootstrap(argv) + let pkgName = argv.pkgName if (!pkgName) { - const publicPkgs = utils.pnpm.listPublicPackages(argv.rootDir) + const publicPkgs = await pnpm.listPublicPackages() pkgName = await promptPackage(publicPkgs) } - const { dependency, dependents } = utils.pnpm.findRecursiveReferences(argv.rootDir, pkgName) - const targetPackages = [dependency, ...dependents] - - const currentVersions = utils.pnpm.versions(targetPackages) - const targetVersions = { ...currentVersions } - - for (const { path: pkgPath, content } of targetPackages) { - if (content.private) { - continue // no need to bump the version of private packages - } - - const jump = await promptJump(content.name, content.version) - if (jump === 'none') { - continue - } - - const next = semver.inc(content.version, jump) - if (!next) { - throw new errors.DepSynkyError(`Invalid version jump: ${jump}`) - } - - targetVersions[content.name] = next - utils.pkgjson.update(pkgPath, { version: next }) - } - - listVersions(argv) - if (argv.sync) { - logger.info('Syncing versions...') - syncVersions(argv, { targetVersions }) - } + await app.bumpVersion({ pkgName, ...argv }) } diff --git a/depsynky/src/commands/check-versions.ts b/depsynky/src/commands/check-versions.ts index 9bff05612..9727fae6b 100644 --- a/depsynky/src/commands/check-versions.ts +++ b/depsynky/src/commands/check-versions.ts @@ -1,57 +1,8 @@ import { YargsConfig } from '@bpinternal/yargs-extra' import * as config from '../config' -import * as errors from '../errors' -import * as utils from '../utils' -import * as semver from 'semver' +import { bootstrap } from '../bootstrap' -const { logger } = utils.logging - -export type CheckVersionsOpts = { - targetVersions: Record -} - -const checker = - (pkg: utils.pkgjson.PackageJson) => (current: Record | undefined, target: Record) => { - if (!current) { - return - } - - for (const [name, targetVersion] of utils.objects.entries(target)) { - const currentVersion = current[name] - if (!currentVersion) { - continue - } - const isLocal = utils.pnpm.isLocalVersion(currentVersion) - const isPublic = !pkg.private - if (isLocal) { - if (isPublic) { - throw new errors.DepSynkyError( - `Package ${pkg.name} is public and cannot depend on local package ${name}. To keep reference on local package, make ${pkg.name} private.` - ) - } - continue - } - - if (!semver.satisfies(targetVersion, currentVersion)) { - throw new errors.DepSynkyError( - `Dependency ${name} is out of sync in ${pkg.name}: ${currentVersion} < ${targetVersion}` - ) - } - } - } - -export const checkVersions = (argv: YargsConfig, opts: Partial = {}) => { - const allPackages = utils.pnpm.searchWorkspaces(argv.rootDir) - const targetVersions = opts.targetVersions ?? utils.pnpm.versions(allPackages) - - for (const { content } of allPackages) { - const { dependencies, devDependencies, peerDependencies } = content - - const check = checker(content) - check(dependencies, targetVersions) - if (!argv.ignorePeers) check(peerDependencies, targetVersions) - if (!argv.ignoreDev) check(devDependencies, targetVersions) - } - - logger.info('All versions are in sync') +export const checkVersions = async (argv: YargsConfig) => { + const { app } = await bootstrap(argv) + await app.checkVersions({ ...argv }) } diff --git a/depsynky/src/commands/list-versions.ts b/depsynky/src/commands/list-versions.ts index ff4b7edfb..dc4dbb753 100644 --- a/depsynky/src/commands/list-versions.ts +++ b/depsynky/src/commands/list-versions.ts @@ -2,20 +2,12 @@ import { YargsConfig } from '@bpinternal/yargs-extra' import * as util from 'util' import * as config from '../config' import * as utils from '../utils' +import { bootstrap } from '../bootstrap' const { logger } = utils.logging -export const listVersions = (argv: YargsConfig) => { - const allPackages = utils.pnpm.searchWorkspaces(argv.rootDir) - - const versions: Record = {} - - for (const { content } of allPackages) { - if (content.private) { - continue - } - versions[content.name] = content.version - } - +export const listVersions = async (argv: YargsConfig) => { + const { app } = await bootstrap(argv) + const versions = await app.listVersions() logger.info('versions:', util.inspect(versions, { depth: Infinity, colors: true })) } diff --git a/depsynky/src/commands/sync-versions.ts b/depsynky/src/commands/sync-versions.ts index f6d8b0f29..2a51f30de 100644 --- a/depsynky/src/commands/sync-versions.ts +++ b/depsynky/src/commands/sync-versions.ts @@ -1,56 +1,8 @@ import { YargsConfig } from '@bpinternal/yargs-extra' import * as config from '../config' -import * as utils from '../utils' -import { searchWorkspaces } from '../utils/pnpm' +import { bootstrap } from '../bootstrap' -export type SyncVersionsOpts = { - targetVersions: Record -} - -const updater = - (pkg: utils.pkgjson.PackageJson) => (current: Record | undefined, target: Record) => { - if (!current) { - return current - } - - for (const [name, version] of utils.objects.entries(target)) { - const currentVersion = current[name] - if (!currentVersion) { - continue - } - const isLocal = utils.pnpm.isLocalVersion(currentVersion) - const isPublic = !pkg.private - if (isLocal) { - if (isPublic) { - utils.logging.logger.warn( - `Package ${pkg.name} is public and cannot depend on local package ${name}. To keep reference on local package, make ${pkg.name} private.` - ) - } - current[name] = currentVersion - continue - } - current[name] = utils.semver.attemptBumpLowerbound(currentVersion, version) - } - return current - } - -export const syncVersions = (argv: YargsConfig, opts: Partial = {}) => { - const allPackages = searchWorkspaces(argv.rootDir) - const targetVersions = opts.targetVersions ?? utils.pnpm.versions(allPackages) - - for (const { path: pkgPath, content } of allPackages) { - const { dependencies, devDependencies, peerDependencies } = content - - const update = updater(content) - - const updatedDeps = update(dependencies, targetVersions) - const updatedPeerDeps = argv.ignorePeers ? peerDependencies : update(peerDependencies, targetVersions) - const updatedDevDeps = argv.ignoreDev ? devDependencies : update(devDependencies, targetVersions) - - utils.pkgjson.update(pkgPath, { - dependencies: updatedDeps, - devDependencies: updatedDevDeps, - peerDependencies: updatedPeerDeps - }) - } +export const syncVersions = async (argv: YargsConfig) => { + const { app } = await bootstrap(argv) + await app.checkVersions({ ...argv }) } diff --git a/depsynky/src/config.ts b/depsynky/src/config.ts index d41bd0b8e..2dff537b0 100644 --- a/depsynky/src/config.ts +++ b/depsynky/src/config.ts @@ -1,5 +1,6 @@ -import { YargsSchema } from '@bpinternal/yargs-extra' +import { YargsConfig, YargsSchema } from '@bpinternal/yargs-extra' +export type CommonConfig = YargsConfig const defaultOptions = { rootDir: { type: 'string', @@ -17,6 +18,7 @@ const defaultOptions = { } } satisfies YargsSchema +export type BumpConfig = YargsConfig & { pkgName?: string } export const bumpSchema = { ...defaultOptions, sync: { @@ -25,12 +27,15 @@ export const bumpSchema = { } } satisfies YargsSchema +export type SyncConfig = YargsConfig export const syncSchema = { ...defaultOptions } satisfies YargsSchema +export type CheckConfig = YargsConfig export const checkSchema = { ...defaultOptions } satisfies YargsSchema +export type ListConfig = YargsConfig export const listSchema = defaultOptions satisfies YargsSchema diff --git a/depsynky/src/infrastructure/fs-repo.ts b/depsynky/src/infrastructure/fs-repo.ts new file mode 100644 index 000000000..432db2144 --- /dev/null +++ b/depsynky/src/infrastructure/fs-repo.ts @@ -0,0 +1,21 @@ +import * as types from '../types' +import * as fs from 'fs' +import * as glob from 'glob' + +export class FsRepo implements types.FsRepository { + public existsSync(path: string): boolean { + return fs.existsSync(path) + } + + public readFile(path: string): Promise { + return fs.promises.readFile(path, 'utf-8') + } + + public writeFile(path: string, content: string): Promise { + return fs.promises.writeFile(path, content, 'utf-8') + } + + public globSync(pattern: string, opts?: Partial): string[] { + return glob.sync(pattern, opts) + } +} diff --git a/depsynky/src/infrastructure/index.ts b/depsynky/src/infrastructure/index.ts new file mode 100644 index 000000000..bcf7753e0 --- /dev/null +++ b/depsynky/src/infrastructure/index.ts @@ -0,0 +1,2 @@ +export * from './prompt-stdin-repo' +export * from './fs-repo' diff --git a/depsynky/src/infrastructure/prompt-stdin-repo.ts b/depsynky/src/infrastructure/prompt-stdin-repo.ts new file mode 100644 index 000000000..4f74371be --- /dev/null +++ b/depsynky/src/infrastructure/prompt-stdin-repo.ts @@ -0,0 +1,17 @@ +import prompts from 'prompts' +import * as types from '../types' + +export class PromptStdinRepo implements types.PromptRepository { + public async promptChoices(args: { + message: string + choices: { name: string; value: T }[] + }): Promise { + const { x } = await prompts({ + type: 'select', + name: 'x', + message: args.message, + choices: args.choices.map((c) => ({ title: c.name, value: c.value })) + }) + return x + } +} diff --git a/depsynky/src/types.ts b/depsynky/src/types.ts new file mode 100644 index 000000000..8a9a771d6 --- /dev/null +++ b/depsynky/src/types.ts @@ -0,0 +1,48 @@ +export type PackageJson = { + name: string + version: string + private?: boolean + dependencies?: Record + devDependencies?: Record + peerDependencies?: Record +} + +export type PackageJsonService = { + read: (filePath: string) => Promise + write: (filePath: string, content: PackageJson) => Promise + update: (filePath: string, content: Partial) => Promise +} + +export type PnpmWorkspace = { + path: string + content: PackageJson +} + +export type PnpmService = { + searchWorkspaces: () => Promise + findDirectReferences: (pkgName: string) => Promise<{ dependency: PnpmWorkspace; dependents: PnpmWorkspace[] }> + findRecursiveReferences: (pkgName: string) => Promise<{ dependency: PnpmWorkspace; dependents: PnpmWorkspace[] }> + listPublicPackages: () => Promise + isLocalVersion: (version: string) => boolean +} + +export type VersionJump = 'patch' | 'minor' | 'major' | 'none' + +export type BumpService = { + promptJump: (args: { pkgName: string; currentVersion: string }) => Promise +} + +export type GlobOptions = { + absolute?: boolean + cwd?: string +} +export type FsRepository = { + existsSync: (path: string) => boolean + readFile: (path: string) => Promise + writeFile: (path: string, content: string) => Promise + globSync: (pattern: string, opts?: Partial) => string[] +} + +export type PromptRepository = { + promptChoices: (args: { message: string; choices: { name: string; value: T }[] }) => Promise +} diff --git a/depsynky/src/utils/index.ts b/depsynky/src/utils/index.ts index dd574a8b9..05f56b98b 100644 --- a/depsynky/src/utils/index.ts +++ b/depsynky/src/utils/index.ts @@ -1,6 +1,4 @@ -export * as pkgjson from './pkgjson' export * as logging from './logging' -export * as pnpm from './pnpm' export * as objects from './objects' export * as semver from './semver' export * as sets from './sets' diff --git a/depsynky/src/utils/pkgjson.ts b/depsynky/src/utils/pkgjson.ts deleted file mode 100644 index 3a0030795..000000000 --- a/depsynky/src/utils/pkgjson.ts +++ /dev/null @@ -1,38 +0,0 @@ -import * as fs from 'fs' -import * as prettier from 'prettier' -import * as objects from './objects' - -export type PackageJson = { - name: string - version: string - private?: boolean - dependencies?: Record - devDependencies?: Record - peerDependencies?: Record -} - -export const read = (filePath: string): PackageJson => { - const strContent = fs.readFileSync(filePath, 'utf-8') - const content = JSON.parse(strContent) - return content -} - -export const write = (filePath: string, content: PackageJson) => { - let strContent = JSON.stringify(content, null, 2) - strContent = prettier.format(strContent, { parser: 'json' }) - fs.writeFileSync(filePath, strContent) -} - -export const update = (filePath: string, content: Partial) => { - const currentPackage = read(filePath) - - // this preserves the order of the keys - const newPackage = objects.keys(currentPackage).reduce((acc, key) => { - if (key in content) { - return { ...acc, [key]: content[key] } - } - return acc - }, currentPackage) - - write(filePath, newPackage) -} diff --git a/depsynky/src/utils/pnpm.ts b/depsynky/src/utils/pnpm.ts deleted file mode 100644 index a666876d2..000000000 --- a/depsynky/src/utils/pnpm.ts +++ /dev/null @@ -1,90 +0,0 @@ -import * as fs from 'fs' -import * as glob from 'glob' -import * as pathlib from 'path' -import * as yaml from 'yaml' -import * as errors from '../errors' -import * as objects from './objects' -import * as pkgjson from './pkgjson' -import * as sets from './sets' - -const abs = (rootDir: string) => (p: string) => pathlib.resolve(rootDir, p) - -export type PnpmWorkspace = { - path: string - content: pkgjson.PackageJson -} - -const PNPM_WORKSPACE_FILE = 'pnpm-workspace.yaml' - -export const searchWorkspaces = (rootDir: string): PnpmWorkspace[] => { - const pnpmWorkspacesFile = pathlib.join(rootDir, PNPM_WORKSPACE_FILE) - if (!fs.existsSync(pnpmWorkspacesFile)) { - throw new errors.DepSynkyError(`Could not find ${PNPM_WORKSPACE_FILE} at "${rootDir}"`) - } - const pnpmWorkspacesContent = fs.readFileSync(pnpmWorkspacesFile, 'utf-8') - const pnpmWorkspaces: string[] = yaml.parse(pnpmWorkspacesContent).packages - const globMatches = pnpmWorkspaces.flatMap((ws) => glob.globSync(ws, { absolute: false, cwd: rootDir })) - const absGlobMatches = globMatches.map(abs(rootDir)) - const packageJsonPaths = absGlobMatches.map((p) => pathlib.join(p, 'package.json')) - const actualPackages = packageJsonPaths.filter(fs.existsSync) - const absolutePaths = actualPackages.map(abs(rootDir)) - return absolutePaths.map((p) => ({ path: p, content: pkgjson.read(p) })) -} - -export const findDirectReferences = (rootDir: string, pkgName: string) => { - const workspaces = searchWorkspaces(rootDir) - const dependency = workspaces.find((w) => w.content.name === pkgName) - if (!dependency) { - throw new errors.DepSynkyError(`Could not find package "${pkgName}"`) - } - const dependents = _findDirectDependents(workspaces, pkgName) - return { dependency, dependents } -} - -export const findRecursiveReferences = (rootDir: string, pkgName: string) => { - const workspaces = searchWorkspaces(rootDir) - const dependency = workspaces.find((w) => w.content.name === pkgName) - if (!dependency) { - throw new errors.DepSynkyError(`Could not find package "${pkgName}"`) - } - - const visited = new sets.SetBy([], (s) => s.content.name) - const queued = new sets.SetBy([dependency], (s) => s.content.name) - - while (queued.length > 0) { - const currentPkg = queued.shift()! - if (visited.hasKey(currentPkg.content.name)) { - continue - } - - visited.add(currentPkg) - - const dependents = _findDirectDependents(workspaces, currentPkg.content.name) - for (const dependent of dependents) { - if (!visited.hasKey(dependent.content.name) && !queued.hasKey(dependent.content.name)) { - queued.add(dependent) - } - } - } - - const dependents = visited.values.filter((w) => w.content.name !== pkgName) - return { dependency, dependents } -} - -const _findDirectDependents = (workspaces: PnpmWorkspace[], pkgName: string): PnpmWorkspace[] => { - return workspaces.filter( - (w) => - w.content.dependencies?.[pkgName] || w.content.devDependencies?.[pkgName] || w.content.peerDependencies?.[pkgName] - ) -} - -export const versions = (workspaces: PnpmWorkspace[]): Record => { - return objects.fromEntries(workspaces.map(({ content: { name, version } }) => [name, version])) -} - -export const listPublicPackages = (rootDir: string): string[] => { - const workspaces = searchWorkspaces(rootDir) - return workspaces.filter((w) => !w.content.private).map((w) => w.content.name) -} - -export const isLocalVersion = (version: string) => version.startsWith('workspace:')