diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md index 0bd6fbe0..4bf15b47 100644 --- a/docs/src/content/docs/configuration.md +++ b/docs/src/content/docs/configuration.md @@ -48,6 +48,116 @@ export npm_config_git_tag_version=false npm version "${CRAFT_NEW_VERSION}" ``` +## Automatic Version Bumping + +When `minVersion: "2.21.0"` or higher is set and no custom `preReleaseCommand` is defined, Craft automatically bumps version numbers based on your configured publish targets. This eliminates the need for a `scripts/bump-version.sh` script in most cases. + +### How It Works + +1. Craft examines your configured `targets` in `.craft.yml` +2. For each target that supports version bumping, Craft updates the appropriate project files +3. Targets are processed in the order they appear in your configuration +4. Each target type is only processed once (e.g., multiple npm targets won't bump `package.json` twice) + +### Supported Targets + +| Target | Detection | Version Bump Method | +| --------- | ----------------------- | ----------------------------------------------------------- | +| `npm` | `package.json` exists | `npm version --no-git-tag-version` (with workspace support) | +| `pypi` | `pyproject.toml` exists | hatch, poetry, setuptools-scm, or direct edit | +| `crates` | `Cargo.toml` exists | `cargo set-version` (requires cargo-edit) | +| `gem` | `*.gemspec` exists | Direct edit of gemspec and `lib/**/version.rb` | +| `pub-dev` | `pubspec.yaml` exists | Direct edit of pubspec.yaml | +| `hex` | `mix.exs` exists | Direct edit of mix.exs | +| `nuget` | `*.csproj` exists | dotnet-setversion or direct XML edit | + +### npm Workspace Support + +For npm/yarn/pnpm monorepos, Craft automatically detects and bumps versions in all workspace packages: + +- **npm 7+**: Uses `npm version --workspaces` to bump all packages at once +- **yarn/pnpm or npm < 7**: Falls back to bumping each non-private package individually + +Workspace detection checks for: + +- `workspaces` field in root `package.json` (npm/yarn) +- `pnpm-workspace.yaml` (pnpm) + +Private packages (`"private": true`) are skipped during workspace version bumping. + +### Python (pypi) Detection Priority + +For Python projects, Craft detects the build tool and uses the appropriate method: + +1. **Hatch** - If `[tool.hatch]` section exists → `hatch version ` +2. **Poetry** - If `[tool.poetry]` section exists → `poetry version ` +3. **setuptools-scm** - If `[tool.setuptools_scm]` section exists → No-op (version derived from git tags) +4. **Direct edit** - If `[project]` section with `version` field exists → Edit `pyproject.toml` directly + +### Enabling Automatic Version Bumping + +To enable automatic version bumping, ensure your `.craft.yml` has: + +```yaml +minVersion: '2.21.0' +targets: + - name: npm # or pypi, crates, etc. + # ... other targets +``` + +And either: + +- Remove any custom `preReleaseCommand`, or +- Don't define `preReleaseCommand` at all + +### Disabling Automatic Version Bumping + +To disable automatic version bumping while still using minVersion 2.21.0+: + +```yaml +minVersion: '2.21.0' +preReleaseCommand: '' # Explicitly set to empty string +``` + +Or define a custom script: + +```yaml +minVersion: '2.21.0' +preReleaseCommand: bash scripts/my-custom-bump.sh +``` + +### Error Handling + +If automatic version bumping fails: + +- **Missing tool**: Craft reports which tool is missing (e.g., "Cannot find 'npm' for version bumping") +- **Command failure**: Craft shows the error from the failed command +- **No supported targets**: Craft warns that no targets support automatic bumping + +In all error cases, Craft suggests defining a custom `preReleaseCommand` as a fallback. + +### Recovery from Failed Prepare + +If version bumping succeeds but `craft prepare` fails mid-way (e.g., during changelog generation or git operations), you may need to clean up manually: + +1. **Check the release branch**: If a release branch was created, you can delete it: + + ```bash + git branch -D release/ + ``` + +2. **Revert version changes**: If files were modified but not committed, reset them: + + ```bash + git checkout -- package.json pyproject.toml Cargo.toml # or whichever files were changed + ``` + +3. **Re-run prepare**: Once the issue is fixed, run `craft prepare` again. Version bumping is idempotent—running it multiple times with the same version is safe. + +:::tip +Use `craft prepare --dry-run` first to preview what changes will be made without modifying any files. +::: + ## Post-release Command This command runs after a successful `craft publish`. Default: `bash scripts/post-release.sh`. @@ -444,12 +554,12 @@ artifactProvider: name: github config: artifacts: - build: release-artifacts # exact workflow → exact artifact - /^build-.*$/: artifacts # workflow pattern → exact artifact - ci: # exact workflow → multiple artifacts + build: release-artifacts # exact workflow → exact artifact + /^build-.*$/: artifacts # workflow pattern → exact artifact + ci: # exact workflow → multiple artifacts - /^output-.*$/ - bundle - /^release-.*$/: # workflow pattern → multiple artifacts + /^release-.*$/: # workflow pattern → multiple artifacts - /^dist-.*$/ - checksums ``` diff --git a/docs/src/content/docs/getting-started.md b/docs/src/content/docs/getting-started.md index 4ac03d82..a5d5ed74 100644 --- a/docs/src/content/docs/getting-started.md +++ b/docs/src/content/docs/getting-started.md @@ -288,7 +288,9 @@ export NUGET_API_TOKEN=abcdefgh 3. **Add `.craft.yml`** to your project with targets and options. -4. **Add a pre-release script** (default: `scripts/bump-version.sh`). +4. **Set up version bumping** (one of): + - **Automatic** (recommended): Set `minVersion: "2.19.0"` and Craft will automatically bump versions based on your targets (npm, pypi, crates, etc.) + - **Custom script**: Add `scripts/bump-version.sh` (or set `preReleaseCommand`) 5. **Configure environment variables** for your targets. diff --git a/package.json b/package.json index 61369534..c5ff725e 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "pnpm": "10.27.0" }, "dependencies": { + "ignore": "^7.0.5", "marked": "^17.0.1", "p-limit": "^6.2.0", "semver": "^7.7.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3917c4b3..cb839023 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: .: dependencies: + ignore: + specifier: ^7.0.5 + version: 7.0.5 marked: specifier: ^17.0.1 version: 17.0.1 diff --git a/src/__tests__/versionBump.test.ts b/src/__tests__/versionBump.test.ts new file mode 100644 index 00000000..e5ebcd59 --- /dev/null +++ b/src/__tests__/versionBump.test.ts @@ -0,0 +1,668 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import { join } from 'path'; +import { mkdtemp, rm, writeFile, mkdir, readFile } from 'fs/promises'; +import { tmpdir } from 'os'; + +import { runAutomaticVersionBumps } from '../utils/versionBump'; +import { NpmTarget } from '../targets/npm'; +import { PypiTarget } from '../targets/pypi'; +import { CratesTarget } from '../targets/crates'; +import { GemTarget } from '../targets/gem'; +import { PubDevTarget } from '../targets/pubDev'; +import { HexTarget } from '../targets/hex'; +import { NugetTarget } from '../targets/nuget'; + +// Use vi.hoisted to create mock functions that are available during vi.mock hoisting +const { mockSpawnProcess, mockHasExecutable } = vi.hoisted(() => ({ + mockSpawnProcess: vi.fn(), + mockHasExecutable: vi.fn(), +})); + +// Mock spawnProcess and hasExecutable to avoid actually running commands +// We need to mock runWithExecutable as well since it internally calls hasExecutable +// which won't be the mocked version due to how module internals work +vi.mock('../utils/system', async () => { + const actual = + await vi.importActual('../utils/system'); + + return { + ...actual, + spawnProcess: mockSpawnProcess, + hasExecutable: mockHasExecutable, + // Re-implement runWithExecutable to use mocked functions + runWithExecutable: async ( + config: import('../utils/system').ExecutableConfig, + args: string[], + options = {}, + ) => { + const bin = actual.resolveExecutable(config); + if (!mockHasExecutable(bin)) { + const hint = config.errorHint + ? ` ${config.errorHint}` + : ' Is it installed?'; + throw new Error(`Cannot find "${bin}".${hint}`); + } + return mockSpawnProcess(bin, args, options); + }, + }; +}); + +// Helper to set up default mocks +function setupDefaultMocks() { + mockSpawnProcess.mockResolvedValue(Buffer.from('')); + mockHasExecutable.mockReturnValue(true); +} + +describe('runAutomaticVersionBumps', () => { + let tempDir: string; + + beforeEach(async () => { + await setupDefaultMocks(); + tempDir = await mkdtemp(join(tmpdir(), 'craft-test-')); + }); + + afterEach(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + } + vi.resetAllMocks(); + }); + + test('returns empty result when no targets are provided', async () => { + const result = await runAutomaticVersionBumps([], tempDir, '1.0.0'); + expect(result.anyBumped).toBe(false); + expect(result.bumpableTargets).toEqual([]); + expect(result.skippedTargets).toEqual([]); + }); + + test('returns no bumpable targets when target does not support version bumping', async () => { + // 'github' target doesn't have bumpVersion + const result = await runAutomaticVersionBumps( + [{ name: 'github' }], + tempDir, + '1.0.0', + ); + expect(result.anyBumped).toBe(false); + expect(result.bumpableTargets).toEqual([]); + expect(result.skippedTargets).toEqual([]); + }); + + test('returns skipped target when target detection fails', async () => { + // npm target but no package.json + const result = await runAutomaticVersionBumps( + [{ name: 'npm' }], + tempDir, + '1.0.0', + ); + expect(result.anyBumped).toBe(false); + expect(result.bumpableTargets).toEqual(['npm']); + expect(result.skippedTargets).toEqual(['npm']); + }); + + test('calls bumpVersion for npm target with package.json', async () => { + await writeFile( + join(tempDir, 'package.json'), + JSON.stringify({ name: 'test', version: '0.0.1' }), + ); + + const result = await runAutomaticVersionBumps( + [{ name: 'npm' }], + tempDir, + '1.0.0', + ); + + expect(result.anyBumped).toBe(true); + expect(result.bumpableTargets).toEqual(['npm']); + expect(result.skippedTargets).toEqual([]); + }); + + test('deduplicates multiple targets of the same type', async () => { + await writeFile( + join(tempDir, 'package.json'), + JSON.stringify({ name: 'test', version: '0.0.1' }), + ); + + const bumpSpy = vi.spyOn(NpmTarget, 'bumpVersion'); + + await runAutomaticVersionBumps( + [{ name: 'npm' }, { name: 'npm', id: 'second' }], + tempDir, + '1.0.0', + ); + + // Should only be called once despite two npm targets + expect(bumpSpy).toHaveBeenCalledTimes(1); + }); + + test('processes multiple different target types', async () => { + // Create files for both npm and pypi + await writeFile( + join(tempDir, 'package.json'), + JSON.stringify({ name: 'test', version: '0.0.1' }), + ); + await writeFile( + join(tempDir, 'pyproject.toml'), + '[project]\nname = "test"\nversion = "0.0.1"', + ); + + const npmSpy = vi.spyOn(NpmTarget, 'bumpVersion'); + const pypiSpy = vi.spyOn(PypiTarget, 'bumpVersion'); + + const result = await runAutomaticVersionBumps( + [{ name: 'npm' }, { name: 'pypi' }], + tempDir, + '1.0.0', + ); + + expect(result.anyBumped).toBe(true); + expect(result.bumpableTargets).toEqual(['npm', 'pypi']); + expect(result.skippedTargets).toEqual([]); + expect(npmSpy).toHaveBeenCalledTimes(1); + expect(pypiSpy).toHaveBeenCalledTimes(1); + }); + + test('throws error with context when bumpVersion fails', async () => { + await writeFile( + join(tempDir, 'package.json'), + JSON.stringify({ name: 'test', version: '0.0.1' }), + ); + + vi.spyOn(NpmTarget, 'bumpVersion').mockRejectedValue( + new Error('npm not found'), + ); + + await expect( + runAutomaticVersionBumps([{ name: 'npm' }], tempDir, '1.0.0'), + ).rejects.toThrow(/Automatic version bump failed for "npm" target/); + }); +}); + +describe('NpmTarget.bumpVersion', () => { + let tempDir: string; + + beforeEach(async () => { + await setupDefaultMocks(); + tempDir = await mkdtemp(join(tmpdir(), 'craft-npm-test-')); + }); + + afterEach(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + } + vi.resetAllMocks(); + }); + + test('returns false when no package.json exists', async () => { + const result = await NpmTarget.bumpVersion(tempDir, '1.0.0'); + expect(result).toBe(false); + }); + + test('returns true and calls npm version when package.json exists', async () => { + await writeFile( + join(tempDir, 'package.json'), + JSON.stringify({ name: 'test', version: '0.0.1' }), + ); + + const { spawnProcess } = await import('../utils/system'); + const result = await NpmTarget.bumpVersion(tempDir, '1.0.0'); + + expect(result).toBe(true); + expect(spawnProcess).toHaveBeenCalledWith( + expect.any(String), + expect.arrayContaining(['version', '1.0.0', '--no-git-tag-version']), + expect.objectContaining({ cwd: tempDir }), + ); + }); +}); + +describe('PypiTarget.bumpVersion', () => { + let tempDir: string; + + beforeEach(async () => { + await setupDefaultMocks(); + tempDir = await mkdtemp(join(tmpdir(), 'craft-pypi-test-')); + }); + + afterEach(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + } + vi.resetAllMocks(); + }); + + test('returns false when no pyproject.toml exists', async () => { + const result = await PypiTarget.bumpVersion(tempDir, '1.0.0'); + expect(result).toBe(false); + }); + + test('uses hatch when [tool.hatch] section exists', async () => { + await writeFile( + join(tempDir, 'pyproject.toml'), + '[tool.hatch]\n[project]\nname = "test"', + ); + + const { spawnProcess } = await import('../utils/system'); + const result = await PypiTarget.bumpVersion(tempDir, '1.0.0'); + + expect(result).toBe(true); + expect(spawnProcess).toHaveBeenCalledWith( + 'hatch', + ['version', '1.0.0'], + expect.objectContaining({ cwd: tempDir }), + ); + }); + + test('uses poetry when [tool.poetry] section exists', async () => { + await writeFile( + join(tempDir, 'pyproject.toml'), + '[tool.poetry]\nname = "test"', + ); + + const { spawnProcess } = await import('../utils/system'); + const result = await PypiTarget.bumpVersion(tempDir, '1.0.0'); + + expect(result).toBe(true); + expect(spawnProcess).toHaveBeenCalledWith( + 'poetry', + ['version', '1.0.0'], + expect.objectContaining({ cwd: tempDir }), + ); + }); + + test('returns true without action for setuptools_scm', async () => { + await writeFile( + join(tempDir, 'pyproject.toml'), + '[tool.setuptools_scm]\n[project]\nname = "test"', + ); + + const { spawnProcess } = await import('../utils/system'); + const result = await PypiTarget.bumpVersion(tempDir, '1.0.0'); + + expect(result).toBe(true); + // Should not call any command for setuptools_scm + expect(spawnProcess).not.toHaveBeenCalled(); + }); + + test('directly edits pyproject.toml when [project] with version exists', async () => { + const originalContent = '[project]\nname = "test"\nversion = "0.0.1"'; + await writeFile(join(tempDir, 'pyproject.toml'), originalContent); + + const result = await PypiTarget.bumpVersion(tempDir, '1.0.0'); + + expect(result).toBe(true); + + const updatedContent = await readFile( + join(tempDir, 'pyproject.toml'), + 'utf-8', + ); + expect(updatedContent).toContain('version = "1.0.0"'); + }); +}); + +describe('GemTarget.bumpVersion', () => { + let tempDir: string; + + beforeEach(async () => { + await setupDefaultMocks(); + tempDir = await mkdtemp(join(tmpdir(), 'craft-gem-test-')); + }); + + afterEach(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + test('returns false when no gemspec exists', async () => { + const result = await GemTarget.bumpVersion(tempDir, '1.0.0'); + expect(result).toBe(false); + }); + + test('updates version in gemspec file', async () => { + const gemspecContent = ` +Gem::Specification.new do |s| + s.name = "test" + s.version = "0.0.1" +end +`; + await writeFile(join(tempDir, 'test.gemspec'), gemspecContent); + + const result = await GemTarget.bumpVersion(tempDir, '1.0.0'); + + expect(result).toBe(true); + + const updatedContent = await readFile( + join(tempDir, 'test.gemspec'), + 'utf-8', + ); + expect(updatedContent).toContain('s.version = "1.0.0"'); + }); + + test('updates VERSION in lib/**/version.rb', async () => { + await writeFile(join(tempDir, 'test.gemspec'), 'Gem::Specification.new'); + await mkdir(join(tempDir, 'lib', 'test'), { recursive: true }); + await writeFile( + join(tempDir, 'lib', 'test', 'version.rb'), + 'module Test\n VERSION = "0.0.1"\nend', + ); + + const result = await GemTarget.bumpVersion(tempDir, '1.0.0'); + + expect(result).toBe(true); + + const updatedContent = await readFile( + join(tempDir, 'lib', 'test', 'version.rb'), + 'utf-8', + ); + expect(updatedContent).toContain('VERSION = "1.0.0"'); + }); + + test('finds gemspec in subdirectory (monorepo like sentry-ruby)', async () => { + // Structure: sentry-ruby/sentry-ruby.gemspec + await mkdir(join(tempDir, 'sentry-ruby')); + await writeFile( + join(tempDir, 'sentry-ruby', 'sentry-ruby.gemspec'), + 'Gem::Specification.new do |s|\n s.version = "0.0.1"\nend', + ); + + const result = await GemTarget.bumpVersion(tempDir, '1.0.0'); + expect(result).toBe(true); + + const content = await readFile( + join(tempDir, 'sentry-ruby', 'sentry-ruby.gemspec'), + 'utf-8', + ); + expect(content).toContain('s.version = "1.0.0"'); + }); + + test('finds gemspec at depth 2 (packages/gem-name pattern)', async () => { + await mkdir(join(tempDir, 'packages', 'my-gem'), { recursive: true }); + await writeFile( + join(tempDir, 'packages', 'my-gem', 'my-gem.gemspec'), + 'Gem::Specification.new do |s|\n s.version = "0.0.1"\nend', + ); + + const result = await GemTarget.bumpVersion(tempDir, '1.0.0'); + expect(result).toBe(true); + + const content = await readFile( + join(tempDir, 'packages', 'my-gem', 'my-gem.gemspec'), + 'utf-8', + ); + expect(content).toContain('s.version = "1.0.0"'); + }); + + test('updates version.rb relative to gemspec in subdirectory', async () => { + // Structure: sentry-ruby/sentry-ruby.gemspec + sentry-ruby/lib/sentry/version.rb + await mkdir(join(tempDir, 'sentry-ruby', 'lib', 'sentry'), { + recursive: true, + }); + await writeFile( + join(tempDir, 'sentry-ruby', 'sentry-ruby.gemspec'), + 'Gem::Specification.new do |s|\n s.version = "0.0.1"\nend', + ); + await writeFile( + join(tempDir, 'sentry-ruby', 'lib', 'sentry', 'version.rb'), + 'module Sentry\n VERSION = "0.0.1"\nend', + ); + + const result = await GemTarget.bumpVersion(tempDir, '1.0.0'); + expect(result).toBe(true); + + const versionContent = await readFile( + join(tempDir, 'sentry-ruby', 'lib', 'sentry', 'version.rb'), + 'utf-8', + ); + expect(versionContent).toContain('VERSION = "1.0.0"'); + }); + + test('skips gemspecs in gitignored directories', async () => { + await writeFile(join(tempDir, '.gitignore'), 'vendor/\n'); + await mkdir(join(tempDir, 'vendor')); + await writeFile( + join(tempDir, 'vendor', 'test.gemspec'), + 'Gem::Specification.new do |s|\n s.version = "0.0.1"\nend', + ); + + const result = await GemTarget.bumpVersion(tempDir, '1.0.0'); + expect(result).toBe(false); + + // Verify the file was not modified + const content = await readFile( + join(tempDir, 'vendor', 'test.gemspec'), + 'utf-8', + ); + expect(content).toContain('s.version = "0.0.1"'); + }); + + test('handles multiple gemspecs in monorepo', async () => { + // Structure like sentry-ruby with multiple gems + await mkdir(join(tempDir, 'sentry-ruby')); + await mkdir(join(tempDir, 'sentry-rails')); + await writeFile( + join(tempDir, 'sentry-ruby', 'sentry-ruby.gemspec'), + 'Gem::Specification.new do |s|\n s.version = "0.0.1"\nend', + ); + await writeFile( + join(tempDir, 'sentry-rails', 'sentry-rails.gemspec'), + 'Gem::Specification.new do |s|\n s.version = "0.0.1"\nend', + ); + + const result = await GemTarget.bumpVersion(tempDir, '1.0.0'); + expect(result).toBe(true); + + const rubyContent = await readFile( + join(tempDir, 'sentry-ruby', 'sentry-ruby.gemspec'), + 'utf-8', + ); + const railsContent = await readFile( + join(tempDir, 'sentry-rails', 'sentry-rails.gemspec'), + 'utf-8', + ); + expect(rubyContent).toContain('s.version = "1.0.0"'); + expect(railsContent).toContain('s.version = "1.0.0"'); + }); +}); + +describe('PubDevTarget.bumpVersion', () => { + let tempDir: string; + + beforeEach(async () => { + await setupDefaultMocks(); + tempDir = await mkdtemp(join(tmpdir(), 'craft-pubdev-test-')); + }); + + afterEach(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + test('returns false when no pubspec.yaml exists', async () => { + const result = await PubDevTarget.bumpVersion(tempDir, '1.0.0'); + expect(result).toBe(false); + }); + + test('updates version in pubspec.yaml', async () => { + await writeFile( + join(tempDir, 'pubspec.yaml'), + 'name: test\nversion: 0.0.1\n', + ); + + const result = await PubDevTarget.bumpVersion(tempDir, '1.0.0'); + + expect(result).toBe(true); + + const updatedContent = await readFile( + join(tempDir, 'pubspec.yaml'), + 'utf-8', + ); + expect(updatedContent).toContain('version: 1.0.0'); + }); +}); + +describe('HexTarget.bumpVersion', () => { + let tempDir: string; + + beforeEach(async () => { + await setupDefaultMocks(); + tempDir = await mkdtemp(join(tmpdir(), 'craft-hex-test-')); + }); + + afterEach(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + test('returns false when no mix.exs exists', async () => { + const result = await HexTarget.bumpVersion(tempDir, '1.0.0'); + expect(result).toBe(false); + }); + + test('updates version: in mix.exs', async () => { + const mixContent = ` +defmodule Test.MixProject do + def project do + [ + app: :test, + version: "0.0.1", + ] + end +end +`; + await writeFile(join(tempDir, 'mix.exs'), mixContent); + + const result = await HexTarget.bumpVersion(tempDir, '1.0.0'); + + expect(result).toBe(true); + + const updatedContent = await readFile(join(tempDir, 'mix.exs'), 'utf-8'); + expect(updatedContent).toContain('version: "1.0.0"'); + }); + + test('updates @version module attribute in mix.exs', async () => { + const mixContent = ` +defmodule Test.MixProject do + @version "0.0.1" + + def project do + [app: :test, version: @version] + end +end +`; + await writeFile(join(tempDir, 'mix.exs'), mixContent); + + const result = await HexTarget.bumpVersion(tempDir, '1.0.0'); + + expect(result).toBe(true); + + const updatedContent = await readFile(join(tempDir, 'mix.exs'), 'utf-8'); + expect(updatedContent).toContain('@version "1.0.0"'); + }); +}); + +describe('NugetTarget.bumpVersion', () => { + let tempDir: string; + + beforeEach(async () => { + await setupDefaultMocks(); + tempDir = await mkdtemp(join(tmpdir(), 'craft-nuget-test-')); + }); + + afterEach(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + } + vi.resetAllMocks(); + }); + + test('returns false when no .csproj exists', async () => { + const result = await NugetTarget.bumpVersion(tempDir, '1.0.0'); + expect(result).toBe(false); + }); + + test('updates Version in .csproj file', async () => { + const csprojContent = ` + + 0.0.1 + +`; + await writeFile(join(tempDir, 'Test.csproj'), csprojContent); + + // Mock spawnProcess to fail (no dotnet-setversion) so it falls through to XML edit + const { spawnProcess } = await import('../utils/system'); + vi.mocked(spawnProcess).mockRejectedValue(new Error('not installed')); + + const result = await NugetTarget.bumpVersion(tempDir, '1.0.0'); + + expect(result).toBe(true); + + const updatedContent = await readFile( + join(tempDir, 'Test.csproj'), + 'utf-8', + ); + expect(updatedContent).toContain('1.0.0'); + }); + + test('updates Directory.Build.props if it exists', async () => { + const buildPropsContent = ` + + 0.0.1 + +`; + await writeFile(join(tempDir, 'Directory.Build.props'), buildPropsContent); + await writeFile(join(tempDir, 'Test.csproj'), ''); + + const { spawnProcess } = await import('../utils/system'); + vi.mocked(spawnProcess).mockRejectedValue(new Error('not installed')); + + const result = await NugetTarget.bumpVersion(tempDir, '1.0.0'); + + expect(result).toBe(true); + + const updatedContent = await readFile( + join(tempDir, 'Directory.Build.props'), + 'utf-8', + ); + expect(updatedContent).toContain('1.0.0'); + }); +}); + +describe('CratesTarget.bumpVersion', () => { + let tempDir: string; + + beforeEach(async () => { + await setupDefaultMocks(); + tempDir = await mkdtemp(join(tmpdir(), 'craft-crates-test-')); + }); + + afterEach(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + } + vi.resetAllMocks(); + }); + + test('returns false when no Cargo.toml exists', async () => { + const result = await CratesTarget.bumpVersion(tempDir, '1.0.0'); + expect(result).toBe(false); + }); + + test('calls cargo set-version when Cargo.toml exists', async () => { + await writeFile( + join(tempDir, 'Cargo.toml'), + '[package]\nname = "test"\nversion = "0.0.1"', + ); + + const { spawnProcess } = await import('../utils/system'); + const result = await CratesTarget.bumpVersion(tempDir, '1.0.0'); + + expect(result).toBe(true); + expect(spawnProcess).toHaveBeenCalledWith( + 'cargo', + ['set-version', '1.0.0'], + expect.objectContaining({ cwd: tempDir }), + ); + }); +}); diff --git a/src/commands/__tests__/prepare.test.ts b/src/commands/__tests__/prepare.test.ts index a620bc00..7dc857c1 100644 --- a/src/commands/__tests__/prepare.test.ts +++ b/src/commands/__tests__/prepare.test.ts @@ -8,6 +8,7 @@ vi.mock('../../utils/system'); describe('runPreReleaseCommand', () => { const oldVersion = '2.3.3'; const newVersion = '2.3.4'; + const rootDir = process.cwd(); const mockedSpawnProcess = spawnProcess as Mock; beforeEach(() => { @@ -17,11 +18,16 @@ describe('runPreReleaseCommand', () => { test('runs with default command', async () => { expect.assertions(1); - await runPreReleaseCommand(oldVersion, newVersion); + await runPreReleaseCommand({ + oldVersion, + newVersion, + rootDir, + preReleaseCommand: 'scripts/bump-version.sh', + }); expect(mockedSpawnProcess).toBeCalledWith( - '/bin/bash', - [pathJoin('scripts', 'bump-version.sh'), oldVersion, newVersion], + 'scripts/bump-version.sh', + [oldVersion, newVersion], { env: { ...process.env, @@ -35,11 +41,12 @@ describe('runPreReleaseCommand', () => { test('runs with custom command', async () => { expect.assertions(1); - await runPreReleaseCommand( + await runPreReleaseCommand({ oldVersion, newVersion, - 'python ./increase_version.py "argument 1"' - ); + rootDir, + preReleaseCommand: 'python ./increase_version.py "argument 1"', + }); expect(mockedSpawnProcess).toBeCalledWith( 'python', diff --git a/src/commands/prepare.ts b/src/commands/prepare.ts index 2e011a4d..5ab3a7c4 100644 --- a/src/commands/prepare.ts +++ b/src/commands/prepare.ts @@ -8,6 +8,7 @@ import { Arguments, Argv, CommandBuilder } from 'yargs'; import { getConfiguration, + getConfigFileDir, DEFAULT_RELEASE_BRANCH_NAME, getGlobalGitHubConfig, requiresMinVersion, @@ -16,7 +17,11 @@ import { getVersioningPolicy, } from '../config'; import { logger } from '../logger'; -import { ChangelogPolicy, VersioningPolicy } from '../schemas/project_config'; +import { + ChangelogPolicy, + TargetConfig, + VersioningPolicy, +} from '../schemas/project_config'; import { calculateCalVer, DEFAULT_CALVER_CONFIG } from '../utils/calver'; import { sleep } from '../utils/async'; import { @@ -53,6 +58,7 @@ import { import { formatJson } from '../utils/strings'; import { spawnProcess } from '../utils/system'; import { isValidVersion } from '../utils/version'; +import { runAutomaticVersionBumps } from '../utils/versionBump'; import { withTracing } from '../utils/tracing'; import { handler as publishMainHandler, PublishOptions } from './publish'; @@ -67,6 +73,9 @@ const DEFAULT_BUMP_VERSION_PATH = join('scripts', 'bump-version.sh'); /** Minimum craft version required for auto-versioning */ const AUTO_VERSION_MIN_VERSION = '2.14.0'; +/** Minimum craft version required for automatic version bumping from targets */ +const AUTO_BUMP_MIN_VERSION = '2.21.0'; + export const builder: CommandBuilder = (yargs: Argv) => yargs .positional('NEW-VERSION', { @@ -276,27 +285,77 @@ async function commitNewVersion( await git.commit(message, ['--all']); } +interface PreReleaseOptions { + oldVersion: string; + newVersion: string; + preReleaseCommand?: string; + targets?: TargetConfig[]; + rootDir: string; +} + /** - * Run an external pre-release command - * - * The command usually executes operations for version bumping and might - * include dependency updates. + * Run pre-release command or automatic version bumping. * - * @param newVersion Version being released - * @param preReleaseCommand Custom pre-release command + * Priority: custom command > automatic bumping (minVersion >= 2.21.0) > default script */ export async function runPreReleaseCommand( + options: PreReleaseOptions, +): Promise { + const { oldVersion, newVersion, preReleaseCommand, targets, rootDir } = + options; + + if (preReleaseCommand !== undefined && preReleaseCommand.length === 0) { + logger.warn('Not running the pre-release command: no command specified'); + return false; + } + + if (preReleaseCommand) { + return runCustomPreReleaseCommand( + oldVersion, + newVersion, + preReleaseCommand, + ); + } + + if ( + requiresMinVersion(AUTO_BUMP_MIN_VERSION) && + targets && + targets.length > 0 + ) { + logger.info('Running automatic version bumping from targets...'); + const result = await runAutomaticVersionBumps(targets, rootDir, newVersion); + + if (!result.anyBumped) { + if (result.bumpableTargets.length === 0) { + logger.warn( + 'None of your configured targets support automatic version bumping. ' + + 'Consider adding a preReleaseCommand to bump versions manually.', + ); + } else { + logger.warn( + `Targets [${result.skippedTargets.join(', ')}] support version bumping ` + + 'but did not find applicable files in your project. ' + + 'Consider adding a preReleaseCommand if you need custom version bumping.', + ); + } + } + + return result.anyBumped; + } + + return runCustomPreReleaseCommand(oldVersion, newVersion, undefined); +} + +/** + * Run a custom pre-release command (or the default bump-version.sh script) + */ +async function runCustomPreReleaseCommand( oldVersion: string, newVersion: string, preReleaseCommand?: string, ): Promise { let sysCommand: string; let args: string[]; - if (preReleaseCommand !== undefined && preReleaseCommand.length === 0) { - // Not running pre-release command - logger.warn('Not running the pre-release command: no command specified'); - return false; - } // This is a workaround for the case when the old version is empty, which // should only happen when the project is new and has no version yet. @@ -304,21 +363,26 @@ export async function runPreReleaseCommand( // avoid breaking the pre-release command as most scripts expect a non-empty // version string. const nonEmptyOldVersion = oldVersion || '0.0.0'; + if (preReleaseCommand) { [sysCommand, ...args] = shellQuote.parse(preReleaseCommand) as string[]; } else { sysCommand = '/bin/bash'; args = [DEFAULT_BUMP_VERSION_PATH]; } + args = [...args, nonEmptyOldVersion, newVersion]; logger.info('Running the pre-release command...'); + const additionalEnv = { CRAFT_NEW_VERSION: newVersion, CRAFT_OLD_VERSION: nonEmptyOldVersion, }; + await spawnProcess(sysCommand, args, { env: { ...process.env, ...additionalEnv }, }); + return true; } @@ -725,11 +789,14 @@ export async function prepareMain(argv: PrepareOptions): Promise { ); // Run a pre-release script (e.g. for version bumping) - const preReleaseCommandRan = await runPreReleaseCommand( + const rootDir = getConfigFileDir() || process.cwd(); + const preReleaseCommandRan = await runPreReleaseCommand({ oldVersion, newVersion, - config.preReleaseCommand, - ); + preReleaseCommand: config.preReleaseCommand, + targets: config.targets, + rootDir, + }); if (preReleaseCommandRan) { // Commit the pending changes diff --git a/src/targets/crates.ts b/src/targets/crates.ts index 33ca8598..3790ff8a 100644 --- a/src/targets/crates.ts +++ b/src/targets/crates.ts @@ -7,16 +7,23 @@ import { GitHubGlobalConfig, TargetConfig } from '../schemas/project_config'; import { forEachChained, sleep, withRetry } from '../utils/async'; import { ConfigurationError } from '../utils/errors'; import { withTempDir } from '../utils/files'; -import { checkExecutableIsPresent, spawnProcess } from '../utils/system'; +import { + checkExecutableIsPresent, + resolveExecutable, + runWithExecutable, +} from '../utils/system'; import { BaseTarget } from './base'; import { BaseArtifactProvider } from '../artifact_providers/base'; -const DEFAULT_CARGO_BIN = 'cargo'; +/** Cargo executable configuration */ +const CARGO_CONFIG = { + name: 'cargo', + envVar: 'CARGO_BIN', + errorHint: 'Install cargo or define a custom preReleaseCommand in .craft.yml', +} as const; -/** - * Command to launch cargo - */ -const CARGO_BIN = process.env.CARGO_BIN || DEFAULT_CARGO_BIN; +/** Resolved cargo binary path */ +const CARGO_BIN = resolveExecutable(CARGO_CONFIG); /** * A message fragment emitted by cargo when publishing fails due to a missing @@ -107,10 +114,47 @@ export class CratesTarget extends BaseTarget { /** GitHub repo configuration */ public readonly githubRepo: GitHubGlobalConfig; + /** + * Bump version in Cargo.toml using cargo set-version (from cargo-edit). + * + * @param rootDir - Project root directory + * @param newVersion - New version string to set + * @returns true if version was bumped, false if no Cargo.toml exists + * @throws Error if cargo is not found or command fails + */ + public static async bumpVersion( + rootDir: string, + newVersion: string, + ): Promise { + const cargoTomlPath = path.join(rootDir, 'Cargo.toml'); + if (!fs.existsSync(cargoTomlPath)) { + return false; + } + + try { + await runWithExecutable(CARGO_CONFIG, ['set-version', newVersion], { + cwd: rootDir, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if ( + message.includes('no such command') || + message.includes('no such subcommand') + ) { + throw new Error( + 'cargo set-version not found. Install cargo-edit: cargo install cargo-edit', + ); + } + throw error; + } + + return true; + } + public constructor( config: TargetConfig, artifactProvider: BaseArtifactProvider, - githubRepo: GitHubGlobalConfig + githubRepo: GitHubGlobalConfig, ) { super(config, artifactProvider, githubRepo); this.cratesConfig = this.getCratesConfig(); @@ -125,7 +169,7 @@ export class CratesTarget extends BaseTarget { if (!process.env.CRATES_IO_TOKEN) { throw new ConfigurationError( `Cannot publish to Crates.io: missing credentials. - Please use CRATES_IO_TOKEN environment variable to pass the API token.` + Please use CRATES_IO_TOKEN environment variable to pass the API token.`, ); } return { @@ -155,13 +199,13 @@ export class CratesTarget extends BaseTarget { ]; this.logger.info( - `Loading workspace information from ${directory}/Cargo.toml` + `Loading workspace information from ${directory}/Cargo.toml`, ); const metadata = await spawnProcess( CARGO_BIN, args, {}, - { enableInDryRunMode: true } + { enableInDryRunMode: true }, ); if (!metadata) { throw new ConfigurationError('Empty Cargo metadata!'); @@ -183,10 +227,13 @@ export class CratesTarget extends BaseTarget { * @returns The sorted list of packages */ public getPublishOrder(packages: CratePackage[]): CratePackage[] { - const remaining = packages.reduce((dict, p) => { - dict[p.name] = p; - return dict; - }, {} as { [index: string]: CratePackage }); + const remaining = packages.reduce( + (dict, p) => { + dict[p.name] = p; + return dict; + }, + {} as { [index: string]: CratePackage }, + ); const ordered: CratePackage[] = []; const isWorkspaceDependency = (dep: CrateDependency) => { @@ -209,7 +256,7 @@ export class CratesTarget extends BaseTarget { while (Object.keys(remaining).length > 0) { const leafDependencies = Object.values(remaining).filter( // Find all packages with no remaining workspace dependencies - p => p.dependencies.filter(isWorkspaceDependency).length === 0 + p => p.dependencies.filter(isWorkspaceDependency).length === 0, ); if (leafDependencies.length === 0) { @@ -246,7 +293,7 @@ export class CratesTarget extends BaseTarget { this.logger.debug( `Publishing packages in the following order: ${crates .map(c => c.name) - .join(', ')}` + .join(', ')}`, ); return forEachChained(crates, async crate => this.publishPackage(crate)); } @@ -265,7 +312,7 @@ export class CratesTarget extends BaseTarget { args.push( '--no-verify', // Verification should be done on the CI stage '--manifest-path', - crate.manifest_path + crate.manifest_path, ); const env = { @@ -282,7 +329,7 @@ export class CratesTarget extends BaseTarget { } catch (err) { if (err instanceof Error && err.message.includes(REPUBLISH_ERROR)) { this.logger.info( - `Skipping ${crate.name}, version ${crate.version} already published` + `Skipping ${crate.name}, version ${crate.version} already published`, ); } else { throw err; @@ -299,7 +346,7 @@ export class CratesTarget extends BaseTarget { await sleep(delay * 1000); delay *= RETRY_EXP_FACTOR; return true; - } + }, ); } @@ -313,7 +360,7 @@ export class CratesTarget extends BaseTarget { public async cloneWithSubmodules( config: GitHubGlobalConfig, revision: string, - directory: string + directory: string, ): Promise { const { owner, repo } = config; const git = createGitClient(directory); @@ -349,7 +396,7 @@ export class CratesTarget extends BaseTarget { await this.publishWorkspace(directory); }, true, - 'craft-crates-' + 'craft-crates-', ); this.logger.info('Crates release complete'); diff --git a/src/targets/gem.ts b/src/targets/gem.ts index 816b2f9e..5dec8c11 100644 --- a/src/targets/gem.ts +++ b/src/targets/gem.ts @@ -1,11 +1,16 @@ +import { readdir, readFile, writeFile } from 'fs/promises'; +import { dirname, join } from 'path'; + import { BaseArtifactProvider, RemoteArtifact, } from '../artifact_providers/base'; import { reportError } from '../utils/errors'; +import { findFiles } from '../utils/files'; import { checkExecutableIsPresent, spawnProcess } from '../utils/system'; import { BaseTarget } from './base'; import { TargetConfig } from '../schemas/project_config'; +import { logger } from '../logger'; const DEFAULT_GEM_BIN = 'gem'; @@ -26,9 +31,106 @@ export class GemTarget extends BaseTarget { /** Target name */ public readonly name: string = 'gem'; + /** + * Bump version in Ruby gem project files. + * + * Supports monorepos by searching for gemspec files up to 2 levels deep, + * respecting .gitignore patterns. + * + * Looks for version patterns in: + * 1. .gemspec files (s.version = "x.y.z") + * 2. lib/.../version.rb files relative to each gemspec (VERSION = "x.y.z") + */ + public static async bumpVersion( + rootDir: string, + newVersion: string, + ): Promise { + // Find all gemspec files up to 2 levels deep, respecting .gitignore + const gemspecFiles = await findFiles(rootDir, { + maxDepth: 2, + fileFilter: name => name.endsWith('.gemspec'), + }); + + if (gemspecFiles.length === 0) { + return false; + } + + let bumped = false; + + for (const gemspecPath of gemspecFiles) { + const content = await readFile(gemspecPath, 'utf-8'); + + // Match: s.version = "1.0.0" or spec.version = '1.0.0' + const versionRegex = /^(\s*\w+\.version\s*=\s*["'])([^"']+)(["'])/m; + + if (versionRegex.test(content)) { + const newContent = content.replace(versionRegex, `$1${newVersion}$3`); + if (newContent !== content) { + logger.debug(`Updating version in ${gemspecPath} to ${newVersion}`); + await writeFile(gemspecPath, newContent); + bumped = true; + } + } + + // Also check for lib/**/version.rb relative to each gemspec's directory + const gemDir = dirname(gemspecPath); + const libDir = join(gemDir, 'lib'); + const libUpdated = await GemTarget.updateVersionRbFiles( + libDir, + newVersion, + ); + bumped = libUpdated || bumped; + } + + return bumped; + } + + /** + * Recursively find and update version.rb files + */ + private static async updateVersionRbFiles( + dir: string, + newVersion: string, + ): Promise { + let updated = false; + let entries; + + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return false; + } + + for (const entry of entries) { + const fullPath = join(dir, entry.name); + + if (entry.isDirectory()) { + const subUpdated = await GemTarget.updateVersionRbFiles( + fullPath, + newVersion, + ); + updated = subUpdated || updated; + } else if (entry.name === 'version.rb') { + const content = await readFile(fullPath, 'utf-8'); + const versionRegex = /^(\s*VERSION\s*=\s*["'])([^"']+)(["'])/m; + + if (versionRegex.test(content)) { + const newContent = content.replace(versionRegex, `$1${newVersion}$3`); + if (newContent !== content) { + logger.debug(`Updating VERSION in ${fullPath} to ${newVersion}`); + await writeFile(fullPath, newContent); + updated = true; + } + } + } + } + + return updated; + } + public constructor( config: TargetConfig, - artifactProvider: BaseArtifactProvider + artifactProvider: BaseArtifactProvider, ) { super(config, artifactProvider); checkExecutableIsPresent(GEM_BIN); @@ -66,7 +168,7 @@ export class GemTarget extends BaseTarget { const path = await this.artifactProvider.downloadArtifact(file); this.logger.info(`Pushing gem "${file.filename}"`); return this.pushGem(path); - }) + }), ); this.logger.info('Successfully registered gem'); diff --git a/src/targets/hex.ts b/src/targets/hex.ts index ee415bce..36098b18 100644 --- a/src/targets/hex.ts +++ b/src/targets/hex.ts @@ -1,3 +1,6 @@ +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; + import { createGitClient } from '../utils/git'; import { BaseTarget } from './base'; @@ -6,6 +9,7 @@ import { checkExecutableIsPresent, spawnProcess } from '../utils/system'; import { GitHubGlobalConfig, TargetConfig } from '../schemas/project_config'; import { BaseArtifactProvider } from '../artifact_providers/base'; import { reportError } from '../utils/errors'; +import { logger } from '../logger'; const DEFAULT_MIX_BIN = 'mix'; @@ -30,6 +34,55 @@ export class HexTarget extends BaseTarget { /** GitHub repo configuration */ public readonly githubRepo: GitHubGlobalConfig; + /** + * Bump version in mix.exs for Elixir projects. + * + * @param rootDir - Project root directory + * @param newVersion - New version string to set + * @returns true if version was bumped, false if no mix.exs exists + * @throws Error if file cannot be updated + */ + public static async bumpVersion( + rootDir: string, + newVersion: string + ): Promise { + const mixExsPath = join(rootDir, 'mix.exs'); + if (!existsSync(mixExsPath)) { + return false; + } + + const content = readFileSync(mixExsPath, 'utf-8'); + const versionPatterns = [ + /^(\s*version:\s*["'])([^"']+)(["'])/m, + /^(\s*@version\s+["'])([^"']+)(["'])/m, + ]; + + let newContent = content; + let updated = false; + + for (const pattern of versionPatterns) { + if (pattern.test(newContent)) { + newContent = newContent.replace(pattern, `$1${newVersion}$3`); + updated = true; + } + } + + if (!updated) { + logger.debug('No version pattern found in mix.exs'); + return false; + } + + if (newContent === content) { + logger.debug('Version already set to target value'); + return true; + } + + logger.debug(`Updating version in ${mixExsPath} to ${newVersion}`); + writeFileSync(mixExsPath, newContent); + + return true; + } + public constructor( config: TargetConfig, artifactProvider: BaseArtifactProvider, diff --git a/src/targets/npm.ts b/src/targets/npm.ts index 7db3d956..e176f22f 100644 --- a/src/targets/npm.ts +++ b/src/targets/npm.ts @@ -1,11 +1,17 @@ import { SpawnOptions, spawnSync } from 'child_process'; +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; import prompts from 'prompts'; import { TargetConfig } from '../schemas/project_config'; import { ConfigurationError, reportError } from '../utils/errors'; import { stringToRegexp } from '../utils/filters'; import { isDryRun } from '../utils/helpers'; -import { hasExecutable, spawnProcess } from '../utils/system'; +import { + hasExecutable, + requireFirstExecutable, + spawnProcess, +} from '../utils/system'; import { isPreviewRelease, parseVersion, @@ -27,6 +33,12 @@ import { withTempFile } from '../utils/files'; import { writeFileSync } from 'fs'; import { logger } from '../logger'; +/** npm executable config */ +export const NPM_CONFIG = { name: 'npm', envVar: 'NPM_BIN' } as const; + +/** yarn executable config */ +export const YARN_CONFIG = { name: 'yarn', envVar: 'YARN_BIN' } as const; + /** Command to launch "npm" */ export const NPM_BIN = process.env.NPM_BIN || 'npm'; @@ -119,7 +131,7 @@ export class NpmTarget extends BaseTarget { */ public static async expand( config: NpmTargetConfig, - rootDir: string + rootDir: string, ): Promise { // If workspaces is not enabled, return the config as-is if (!config.workspaces) { @@ -130,7 +142,7 @@ export class NpmTarget extends BaseTarget { if (result.type === 'none' || result.packages.length === 0) { logger.warn( - 'npm target has workspaces enabled but no workspace packages were found' + 'npm target has workspaces enabled but no workspace packages were found', ); return []; } @@ -148,28 +160,28 @@ export class NpmTarget extends BaseTarget { const filteredPackages = filterWorkspacePackages( result.packages, includePattern, - excludePattern + excludePattern, ); // Also filter out private packages by default (they shouldn't be published) const publishablePackages = filteredPackages.filter(pkg => !pkg.private); const privatePackageNames = new Set( - filteredPackages.filter(pkg => pkg.private).map(pkg => pkg.name) + filteredPackages.filter(pkg => pkg.private).map(pkg => pkg.name), ); // Validate: public packages should not depend on private workspace packages for (const pkg of publishablePackages) { const privateDeps = pkg.workspaceDependencies.filter(dep => - privatePackageNames.has(dep) + privatePackageNames.has(dep), ); if (privateDeps.length > 0) { throw new ConfigurationError( `Public package "${ pkg.name }" depends on private workspace package(s): ${privateDeps.join( - ', ' + ', ', )}. ` + - `Private packages cannot be published to npm, so this dependency cannot be resolved by consumers.` + `Private packages cannot be published to npm, so this dependency cannot be resolved by consumers.`, ); } @@ -178,7 +190,7 @@ export class NpmTarget extends BaseTarget { if (isScoped && !pkg.hasPublicAccess) { logger.warn( `Scoped package "${pkg.name}" does not have publishConfig.access set to 'public'. ` + - `This may cause npm publish to fail for public packages.` + `This may cause npm publish to fail for public packages.`, ); } } @@ -189,11 +201,9 @@ export class NpmTarget extends BaseTarget { } logger.info( - `Discovered ${publishablePackages.length} publishable ${result.type} workspace packages` + `Discovered ${publishablePackages.length} publishable ${result.type} workspace packages`, ); - - // Sort packages by dependency order (dependencies first, then dependents) const sortedPackages = topologicalSortPackages(publishablePackages); @@ -202,7 +212,7 @@ export class NpmTarget extends BaseTarget { sortedPackages.length } packages (dependency order): ${sortedPackages .map(p => p.name) - .join(', ')}` + .join(', ')}`, ); // Generate a target config for each package @@ -212,7 +222,7 @@ export class NpmTarget extends BaseTarget { if (config.artifactTemplate) { includeNames = packageNameToArtifactFromTemplate( pkg.name, - config.artifactTemplate + config.artifactTemplate, ); } else { includeNames = packageNameToArtifactPattern(pkg.name); @@ -242,9 +252,121 @@ export class NpmTarget extends BaseTarget { }); } + /** + * Bump version in package.json using npm or yarn. + * Supports workspaces - bumps root and all workspace packages. + * + * @param rootDir - Project root directory + * @param newVersion - New version string to set + * @returns true if version was bumped, false if no package.json exists + * @throws Error if npm/yarn is not found or command fails + */ + public static async bumpVersion( + rootDir: string, + newVersion: string, + ): Promise { + const packageJsonPath = join(rootDir, 'package.json'); + if (!existsSync(packageJsonPath)) { + return false; + } + + const { bin, index: execIndex } = requireFirstExecutable( + [NPM_CONFIG, YARN_CONFIG], + 'Install npm/yarn or define a custom preReleaseCommand in .craft.yml', + ); + const isNpm = execIndex === 0; + + const workspaces = await discoverWorkspaces(rootDir); + const isWorkspace = + workspaces.type !== 'none' && workspaces.packages.length > 0; + + // --no-git-tag-version prevents npm from creating a git commit and tag + // --allow-same-version allows setting the same version (useful for re-runs) + const baseArgs = isNpm + ? ['version', newVersion, '--no-git-tag-version', '--allow-same-version'] + : ['version', newVersion, '--no-git-tag-version']; + + logger.debug(`Running: ${bin} ${baseArgs.join(' ')}`); + await spawnProcess(bin, baseArgs, { cwd: rootDir }); + + if (isWorkspace) { + if (isNpm) { + // npm 7+ supports --workspaces flag + const workspaceArgs = [ + ...baseArgs, + '--workspaces', + '--include-workspace-root', + ]; + logger.debug( + `Running: ${bin} ${workspaceArgs.join(' ')} (for workspaces)`, + ); + try { + await spawnProcess(bin, workspaceArgs, { cwd: rootDir }); + } catch { + // If --workspaces fails (npm < 7), fall back to individual package bumping + logger.debug( + 'npm --workspaces failed, falling back to individual package bumping', + ); + await NpmTarget.bumpWorkspacePackagesIndividually( + bin, + workspaces.packages, + newVersion, + baseArgs, + ); + } + } else { + // yarn doesn't have --workspaces for version command, bump individually + await NpmTarget.bumpWorkspacePackagesIndividually( + bin, + workspaces.packages, + newVersion, + baseArgs, + ); + } + + logger.info( + `Bumped version in root and ${workspaces.packages.length} workspace packages`, + ); + } + + return true; + } + + /** + * Bump version in each workspace package individually + */ + private static async bumpWorkspacePackagesIndividually( + bin: string, + packages: { name: string; location: string }[], + newVersion: string, + baseArgs: string[], + ): Promise { + for (const pkg of packages) { + const pkgJsonPath = join(pkg.location, 'package.json'); + if (!existsSync(pkgJsonPath)) { + continue; + } + + let pkgJson: { private?: boolean }; + try { + pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')); + } catch { + continue; + } + + if (pkgJson.private) { + logger.debug(`Skipping private package: ${pkg.name}`); + continue; + } + + logger.debug(`Bumping version for workspace package: ${pkg.name}`); + await spawnProcess(bin, baseArgs, { cwd: pkg.location }); + } + } + public constructor( config: NpmTargetConfig, - artifactProvider: BaseArtifactProvider + artifactProvider: BaseArtifactProvider, ) { super(config, artifactProvider); this.checkRequirements(); @@ -270,7 +392,7 @@ export class NpmTarget extends BaseTarget { (major === NPM_MIN_MAJOR && minor < NPM_MIN_MINOR) ) { reportError( - `NPM version is too old: ${npmVersion}. Please update your NodeJS` + `NPM version is too old: ${npmVersion}. Please update your NodeJS`, ); } this.logger.debug(`Found NPM version ${npmVersion}`); @@ -316,7 +438,7 @@ export class NpmTarget extends BaseTarget { npmConfig.access = this.config.access; } else { throw new ConfigurationError( - `Invalid value for "npm.access" option: ${this.config.access}` + `Invalid value for "npm.access" option: ${this.config.access}`, ); } } @@ -336,7 +458,7 @@ export class NpmTarget extends BaseTarget { */ protected async publishPackage( path: string, - options: NpmPublishOptions + options: NpmPublishOptions, ): Promise { // NOTE: --ignore-scripts prevents execution of lifecycle scripts (prepublish, // prepublishOnly, prepack, postpack, publish, postpublish) which could run @@ -374,7 +496,7 @@ export class NpmTarget extends BaseTarget { spawnOptions.env.npm_config_userconfig = filePath; writeFileSync( filePath, - `//registry.npmjs.org/:_authToken=\${${NPM_TOKEN_ENV_VAR}}` + `//registry.npmjs.org/:_authToken=\${${NPM_TOKEN_ENV_VAR}}`, ); // The path has to be pushed always as the last arg @@ -414,7 +536,7 @@ export class NpmTarget extends BaseTarget { this.config.checkPackageName, this.npmConfig, this.logger, - publishOptions.otp + publishOptions.otp, ); if (tag) { publishOptions.tag = tag; @@ -425,7 +547,7 @@ export class NpmTarget extends BaseTarget { const path = await this.artifactProvider.downloadArtifact(file); this.logger.info(`Releasing ${file.filename} to NPM`); return this.publishPackage(path, publishOptions); - }) + }), ); this.logger.info('NPM release complete'); @@ -438,7 +560,7 @@ export class NpmTarget extends BaseTarget { export async function getLatestVersion( packageName: string, npmConfig: NpmTargetOptions, - otp?: NpmPublishOptions['otp'] + otp?: NpmPublishOptions['otp'], ): Promise { const args = ['info', packageName, 'version']; const bin = NPM_BIN; @@ -456,7 +578,7 @@ export async function getLatestVersion( spawnOptions.env.npm_config_userconfig = filePath; writeFileSync( filePath, - `//registry.npmjs.org/:_authToken=\${${NPM_TOKEN_ENV_VAR}}` + `//registry.npmjs.org/:_authToken=\${${NPM_TOKEN_ENV_VAR}}`, ); return spawnProcess(bin, args, spawnOptions); @@ -481,7 +603,7 @@ export async function getPublishTag( checkPackageName: string | undefined, npmConfig: NpmTargetOptions, logger: NpmTarget['logger'], - otp?: NpmPublishOptions['otp'] + otp?: NpmPublishOptions['otp'], ): Promise { if (isPreviewRelease(version)) { logger.warn('Detected pre-release version for npm package!'); @@ -497,14 +619,14 @@ export async function getPublishTag( const latestVersion = await getLatestVersion( checkPackageName, npmConfig, - otp + otp, ); const parsedLatestVersion = latestVersion && parseVersion(latestVersion); const parsedNewVersion = parseVersion(version); if (!parsedLatestVersion) { logger.warn( - `Could not fetch current version for package ${checkPackageName}` + `Could not fetch current version for package ${checkPackageName}`, ); return undefined; } @@ -516,7 +638,7 @@ export async function getPublishTag( !versionGreaterOrEqualThan(parsedNewVersion, parsedLatestVersion) ) { logger.warn( - `Detected older version than currently published version (${latestVersion}) for ${checkPackageName}` + `Detected older version than currently published version (${latestVersion}) for ${checkPackageName}`, ); logger.warn('Adding tag "old" to not make it "latest" in registry.'); return 'old'; diff --git a/src/targets/nuget.ts b/src/targets/nuget.ts index aba0a5eb..3eaea1b9 100644 --- a/src/targets/nuget.ts +++ b/src/targets/nuget.ts @@ -1,11 +1,19 @@ +import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs'; +import { join } from 'path'; + import { TargetConfig } from '../schemas/project_config'; import { ConfigurationError, reportError } from '../utils/errors'; -import { checkExecutableIsPresent, spawnProcess } from '../utils/system'; +import { + checkExecutableIsPresent, + hasExecutable, + spawnProcess, +} from '../utils/system'; import { BaseTarget } from './base'; import { BaseArtifactProvider, RemoteArtifact, } from '../artifact_providers/base'; +import { logger } from '../logger'; /** Command to launch dotnet tools */ export const NUGET_DOTNET_BIN = process.env.NUGET_DOTNET_BIN || 'dotnet'; @@ -40,6 +48,112 @@ export class NugetTarget extends BaseTarget { /** Target options */ public readonly nugetConfig: NugetTargetOptions; + /** + * Bump version in .NET project files (.csproj, Directory.Build.props). + * + * Tries dotnet-setversion first if available, otherwise edits XML directly. + * + * @param rootDir - Project root directory + * @param newVersion - New version string to set + * @returns true if version was bumped, false if no .NET project found + * @throws Error if version cannot be updated + */ + public static async bumpVersion( + rootDir: string, + newVersion: string + ): Promise { + // Check for .NET project files + const csprojFiles = readdirSync(rootDir).filter(f => f.endsWith('.csproj')); + const hasDotNet = + csprojFiles.length > 0 || + existsSync(join(rootDir, 'Directory.Build.props')); + + if (!hasDotNet) { + return false; + } + + if (hasExecutable(NUGET_DOTNET_BIN)) { + try { + const result = await spawnProcess( + NUGET_DOTNET_BIN, + ['setversion', newVersion], + { cwd: rootDir }, + { enableInDryRunMode: true } + ); + if (result !== null) { + return true; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!message.includes('not installed') && !message.includes('Could not execute')) { + throw error; + } + logger.debug('dotnet-setversion not available, falling back to manual edit'); + } + } + + let bumped = false; + + // Try Directory.Build.props first (centralized version management) + const buildPropsPath = join(rootDir, 'Directory.Build.props'); + if (existsSync(buildPropsPath)) { + if (NugetTarget.updateVersionInXml(buildPropsPath, newVersion)) { + bumped = true; + } + } + + if (!bumped) { + for (const csproj of csprojFiles) { + const csprojPath = join(rootDir, csproj); + if (NugetTarget.updateVersionInXml(csprojPath, newVersion)) { + bumped = true; + } + } + } + + return bumped; + } + + /** + * Update version in an XML project file (.csproj or Directory.Build.props) + */ + private static updateVersionInXml(filePath: string, newVersion: string): boolean { + const content = readFileSync(filePath, 'utf-8'); + + // Match x.y.z or x.y.z + const versionPatterns = [ + /()([^<]+)(<\/Version>)/g, + /()([^<]+)(<\/PackageVersion>)/g, + /()([^<]+)(<\/AssemblyVersion>)/g, + /()([^<]+)(<\/FileVersion>)/g, + ]; + + let newContent = content; + let updated = false; + + for (const pattern of versionPatterns) { + if (pattern.test(newContent)) { + // Reset lastIndex for global regex + pattern.lastIndex = 0; + newContent = newContent.replace(pattern, `$1${newVersion}$3`); + updated = true; + } + } + + if (!updated) { + return false; + } + + if (newContent === content) { + return true; // Already at target version + } + + logger.debug(`Updating version in ${filePath} to ${newVersion}`); + writeFileSync(filePath, newContent); + + return true; + } + public constructor( config: TargetConfig, artifactProvider: BaseArtifactProvider diff --git a/src/targets/pubDev.ts b/src/targets/pubDev.ts index ec882faf..a4d59e8e 100644 --- a/src/targets/pubDev.ts +++ b/src/targets/pubDev.ts @@ -1,4 +1,4 @@ -import { constants, promises as fsPromises } from 'fs'; +import { constants, existsSync, promises as fsPromises, readFileSync, writeFileSync } from 'fs'; import { homedir, platform } from 'os'; import { join, dirname } from 'path'; import { load, dump } from 'js-yaml'; @@ -12,6 +12,7 @@ import { withTempDir } from '../utils/files'; import { checkExecutableIsPresent, spawnProcess } from '../utils/system'; import { isDryRun } from '../utils/helpers'; import { logDryRun } from '../utils/dryRun'; +import { logger } from '../logger'; export const targetSecrets = [ 'PUBDEV_ACCESS_TOKEN', @@ -52,6 +53,39 @@ export class PubDevTarget extends BaseTarget { /** GitHub repo configuration */ public readonly githubRepo: GitHubGlobalConfig; + /** + * Bump version in pubspec.yaml for Dart/Flutter projects. + * + * @param rootDir - Project root directory + * @param newVersion - New version string to set + * @returns true if version was bumped, false if no pubspec.yaml exists + * @throws Error if file cannot be updated + */ + public static async bumpVersion( + rootDir: string, + newVersion: string + ): Promise { + const pubspecPath = join(rootDir, 'pubspec.yaml'); + if (!existsSync(pubspecPath)) { + return false; + } + + const content = readFileSync(pubspecPath, 'utf-8'); + const pubspec = load(content) as Record; + + if (!pubspec || typeof pubspec !== 'object') { + logger.debug('pubspec.yaml is not a valid YAML object'); + return false; + } + + pubspec.version = newVersion; + + logger.debug(`Updating version in ${pubspecPath} to ${newVersion}`); + writeFileSync(pubspecPath, dump(pubspec)); + + return true; + } + public constructor( config: TargetConfig, artifactProvider: BaseArtifactProvider, diff --git a/src/targets/pypi.ts b/src/targets/pypi.ts index 79562dc8..0d89ee0c 100644 --- a/src/targets/pypi.ts +++ b/src/targets/pypi.ts @@ -1,11 +1,15 @@ +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; + import { TargetConfig } from '../schemas/project_config'; import { BaseArtifactProvider, RemoteArtifact, } from '../artifact_providers/base'; import { ConfigurationError, reportError } from '../utils/errors'; -import { checkExecutableIsPresent, spawnProcess } from '../utils/system'; +import { checkExecutableIsPresent, runWithExecutable } from '../utils/system'; import { BaseTarget } from './base'; +import { logger } from '../logger'; const DEFAULT_TWINE_BIN = 'twine'; @@ -36,9 +40,129 @@ export class PypiTarget extends BaseTarget { /** Target options */ public readonly pypiConfig: PypiTargetOptions; + /** + * Bump version in Python project files. + * + * Detection priority: + * 1. [tool.hatch] in pyproject.toml → hatch version + * 2. [tool.poetry] in pyproject.toml → poetry version + * 3. [tool.setuptools_scm] in pyproject.toml → no-op (version from git tags) + * 4. [project] with version field → direct TOML edit + * + * @param rootDir - Project root directory + * @param newVersion - New version string to set + * @returns true if version was bumped, false if no pyproject.toml exists + * @throws Error if tool is not found or command fails + */ + public static async bumpVersion( + rootDir: string, + newVersion: string, + ): Promise { + const pyprojectPath = join(rootDir, 'pyproject.toml'); + if (!existsSync(pyprojectPath)) { + return false; + } + + const content = readFileSync(pyprojectPath, 'utf-8'); + + if (content.includes('[tool.hatch]')) { + return PypiTarget.bumpWithHatch(rootDir, newVersion); + } + + if (content.includes('[tool.poetry]')) { + return PypiTarget.bumpWithPoetry(rootDir, newVersion); + } + + if (content.includes('[tool.setuptools_scm]')) { + // setuptools_scm derives version from git tags, no bump needed + logger.debug('setuptools_scm project - version derived from git tags'); + return true; + } + + if (content.includes('[project]')) { + return PypiTarget.bumpDirectToml(pyprojectPath, content, newVersion); + } + + // No recognized Python project structure + return false; + } + + /** + * Bump version using hatch + */ + private static async bumpWithHatch( + rootDir: string, + newVersion: string, + ): Promise { + await runWithExecutable( + { + name: 'hatch', + envVar: 'HATCH_BIN', + errorHint: + 'Install hatch or define a custom preReleaseCommand in .craft.yml', + }, + ['version', newVersion], + { cwd: rootDir }, + ); + return true; + } + + /** + * Bump version using poetry + */ + private static async bumpWithPoetry( + rootDir: string, + newVersion: string, + ): Promise { + await runWithExecutable( + { + name: 'poetry', + envVar: 'POETRY_BIN', + errorHint: + 'Install poetry or define a custom preReleaseCommand in .craft.yml', + }, + ['version', newVersion], + { cwd: rootDir }, + ); + return true; + } + + /** + * Bump version by directly editing pyproject.toml + * This handles standard PEP 621 [project] section with version field + */ + private static bumpDirectToml( + pyprojectPath: string, + content: string, + newVersion: string, + ): boolean { + // Match version in [project] section + // This regex handles: version = "1.0.0" or version = '1.0.0' + const versionRegex = /^(\s*version\s*=\s*["'])([^"']+)(["'])/m; + + if (!versionRegex.test(content)) { + logger.debug( + 'pyproject.toml has [project] section but no version field found', + ); + return false; + } + + const newContent = content.replace(versionRegex, `$1${newVersion}$3`); + + if (newContent === content) { + logger.debug('Version already set to target value'); + return true; + } + + logger.debug(`Updating version in ${pyprojectPath} to ${newVersion}`); + writeFileSync(pyprojectPath, newContent); + + return true; + } + public constructor( config: TargetConfig, - artifactProvider: BaseArtifactProvider + artifactProvider: BaseArtifactProvider, ) { super(config, artifactProvider); this.pypiConfig = this.getPypiConfig(); @@ -54,8 +178,8 @@ export class PypiTarget extends BaseTarget { `Cannot perform PyPI release: missing credentials. Please use TWINE_USERNAME and TWINE_PASSWORD environment variables.`.replace( /^\s+/gm, - '' - ) + '', + ), ); } return { @@ -93,7 +217,7 @@ export class PypiTarget extends BaseTarget { packageFiles.map(async (file: RemoteArtifact) => { this.logger.info(`Uploading file "${file.filename}" via twine`); return this.artifactProvider.downloadArtifact(file); - }) + }), ); await this.uploadAssets(paths); diff --git a/src/utils/files.ts b/src/utils/files.ts index 744cd2a2..d509a383 100644 --- a/src/utils/files.ts +++ b/src/utils/files.ts @@ -1,4 +1,6 @@ import * as fs from 'fs'; +import { opendir, readFile } from 'fs/promises'; +import ignore, { Ignore } from 'ignore'; import * as os from 'os'; import * as path from 'path'; import rimraf from 'rimraf'; @@ -26,7 +28,7 @@ const readdir = util.promisify(fs.readdir); */ export async function scan( directory: string, - results: string[] = [] + results: string[] = [], ): Promise { const files = await readdirp(directory); for (const f of files) { @@ -76,7 +78,7 @@ export async function listFiles(directory: string): Promise { export async function withTempDir( callback: (arg: string) => T | Promise, cleanup = true, - prefix = 'craft-' + prefix = 'craft-', ): Promise { const directory = await mkdtemp(path.join(os.tmpdir(), prefix)); try { @@ -112,7 +114,7 @@ export async function withTempDir( export async function withTempFile( callback: (arg: string) => T | Promise, cleanup = true, - prefix = 'craft-' + prefix = 'craft-', ): Promise { tmp.setGracefulCleanup(); const tmpFile = tmp.fileSync({ prefix }); @@ -145,3 +147,103 @@ export function detectContentType(artifactName: string): string | undefined { } return undefined; } + +/** + * Options for the findFiles function + */ +export interface FindFilesOptions { + /** Maximum directory depth to traverse (default: 2) */ + maxDepth?: number; + /** Filter function to select which files to include */ + fileFilter?: (name: string) => boolean; +} + +/** + * Load and parse .gitignore file from a directory + */ +async function loadGitignore(rootDir: string): Promise { + const ig = ignore(); + try { + const content = await readFile(path.join(rootDir, '.gitignore'), 'utf-8'); + ig.add(content); + } catch { + // No .gitignore file, use empty ignore list + } + // Always ignore .git directory + ig.add('.git'); + return ig; +} + +/** + * Recursively walk a directory up to a maximum depth + */ +async function walkDirectory( + rootDir: string, + currentDir: string, + ig: Ignore, + options: FindFilesOptions, + depth: number, +): Promise { + const { maxDepth = 2, fileFilter } = options; + const results: string[] = []; + + let dir; + try { + dir = await opendir(currentDir); + } catch { + return results; + } + + // for await...of automatically closes the directory when iteration completes + for await (const entry of dir) { + const fullPath = path.join(currentDir, entry.name); + const relativePath = path.relative(rootDir, fullPath); + + // Skip ignored paths + if (ig.ignores(relativePath)) { + continue; + } + + if (entry.isFile()) { + if (!fileFilter || fileFilter(entry.name)) { + results.push(fullPath); + } + } else if (entry.isDirectory() && depth < maxDepth) { + const subResults = await walkDirectory( + rootDir, + fullPath, + ig, + options, + depth + 1, + ); + results.push(...subResults); + } + } + + return results; +} + +/** + * Find files matching a filter, respecting .gitignore rules. + * + * Recursively searches directories up to maxDepth levels deep, + * skipping any paths that match .gitignore patterns. + * + * @param rootDir - Starting directory for the search + * @param options - Search options including maxDepth and fileFilter + * @returns Array of absolute file paths matching the filter + * + * @example + * // Find all .gemspec files up to 2 levels deep + * const gemspecs = await findFiles(projectRoot, { + * maxDepth: 2, + * fileFilter: name => name.endsWith('.gemspec'), + * }); + */ +export async function findFiles( + rootDir: string, + options: FindFilesOptions = {}, +): Promise { + const ig = await loadGitignore(rootDir); + return walkDirectory(rootDir, rootDir, ig, options, 0); +} diff --git a/src/utils/system.ts b/src/utils/system.ts index e3b5077a..c55e9c60 100644 --- a/src/utils/system.ts +++ b/src/utils/system.ts @@ -286,7 +286,7 @@ function isExecutable(filePath: string): boolean { try { fs.accessSync(filePath, fs.constants.F_OK | fs.constants.X_OK); return true; - } catch (e) { + } catch { return false; } } @@ -320,6 +320,170 @@ export function checkExecutableIsPresent(name: string): void { } } +/** + * Configuration for runWithExecutable helper + */ +export interface ExecutableConfig { + /** Default binary name (e.g., 'npm', 'cargo') */ + name: string; + /** Optional environment variable for custom binary path (e.g., 'NPM_BIN') */ + envVar?: string; + /** Hint to show in error message when executable is not found */ + errorHint?: string; +} + +/** + * Resolves the executable path from config, checking env var override first + * + * @param config Executable configuration + * @returns The resolved binary name/path + */ +export function resolveExecutable(config: ExecutableConfig): string { + const { name, envVar } = config; + return envVar ? process.env[envVar] || name : name; +} + +/** + * Result from findFirstExecutable when an executable is found + */ +export interface FoundExecutable { + /** The resolved binary path */ + bin: string; + /** The config that matched */ + config: ExecutableConfig; + /** Index in the original array */ + index: number; +} + +/** + * Finds the first available executable from an array of configs + * + * @param configs Array of executable configurations to try in order + * @returns The first found executable info, or undefined if none found + * + * @example + * ```typescript + * const found = findFirstExecutable([ + * { name: 'npm', envVar: 'NPM_BIN' }, + * { name: 'yarn', envVar: 'YARN_BIN' }, + * ]); + * if (found) { + * console.log(`Using ${found.bin}`); + * } + * ``` + */ +export function findFirstExecutable( + configs: ExecutableConfig[], +): FoundExecutable | undefined { + for (let i = 0; i < configs.length; i++) { + const config = configs[i]; + const bin = resolveExecutable(config); + if (hasExecutable(bin)) { + return { bin, config, index: i }; + } + } + return undefined; +} + +/** + * Requires at least one executable from an array of configs to be present + * + * @param configs Array of executable configurations to try in order + * @param errorHint Optional hint to show in error message + * @returns The first found executable info + * @throws Error if no executable is found + * + * @example + * ```typescript + * const { bin } = requireFirstExecutable( + * [{ name: 'npm' }, { name: 'yarn' }], + * 'Install npm or yarn' + * ); + * ``` + */ +export function requireFirstExecutable( + configs: ExecutableConfig[], + errorHint?: string, +): FoundExecutable { + const found = findFirstExecutable(configs); + if (!found) { + const names = configs.map(c => `"${resolveExecutable(c)}"`).join(' or '); + const hint = errorHint ? ` ${errorHint}` : ''; + throw new Error(`Cannot find ${names}.${hint}`); + } + return found; +} + +/** + * Checks if an executable exists and runs it with the provided arguments + * + * This helper abstracts the common pattern of: + * 1. Checking for an executable (with optional env var override) + * 2. Throwing a helpful error if not found + * 3. Running the command with provided arguments + * + * Accepts either a single config or an array of configs (tries in order). + * + * @param config Executable configuration or array of configs to try in order + * @param args Arguments to pass to the executable + * @param options Optional spawn options (cwd, env, etc.) + * @param spawnOpts Optional spawn process options + * @returns Promise resolving to stdout buffer + * @throws Error if executable is not found + * + * @example + * ```typescript + * // Single executable + * await runWithExecutable( + * { name: 'cargo', envVar: 'CARGO_BIN', errorHint: 'Install Rust toolchain' }, + * ['build', '--release'], + * { cwd: projectDir } + * ); + * + * // Multiple executables (tries in order) + * await runWithExecutable( + * [ + * { name: 'npm', envVar: 'NPM_BIN' }, + * { name: 'yarn', envVar: 'YARN_BIN' }, + * ], + * ['install'], + * { cwd: projectDir } + * ); + * ``` + */ +export async function runWithExecutable( + config: ExecutableConfig | ExecutableConfig[], + args: string[], + options: SpawnOptions = {}, + spawnOpts: SpawnProcessOptions = {}, +): Promise { + let bin: string; + let errorHint: string | undefined; + + if (Array.isArray(config)) { + const found = findFirstExecutable(config); + if (!found) { + const names = config.map(c => `"${resolveExecutable(c)}"`).join(' or '); + // Use errorHint from the first config if available + errorHint = config[0]?.errorHint; + const hint = errorHint ? ` ${errorHint}` : ' Is it installed?'; + throw new Error(`Cannot find ${names}.${hint}`); + } + bin = found.bin; + } else { + bin = resolveExecutable(config); + errorHint = config.errorHint; + + if (!hasExecutable(bin)) { + const hint = errorHint ? ` ${errorHint}` : ' Is it installed?'; + throw new Error(`Cannot find "${bin}".${hint}`); + } + } + + logger.debug(`Running: ${bin} ${args.join(' ')}`); + return spawnProcess(bin, args, options, spawnOpts); +} + /** * Extracts a source code tarball in the specified directory * diff --git a/src/utils/versionBump.ts b/src/utils/versionBump.ts new file mode 100644 index 00000000..020ea873 --- /dev/null +++ b/src/utils/versionBump.ts @@ -0,0 +1,110 @@ +import { TargetConfig } from '../schemas/project_config'; +import { TARGET_MAP } from '../targets'; +import { logger } from '../logger'; + +/** + * Interface for target classes that support automatic version bumping. + * Targets implement this as a static method to bump version in project files. + */ +export interface VersionBumpableTarget { + /** + * Bump version in project files for this target's ecosystem. + * + * @param rootDir - Project root directory + * @param newVersion - New version string to set + * @returns true if version was bumped, false if target doesn't apply to this project + * @throws Error if bumping fails (missing tool, command error, file write error, etc.) + */ + bumpVersion(rootDir: string, newVersion: string): Promise; +} + +/** + * Check if a target class has the bumpVersion static method + */ +function hasVersionBump( + targetClass: unknown, +): targetClass is { bumpVersion: VersionBumpableTarget['bumpVersion'] } { + return ( + typeof targetClass === 'function' && + 'bumpVersion' in targetClass && + typeof (targetClass as any).bumpVersion === 'function' + ); +} + +/** + * Result of running automatic version bumps + */ +export interface VersionBumpResult { + /** Whether at least one target successfully bumped the version */ + anyBumped: boolean; + /** Targets that support version bumping (have bumpVersion method) */ + bumpableTargets: string[]; + /** Targets that support bumping but didn't apply (e.g., no matching files) */ + skippedTargets: string[]; +} + +/** + * Run automatic version bumps for all applicable targets. + * Calls bumpVersion() on each unique target class in config order. + * + * @param targets - Target configs from .craft.yml + * @param rootDir - Project root directory + * @param newVersion - New version to set + * @returns Result with bump status and target details + * @throws Error if any bumpVersion() call throws + */ +export async function runAutomaticVersionBumps( + targets: TargetConfig[], + rootDir: string, + newVersion: string, +): Promise { + // Deduplicate: multiple npm targets should only bump package.json once + const processedTargetTypes = new Set(); + let anyBumped = false; + const bumpableTargets: string[] = []; + const skippedTargets: string[] = []; + + for (const targetConfig of targets) { + const targetName = targetConfig.name; + + // Skip if we've already processed this target type + if (processedTargetTypes.has(targetName)) { + continue; + } + processedTargetTypes.add(targetName); + + const targetClass = TARGET_MAP[targetName]; + if (!targetClass) { + logger.debug(`Unknown target "${targetName}", skipping version bump`); + continue; + } + + if (!hasVersionBump(targetClass)) { + logger.debug( + `Target "${targetName}" does not support automatic version bumping`, + ); + continue; + } + + bumpableTargets.push(targetName); + logger.debug(`Running version bump for target "${targetName}"...`); + + try { + const bumped = await targetClass.bumpVersion(rootDir, newVersion); + if (bumped) { + logger.info(`Version bumped by "${targetName}" target`); + anyBumped = true; + } else { + logger.debug(`Target "${targetName}" did not apply (detection failed)`); + skippedTargets.push(targetName); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Automatic version bump failed for "${targetName}" target: ${message}`, + ); + } + } + + return { anyBumped, bumpableTargets, skippedTargets }; +}