From 5ccd79bb9983b2c3dfc863f0552c3a92c0de1b32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Levasseur?= Date: Wed, 25 Mar 2026 14:24:29 -0400 Subject: [PATCH 1/9] chore(depsynky): use fs promises --- depsynky/src/commands/bump-versions.ts | 4 ++-- depsynky/src/commands/check-versions.ts | 7 +++++-- depsynky/src/commands/list-versions.ts | 4 ++-- depsynky/src/commands/sync-versions.ts | 7 +++++-- depsynky/src/utils/pkgjson.ts | 14 +++++++------- depsynky/src/utils/pnpm.ts | 17 +++++++++-------- 6 files changed, 30 insertions(+), 23 deletions(-) diff --git a/depsynky/src/commands/bump-versions.ts b/depsynky/src/commands/bump-versions.ts index 212506dd8..3ccf17e49 100644 --- a/depsynky/src/commands/bump-versions.ts +++ b/depsynky/src/commands/bump-versions.ts @@ -48,11 +48,11 @@ const promptPackage = async (publicPkgs: string[]): Promise => { export const bumpVersion = async (argv: YargsConfig & { pkgName?: string }) => { let pkgName = argv.pkgName if (!pkgName) { - const publicPkgs = utils.pnpm.listPublicPackages(argv.rootDir) + const publicPkgs = await utils.pnpm.listPublicPackages(argv.rootDir) pkgName = await promptPackage(publicPkgs) } - const { dependency, dependents } = utils.pnpm.findRecursiveReferences(argv.rootDir, pkgName) + const { dependency, dependents } = await utils.pnpm.findRecursiveReferences(argv.rootDir, pkgName) const targetPackages = [dependency, ...dependents] const currentVersions = utils.pnpm.versions(targetPackages) diff --git a/depsynky/src/commands/check-versions.ts b/depsynky/src/commands/check-versions.ts index 9bff05612..c04b8a84c 100644 --- a/depsynky/src/commands/check-versions.ts +++ b/depsynky/src/commands/check-versions.ts @@ -40,8 +40,11 @@ const checker = } } -export const checkVersions = (argv: YargsConfig, opts: Partial = {}) => { - const allPackages = utils.pnpm.searchWorkspaces(argv.rootDir) +export const checkVersions = async ( + argv: YargsConfig, + opts: Partial = {} +) => { + const allPackages = await utils.pnpm.searchWorkspaces(argv.rootDir) const targetVersions = opts.targetVersions ?? utils.pnpm.versions(allPackages) for (const { content } of allPackages) { diff --git a/depsynky/src/commands/list-versions.ts b/depsynky/src/commands/list-versions.ts index ff4b7edfb..0c977d7bb 100644 --- a/depsynky/src/commands/list-versions.ts +++ b/depsynky/src/commands/list-versions.ts @@ -5,8 +5,8 @@ import * as utils from '../utils' const { logger } = utils.logging -export const listVersions = (argv: YargsConfig) => { - const allPackages = utils.pnpm.searchWorkspaces(argv.rootDir) +export const listVersions = async (argv: YargsConfig) => { + const allPackages = await utils.pnpm.searchWorkspaces(argv.rootDir) const versions: Record = {} diff --git a/depsynky/src/commands/sync-versions.ts b/depsynky/src/commands/sync-versions.ts index f6d8b0f29..efe168449 100644 --- a/depsynky/src/commands/sync-versions.ts +++ b/depsynky/src/commands/sync-versions.ts @@ -34,8 +34,11 @@ const updater = return current } -export const syncVersions = (argv: YargsConfig, opts: Partial = {}) => { - const allPackages = searchWorkspaces(argv.rootDir) +export const syncVersions = async ( + argv: YargsConfig, + opts: Partial = {} +) => { + const allPackages = await searchWorkspaces(argv.rootDir) const targetVersions = opts.targetVersions ?? utils.pnpm.versions(allPackages) for (const { path: pkgPath, content } of allPackages) { diff --git a/depsynky/src/utils/pkgjson.ts b/depsynky/src/utils/pkgjson.ts index 3a0030795..8f89e3bfb 100644 --- a/depsynky/src/utils/pkgjson.ts +++ b/depsynky/src/utils/pkgjson.ts @@ -11,20 +11,20 @@ export type PackageJson = { peerDependencies?: Record } -export const read = (filePath: string): PackageJson => { - const strContent = fs.readFileSync(filePath, 'utf-8') +export const read = async (filePath: string): Promise => { + const strContent = await fs.promises.readFile(filePath, 'utf-8') const content = JSON.parse(strContent) return content } -export const write = (filePath: string, content: PackageJson) => { +export const write = async (filePath: string, content: PackageJson): Promise => { let strContent = JSON.stringify(content, null, 2) strContent = prettier.format(strContent, { parser: 'json' }) - fs.writeFileSync(filePath, strContent) + await fs.promises.writeFile(filePath, strContent) } -export const update = (filePath: string, content: Partial) => { - const currentPackage = read(filePath) +export const update = async (filePath: string, content: Partial) => { + const currentPackage = await read(filePath) // this preserves the order of the keys const newPackage = objects.keys(currentPackage).reduce((acc, key) => { @@ -34,5 +34,5 @@ export const update = (filePath: string, content: Partial) => { return acc }, currentPackage) - write(filePath, newPackage) + await write(filePath, newPackage) } diff --git a/depsynky/src/utils/pnpm.ts b/depsynky/src/utils/pnpm.ts index a666876d2..cbfd60719 100644 --- a/depsynky/src/utils/pnpm.ts +++ b/depsynky/src/utils/pnpm.ts @@ -16,7 +16,7 @@ export type PnpmWorkspace = { const PNPM_WORKSPACE_FILE = 'pnpm-workspace.yaml' -export const searchWorkspaces = (rootDir: string): PnpmWorkspace[] => { +export const searchWorkspaces = async (rootDir: string): Promise => { const pnpmWorkspacesFile = pathlib.join(rootDir, PNPM_WORKSPACE_FILE) if (!fs.existsSync(pnpmWorkspacesFile)) { throw new errors.DepSynkyError(`Could not find ${PNPM_WORKSPACE_FILE} at "${rootDir}"`) @@ -28,11 +28,12 @@ export const searchWorkspaces = (rootDir: string): PnpmWorkspace[] => { 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) })) + const workspaces = await Promise.all(absolutePaths.map(async (p) => ({ path: p, content: await pkgjson.read(p) }))) + return workspaces } -export const findDirectReferences = (rootDir: string, pkgName: string) => { - const workspaces = searchWorkspaces(rootDir) +export const findDirectReferences = async (rootDir: string, pkgName: string) => { + const workspaces = await searchWorkspaces(rootDir) const dependency = workspaces.find((w) => w.content.name === pkgName) if (!dependency) { throw new errors.DepSynkyError(`Could not find package "${pkgName}"`) @@ -41,8 +42,8 @@ export const findDirectReferences = (rootDir: string, pkgName: string) => { return { dependency, dependents } } -export const findRecursiveReferences = (rootDir: string, pkgName: string) => { - const workspaces = searchWorkspaces(rootDir) +export const findRecursiveReferences = async (rootDir: string, pkgName: string) => { + const workspaces = await searchWorkspaces(rootDir) const dependency = workspaces.find((w) => w.content.name === pkgName) if (!dependency) { throw new errors.DepSynkyError(`Could not find package "${pkgName}"`) @@ -82,8 +83,8 @@ 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) +export const listPublicPackages = async (rootDir: string): Promise => { + const workspaces = await searchWorkspaces(rootDir) return workspaces.filter((w) => !w.content.private).map((w) => w.content.name) } From cb5d773b24ecdfe010c9f0777ec5808324467e11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Levasseur?= Date: Wed, 25 Mar 2026 15:50:10 -0400 Subject: [PATCH 2/9] chore(depsynky): codebase is structured with DDD --- depsynky/src/application/application.ts | 211 ++++++++++++++++++ depsynky/src/application/index.ts | 1 + depsynky/src/bootstrap.ts | 16 ++ depsynky/src/commands/bump-versions.ts | 59 +---- depsynky/src/commands/check-versions.ts | 60 +---- depsynky/src/commands/list-versions.ts | 14 +- depsynky/src/commands/sync-versions.ts | 59 +---- depsynky/src/config.ts | 7 +- depsynky/src/infrastructure/index.ts | 3 + .../src/infrastructure/pkgjson-fs-repo.ts | 32 +++ depsynky/src/infrastructure/pnpm-fs-repo.ts | 47 ++++ .../src/infrastructure/prompt-stdin-repo.ts | 21 ++ depsynky/src/types.ts | 29 +++ depsynky/src/utils/index.ts | 1 - depsynky/src/utils/pkgjson.ts | 38 ---- depsynky/src/utils/pnpm.ts | 88 +------- 16 files changed, 387 insertions(+), 299 deletions(-) create mode 100644 depsynky/src/application/application.ts create mode 100644 depsynky/src/application/index.ts create mode 100644 depsynky/src/bootstrap.ts create mode 100644 depsynky/src/infrastructure/index.ts create mode 100644 depsynky/src/infrastructure/pkgjson-fs-repo.ts create mode 100644 depsynky/src/infrastructure/pnpm-fs-repo.ts create mode 100644 depsynky/src/infrastructure/prompt-stdin-repo.ts create mode 100644 depsynky/src/types.ts delete mode 100644 depsynky/src/utils/pkgjson.ts diff --git a/depsynky/src/application/application.ts b/depsynky/src/application/application.ts new file mode 100644 index 000000000..60ce3b8f0 --- /dev/null +++ b/depsynky/src/application/application.ts @@ -0,0 +1,211 @@ +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 _pnpmRepo: types.PnpmRepository, + private readonly _pkgJsonRepo: types.PackageJsonRepository, + private readonly _promptRepo: types.PomptRepository + ) {} + + public async bumpVersion(args: BumpVersionArgs) { + let pkgName = args.pkgName + + const { dependency, dependents } = await this._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._promptRepo.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 + await this._pkgJsonRepo.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._pnpmRepo.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._pnpmRepo.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._pnpmRepo.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._pkgJsonRepo.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 = 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 + } + + 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 = 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}` + ) + } + } + } + + private _findRecursiveReferences = async (pkgName: string) => { + const workspaces = await this._pnpmRepo.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 = utils.pnpm.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 } + } + + private _pnpmVersions = (workspaces: types.PnpmWorkspace[]): Record => { + return utils.objects.fromEntries(workspaces.map(({ content: { name, version } }) => [name, version])) + } +} diff --git a/depsynky/src/application/index.ts b/depsynky/src/application/index.ts new file mode 100644 index 000000000..26e4b031f --- /dev/null +++ b/depsynky/src/application/index.ts @@ -0,0 +1 @@ +export * from './application' diff --git a/depsynky/src/bootstrap.ts b/depsynky/src/bootstrap.ts new file mode 100644 index 000000000..c58f0edef --- /dev/null +++ b/depsynky/src/bootstrap.ts @@ -0,0 +1,16 @@ +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 pkgJsonRepo = new repo.PackageJsonFsRepo() + const pnpmRepo = new repo.PnpmFsRepo(pkgJsonRepo, argv.rootDir) + const application = new app.DepSynkyApplication(pnpmRepo, pkgJsonRepo, promptRepo) + return { + app: application, + promptRepo, + pkgJsonRepo, + pnpmRepo + } +} diff --git a/depsynky/src/commands/bump-versions.ts b/depsynky/src/commands/bump-versions.ts index 3ccf17e49..245b75267 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, pnpmRepo } = await bootstrap(argv) + let pkgName = argv.pkgName if (!pkgName) { - const publicPkgs = await utils.pnpm.listPublicPackages(argv.rootDir) + const publicPkgs = await pnpmRepo.listPublicPackages() pkgName = await promptPackage(publicPkgs) } - const { dependency, dependents } = await 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 c04b8a84c..9727fae6b 100644 --- a/depsynky/src/commands/check-versions.ts +++ b/depsynky/src/commands/check-versions.ts @@ -1,60 +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 = async ( - argv: YargsConfig, - opts: Partial = {} -) => { - const allPackages = await 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 0c977d7bb..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 = async (argv: YargsConfig) => { - const allPackages = await utils.pnpm.searchWorkspaces(argv.rootDir) - - const versions: Record = {} - - for (const { content } of allPackages) { - if (content.private) { - continue - } - versions[content.name] = content.version - } - + 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 efe168449..2a51f30de 100644 --- a/depsynky/src/commands/sync-versions.ts +++ b/depsynky/src/commands/sync-versions.ts @@ -1,59 +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 = async ( - argv: YargsConfig, - opts: Partial = {} -) => { - const allPackages = await 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/index.ts b/depsynky/src/infrastructure/index.ts new file mode 100644 index 000000000..091bd5204 --- /dev/null +++ b/depsynky/src/infrastructure/index.ts @@ -0,0 +1,3 @@ +export * from './pkgjson-fs-repo' +export * from './pnpm-fs-repo' +export * from './prompt-stdin-repo' diff --git a/depsynky/src/infrastructure/pkgjson-fs-repo.ts b/depsynky/src/infrastructure/pkgjson-fs-repo.ts new file mode 100644 index 000000000..93aab931d --- /dev/null +++ b/depsynky/src/infrastructure/pkgjson-fs-repo.ts @@ -0,0 +1,32 @@ +import * as fs from 'fs' +import * as prettier from 'prettier' +import * as objects from '../utils/objects' +import * as types from '../types' + +export class PackageJsonFsRepo implements types.PackageJsonRepository { + public read = async (filePath: string): Promise => { + const strContent = await fs.promises.readFile(filePath, 'utf-8') + 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 fs.promises.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/infrastructure/pnpm-fs-repo.ts b/depsynky/src/infrastructure/pnpm-fs-repo.ts new file mode 100644 index 000000000..15c1f5255 --- /dev/null +++ b/depsynky/src/infrastructure/pnpm-fs-repo.ts @@ -0,0 +1,47 @@ +import * as types from '../types' +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 utils from '../utils' + +export class PnpmFsRepo implements types.PnpmRepository { + public constructor(private _pkgJsonRepo: types.PackageJsonRepository, private _rootDir: string) {} + public searchWorkspaces = async () => { + const pnpmWorkspacesFile = pathlib.join(this._rootDir, utils.pnpm.PNPM_WORKSPACE_FILE) + if (!fs.existsSync(pnpmWorkspacesFile)) { + throw new errors.DepSynkyError(`Could not find ${utils.pnpm.PNPM_WORKSPACE_FILE} at "${this._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: this._rootDir })) + const absGlobMatches = globMatches.map(this._abs) + const packageJsonPaths = absGlobMatches.map((p) => pathlib.join(p, 'package.json')) + const actualPackages = packageJsonPaths.filter(fs.existsSync) + const absolutePaths = actualPackages.map(this._abs) + const workspaces = await Promise.all( + absolutePaths.map(async (p) => ({ path: p, content: await this._pkgJsonRepo.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 = utils.pnpm.findDirectDependents(workspaces, pkgName) + return { dependency, dependents } + } + + 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/infrastructure/prompt-stdin-repo.ts b/depsynky/src/infrastructure/prompt-stdin-repo.ts new file mode 100644 index 000000000..50e54427c --- /dev/null +++ b/depsynky/src/infrastructure/prompt-stdin-repo.ts @@ -0,0 +1,21 @@ +import * as prompts from 'prompts' +import * as types from '../types' + +type VersionJump = 'major' | 'minor' | 'patch' | 'none' + +export class PromptStdinRepo implements types.PomptRepository { + async promptJump(pkgName: string, currentVersion: string): Promise { + const { jump: promptedJump } = await prompts.prompt({ + type: 'select', + name: 'jump', + message: `Bump ${pkgName} version from ${currentVersion}`, + choices: [ + { title: 'Patch', value: 'patch' }, + { title: 'Minor', value: 'minor' }, + { title: 'Major', value: 'major' }, + { title: 'None', value: 'none' } + ] + }) + return promptedJump + } +} diff --git a/depsynky/src/types.ts b/depsynky/src/types.ts new file mode 100644 index 000000000..590174932 --- /dev/null +++ b/depsynky/src/types.ts @@ -0,0 +1,29 @@ +export type PackageJson = { + name: string + version: string + private?: boolean + dependencies?: Record + devDependencies?: Record + peerDependencies?: Record +} + +export type PackageJsonRepository = { + 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 PnpmRepository = { + searchWorkspaces: () => Promise + findDirectReferences: (pkgName: string) => Promise<{ dependency: PnpmWorkspace; dependents: PnpmWorkspace[] }> + listPublicPackages: () => Promise +} + +export type PomptRepository = { + promptJump: (pkgName: string, currentVersion: string) => Promise<'patch' | 'minor' | 'major' | 'none'> +} diff --git a/depsynky/src/utils/index.ts b/depsynky/src/utils/index.ts index dd574a8b9..f49fde413 100644 --- a/depsynky/src/utils/index.ts +++ b/depsynky/src/utils/index.ts @@ -1,4 +1,3 @@ -export * as pkgjson from './pkgjson' export * as logging from './logging' export * as pnpm from './pnpm' export * as objects from './objects' diff --git a/depsynky/src/utils/pkgjson.ts b/depsynky/src/utils/pkgjson.ts deleted file mode 100644 index 8f89e3bfb..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 = async (filePath: string): Promise => { - const strContent = await fs.promises.readFile(filePath, 'utf-8') - const content = JSON.parse(strContent) - return content -} - -export const write = async (filePath: string, content: PackageJson): Promise => { - let strContent = JSON.stringify(content, null, 2) - strContent = prettier.format(strContent, { parser: 'json' }) - await fs.promises.writeFile(filePath, strContent) -} - -export const update = async (filePath: string, content: Partial) => { - const currentPackage = await 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 write(filePath, newPackage) -} diff --git a/depsynky/src/utils/pnpm.ts b/depsynky/src/utils/pnpm.ts index cbfd60719..8f4df086f 100644 --- a/depsynky/src/utils/pnpm.ts +++ b/depsynky/src/utils/pnpm.ts @@ -1,91 +1,13 @@ -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' +import * as types from '../types' -const abs = (rootDir: string) => (p: string) => pathlib.resolve(rootDir, p) +export const PNPM_WORKSPACE_FILE = 'pnpm-workspace.yaml' +export const LOCAL_VERSION_PREFIX = 'workspace:' -export type PnpmWorkspace = { - path: string - content: pkgjson.PackageJson -} - -const PNPM_WORKSPACE_FILE = 'pnpm-workspace.yaml' - -export const searchWorkspaces = async (rootDir: string): Promise => { - 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)) - const workspaces = await Promise.all(absolutePaths.map(async (p) => ({ path: p, content: await pkgjson.read(p) }))) - return workspaces -} - -export const findDirectReferences = async (rootDir: string, pkgName: string) => { - const workspaces = await 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 = async (rootDir: string, pkgName: string) => { - const workspaces = await 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[] => { +export const findDirectDependents = (workspaces: types.PnpmWorkspace[], pkgName: string): types.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 = async (rootDir: string): Promise => { - const workspaces = await searchWorkspaces(rootDir) - return workspaces.filter((w) => !w.content.private).map((w) => w.content.name) -} - -export const isLocalVersion = (version: string) => version.startsWith('workspace:') +export const isLocalVersion = (version: string) => version.startsWith(LOCAL_VERSION_PREFIX) From 3a30c24ed4087055b11dfe16344cb8eeabff41f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Levasseur?= Date: Wed, 25 Mar 2026 16:02:55 -0400 Subject: [PATCH 3/9] chore(depsynky): add test in-memory repositories --- depsynky/package.json | 2 + depsynky/pnpm-lock.yaml | 15 +++++++ .../src/__tests__/infrastructure/mem-fs.ts | 26 ++++++++++++ .../infrastructure/pkgjson-mem-repo.ts | 34 +++++++++++++++ .../__tests__/infrastructure/pnpm-mem-repo.ts | 42 +++++++++++++++++++ 5 files changed, 119 insertions(+) create mode 100644 depsynky/src/__tests__/infrastructure/mem-fs.ts create mode 100644 depsynky/src/__tests__/infrastructure/pkgjson-mem-repo.ts create mode 100644 depsynky/src/__tests__/infrastructure/pnpm-mem-repo.ts diff --git a/depsynky/package.json b/depsynky/package.json index b3d91c9dc..d8267dacf 100644 --- a/depsynky/package.json +++ b/depsynky/package.json @@ -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__/infrastructure/mem-fs.ts b/depsynky/src/__tests__/infrastructure/mem-fs.ts new file mode 100644 index 000000000..1581674f5 --- /dev/null +++ b/depsynky/src/__tests__/infrastructure/mem-fs.ts @@ -0,0 +1,26 @@ +import picomatch from 'picomatch' + +export class InMemoryFileSystem { + 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__/infrastructure/pkgjson-mem-repo.ts b/depsynky/src/__tests__/infrastructure/pkgjson-mem-repo.ts new file mode 100644 index 000000000..8ef7d04f3 --- /dev/null +++ b/depsynky/src/__tests__/infrastructure/pkgjson-mem-repo.ts @@ -0,0 +1,34 @@ +import * as prettier from 'prettier' +import * as objects from '../../utils/objects' +import * as types from '../../types' +import { InMemoryFileSystem } from './mem-fs' + +export class PackageJsonInMemoryRepo implements types.PackageJsonRepository { + public constructor(private _fs: InMemoryFileSystem) {} + + 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/__tests__/infrastructure/pnpm-mem-repo.ts b/depsynky/src/__tests__/infrastructure/pnpm-mem-repo.ts new file mode 100644 index 000000000..275417027 --- /dev/null +++ b/depsynky/src/__tests__/infrastructure/pnpm-mem-repo.ts @@ -0,0 +1,42 @@ +import * as types from '../../types' +import * as yaml from 'yaml' +import * as errors from '../../errors' +import * as utils from '../../utils' +import * as pathlib from 'path' +import { InMemoryFileSystem } from './mem-fs' + +export class PnpmInMemoryRepo implements types.PnpmRepository { + public constructor(private _pkgJsonRepo: types.PackageJsonRepository, private _fs: InMemoryFileSystem) {} + public searchWorkspaces = async () => { + const pnpmWorkspacesFile = utils.pnpm.PNPM_WORKSPACE_FILE + if (!this._fs.existsSync(pnpmWorkspacesFile)) { + throw new errors.DepSynkyError(`Could not find ${utils.pnpm.PNPM_WORKSPACE_FILE} at root directory`) + } + const pnpmWorkspacesContent = await this._fs.readFile(pnpmWorkspacesFile) + const pnpmWorkspaces: string[] = yaml.parse(pnpmWorkspacesContent).packages + const absGlobMatches = pnpmWorkspaces.flatMap((ws) => this._fs.globSync(ws)) + const packageJsonPaths = absGlobMatches.map((p) => pathlib.join(p, 'package.json')) + const absolutePaths = packageJsonPaths.filter((f) => this._fs.existsSync(f)) + const workspaces = await Promise.all( + absolutePaths.map(async (p) => ({ path: p, content: await this._pkgJsonRepo.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 = utils.pnpm.findDirectDependents(workspaces, pkgName) + return { dependency, dependents } + } + + public listPublicPackages = async (): Promise => { + const workspaces = await this.searchWorkspaces() + return workspaces.filter((w) => !w.content.private).map((w) => w.content.name) + } +} From 461c3bf095dfa5dd64ae480afad957fd9d384fc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Levasseur?= Date: Wed, 25 Mar 2026 16:49:36 -0400 Subject: [PATCH 4/9] chore(depsynky): only fs is a repo and pnpm and json are services --- .../src/__tests__/infrastructure/mem-fs.ts | 3 +- .../infrastructure/pkgjson-mem-repo.ts | 34 ------- .../__tests__/infrastructure/pnpm-mem-repo.ts | 42 --------- depsynky/src/application/application.ts | 63 +++++-------- depsynky/src/application/index.ts | 2 + .../pkgjson-service.ts} | 9 +- depsynky/src/application/pnpm-service.ts | 93 +++++++++++++++++++ depsynky/src/bootstrap.ts | 13 +-- depsynky/src/commands/bump-versions.ts | 4 +- depsynky/src/infrastructure/fs-repo.ts | 21 +++++ depsynky/src/infrastructure/index.ts | 3 +- depsynky/src/infrastructure/pnpm-fs-repo.ts | 47 ---------- .../src/infrastructure/prompt-stdin-repo.ts | 26 +++--- depsynky/src/types.ts | 21 ++++- depsynky/src/utils/index.ts | 1 - depsynky/src/utils/pnpm.ts | 13 --- 16 files changed, 182 insertions(+), 213 deletions(-) delete mode 100644 depsynky/src/__tests__/infrastructure/pkgjson-mem-repo.ts delete mode 100644 depsynky/src/__tests__/infrastructure/pnpm-mem-repo.ts rename depsynky/src/{infrastructure/pkgjson-fs-repo.ts => application/pkgjson-service.ts} (79%) create mode 100644 depsynky/src/application/pnpm-service.ts create mode 100644 depsynky/src/infrastructure/fs-repo.ts delete mode 100644 depsynky/src/infrastructure/pnpm-fs-repo.ts delete mode 100644 depsynky/src/utils/pnpm.ts diff --git a/depsynky/src/__tests__/infrastructure/mem-fs.ts b/depsynky/src/__tests__/infrastructure/mem-fs.ts index 1581674f5..1ad945905 100644 --- a/depsynky/src/__tests__/infrastructure/mem-fs.ts +++ b/depsynky/src/__tests__/infrastructure/mem-fs.ts @@ -1,6 +1,7 @@ import picomatch from 'picomatch' +import * as types from '../../types' -export class InMemoryFileSystem { +export class InMemoryFileSystem implements types.FsRepository { public constructor(private _files: Record) {} public existsSync = (path: string): boolean => { diff --git a/depsynky/src/__tests__/infrastructure/pkgjson-mem-repo.ts b/depsynky/src/__tests__/infrastructure/pkgjson-mem-repo.ts deleted file mode 100644 index 8ef7d04f3..000000000 --- a/depsynky/src/__tests__/infrastructure/pkgjson-mem-repo.ts +++ /dev/null @@ -1,34 +0,0 @@ -import * as prettier from 'prettier' -import * as objects from '../../utils/objects' -import * as types from '../../types' -import { InMemoryFileSystem } from './mem-fs' - -export class PackageJsonInMemoryRepo implements types.PackageJsonRepository { - public constructor(private _fs: InMemoryFileSystem) {} - - 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/__tests__/infrastructure/pnpm-mem-repo.ts b/depsynky/src/__tests__/infrastructure/pnpm-mem-repo.ts deleted file mode 100644 index 275417027..000000000 --- a/depsynky/src/__tests__/infrastructure/pnpm-mem-repo.ts +++ /dev/null @@ -1,42 +0,0 @@ -import * as types from '../../types' -import * as yaml from 'yaml' -import * as errors from '../../errors' -import * as utils from '../../utils' -import * as pathlib from 'path' -import { InMemoryFileSystem } from './mem-fs' - -export class PnpmInMemoryRepo implements types.PnpmRepository { - public constructor(private _pkgJsonRepo: types.PackageJsonRepository, private _fs: InMemoryFileSystem) {} - public searchWorkspaces = async () => { - const pnpmWorkspacesFile = utils.pnpm.PNPM_WORKSPACE_FILE - if (!this._fs.existsSync(pnpmWorkspacesFile)) { - throw new errors.DepSynkyError(`Could not find ${utils.pnpm.PNPM_WORKSPACE_FILE} at root directory`) - } - const pnpmWorkspacesContent = await this._fs.readFile(pnpmWorkspacesFile) - const pnpmWorkspaces: string[] = yaml.parse(pnpmWorkspacesContent).packages - const absGlobMatches = pnpmWorkspaces.flatMap((ws) => this._fs.globSync(ws)) - const packageJsonPaths = absGlobMatches.map((p) => pathlib.join(p, 'package.json')) - const absolutePaths = packageJsonPaths.filter((f) => this._fs.existsSync(f)) - const workspaces = await Promise.all( - absolutePaths.map(async (p) => ({ path: p, content: await this._pkgJsonRepo.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 = utils.pnpm.findDirectDependents(workspaces, pkgName) - return { dependency, dependents } - } - - public listPublicPackages = async (): Promise => { - const workspaces = await this.searchWorkspaces() - return workspaces.filter((w) => !w.content.private).map((w) => w.content.name) - } -} diff --git a/depsynky/src/application/application.ts b/depsynky/src/application/application.ts index 60ce3b8f0..494cc40f5 100644 --- a/depsynky/src/application/application.ts +++ b/depsynky/src/application/application.ts @@ -26,15 +26,15 @@ export type SyncVersionsArgs = { export class DepSynkyApplication { public constructor( - private readonly _pnpmRepo: types.PnpmRepository, - private readonly _pkgJsonRepo: types.PackageJsonRepository, - private readonly _promptRepo: types.PomptRepository + private readonly _pnpm: types.PnpmService, + private readonly _pkgJson: types.PackageJsonService, + private readonly _prompt: types.PromptRepository ) {} public async bumpVersion(args: BumpVersionArgs) { let pkgName = args.pkgName - const { dependency, dependents } = await this._findRecursiveReferences(pkgName) + const { dependency, dependents } = await this._pnpm.findRecursiveReferences(pkgName) const targetPackages = [dependency, ...dependents] const currentVersions = this._pnpmVersions(targetPackages) @@ -45,7 +45,16 @@ export class DepSynkyApplication { continue // no need to bump the version of private packages } - const jump = await this._promptRepo.promptJump(content.name, content.version) + const jump = await this._prompt.promptChoices({ + message: `Bump ${pkgName} version from ${content.version}`, + choices: [ + { name: 'Patch', value: 'patch' }, + { name: 'Minor', value: 'minor' }, + { name: 'Major', value: 'major' }, + { name: 'None', value: 'none' } + ] + }) + if (jump === 'none') { continue } @@ -56,7 +65,7 @@ export class DepSynkyApplication { } targetVersions[content.name] = next - await this._pkgJsonRepo.update(pkgPath, { version: next }) + await this._pkgJson.update(pkgPath, { version: next }) } await this.listVersions() @@ -67,7 +76,7 @@ export class DepSynkyApplication { } public async checkVersions(args: CheckVersionsArgs) { - const allPackages = await this._pnpmRepo.searchWorkspaces() + const allPackages = await this._pnpm.searchWorkspaces() const targetVersions = args.targetVersions ?? this._pnpmVersions(allPackages) for (const { content } of allPackages) { @@ -83,7 +92,7 @@ export class DepSynkyApplication { } public async listVersions(): Promise { - const allPackages = await this._pnpmRepo.searchWorkspaces() + const allPackages = await this._pnpm.searchWorkspaces() const versions: Record = {} @@ -98,7 +107,7 @@ export class DepSynkyApplication { } public async syncVersions(args: SyncVersionsArgs) { - const allPackages = await this._pnpmRepo.searchWorkspaces() + const allPackages = await this._pnpm.searchWorkspaces() const targetVersions = args.targetVersions ?? this._pnpmVersions(allPackages) for (const { path: pkgPath, content } of allPackages) { @@ -110,7 +119,7 @@ export class DepSynkyApplication { const updatedPeerDeps = args.ignorePeers ? peerDependencies : update(peerDependencies, targetVersions) const updatedDevDeps = args.ignoreDev ? devDependencies : update(devDependencies, targetVersions) - await this._pkgJsonRepo.update(pkgPath, { + await this._pkgJson.update(pkgPath, { dependencies: updatedDeps, devDependencies: updatedDevDeps, peerDependencies: updatedPeerDeps @@ -129,7 +138,7 @@ export class DepSynkyApplication { if (!currentVersion) { continue } - const isLocal = utils.pnpm.isLocalVersion(currentVersion) + const isLocal = this._pnpm.isLocalVersion(currentVersion) const isPublic = !pkg.private if (isLocal) { if (isPublic) { @@ -156,7 +165,7 @@ export class DepSynkyApplication { if (!currentVersion) { continue } - const isLocal = utils.pnpm.isLocalVersion(currentVersion) + const isLocal = this._pnpm.isLocalVersion(currentVersion) const isPublic = !pkg.private if (isLocal) { if (isPublic) { @@ -175,36 +184,6 @@ export class DepSynkyApplication { } } - private _findRecursiveReferences = async (pkgName: string) => { - const workspaces = await this._pnpmRepo.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 = utils.pnpm.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 } - } - private _pnpmVersions = (workspaces: types.PnpmWorkspace[]): Record => { return utils.objects.fromEntries(workspaces.map(({ content: { name, version } }) => [name, version])) } diff --git a/depsynky/src/application/index.ts b/depsynky/src/application/index.ts index 26e4b031f..defa149b3 100644 --- a/depsynky/src/application/index.ts +++ b/depsynky/src/application/index.ts @@ -1 +1,3 @@ export * from './application' +export * from './pkgjson-service' +export * from './pnpm-service' diff --git a/depsynky/src/infrastructure/pkgjson-fs-repo.ts b/depsynky/src/application/pkgjson-service.ts similarity index 79% rename from depsynky/src/infrastructure/pkgjson-fs-repo.ts rename to depsynky/src/application/pkgjson-service.ts index 93aab931d..92f8c5859 100644 --- a/depsynky/src/infrastructure/pkgjson-fs-repo.ts +++ b/depsynky/src/application/pkgjson-service.ts @@ -1,11 +1,12 @@ -import * as fs from 'fs' import * as prettier from 'prettier' import * as objects from '../utils/objects' import * as types from '../types' -export class PackageJsonFsRepo implements types.PackageJsonRepository { +export class PackageJsonService implements types.PackageJsonService { + public constructor(private _fs: types.FsRepository) {} + public read = async (filePath: string): Promise => { - const strContent = await fs.promises.readFile(filePath, 'utf-8') + const strContent = await this._fs.readFile(filePath) const content = JSON.parse(strContent) return content } @@ -13,7 +14,7 @@ export class PackageJsonFsRepo implements types.PackageJsonRepository { public write = async (filePath: string, content: types.PackageJson): Promise => { let strContent = JSON.stringify(content, null, 2) strContent = prettier.format(strContent, { parser: 'json' }) - await fs.promises.writeFile(filePath, strContent) + await this._fs.writeFile(filePath, strContent) } public update = async (filePath: string, content: Partial) => { diff --git a/depsynky/src/application/pnpm-service.ts b/depsynky/src/application/pnpm-service.ts new file mode 100644 index 000000000..8db6083d9 --- /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' + +const PNPM_WORKSPACE_FILE = 'pnpm-workspace.yaml' +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 index c58f0edef..01e9880a2 100644 --- a/depsynky/src/bootstrap.ts +++ b/depsynky/src/bootstrap.ts @@ -4,13 +4,14 @@ import * as config from './config' export const bootstrap = async (argv: config.CommonConfig) => { const promptRepo = new repo.PromptStdinRepo() - const pkgJsonRepo = new repo.PackageJsonFsRepo() - const pnpmRepo = new repo.PnpmFsRepo(pkgJsonRepo, argv.rootDir) - const application = new app.DepSynkyApplication(pnpmRepo, pkgJsonRepo, promptRepo) + const fs = new repo.FsRepo() + + const pkgJsonService = new app.PackageJsonService(fs) + const pnpmWorkspaceService = new app.PnpmWorkspaceService(pkgJsonService, fs, argv.rootDir) + const application = new app.DepSynkyApplication(pnpmWorkspaceService, pkgJsonService, promptRepo) return { app: application, - promptRepo, - pkgJsonRepo, - pnpmRepo + pnpm: pnpmWorkspaceService, + pkgJson: pkgJsonService } } diff --git a/depsynky/src/commands/bump-versions.ts b/depsynky/src/commands/bump-versions.ts index 245b75267..b011369ce 100644 --- a/depsynky/src/commands/bump-versions.ts +++ b/depsynky/src/commands/bump-versions.ts @@ -24,11 +24,11 @@ const promptPackage = async (publicPkgs: string[]): Promise => { } export const bumpVersion = async (argv: YargsConfig & { pkgName?: string }) => { - const { app, pnpmRepo } = await bootstrap(argv) + const { app, pnpm } = await bootstrap(argv) let pkgName = argv.pkgName if (!pkgName) { - const publicPkgs = await pnpmRepo.listPublicPackages() + const publicPkgs = await pnpm.listPublicPackages() pkgName = await promptPackage(publicPkgs) } 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 index 091bd5204..bcf7753e0 100644 --- a/depsynky/src/infrastructure/index.ts +++ b/depsynky/src/infrastructure/index.ts @@ -1,3 +1,2 @@ -export * from './pkgjson-fs-repo' -export * from './pnpm-fs-repo' export * from './prompt-stdin-repo' +export * from './fs-repo' diff --git a/depsynky/src/infrastructure/pnpm-fs-repo.ts b/depsynky/src/infrastructure/pnpm-fs-repo.ts deleted file mode 100644 index 15c1f5255..000000000 --- a/depsynky/src/infrastructure/pnpm-fs-repo.ts +++ /dev/null @@ -1,47 +0,0 @@ -import * as types from '../types' -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 utils from '../utils' - -export class PnpmFsRepo implements types.PnpmRepository { - public constructor(private _pkgJsonRepo: types.PackageJsonRepository, private _rootDir: string) {} - public searchWorkspaces = async () => { - const pnpmWorkspacesFile = pathlib.join(this._rootDir, utils.pnpm.PNPM_WORKSPACE_FILE) - if (!fs.existsSync(pnpmWorkspacesFile)) { - throw new errors.DepSynkyError(`Could not find ${utils.pnpm.PNPM_WORKSPACE_FILE} at "${this._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: this._rootDir })) - const absGlobMatches = globMatches.map(this._abs) - const packageJsonPaths = absGlobMatches.map((p) => pathlib.join(p, 'package.json')) - const actualPackages = packageJsonPaths.filter(fs.existsSync) - const absolutePaths = actualPackages.map(this._abs) - const workspaces = await Promise.all( - absolutePaths.map(async (p) => ({ path: p, content: await this._pkgJsonRepo.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 = utils.pnpm.findDirectDependents(workspaces, pkgName) - return { dependency, dependents } - } - - 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/infrastructure/prompt-stdin-repo.ts b/depsynky/src/infrastructure/prompt-stdin-repo.ts index 50e54427c..4f74371be 100644 --- a/depsynky/src/infrastructure/prompt-stdin-repo.ts +++ b/depsynky/src/infrastructure/prompt-stdin-repo.ts @@ -1,21 +1,17 @@ -import * as prompts from 'prompts' +import prompts from 'prompts' import * as types from '../types' -type VersionJump = 'major' | 'minor' | 'patch' | 'none' - -export class PromptStdinRepo implements types.PomptRepository { - async promptJump(pkgName: string, currentVersion: string): Promise { - const { jump: promptedJump } = await prompts.prompt({ +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: 'jump', - message: `Bump ${pkgName} version from ${currentVersion}`, - choices: [ - { title: 'Patch', value: 'patch' }, - { title: 'Minor', value: 'minor' }, - { title: 'Major', value: 'major' }, - { title: 'None', value: 'none' } - ] + name: 'x', + message: args.message, + choices: args.choices.map((c) => ({ title: c.name, value: c.value })) }) - return promptedJump + return x } } diff --git a/depsynky/src/types.ts b/depsynky/src/types.ts index 590174932..2f445e1de 100644 --- a/depsynky/src/types.ts +++ b/depsynky/src/types.ts @@ -7,7 +7,7 @@ export type PackageJson = { peerDependencies?: Record } -export type PackageJsonRepository = { +export type PackageJsonService = { read: (filePath: string) => Promise write: (filePath: string, content: PackageJson) => Promise update: (filePath: string, content: Partial) => Promise @@ -18,12 +18,25 @@ export type PnpmWorkspace = { content: PackageJson } -export type PnpmRepository = { +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 PomptRepository = { - promptJump: (pkgName: string, currentVersion: string) => Promise<'patch' | 'minor' | 'major' | 'none'> +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 f49fde413..05f56b98b 100644 --- a/depsynky/src/utils/index.ts +++ b/depsynky/src/utils/index.ts @@ -1,5 +1,4 @@ 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/pnpm.ts b/depsynky/src/utils/pnpm.ts deleted file mode 100644 index 8f4df086f..000000000 --- a/depsynky/src/utils/pnpm.ts +++ /dev/null @@ -1,13 +0,0 @@ -import * as types from '../types' - -export const PNPM_WORKSPACE_FILE = 'pnpm-workspace.yaml' -export const LOCAL_VERSION_PREFIX = 'workspace:' - -export const findDirectDependents = (workspaces: types.PnpmWorkspace[], pkgName: string): types.PnpmWorkspace[] => { - return workspaces.filter( - (w) => - w.content.dependencies?.[pkgName] || w.content.devDependencies?.[pkgName] || w.content.peerDependencies?.[pkgName] - ) -} - -export const isLocalVersion = (version: string) => version.startsWith(LOCAL_VERSION_PREFIX) From babac3b3fa846d2d17f704d5b778f958a6144f9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Levasseur?= Date: Fri, 3 Apr 2026 19:33:32 -0400 Subject: [PATCH 5/9] chore: add buch of tests --- depsynky/src/__tests__/bump.test.ts | 163 ++++++++++++++++++ depsynky/src/__tests__/check.test.ts | 147 ++++++++++++++++ depsynky/src/__tests__/ls.test.ts | 77 +++++++++ depsynky/src/__tests__/sync.test.ts | 160 +++++++++++++++++ .../{infrastructure => utils}/mem-fs.ts | 0 depsynky/src/__tests__/utils/test-setup.ts | 55 ++++++ depsynky/src/application/application.ts | 13 +- depsynky/src/application/bump-service.ts | 19 ++ depsynky/src/application/index.ts | 1 + depsynky/src/application/pnpm-service.ts | 4 +- depsynky/src/bootstrap.ts | 4 +- depsynky/src/types.ts | 6 + 12 files changed, 637 insertions(+), 12 deletions(-) create mode 100644 depsynky/src/__tests__/bump.test.ts create mode 100644 depsynky/src/__tests__/check.test.ts create mode 100644 depsynky/src/__tests__/ls.test.ts create mode 100644 depsynky/src/__tests__/sync.test.ts rename depsynky/src/__tests__/{infrastructure => utils}/mem-fs.ts (100%) create mode 100644 depsynky/src/__tests__/utils/test-setup.ts create mode 100644 depsynky/src/application/bump-service.ts diff --git a/depsynky/src/__tests__/bump.test.ts b/depsynky/src/__tests__/bump.test.ts new file mode 100644 index 000000000..7e8269d52 --- /dev/null +++ b/depsynky/src/__tests__/bump.test.ts @@ -0,0 +1,163 @@ +import { test, expect, describe } from 'vitest' +import { buildApp, readPkgJson } from './utils/test-setup' +import { DepSynkyError } from '../errors' + +describe('bumpVersion', () => { + test('bumps a package version with patch', async () => { + const { app, fs } = 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 readPkgJson(fs, 'pkg-a') + expect(pkgA.version).toBe('1.0.1') + }) + + test('bumps a package version with minor', async () => { + const { app, fs } = buildApp( + { + packages: [{ name: 'pkg-a', version: '1.0.0' }] + }, + async () => 'minor' + ) + + await app.bumpVersion({ pkgName: 'pkg-a', sync: false }) + + const pkgA = await readPkgJson(fs, 'pkg-a') + expect(pkgA.version).toBe('1.1.0') + }) + + test('bumps a package version with major', async () => { + const { app, fs } = buildApp( + { + packages: [{ name: 'pkg-a', version: '1.0.0' }] + }, + async () => 'major' + ) + + await app.bumpVersion({ pkgName: 'pkg-a', sync: false }) + + const pkgA = await readPkgJson(fs, 'pkg-a') + expect(pkgA.version).toBe('2.0.0') + }) + + test('skips bump when "none" is selected', async () => { + const { app, fs } = buildApp( + { + packages: [{ name: 'pkg-a', version: '1.0.0' }] + }, + async () => 'none' + ) + + await app.bumpVersion({ pkgName: 'pkg-a', sync: false }) + + const pkgA = await readPkgJson(fs, 'pkg-a') + expect(pkgA.version).toBe('1.0.0') + }) + + test('skips private packages during bump', async () => { + const { app, fs } = 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 readPkgJson(fs, 'pkg-a') + expect(pkgA.version).toBe('1.0.1') + + const pkgB = await readPkgJson(fs, 'pkg-b') + expect(pkgB.version).toBe('1.0.0') // not bumped + }) + + test('bumps dependents recursively', async () => { + const { app, fs } = 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 readPkgJson(fs, 'pkg-a') + expect(pkgA.version).toBe('1.0.1') + + const pkgB = await readPkgJson(fs, 'pkg-b') + expect(pkgB.version).toBe('1.1.0') + + const pkgC = await readPkgJson(fs, 'pkg-c') + expect(pkgC.version).toBe('1.0.1') + }) + + test('calls syncVersions when sync is true', async () => { + const { app, fs } = 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 readPkgJson(fs, '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 readPkgJson(fs, '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, fs } = 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 readPkgJson(fs, '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..5bed3af3b --- /dev/null +++ b/depsynky/src/__tests__/sync.test.ts @@ -0,0 +1,160 @@ +import { test, expect, describe } from 'vitest' +import { buildApp, readPkgJson } from './utils/test-setup' + +describe('syncVersions', () => { + test('updates dependencies to match target versions', async () => { + const { app, fs } = 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 readPkgJson(fs, 'pkg-b') + expect(pkgB.dependencies?.['pkg-a']).toBe('^2.0.0') + }) + + test('updates devDependencies to match target versions', async () => { + const { app, fs } = 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 readPkgJson(fs, 'pkg-b') + expect(pkgB.devDependencies?.['pkg-a']).toBe('^2.0.0') + }) + + test('updates peerDependencies to match target versions', async () => { + const { app, fs } = 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 readPkgJson(fs, 'pkg-b') + expect(pkgB.peerDependencies?.['pkg-a']).toBe('^2.0.0') + }) + + test('skips peerDependencies when ignorePeers is true', async () => { + const { app, fs } = 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 readPkgJson(fs, 'pkg-b') + expect(pkgB.peerDependencies?.['pkg-a']).toBe('^1.0.0') + }) + + test('skips devDependencies when ignoreDev is true', async () => { + const { app, fs } = 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 readPkgJson(fs, 'pkg-b') + expect(pkgB.devDependencies?.['pkg-a']).toBe('^1.0.0') + }) + + test('preserves workspace: references for private packages', async () => { + const { app, fs } = 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 readPkgJson(fs, 'pkg-b') + expect(pkgB.dependencies?.['pkg-a']).toBe('workspace:*') + }) + + test('uses custom targetVersions when provided', async () => { + const { app, fs } = 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 readPkgJson(fs, 'pkg-b') + expect(pkgB.dependencies?.['pkg-a']).toBe('^1.5.0') + }) + + test('preserves tilde ranges when syncing', async () => { + const { app, fs } = 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 readPkgJson(fs, 'pkg-b') + expect(pkgB.dependencies?.['pkg-a']).toBe('~2.0.0') + }) + + test('does not modify packages with no matching dependencies', async () => { + const { app, fs } = 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 readPkgJson(fs, 'pkg-b') + expect(pkgB.dependencies?.['lodash']).toBe('^4.0.0') + }) + + test('syncs multiple packages at once', async () => { + const { app, fs } = 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 readPkgJson(fs, '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, fs } = 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 readPkgJson(fs, 'pkg-b') + expect(pkgB.dependencies?.['pkg-a']).toBe('^1.0.0') + }) +}) diff --git a/depsynky/src/__tests__/infrastructure/mem-fs.ts b/depsynky/src/__tests__/utils/mem-fs.ts similarity index 100% rename from depsynky/src/__tests__/infrastructure/mem-fs.ts rename to depsynky/src/__tests__/utils/mem-fs.ts diff --git a/depsynky/src/__tests__/utils/test-setup.ts b/depsynky/src/__tests__/utils/test-setup.ts new file mode 100644 index 000000000..948bf7d52 --- /dev/null +++ b/depsynky/src/__tests__/utils/test-setup.ts @@ -0,0 +1,55 @@ +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[] +} + +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, bump: types.BumpService['promptJump'] = async () => 'none') => { + const fs = _buildFs(monorepo) + const pkg = new PackageJsonService(fs) + const pnpm = new PnpmWorkspaceService(pkg, fs, ROOT_DIR) + const app = new DepSynkyApplication(pnpm, pkg, { promptJump: bump }) + return { app, fs, prompt: bump, pkg, pnpm } +} + +export const readPkgJson = async (fs: InMemoryFileSystem, pkgName: string) => { + const dirName = kebabCase(pkgName) + const pkgJsonPath = `${ROOT_DIR}/packages/${dirName}/package.json` + const content = await fs.readFile(pkgJsonPath) + return JSON.parse(content) as types.PackageJson +} diff --git a/depsynky/src/application/application.ts b/depsynky/src/application/application.ts index 494cc40f5..137d9b5ad 100644 --- a/depsynky/src/application/application.ts +++ b/depsynky/src/application/application.ts @@ -28,7 +28,7 @@ export class DepSynkyApplication { public constructor( private readonly _pnpm: types.PnpmService, private readonly _pkgJson: types.PackageJsonService, - private readonly _prompt: types.PromptRepository + private readonly _bump: types.BumpService ) {} public async bumpVersion(args: BumpVersionArgs) { @@ -45,14 +45,9 @@ export class DepSynkyApplication { continue // no need to bump the version of private packages } - const jump = await this._prompt.promptChoices({ - message: `Bump ${pkgName} version from ${content.version}`, - choices: [ - { name: 'Patch', value: 'patch' }, - { name: 'Minor', value: 'minor' }, - { name: 'Major', value: 'major' }, - { name: 'None', value: 'none' } - ] + const jump = await this._bump.promptJump({ + pkgName: content.name, + currentVersion: content.version }) if (jump === 'none') { 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 index defa149b3..f036daf6f 100644 --- a/depsynky/src/application/index.ts +++ b/depsynky/src/application/index.ts @@ -1,3 +1,4 @@ export * from './application' export * from './pkgjson-service' export * from './pnpm-service' +export * from './bump-service' diff --git a/depsynky/src/application/pnpm-service.ts b/depsynky/src/application/pnpm-service.ts index 8db6083d9..f0457fa27 100644 --- a/depsynky/src/application/pnpm-service.ts +++ b/depsynky/src/application/pnpm-service.ts @@ -4,8 +4,8 @@ import * as yaml from 'yaml' import * as errors from '../errors' import * as utils from '../utils' -const PNPM_WORKSPACE_FILE = 'pnpm-workspace.yaml' -const LOCAL_VERSION_PREFIX = 'workspace:' +export const PNPM_WORKSPACE_FILE = 'pnpm-workspace.yaml' +export const LOCAL_VERSION_PREFIX = 'workspace:' export class PnpmWorkspaceService implements types.PnpmService { public constructor( diff --git a/depsynky/src/bootstrap.ts b/depsynky/src/bootstrap.ts index 01e9880a2..bc41812f2 100644 --- a/depsynky/src/bootstrap.ts +++ b/depsynky/src/bootstrap.ts @@ -8,7 +8,9 @@ export const bootstrap = async (argv: config.CommonConfig) => { const pkgJsonService = new app.PackageJsonService(fs) const pnpmWorkspaceService = new app.PnpmWorkspaceService(pkgJsonService, fs, argv.rootDir) - const application = new app.DepSynkyApplication(pnpmWorkspaceService, pkgJsonService, promptRepo) + const bumpService = new app.BumpService(promptRepo) + + const application = new app.DepSynkyApplication(pnpmWorkspaceService, pkgJsonService, bumpService) return { app: application, pnpm: pnpmWorkspaceService, diff --git a/depsynky/src/types.ts b/depsynky/src/types.ts index 2f445e1de..8a9a771d6 100644 --- a/depsynky/src/types.ts +++ b/depsynky/src/types.ts @@ -26,6 +26,12 @@ export type PnpmService = { 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 From 352fa7db052956da49a0be78e9ba78b08b2d3ed2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Levasseur?= Date: Fri, 3 Apr 2026 19:34:25 -0400 Subject: [PATCH 6/9] update --- depsynky/src/__tests__/utils/test-setup.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/depsynky/src/__tests__/utils/test-setup.ts b/depsynky/src/__tests__/utils/test-setup.ts index 948bf7d52..bb747deee 100644 --- a/depsynky/src/__tests__/utils/test-setup.ts +++ b/depsynky/src/__tests__/utils/test-setup.ts @@ -39,12 +39,13 @@ const _buildFs = (monorepo: Monorepo): InMemoryFileSystem => { return new InMemoryFileSystem(files) } -export const buildApp = (monorepo: Monorepo, bump: types.BumpService['promptJump'] = async () => 'none') => { +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 app = new DepSynkyApplication(pnpm, pkg, { promptJump: bump }) - return { app, fs, prompt: bump, pkg, pnpm } + const bump: types.BumpService = { promptJump: bumpFn } + const app = new DepSynkyApplication(pnpm, pkg, bump) + return { app, fs, bump, pkg, pnpm } } export const readPkgJson = async (fs: InMemoryFileSystem, pkgName: string) => { From 977d742f56c01bd7d39ff01af24e900616a0d532 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Levasseur?= Date: Fri, 3 Apr 2026 19:40:45 -0400 Subject: [PATCH 7/9] update --- depsynky/src/__tests__/bump.test.ts | 42 ++++++++++---------- depsynky/src/__tests__/sync.test.ts | 46 +++++++++++----------- depsynky/src/__tests__/utils/test-setup.ts | 33 ++++++++++------ 3 files changed, 65 insertions(+), 56 deletions(-) diff --git a/depsynky/src/__tests__/bump.test.ts b/depsynky/src/__tests__/bump.test.ts index 7e8269d52..1ccb9e6e4 100644 --- a/depsynky/src/__tests__/bump.test.ts +++ b/depsynky/src/__tests__/bump.test.ts @@ -1,10 +1,10 @@ import { test, expect, describe } from 'vitest' -import { buildApp, readPkgJson } from './utils/test-setup' +import { buildApp } from './utils/test-setup' import { DepSynkyError } from '../errors' describe('bumpVersion', () => { test('bumps a package version with patch', async () => { - const { app, fs } = buildApp( + const { app, pkg } = buildApp( { packages: [ { name: 'pkg-a', version: '1.0.0' }, @@ -16,12 +16,12 @@ describe('bumpVersion', () => { await app.bumpVersion({ pkgName: 'pkg-a', sync: false }) - const pkgA = await readPkgJson(fs, 'pkg-a') + const pkgA = await pkg.read('pkg-a') expect(pkgA.version).toBe('1.0.1') }) test('bumps a package version with minor', async () => { - const { app, fs } = buildApp( + const { app, pkg } = buildApp( { packages: [{ name: 'pkg-a', version: '1.0.0' }] }, @@ -30,12 +30,12 @@ describe('bumpVersion', () => { await app.bumpVersion({ pkgName: 'pkg-a', sync: false }) - const pkgA = await readPkgJson(fs, 'pkg-a') + const pkgA = await pkg.read('pkg-a') expect(pkgA.version).toBe('1.1.0') }) test('bumps a package version with major', async () => { - const { app, fs } = buildApp( + const { app, pkg } = buildApp( { packages: [{ name: 'pkg-a', version: '1.0.0' }] }, @@ -44,12 +44,12 @@ describe('bumpVersion', () => { await app.bumpVersion({ pkgName: 'pkg-a', sync: false }) - const pkgA = await readPkgJson(fs, 'pkg-a') + const pkgA = await pkg.read('pkg-a') expect(pkgA.version).toBe('2.0.0') }) test('skips bump when "none" is selected', async () => { - const { app, fs } = buildApp( + const { app, pkg } = buildApp( { packages: [{ name: 'pkg-a', version: '1.0.0' }] }, @@ -58,12 +58,12 @@ describe('bumpVersion', () => { await app.bumpVersion({ pkgName: 'pkg-a', sync: false }) - const pkgA = await readPkgJson(fs, 'pkg-a') + const pkgA = await pkg.read('pkg-a') expect(pkgA.version).toBe('1.0.0') }) test('skips private packages during bump', async () => { - const { app, fs } = buildApp( + const { app, pkg } = buildApp( { packages: [ { name: 'pkg-a', version: '1.0.0' }, @@ -75,15 +75,15 @@ describe('bumpVersion', () => { await app.bumpVersion({ pkgName: 'pkg-a', sync: false }) - const pkgA = await readPkgJson(fs, 'pkg-a') + const pkgA = await pkg.read('pkg-a') expect(pkgA.version).toBe('1.0.1') - const pkgB = await readPkgJson(fs, 'pkg-b') + const pkgB = await pkg.read('pkg-b') expect(pkgB.version).toBe('1.0.0') // not bumped }) test('bumps dependents recursively', async () => { - const { app, fs } = buildApp( + const { app, pkg } = buildApp( { packages: [ { name: 'pkg-a', version: '1.0.0' }, @@ -101,18 +101,18 @@ describe('bumpVersion', () => { await app.bumpVersion({ pkgName: 'pkg-a', sync: false }) - const pkgA = await readPkgJson(fs, 'pkg-a') + const pkgA = await pkg.read('pkg-a') expect(pkgA.version).toBe('1.0.1') - const pkgB = await readPkgJson(fs, 'pkg-b') + const pkgB = await pkg.read('pkg-b') expect(pkgB.version).toBe('1.1.0') - const pkgC = await readPkgJson(fs, 'pkg-c') + const pkgC = await pkg.read('pkg-c') expect(pkgC.version).toBe('1.0.1') }) test('calls syncVersions when sync is true', async () => { - const { app, fs } = buildApp( + const { app, pkg } = buildApp( { packages: [ { name: 'pkg-a', version: '1.0.0' }, @@ -124,14 +124,14 @@ describe('bumpVersion', () => { await app.bumpVersion({ pkgName: 'pkg-a', sync: true }) - const pkgA = await readPkgJson(fs, 'pkg-a') + 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 readPkgJson(fs, 'pkg-b') + const pkgB = await pkg.read('pkg-b') expect(pkgB.dependencies?.['pkg-a']).toBe('^1.0.1') }) @@ -144,7 +144,7 @@ describe('bumpVersion', () => { }) test('does not sync when sync is false', async () => { - const { app, fs } = buildApp( + const { app, pkg } = buildApp( { packages: [ { name: 'pkg-a', version: '1.0.0' }, @@ -156,7 +156,7 @@ describe('bumpVersion', () => { await app.bumpVersion({ pkgName: 'pkg-a', sync: false }) - const pkgB = await readPkgJson(fs, 'pkg-b') + 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__/sync.test.ts b/depsynky/src/__tests__/sync.test.ts index 5bed3af3b..d8f8cae4d 100644 --- a/depsynky/src/__tests__/sync.test.ts +++ b/depsynky/src/__tests__/sync.test.ts @@ -1,9 +1,9 @@ import { test, expect, describe } from 'vitest' -import { buildApp, readPkgJson } from './utils/test-setup' +import { buildApp } from './utils/test-setup' describe('syncVersions', () => { test('updates dependencies to match target versions', async () => { - const { app, fs } = buildApp({ + 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' } } @@ -12,12 +12,12 @@ describe('syncVersions', () => { await app.syncVersions({}) - const pkgB = await readPkgJson(fs, 'pkg-b') + 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, fs } = buildApp({ + 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' } } @@ -26,12 +26,12 @@ describe('syncVersions', () => { await app.syncVersions({}) - const pkgB = await readPkgJson(fs, 'pkg-b') + 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, fs } = buildApp({ + 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' } } @@ -40,12 +40,12 @@ describe('syncVersions', () => { await app.syncVersions({}) - const pkgB = await readPkgJson(fs, 'pkg-b') + 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, fs } = buildApp({ + 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' } } @@ -54,12 +54,12 @@ describe('syncVersions', () => { await app.syncVersions({ ignorePeers: true }) - const pkgB = await readPkgJson(fs, 'pkg-b') + 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, fs } = buildApp({ + 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' } } @@ -68,12 +68,12 @@ describe('syncVersions', () => { await app.syncVersions({ ignoreDev: true }) - const pkgB = await readPkgJson(fs, 'pkg-b') + 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, fs } = buildApp({ + 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:*' } } @@ -82,12 +82,12 @@ describe('syncVersions', () => { await app.syncVersions({}) - const pkgB = await readPkgJson(fs, 'pkg-b') + const pkgB = await pkg.read('pkg-b') expect(pkgB.dependencies?.['pkg-a']).toBe('workspace:*') }) test('uses custom targetVersions when provided', async () => { - const { app, fs } = buildApp({ + 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' } } @@ -96,12 +96,12 @@ describe('syncVersions', () => { await app.syncVersions({ targetVersions: { 'pkg-a': '1.5.0' } }) - const pkgB = await readPkgJson(fs, 'pkg-b') + 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, fs } = buildApp({ + 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' } } @@ -110,12 +110,12 @@ describe('syncVersions', () => { await app.syncVersions({}) - const pkgB = await readPkgJson(fs, 'pkg-b') + 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, fs } = buildApp({ + const { app, pkg } = buildApp({ packages: [ { name: 'pkg-a', version: '2.0.0' }, { name: 'pkg-b', version: '1.0.0', dependencies: { lodash: '^4.0.0' } } @@ -124,12 +124,12 @@ describe('syncVersions', () => { await app.syncVersions({}) - const pkgB = await readPkgJson(fs, 'pkg-b') + const pkgB = await pkg.read('pkg-b') expect(pkgB.dependencies?.['lodash']).toBe('^4.0.0') }) test('syncs multiple packages at once', async () => { - const { app, fs } = buildApp({ + const { app, pkg } = buildApp({ packages: [ { name: 'pkg-a', version: '2.0.0' }, { name: 'pkg-b', version: '3.0.0' }, @@ -139,13 +139,13 @@ describe('syncVersions', () => { await app.syncVersions({}) - const pkgC = await readPkgJson(fs, 'pkg-c') + 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, fs } = buildApp({ + 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' } } @@ -154,7 +154,7 @@ describe('syncVersions', () => { await app.syncVersions({}) - const pkgB = await readPkgJson(fs, 'pkg-b') + const pkgB = await pkg.read('pkg-b') expect(pkgB.dependencies?.['pkg-a']).toBe('^1.0.0') }) }) diff --git a/depsynky/src/__tests__/utils/test-setup.ts b/depsynky/src/__tests__/utils/test-setup.ts index bb747deee..a7696641d 100644 --- a/depsynky/src/__tests__/utils/test-setup.ts +++ b/depsynky/src/__tests__/utils/test-setup.ts @@ -4,8 +4,12 @@ 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[] +export type Monorepo = { + packages: (types.PackageJson & { name: Name })[] +} + +export type PkgReader = { + read: (pkgName: Name) => Promise } const ROOT_DIR = '/repo' @@ -18,7 +22,7 @@ const kebabCase = (str: string) => { return tokens.map((t) => t.toLowerCase()).join('-') } -const _buildFs = (monorepo: Monorepo): InMemoryFileSystem => { +const _buildFs = (monorepo: Monorepo): InMemoryFileSystem => { const files: Record = {} const workspacePatterns = ['packages/*'] @@ -39,18 +43,23 @@ const _buildFs = (monorepo: Monorepo): InMemoryFileSystem => { return new InMemoryFileSystem(files) } -export const buildApp = (monorepo: Monorepo, bumpFn: types.BumpService['promptJump'] = async () => 'none') => { +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, fs, bump, pkg, pnpm } -} - -export const readPkgJson = async (fs: InMemoryFileSystem, pkgName: string) => { - const dirName = kebabCase(pkgName) - const pkgJsonPath = `${ROOT_DIR}/packages/${dirName}/package.json` - const content = await fs.readFile(pkgJsonPath) - return JSON.parse(content) as types.PackageJson + return { + app, + pkg: { + read: async (pkgName: Name) => { + const dirName = kebabCase(pkgName) + const pkgJsonPath = `${ROOT_DIR}/packages/${dirName}/package.json` + return pkg.read(pkgJsonPath) + } + } + } } From fbc64f2c4dd140a306d45c36acabee66cd34b047 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Levasseur?= Date: Fri, 3 Apr 2026 19:41:12 -0400 Subject: [PATCH 8/9] update --- depsynky/src/__tests__/utils/test-setup.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/depsynky/src/__tests__/utils/test-setup.ts b/depsynky/src/__tests__/utils/test-setup.ts index a7696641d..5208f7754 100644 --- a/depsynky/src/__tests__/utils/test-setup.ts +++ b/depsynky/src/__tests__/utils/test-setup.ts @@ -14,7 +14,7 @@ export type PkgReader = { const ROOT_DIR = '/repo' -const kebabCase = (str: string) => { +const _kebabCase = (str: string) => { const tokens = str.match(/[A-Za-z0-9]+/g) if (!tokens) { return str @@ -34,7 +34,7 @@ const _buildFs = (monorepo: Monorepo): InMemoryFileSystem => { ].join('\n') for (const pkg of monorepo.packages) { - const dirName = kebabCase(pkg.name) + const dirName = _kebabCase(pkg.name) files[`packages/${dirName}`] = '' const pkgJsonPath = `${ROOT_DIR}/packages/${dirName}/package.json` files[pkgJsonPath] = JSON.stringify(pkg) @@ -56,7 +56,7 @@ export const buildApp = ( app, pkg: { read: async (pkgName: Name) => { - const dirName = kebabCase(pkgName) + const dirName = _kebabCase(pkgName) const pkgJsonPath = `${ROOT_DIR}/packages/${dirName}/package.json` return pkg.read(pkgJsonPath) } From d1561bfcc2df76809086153c9e2aeea65028f3e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Levasseur?= Date: Fri, 3 Apr 2026 19:42:37 -0400 Subject: [PATCH 9/9] update --- depsynky/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/depsynky/package.json b/depsynky/package.json index d8267dacf..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": {