From e60a369f5d2bae56becd2501dd0278c7325fdd0d Mon Sep 17 00:00:00 2001 From: gevik Date: Wed, 15 Jul 2026 09:46:12 +0200 Subject: [PATCH] feat(sync): add command to realign drifted packages to highest version Add `lockstep sync` to recover a workspace whose packages have fallen out of lockstep. It scans every package plus the repository root, selects the highest version by numeric semver precedence (so 1.10.0 beats 1.9.0), and rewrites each package and its internal dependency ranges to that version, preserving range operators. It is idempotent, rewrites package.json files only (no git commit, tag, or changelog), and supports --dry-run. Extract the shared version-writing logic out of `version()` into a common `applyVersion()` helper, and add `semverCompare()`/`highestVersion()`. Covered by spec tests (real temp git fixtures) and implementation tests for the precedence internals; README and CLI help updated. --- README.md | 32 ++++++++ src/cli.ts | 23 +++++- src/index.ts | 1 + src/lockstep.ts | 178 ++++++++++++++++++++++++++++++++++++------ src/sync.impl.test.ts | 66 ++++++++++++++++ src/sync.spec.test.ts | 152 ++++++++++++++++++++++++++++++++++++ src/types.ts | 10 ++- 7 files changed, 436 insertions(+), 26 deletions(-) create mode 100644 src/sync.impl.test.ts create mode 100644 src/sync.spec.test.ts diff --git a/README.md b/README.md index 379369f..c760ff2 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ A comprehensive monorepo package management tool that maintains synchronized ver ## Features - **Lockstep Versioning**: All packages maintain the same version number +- **Drift Recovery**: `sync` realigns packages that fell out of lockstep to the highest version found - **Dependency-Aware Publishing**: Uses topological sorting to publish dependencies first - **Branch-Based Dist-Tags**: Automatic prefixing based on git branch - **Conventional Commits**: Automatic version detection from commit messages @@ -47,6 +48,9 @@ lockstep version --type patch # Automatically determine version from conventional commits lockstep version --type auto +# Realign packages that drifted out of lockstep to the highest version +lockstep sync + # Publish all packages to latest lockstep publish --tag latest @@ -93,6 +97,31 @@ lockstep version --type patch --no-changelog By default a version bump also generates changelog and release-notes files and folds them into the release commit. See [AI Changelog & Release Notes](#ai-changelog--release-notes) below. +### Sync Command + +Realigns packages that have drifted out of lockstep by setting every package — and its internal +dependency ranges — to the **highest** version found across the workspace. This is the recovery +path for when `version`, `changelog`, or `publish` fail because the packages no longer share a +single version. + +```bash +lockstep sync [options] +``` + +Unlike `version`, `sync` mints no new version and touches no git state: it rewrites `package.json` +files only, leaving the commit and tag to you. When the workspace is already uniform it reports so +and changes nothing. The highest version is chosen by numeric semver precedence, so `1.10.0` +correctly wins over `1.9.0`. + +**Options:** +- `--dry-run` - Print which packages would change; write nothing + +**Examples:** +```bash +lockstep sync +lockstep sync --dry-run +``` + ### Changelog Command Generates a per-package `CHANGELOG.md` and a root `RELEASE_NOTES.md` for the current release, @@ -274,6 +303,9 @@ await lockstep.version({ noGitCommit: false }); +// Realign drifted packages to the highest version found (rewrites package.json only) +await lockstep.syncVersions({ dryRun: false }); + // Publish all packages await lockstep.publish({ tag: 'latest', diff --git a/src/cli.ts b/src/cli.ts index 41a6cef..b31022c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -45,7 +45,10 @@ USAGE: COMMANDS: version --type [options] Bump versions of all packages in lockstep - + + sync [options] + Realign packages that drifted out of lockstep to the highest version found + publish --tag [options] Publish all packages in dependency order @@ -61,6 +64,9 @@ VERSION OPTIONS: --no-git-commit Skip git commit and tag operations --no-changelog Skip AI changelog generation during the bump +SYNC OPTIONS: + --dry-run Print which packages would change; write nothing + CHANGELOG OPTIONS: --dry-run Print what would be generated; write nothing --verbose Print detailed progress and token usage @@ -78,7 +84,10 @@ EXAMPLES: lockstep version --type major --no-git-commit lockstep version --type auto lockstep version --type auto --ci - + + lockstep sync + lockstep sync --dry-run + lockstep publish --tag latest lockstep publish --tag alpha lockstep publish --tag beta --dry @@ -99,6 +108,10 @@ NOTES: • Lockstep versioning: All packages maintain the same version number + • lockstep sync recovers from version drift: it sets every package (and its internal + dependency ranges) to the highest version found. It rewrites package.json files only — + no git commit, tag, or changelog — so you review and commit the result yourself. + • --provenance: Generates npm provenance attestations. Requires a supported CI (GitHub Actions or GitLab CI) with id-token permission, and a "repository" field in each package.json. Outside supported CI it is skipped with a warning. @@ -159,6 +172,12 @@ async function main(): Promise { await lockstep.version({ type, skipCi, noGitCommit, noChangelog }); + } else if (cmd === 'sync') { + // Handle sync command - realign drifted packages to the highest version found + const dryRun = Boolean(opts['dry-run']); + + await lockstep.syncVersions({ dryRun }); + } else if (cmd === 'changelog') { // Handle changelog command const dryRun = Boolean(opts['dry-run']); diff --git a/src/index.ts b/src/index.ts index c6825d2..dcc4bff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ export type { WorkspacePackage, WorkspaceInfo, PublishOptions, + SyncOptions, VersionOptions, CliOptions, LockstepConfig, diff --git a/src/lockstep.ts b/src/lockstep.ts index b6721ca..605f5ac 100644 --- a/src/lockstep.ts +++ b/src/lockstep.ts @@ -22,6 +22,7 @@ import type { PackageJson, PackageManager, PublishOptions, + SyncOptions, VersionOptions, WorkspaceInfo, WorkspacePackage @@ -254,6 +255,63 @@ export class Lockstep { return `${major}.${minor}.${patch}`; } + /** + * Compares two semantic versions by precedence. + * + * The three numeric fields are compared as integers, so `1.10.0` correctly outranks `1.9.0` — + * a plain string comparison would get this backwards. A normal release outranks its own + * pre-release (`1.0.0` > `1.0.0-alpha`, per semver); when both carry a pre-release and their + * numeric cores are equal, the pre-release identifiers are compared as plain strings. That last + * step is a deliberate simplification: it orders the common cases (`alpha` < `beta`) without a + * full dot-separated precedence walk, which lockstep's uniform versions never need. + * + * @param a - First version string + * @param b - Second version string + * @returns Negative if a precedes b, positive if a follows b, zero if equal in precedence + * @throws Error if either argument is not a valid semver version + * + * @example + * lockstep.semverCompare("1.10.0", "1.9.0"); // > 0 (10 is greater than 9 numerically) + */ + semverCompare(a: string, b: string): number { + const parse = (v: string): { nums: number[]; pre: string } => { + const m = v.match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/); + if (!m) throw new Error(`Not a semver version: ${v}`); + return { nums: [+m[1], +m[2], +m[3]], pre: m[4] ?? "" }; + }; + + const pa = parse(a); + const pb = parse(b); + + for (let i = 0; i < 3; i++) { + if (pa.nums[i] !== pb.nums[i]) return pa.nums[i] - pb.nums[i]; + } + + // Equal numeric cores: a missing pre-release ranks above any present one. + if (pa.pre === pb.pre) return 0; + if (pa.pre === "") return 1; + if (pb.pre === "") return -1; + return pa.pre < pb.pre ? -1 : 1; + } + + /** + * Returns the highest-precedence version from a non-empty list, using {@link semverCompare}. + * @param versions - Version strings to compare (at least one) + * @returns The greatest version by semver precedence + * @throws Error if the list is empty or any entry is not a valid semver version + * + * @example + * lockstep.highestVersion(["1.0.0", "1.10.0", "1.9.0"]); // "1.10.0" + */ + highestVersion(versions: string[]): string { + if (versions.length === 0) { + throw new Error("highestVersion requires at least one version"); + } + // Seed the reduction with the first entry and compare every element, so an invalid version + // anywhere — including a lone entry — is rejected by semverCompare rather than passed through. + return versions.reduce((max, v) => (this.semverCompare(v, max) > 0 ? v : max), versions[0]); + } + /** * Preserves the version range operator when updating dependency versions * @param oldRange - Original version range (e.g., "^1.2.3", "~1.2.3") @@ -434,15 +492,49 @@ export class Lockstep { const current = this.ensureAllSameVersion(packages); const next = this.semverBump(current, actualType); - // Create set for quick internal package lookup + this.applyVersion(packages, next); + + // Generate the changelog into the release commit, unless opted out. A changelog failure + // must never abort a release that already succeeded, so any error degrades to a warning. + if (!noChangelog) { + try { + await this.changelog({}); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + console.warn(`Changelog generation failed, continuing release: ${detail}`); + } + } + + // Perform git operations unless explicitly skipped + if (!noGitCommit) { + execSync(`git add .`, { cwd: this.config.root, stdio: "inherit" }); + const commitMessage = `chore(release): v${next}${skipCi ? " [skip ci]" : ""}`; + execSync(`git commit -m "${commitMessage}"`, { cwd: this.config.root, stdio: "inherit" }); + execSync(`git tag v${next}`, { cwd: this.config.root, stdio: "inherit" }); + + console.log(`\nAll packages bumped to v${next} and tagged.`); + } else { + console.log(`\nAll packages bumped to v${next}. Git commit and tag skipped.`); + } + } + + /** + * Writes a single target version across every workspace package and the repository-root + * package.json, rewriting internal cross-dependency ranges to match while preserving each + * range's operator. Shared by the lockstep bump and the drift-recovery sync so both apply a + * version identically. + * @param packages - The workspace packages to rewrite + * @param next - The version string to apply everywhere + */ + protected applyVersion(packages: WorkspacePackage[], next: string): void { + // Internal package names drive which dependency ranges get rewritten; external dependencies + // are left untouched so only the monorepo's own cross-references move in lockstep. const internalNames = new Set(packages.map((p) => p.name)); - // Update version in all packages and their internal dependencies for (const p of packages) { const pkg = p.data; pkg.version = next; - // Update internal dependency versions for (const field of DEP_FIELDS) { const deps = pkg[field]; if (!deps) continue; @@ -450,7 +542,6 @@ export class Lockstep { for (const [dep, range] of Object.entries(deps)) { if (!internalNames.has(dep)) continue; if (typeof range !== "string") continue; - // Update internal dependency version while preserving range operator deps[dep] = this.preserveOperator(range, next); } } @@ -459,7 +550,8 @@ export class Lockstep { console.log(`✔ ${p.name} -> ${next}`); } - // Update root package.json version if it exists + // A monorepo's workspace-root package.json is not part of `packages`; keep it in step so its + // version never drifts from the packages it aggregates. const rootPkgPath = path.join(this.config.root, "package.json"); if (exists(rootPkgPath)) { const rootPkg = readJSON(rootPkgPath); @@ -469,29 +561,69 @@ export class Lockstep { console.log(`✔ root -> ${next}`); } } + } - // Generate the changelog into the release commit, unless opted out. A changelog failure - // must never abort a release that already succeeded, so any error degrades to a warning. - if (!noChangelog) { - try { - await this.changelog({}); - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - console.warn(`Changelog generation failed, continuing release: ${detail}`); - } + /** + * Realigns every package to the highest version found, recovering a workspace whose packages + * have drifted out of lockstep. + * + * Unlike {@link version} this mints no new version and touches no git state: it scans all + * packages (and the repository-root package.json), selects the greatest version by semver + * precedence, and rewrites every package plus its internal dependency ranges to that version. + * When the workspace is already uniform it reports so and writes nothing. This is the recovery + * path for the "all packages share a version" invariant that {@link version}, changelog + * generation, and {@link publish} all depend on. + * + * @param options - Dry-run switch to preview the change set without writing + * + * @example + * await lockstep.syncVersions(); // realign every package to the highest version + * await lockstep.syncVersions({ dryRun: true }); // preview which packages would change + */ + async syncVersions(options: SyncOptions = {}): Promise { + const { dryRun = false } = options; + + const { packages } = this.buildWorkspace(); + if (packages.length === 0) { + console.log("No packages found; nothing to sync."); + return; } - // Perform git operations unless explicitly skipped - if (!noGitCommit) { - execSync(`git add .`, { cwd: this.config.root, stdio: "inherit" }); - const commitMessage = `chore(release): v${next}${skipCi ? " [skip ci]" : ""}`; - execSync(`git commit -m "${commitMessage}"`, { cwd: this.config.root, stdio: "inherit" }); - execSync(`git tag v${next}`, { cwd: this.config.root, stdio: "inherit" }); + // A single-package repo discovers its root as the package itself; a monorepo keeps a + // separate aggregator root outside `packages`. Only that separate root needs folding into + // the candidate set and drift check — otherwise the root is already covered by `packages`. + const rootPkgPath = path.join(this.config.root, "package.json"); + const rootIsPackage = packages.some((p) => p.pkgPath === rootPkgPath); - console.log(`\nAll packages bumped to v${next} and tagged.`); - } else { - console.log(`\nAll packages bumped to v${next}. Git commit and tag skipped.`); + let rootVersion: string | undefined; + if (!rootIsPackage && exists(rootPkgPath)) { + const v = readJSON(rootPkgPath).version; + if (v) rootVersion = v; } + + const candidates = packages.map((p) => p.version); + if (rootVersion) candidates.push(rootVersion); + + const target = this.highestVersion(candidates); + + const behind = packages.filter((p) => p.version !== target); + const rootBehind = rootVersion !== undefined && rootVersion !== target; + + if (behind.length === 0 && !rootBehind) { + console.log(`All packages already in sync at v${target}.`); + return; + } + + if (dryRun) { + console.log(`[dry-run] Would sync to v${target}:`); + for (const p of behind) console.log(` ${p.name}: ${p.version} -> ${target}`); + if (rootBehind) console.log(` (root package.json): ${rootVersion} -> ${target}`); + return; + } + + console.log(`Syncing all packages to the highest version found: v${target}`); + this.applyVersion(packages, target); + console.log(`\nAll packages synced to v${target}.`); } /** diff --git a/src/sync.impl.test.ts b/src/sync.impl.test.ts new file mode 100644 index 0000000..408bbb5 --- /dev/null +++ b/src/sync.impl.test.ts @@ -0,0 +1,66 @@ +/** + * Implementation tests for the sync command's version-precedence internals. + * + * `semverCompare` and `highestVersion` are pure helpers underpinning drift recovery; these cover + * their ordering rules and boundary conditions directly, without a workspace fixture. + */ + +import { describe, expect, it } from 'vitest'; +import { Lockstep } from './lockstep.js'; + +describe('semverCompare', () => { + const ls = new Lockstep(); + + it('should return a positive number when the first version is higher', () => { + expect(ls.semverCompare('2.0.0', '1.9.9')).toBeGreaterThan(0); + }); + + it('should return a negative number when the first version is lower', () => { + expect(ls.semverCompare('1.0.0', '1.0.1')).toBeLessThan(0); + }); + + it('should return zero for versions equal in precedence', () => { + expect(ls.semverCompare('1.2.3', '1.2.3')).toBe(0); + }); + + it('should compare the minor field numerically, not lexically', () => { + // 1.9.0 vs 1.10.0: lexically "9" > "10", numerically 10 > 9 — the numeric order must win. + expect(ls.semverCompare('1.10.0', '1.9.0')).toBeGreaterThan(0); + }); + + it('should rank a normal release above its own pre-release', () => { + expect(ls.semverCompare('1.0.0', '1.0.0-alpha')).toBeGreaterThan(0); + }); + + it('should throw when either argument is not valid semver', () => { + expect(() => ls.semverCompare('1.0', '1.0.0')).toThrow('Not a semver version'); + }); +}); + +describe('highestVersion', () => { + const ls = new Lockstep(); + + it('should return the greatest version by numeric precedence', () => { + expect(ls.highestVersion(['1.0.0', '1.10.0', '1.9.0'])).toBe('1.10.0'); + }); + + it('should return the sole element for a single-item list', () => { + expect(ls.highestVersion(['3.4.5'])).toBe('3.4.5'); + }); + + it('should prefer a normal release over a competing pre-release of the same core', () => { + expect(ls.highestVersion(['2.0.0-rc.1', '2.0.0'])).toBe('2.0.0'); + }); + + it('should be independent of input order', () => { + expect(ls.highestVersion(['1.9.0', '1.10.0'])).toBe(ls.highestVersion(['1.10.0', '1.9.0'])); + }); + + it('should throw for an empty list', () => { + expect(() => ls.highestVersion([])).toThrow('at least one'); + }); + + it('should throw when any entry is not valid semver', () => { + expect(() => ls.highestVersion(['1.0.0', 'nope'])).toThrow('Not a semver version'); + }); +}); diff --git a/src/sync.spec.test.ts b/src/sync.spec.test.ts new file mode 100644 index 0000000..9aac1fa --- /dev/null +++ b/src/sync.spec.test.ts @@ -0,0 +1,152 @@ +/** + * Specification tests for the `sync` command — version-drift reconciliation. + * + * lockstep requires every package to share one version; the moment they drift, `version`, + * `changelog`, and `publish` all fail. `sync` is the recovery path: it finds the highest version + * present and realigns every package (and its internal dependency ranges) to it, rewriting + * package.json files only — no git commit, tag, or changelog. These tests derive from that + * contract over real temp git fixtures. + */ + +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { Lockstep } from './lockstep.js'; + +const dirs: string[] = []; +afterEach(() => { + for (const d of dirs.splice(0)) fs.rmSync(d, { recursive: true, force: true }); +}); + +function git(dir: string, cmd: string): void { + execSync(`git ${cmd}`, { cwd: dir, stdio: 'pipe' }); +} + +/** + * A two-package monorepo (`@scope/a`, `@scope/b` where b depends on a) plus a private aggregator + * root, each at the caller-chosen version, committed once so git side effects are observable. + */ +function makeDriftedMonorepo(versions: { root?: string; a: string; b: string }): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lockstep-sync-')); + dirs.push(dir); + git(dir, 'init -q'); + git(dir, 'config user.email t@e.com'); + git(dir, 'config user.name T'); + git(dir, 'config commit.gpgsign false'); + + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ name: 'root', version: versions.root ?? '1.0.0', private: true }, null, 2) + ); + fs.mkdirSync(path.join(dir, 'packages', 'a'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'packages', 'a', 'package.json'), + JSON.stringify({ name: '@scope/a', version: versions.a }, null, 2) + ); + fs.mkdirSync(path.join(dir, 'packages', 'b'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'packages', 'b', 'package.json'), + JSON.stringify({ name: '@scope/b', version: versions.b, dependencies: { '@scope/a': `^${versions.a}` } }, null, 2) + ); + + git(dir, 'add -A'); + git(dir, 'commit -q -m "chore: init"'); + return dir; +} + +/** Reads a package.json relative to the repo root (use '.' for the root package). */ +function readPkg(dir: string, rel: string): any { + return JSON.parse(fs.readFileSync(path.join(dir, rel, 'package.json'), 'utf8')); +} + +function readVersion(dir: string, rel: string): string { + return readPkg(dir, rel).version; +} + +/** A comparable snapshot of every version and the internal dependency range in the fixture. */ +function snapshotVersions(dir: string): Record { + return { + root: readVersion(dir, '.'), + a: readVersion(dir, 'packages/a'), + b: readVersion(dir, 'packages/b'), + bDependsOnA: readPkg(dir, 'packages/b').dependencies['@scope/a'] + }; +} + +describe('sync command — version reconciliation', () => { + // Drifted packages must all move up to the highest version present. + it('should set all packages to the highest version when versions have drifted', async () => { + const dir = makeDriftedMonorepo({ a: '1.0.0', b: '1.2.0' }); + await new Lockstep({ root: dir }).syncVersions(); + expect(readVersion(dir, 'packages/a')).toBe('1.2.0'); + expect(readVersion(dir, 'packages/b')).toBe('1.2.0'); + }); + + // "Highest" is decided by numeric semver precedence, not lexical order: 1.10.0 is newer than + // 1.9.0 even though the string "1.10.0" sorts before "1.9.0". + it('should pick the highest version by numeric precedence, not string order', async () => { + const dir = makeDriftedMonorepo({ a: '1.9.0', b: '1.10.0' }); + await new Lockstep({ root: dir }).syncVersions(); + expect(readVersion(dir, 'packages/a')).toBe('1.10.0'); + expect(readVersion(dir, 'packages/b')).toBe('1.10.0'); + }); + + // Internal cross-dependency ranges follow the synced version, keeping their range operator. + it('should update internal dependency ranges to the highest version, preserving operators', async () => { + const dir = makeDriftedMonorepo({ a: '1.0.0', b: '1.2.0' }); + await new Lockstep({ root: dir }).syncVersions(); + expect(readPkg(dir, 'packages/b').dependencies['@scope/a']).toBe('^1.2.0'); + }); + + // The workspace-root package.json is kept in step even though it is not part of `packages`. + it('should update the root package.json to the highest version', async () => { + const dir = makeDriftedMonorepo({ root: '1.0.0', a: '1.0.0', b: '1.2.0' }); + await new Lockstep({ root: dir }).syncVersions(); + expect(readVersion(dir, '.')).toBe('1.2.0'); + }); + + // Even when every package already agrees, a lagging aggregator root is brought up to match. + it('should bring a lagging root up when all packages already agree', async () => { + const dir = makeDriftedMonorepo({ root: '1.0.0', a: '1.2.0', b: '1.2.0' }); + await new Lockstep({ root: dir }).syncVersions(); + expect(readVersion(dir, '.')).toBe('1.2.0'); + }); + + // An already-uniform workspace is a no-op: nothing on disk changes. + it('should leave files unchanged when all packages already share a version', async () => { + const dir = makeDriftedMonorepo({ root: '2.0.0', a: '2.0.0', b: '2.0.0' }); + const before = snapshotVersions(dir); + await new Lockstep({ root: dir }).syncVersions(); + expect(snapshotVersions(dir)).toEqual(before); + }); + + // sync rewrites files only; it must never create a commit or a tag. + it('should not create any git commit or tag (files-only)', async () => { + const dir = makeDriftedMonorepo({ a: '1.0.0', b: '1.2.0' }); + const commitsBefore = execSync('git rev-list --count HEAD', { cwd: dir }).toString().trim(); + + await new Lockstep({ root: dir }).syncVersions(); + + const commitsAfter = execSync('git rev-list --count HEAD', { cwd: dir }).toString().trim(); + expect(commitsAfter).toBe(commitsBefore); + expect(execSync('git tag', { cwd: dir }).toString().trim()).toBe(''); + // The edits live in the working tree, proving sync wrote the files but did not commit them. + expect(execSync('git status --porcelain', { cwd: dir }).toString()).toContain('package.json'); + }); + + // dry-run previews the change set without touching any file. + it('should write nothing in dry-run mode', async () => { + const dir = makeDriftedMonorepo({ a: '1.0.0', b: '1.2.0' }); + await new Lockstep({ root: dir }).syncVersions({ dryRun: true }); + expect(readVersion(dir, 'packages/a')).toBe('1.0.0'); + expect(readVersion(dir, 'packages/b')).toBe('1.2.0'); + }); + + // A non-semver version anywhere is a hard error, never a silent mis-sort. + it('should throw when a package has a non-semver version', async () => { + const dir = makeDriftedMonorepo({ a: 'not-a-version', b: '1.2.0' }); + await expect(new Lockstep({ root: dir }).syncVersions()).rejects.toThrow('Not a semver version'); + }); +}); diff --git a/src/types.ts b/src/types.ts index 2556b7c..e1dd720 100644 --- a/src/types.ts +++ b/src/types.ts @@ -66,6 +66,14 @@ export interface PublishOptions { provenance?: boolean; } +/** + * Options for the sync command + */ +export interface SyncOptions { + /** Preview which packages would change without writing any files */ + dryRun?: boolean; +} + /** * Options for the version command */ @@ -113,4 +121,4 @@ export type DependencyField = export type PackageManager = 'npm' | 'yarn' | 'pnpm'; /** CLI command types */ -export type Command = 'version' | 'publish' | 'help'; +export type Command = 'version' | 'sync' | 'changelog' | 'publish' | 'help';