diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 000000000..78e2eaff5 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,202 @@ +# LaunchQL Publish Flow Implementation Plan + +## Overview + +This document outlines the implementation of three new CLI commands for LaunchQL's publish flow: + +1. `lql validate` - Ensure package consistency before bumping +2. `lql sync` - Synchronize artifacts with the new bumped version +3. `lql version` - Detect changed packages, bump versions, update dependencies, commit and tag + +## Architecture & Patterns + +### Existing Infrastructure to Leverage + +- **LaunchQLPackage class** (`packages/core/src/core/class/launchql.ts`): Core orchestrator for workspace and module management +- **analyzeModule()** method: Comprehensive validation logic for package consistency +- **Control file generation** (`packages/core/src/files/extension/writer.ts`): `generateControlFileContent()` for PostgreSQL control files +- **Plan file management** (`packages/core/src/files/plan/`): Parsing and writing launchql.plan files with tag support +- **Dependency resolution** (`packages/core/src/resolution/deps.ts`): Workspace dependency resolution and topological sorting +- **CLI command patterns** (`packages/cli/src/commands/`): Consistent structure with usage text, error handling, and logging +- **Lerna integration** (`lerna.json`): `conventionalCommits: true` for automatic change detection and version bumping + +### Workspace Structure + +- **Yarn workspaces** with `packages/*` pattern +- **Independent versioning** via lerna +- **Workspace dependencies** using `workspace:*` and `workspace:^` semantics +- **Package.json** files in each module with version management + +## Command Implementations + +### 1. `lql validate` Command + +**Purpose**: Ensure package is consistent before bumping + +**Implementation Strategy**: +- Extend existing `LaunchQLPackage.analyzeModule()` method as foundation +- Add specific validation checks: + - `.control.default_version === package.json.version` + - SQL migration file for current version exists (`sql/--.sql`) + - `launchql.plan` has a tag for current version + - Dependencies in `launchql.plan` reference real published versions +- Return exit code 0 if valid, 1 if inconsistencies found + +**Key Files**: +- `packages/cli/src/commands/validate.ts` (new) +- Leverage `packages/core/src/core/class/launchql.ts` analyzeModule() + +### 2. `lql sync` Command + +**Purpose**: Synchronize artifacts with the new bumped version + +**Implementation Strategy**: +- Read `package.json` version +- Update PostgreSQL control file using `generateControlFileContent()` +- Write `default_version = ''` in `.control` +- Generate SQL migration file ensuring `sql/--.sql` exists +- Use existing file creation patterns from LaunchQLPackage + +**Key Files**: +- `packages/cli/src/commands/sync.ts` (new) +- Leverage `packages/core/src/files/extension/writer.ts` + +### 3. `lql version` Command (Independent Mode) + +**Purpose**: Comprehensive version management workflow + +**Implementation Strategy**: +- **Change Detection**: Use git operations and lerna's conventional commit parsing +- **Version Bumping**: Support `--bump` flags (patch|minor|major|prerelease|exact) +- **Package Updates**: Update `package.json` versions for changed packages +- **Dependency Management**: + - For `workspace:*`, do nothing (pnpm resolves at pack time) + - For caret ranges, rewrite version ranges (e.g., `^1.2.3` → `^1.3.0`) +- **Synchronization**: Run `lql sync` per bumped package +- **Plan Updates**: Append plan tags using existing tag management +- **Git Operations**: Stage + commit with "chore(release): publish" message +- **Tagging**: Create per-package git tags `name@version` + +**Key Files**: +- `packages/cli/src/commands/version.ts` (new) +- Leverage dependency resolution from `packages/core/src/resolution/deps.ts` +- Use plan file writing from `packages/core/src/files/plan/writer.ts` + +## Implementation Details + +### CLI Command Structure + +Following existing patterns from `packages/cli/src/commands/deploy.ts`: + +```typescript +export default async ( + argv: Partial, + prompter: Inquirerer, + _options: CLIOptions +) => { + // Usage text handling + if (argv.help || argv.h) { + console.log(usageText); + process.exit(0); + } + + // Argument processing and validation + // Core logic using LaunchQLPackage + // Error handling and logging + // Return argv +}; +``` + +### Error Handling & Logging + +- Use `Logger` from `@launchql/logger` for consistent logging +- Proper exit codes: 0 for success, 1 for errors +- Comprehensive error messages with context +- Usage text for each command following existing patterns + +### Workspace Dependency Updates + +For `lql version` command, handle workspace dependencies: + +1. **Detection**: Use existing dependency resolution to find internal dependencies +2. **Version Range Updates**: + - `workspace:*` → no change (pnpm handles at pack time) + - `^1.2.3` → `^1.3.0` (update caret ranges between workspace packages) +3. **Dependency Order**: Use topological sorting from existing dependency resolution + +### Git Integration + +- Use `execSync` for git operations following patterns in codebase +- Conventional commit message format: "chore(release): publish" +- Per-package tags in format: `name@version` +- Single commit for all version updates + +## File Structure + +``` +packages/cli/src/commands/ +├── validate.ts # New: Package validation command +├── sync.ts # New: Artifact synchronization command +├── version.ts # New: Version management command +└── ...existing commands + +packages/cli/src/commands.ts # Updated: Add new commands to registry +``` + +## Testing Strategy + +1. **Unit Testing**: Test each command with valid/invalid scenarios +2. **Integration Testing**: Test full workflow (validate → version → sync) +3. **Workspace Testing**: Test with multiple packages and dependencies +4. **Git Testing**: Verify commit and tagging behavior +5. **Regression Testing**: Ensure existing functionality remains intact + +## Dependencies & Imports + +Reuse existing dependencies: +- `@launchql/core` - LaunchQLPackage class and file operations +- `@launchql/logger` - Consistent logging +- `@launchql/types` - Type definitions and error handling +- `inquirerer` - CLI prompting (following existing patterns) +- `minimist` - Argument parsing +- `child_process` - Git operations via execSync + +## Validation Criteria + +### `lql validate` Success Criteria: +- ✅ Control file `default_version` matches `package.json` version +- ✅ SQL migration file exists for current version +- ✅ `launchql.plan` has tag for current version +- ✅ All dependencies reference valid published versions +- ✅ Exit code 0 for valid packages, 1 for invalid + +### `lql sync` Success Criteria: +- ✅ Control file updated with correct version +- ✅ SQL migration file created/updated +- ✅ File operations follow existing patterns +- ✅ Proper error handling for file system operations + +### `lql version` Success Criteria: +- ✅ Changed packages detected correctly +- ✅ Version bumping follows conventional commits or explicit flags +- ✅ Internal dependency ranges updated appropriately +- ✅ `lql sync` executed for each bumped package +- ✅ Plan tags appended correctly +- ✅ Single commit with proper message +- ✅ Per-package git tags created + +## Risk Mitigation + +1. **Backward Compatibility**: All changes extend existing functionality without breaking current workflows +2. **Error Recovery**: Comprehensive error handling with clear messages +3. **Validation**: Extensive validation before making changes +4. **Atomic Operations**: Git operations are atomic to prevent partial state +5. **Testing**: Thorough testing of edge cases and error scenarios + +## Future Enhancements + +- Integration with CI/CD pipelines +- Support for pre-release versions +- Automated changelog generation +- Integration with npm/yarn publish workflows +- Support for custom version bump strategies diff --git a/packages/cli/__tests__/sync.test.ts b/packages/cli/__tests__/sync.test.ts new file mode 100644 index 000000000..7b72c7dcc --- /dev/null +++ b/packages/cli/__tests__/sync.test.ts @@ -0,0 +1,127 @@ +import { LaunchQLPackage } from '@launchql/core'; +import * as fs from 'fs'; +import { Inquirerer } from 'inquirerer'; +import { ParsedArgs } from 'minimist'; +import * as path from 'path'; + +import { commands } from '../src/commands'; +import { TestFixture } from '../test-utils'; + +describe('cmds:sync', () => { + let fixture: TestFixture; + + beforeAll(() => { + fixture = new TestFixture('sqitch', 'launchql'); + }); + + afterAll(() => { + fixture.cleanup(); + }); + + const runCommand = async (argv: ParsedArgs) => { + const prompter = new Inquirerer({ + input: process.stdin, + output: process.stdout, + noTty: true + }); + + return commands(argv, prompter, { + noTty: true, + input: process.stdin, + output: process.stdout, + version: '1.0.0', + minimistOpts: {} + }); + }; + + it('syncs control file and creates SQL migration file', async () => { + const modulePath = fixture.getFixturePath('packages', 'secrets'); + + const pkgJsonPath = path.join(modulePath, 'package.json'); + const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')); + pkgJson.version = '1.2.3'; + fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2)); + + await runCommand({ + _: ['sync'], + cwd: modulePath + }); + + const project = new LaunchQLPackage(modulePath); + const info = project.getModuleInfo(); + const controlContent = fs.readFileSync(info.controlFile, 'utf8'); + expect(controlContent).toContain("default_version = '1.2.3'"); + + const sqlFile = path.join(modulePath, 'sql', 'secrets--1.2.3.sql'); + expect(fs.existsSync(sqlFile)).toBe(true); + + const sqlContent = fs.readFileSync(sqlFile, 'utf8'); + expect(sqlContent).toContain('secrets extension version 1.2.3'); + }); + + it('does not overwrite existing SQL migration file', async () => { + const modulePath = fixture.getFixturePath('packages', 'secrets'); + + const pkgJsonPath = path.join(modulePath, 'package.json'); + const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')); + pkgJson.version = '1.2.3'; + fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2)); + + const sqlDir = path.join(modulePath, 'sql'); + if (!fs.existsSync(sqlDir)) { + fs.mkdirSync(sqlDir, { recursive: true }); + } + const sqlFile = path.join(sqlDir, 'secrets--1.2.3.sql'); + const customContent = '-- Custom SQL content\nCREATE FUNCTION test();'; + fs.writeFileSync(sqlFile, customContent); + + await runCommand({ + _: ['sync'], + cwd: modulePath + }); + + const sqlContent = fs.readFileSync(sqlFile, 'utf8'); + expect(sqlContent).toBe(customContent); + }); + + it('fails when not run in a module directory', async () => { + const nonModuleDir = path.join(fixture.tempDir, 'not-a-module'); + fs.mkdirSync(nonModuleDir, { recursive: true }); + + try { + await runCommand({ + _: ['sync'], + cwd: nonModuleDir + }); + } catch (error) { + expect(error).toBeDefined(); + } + }); + + it('fails when package.json has no version', async () => { + const modulePath = fixture.getFixturePath('packages', 'secrets'); + + const pkgJsonPath = path.join(modulePath, 'package.json'); + const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')); + delete pkgJson.version; + fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2)); + + try { + await runCommand({ + _: ['sync'], + cwd: modulePath + }); + } catch (error) { + expect(error).toBeDefined(); + } + }); + + it('shows help when --help flag is provided', async () => { + const result = await runCommand({ + _: ['sync'], + help: true + }); + + expect(result).toBeDefined(); + }); +}); diff --git a/packages/cli/__tests__/validate.test.ts b/packages/cli/__tests__/validate.test.ts new file mode 100644 index 000000000..527a93360 --- /dev/null +++ b/packages/cli/__tests__/validate.test.ts @@ -0,0 +1,136 @@ +import { LaunchQLPackage } from '@launchql/core'; +import * as fs from 'fs'; +import { Inquirerer } from 'inquirerer'; +import { ParsedArgs } from 'minimist'; +import * as path from 'path'; + +import { commands } from '../src/commands'; +import { TestFixture } from '../test-utils'; + +describe('cmds:validate', () => { + let fixture: TestFixture; + + beforeAll(() => { + fixture = new TestFixture('sqitch', 'launchql'); + }); + + afterAll(() => { + fixture.cleanup(); + }); + + const runCommand = async (argv: ParsedArgs) => { + const prompter = new Inquirerer({ + input: process.stdin, + output: process.stdout, + noTty: true + }); + + return commands(argv, prompter, { + noTty: true, + input: process.stdin, + output: process.stdout, + version: '1.0.0', + minimistOpts: {} + }); + }; + + it('validates a consistent package successfully', async () => { + const modulePath = fixture.getFixturePath('packages', 'secrets'); + + const pkgJsonPath = path.join(modulePath, 'package.json'); + const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')); + pkgJson.version = '0.0.1'; + fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2)); + + const project = new LaunchQLPackage(modulePath); + const info = project.getModuleInfo(); + const controlContent = `comment = 'secrets extension' +default_version = '0.0.1' +module_pathname = '$libdir/secrets' +relocatable = false +`; + fs.writeFileSync(info.controlFile, controlContent); + + const sqlDir = path.join(modulePath, 'sql'); + if (!fs.existsSync(sqlDir)) { + fs.mkdirSync(sqlDir, { recursive: true }); + } + const sqlFile = path.join(sqlDir, 'secrets--0.0.1.sql'); + fs.writeFileSync(sqlFile, '-- secrets extension version 0.0.1\n'); + + const result = await runCommand({ + _: ['validate'], + cwd: modulePath + }); + + expect(result).toBeDefined(); + }); + + it('detects version mismatch between package.json and control file', async () => { + const modulePath = fixture.getFixturePath('packages', 'secrets'); + + const pkgJsonPath = path.join(modulePath, 'package.json'); + const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')); + pkgJson.version = '0.0.2'; + fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2)); + + const project = new LaunchQLPackage(modulePath); + const info = project.getModuleInfo(); + const controlContent = `comment = 'secrets extension' +default_version = '0.0.1' +module_pathname = '$libdir/secrets' +relocatable = false +`; + fs.writeFileSync(info.controlFile, controlContent); + + try { + await runCommand({ + _: ['validate'], + cwd: modulePath + }); + } catch (error) { + expect(error).toBeDefined(); + } + }); + + it('detects missing SQL migration file', async () => { + const modulePath = fixture.getFixturePath('packages', 'secrets'); + + const pkgJsonPath = path.join(modulePath, 'package.json'); + const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')); + pkgJson.version = '0.0.1'; + fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2)); + + const project = new LaunchQLPackage(modulePath); + const info = project.getModuleInfo(); + const controlContent = `comment = 'secrets extension' +default_version = '0.0.1' +module_pathname = '$libdir/secrets' +relocatable = false +`; + fs.writeFileSync(info.controlFile, controlContent); + + const sqlFile = path.join(modulePath, 'sql', 'secrets--0.0.1.sql'); + if (fs.existsSync(sqlFile)) { + fs.rmSync(sqlFile); + } + + try { + await runCommand({ + _: ['validate'], + cwd: modulePath + }); + } catch (error) { + expect(error).toBeDefined(); + } + }); + + it('shows help when --help flag is provided', async () => { + const result = await runCommand({ + _: ['validate'], + help: true + }); + + expect(result).toBeDefined(); + }); +}); diff --git a/packages/cli/__tests__/version.test.ts b/packages/cli/__tests__/version.test.ts new file mode 100644 index 000000000..b99d8fe91 --- /dev/null +++ b/packages/cli/__tests__/version.test.ts @@ -0,0 +1,151 @@ +import { LaunchQLPackage } from '@launchql/core'; +import * as fs from 'fs'; +import { Inquirerer } from 'inquirerer'; +import { ParsedArgs } from 'minimist'; +import * as path from 'path'; +import { execSync } from 'child_process'; + +import { commands } from '../src/commands'; +import { TestFixture } from '../test-utils'; + +describe('cmds:version', () => { + let fixture: TestFixture; + + beforeAll(() => { + fixture = new TestFixture('sqitch', 'launchql'); + }); + + afterAll(() => { + fixture.cleanup(); + }); + + const runCommand = async (argv: ParsedArgs) => { + const prompter = new Inquirerer({ + input: process.stdin, + output: process.stdout, + noTty: true + }); + + return commands(argv, prompter, { + noTty: true, + input: process.stdin, + output: process.stdout, + version: '1.0.0', + minimistOpts: {} + }); + }; + + const initGitRepo = (workspacePath: string) => { + try { + execSync('git init', { cwd: workspacePath, stdio: 'pipe' }); + execSync('git config user.name "Test User"', { cwd: workspacePath, stdio: 'pipe' }); + execSync('git config user.email "test@example.com"', { cwd: workspacePath, stdio: 'pipe' }); + execSync('git add .', { cwd: workspacePath, stdio: 'pipe' }); + execSync('git commit -m "Initial commit"', { cwd: workspacePath, stdio: 'pipe' }); + } catch (error) { + } + }; + + it('bumps version and updates dependencies in dry-run mode', async () => { + const workspacePath = fixture.tempFixtureDir; + initGitRepo(workspacePath); + + const secretsPath = path.join(workspacePath, 'packages', 'secrets'); + const secretsPkgPath = path.join(secretsPath, 'package.json'); + const secretsPkg = JSON.parse(fs.readFileSync(secretsPkgPath, 'utf8')); + secretsPkg.version = '1.0.0'; + fs.writeFileSync(secretsPkgPath, JSON.stringify(secretsPkg, null, 2)); + + const result = await runCommand({ + _: ['version'], + cwd: workspacePath, + bump: 'patch', + 'dry-run': true + }); + + expect(result).toBeDefined(); + + const updatedPkg = JSON.parse(fs.readFileSync(secretsPkgPath, 'utf8')); + expect(updatedPkg.version).toBe('1.0.0'); // Should remain unchanged + }); + + it('bumps version with exact version specified', async () => { + const workspacePath = fixture.tempFixtureDir; + initGitRepo(workspacePath); + + const secretsPath = path.join(workspacePath, 'packages', 'secrets'); + const secretsPkgPath = path.join(secretsPath, 'package.json'); + const secretsPkg = JSON.parse(fs.readFileSync(secretsPkgPath, 'utf8')); + secretsPkg.version = '1.0.0'; + fs.writeFileSync(secretsPkgPath, JSON.stringify(secretsPkg, null, 2)); + + const result = await runCommand({ + _: ['version'], + cwd: workspacePath, + bump: 'exact', + exact: '2.0.0', + 'dry-run': true + }); + + expect(result).toBeDefined(); + }); + + it('filters packages by pattern', async () => { + const workspacePath = fixture.tempFixtureDir; + initGitRepo(workspacePath); + + const result = await runCommand({ + _: ['version'], + cwd: workspacePath, + filter: 'secrets', + bump: 'patch', + 'dry-run': true + }); + + expect(result).toBeDefined(); + }); + + it('fails when not run from workspace root', async () => { + const workspacePath = fixture.tempFixtureDir; + const modulePath = fixture.getFixturePath('packages', 'secrets'); + + try { + await runCommand({ + _: ['version'], + cwd: modulePath, + bump: 'patch', + 'dry-run': true + }); + } catch (error) { + expect(error).toBeDefined(); + } + }); + + it('handles no changed packages scenario', async () => { + const workspacePath = fixture.tempFixtureDir; + initGitRepo(workspacePath); + + try { + execSync('git tag secrets@1.0.0', { cwd: workspacePath, stdio: 'pipe' }); + } catch (error) { + } + + const result = await runCommand({ + _: ['version'], + cwd: workspacePath, + bump: 'patch', + 'dry-run': true + }); + + expect(result).toBeDefined(); + }); + + it('shows help when --help flag is provided', async () => { + const result = await runCommand({ + _: ['version'], + help: true + }); + + expect(result).toBeDefined(); + }); +}); diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 835cb485b..cc8bbd45c 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -22,6 +22,9 @@ import tag from './commands/tag'; import verify from './commands/verify'; import analyze from './commands/analyze'; import renameCmd from './commands/rename'; +import validate from './commands/validate'; +import sync from './commands/sync'; +import version from './commands/version'; import { readAndParsePackageJson } from './package'; import { extractFirst, usageText } from './utils'; @@ -55,6 +58,9 @@ const createCommandMap = (skipPgTeardown: boolean = false): Record Working directory (default: current directory) + +Behavior: + - Reads package.json version + - Updates PostgreSQL control file with default_version = '' + - Generates SQL migration file (sql/--.sql) + +Examples: + lql sync Sync current module + lql sync --cwd ./my-module Sync specific module +`; + +export default async ( + argv: Partial, + prompter: Inquirerer, + _options: CLIOptions +) => { + if (argv.help || argv.h) { + console.log(syncUsageText); + return argv; + } + + const log = new Logger('sync'); + + let { cwd } = await prompter.prompt(argv, [ + { + type: 'text', + name: 'cwd', + message: 'Working directory', + required: false, + default: process.cwd(), + useDefault: true + } + ]); + + log.debug(`Using directory: ${cwd}`); + + const project = new LaunchQLPackage(cwd); + const result = project.syncModule(); + + if (!result.success) { + log.error(`Sync failed: ${result.message}`); + throw new Error(result.message); + } + + log.success(result.message); + for (const file of result.files) { + if (file.includes('--')) { + log.success(`Created SQL migration file: ${file}`); + } else { + log.success(`Updated control file: ${file}`); + } + } + + return argv; +}; diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts new file mode 100644 index 000000000..919f58fd9 --- /dev/null +++ b/packages/cli/src/commands/validate.ts @@ -0,0 +1,74 @@ +import { LaunchQLPackage } from '@launchql/core'; +import { Logger } from '@launchql/logger'; +import { CLIOptions, Inquirerer } from 'inquirerer'; +import { ParsedArgs } from 'minimist'; + +const validateUsageText = ` +LaunchQL Validate Command: + + lql validate [OPTIONS] + + Ensure package is consistent before bumping. + +Options: + --help, -h Show this help message + --cwd Working directory (default: current directory) + +Validation Checks: + - .control.default_version === package.json.version + - SQL migration file for current version exists + - launchql.plan has a tag for current version + - Dependencies in launchql.plan reference real published versions + +Exit Codes: + 0 - Package is valid and consistent + 1 - Inconsistencies found + +Examples: + lql validate Validate current module + lql validate --cwd ./my-module Validate specific module +`; + +export default async ( + argv: Partial, + prompter: Inquirerer, + _options: CLIOptions +) => { + if (argv.help || argv.h) { + console.log(validateUsageText); + return argv; + } + + const log = new Logger('validate'); + + let { cwd } = await prompter.prompt(argv, [ + { + type: 'text', + name: 'cwd', + message: 'Working directory', + required: false, + default: process.cwd(), + useDefault: true + } + ]); + + log.debug(`Using directory: ${cwd}`); + + const project = new LaunchQLPackage(cwd); + const result = project.validateModule(); + + if (!result.ok) { + log.error('Package validation failed:'); + for (const issue of result.issues) { + log.error(` ${issue.code}: ${issue.message}`); + if (issue.file) { + log.error(` File: ${issue.file}`); + } + } + throw new Error('Package validation failed'); + } else { + log.success('Package validation passed'); + } + + return argv; +}; diff --git a/packages/cli/src/commands/version.ts b/packages/cli/src/commands/version.ts new file mode 100644 index 000000000..5f24e9e44 --- /dev/null +++ b/packages/cli/src/commands/version.ts @@ -0,0 +1,124 @@ +import { LaunchQLPackage } from '@launchql/core'; +import { Logger } from '@launchql/logger'; +import { CLIOptions, Inquirerer } from 'inquirerer'; +import { ParsedArgs } from 'minimist'; + +const versionUsageText = ` +LaunchQL Version Command: + + lql version [OPTIONS] + + Detect changed packages, bump versions, update dependencies, commit and tag. + +Options: + --help, -h Show this help message + --filter Filter packages by pattern + --bump Bump type: patch|minor|major|prerelease|exact + --exact Set exact version (use with --bump exact) + --cwd Working directory (default: current directory) + --dry-run Show what would be done without making changes + +Behavior: + - Detects changed packages since last release/tag + - Decides bump strategy from conventional commits or --bump flag + - Updates versions in package.json files + - Updates internal dependency ranges (respects workspace:* semantics) + - Runs lql sync per bumped package + - Stages and commits changes + - Creates per-package git tags (name@version) + +Examples: + lql version Auto-detect changes and bump + lql version --bump minor Force minor version bump + lql version --filter "my-*" Only process packages matching pattern + lql version --bump exact --exact 2.0.0 Set exact version +`; + +export default async ( + argv: Partial, + prompter: Inquirerer, + _options: CLIOptions +) => { + if (argv.help || argv.h) { + console.log(versionUsageText); + return argv; + } + + const log = new Logger('version'); + + let { cwd, filter, bump, exact, dryRun } = await prompter.prompt(argv, [ + { + type: 'text', + name: 'cwd', + message: 'Working directory', + required: false, + default: process.cwd(), + useDefault: true + }, + { + type: 'text', + name: 'filter', + message: 'Package filter pattern', + required: false, + when: () => !argv.filter + }, + { + type: 'list', + name: 'bump', + message: 'Bump type', + options: ['patch', 'minor', 'major', 'prerelease', 'exact'], + required: false, + when: () => !argv.bump + }, + { + type: 'text', + name: 'exact', + message: 'Exact version', + required: false, + when: (answers) => (answers.bump || argv.bump) === 'exact' && !argv.exact + }, + { + type: 'confirm', + name: 'dryRun', + message: 'Dry run (show changes without applying)?', + default: false, + useDefault: true, + when: () => typeof argv['dry-run'] === 'undefined' + } + ]); + + if (argv['dry-run']) dryRun = true; + + log.debug(`Using directory: ${cwd}`); + + const project = new LaunchQLPackage(cwd); + const result = project.versionWorkspace({ + filter, + bump: bump as 'patch' | 'minor' | 'major' | 'prerelease' | 'exact', + exact, + dryRun + }); + + if (!result.success) { + log.error(`Version command failed: ${result.message}`); + throw new Error(result.message); + } + + if (result.packages.length === 0) { + log.info(result.message); + return argv; + } + + log.info(`Found ${result.packages.length} changed packages:`); + for (const pkg of result.packages) { + log.info(` ${pkg.name}: ${pkg.oldVersion} → ${pkg.newVersion}`); + } + + if (dryRun) { + log.info('Dry run mode - no changes will be made'); + } else { + log.success(result.message); + } + + return argv; +}; diff --git a/packages/core/__tests__/core/sync-module.test.ts b/packages/core/__tests__/core/sync-module.test.ts new file mode 100644 index 000000000..009002b74 --- /dev/null +++ b/packages/core/__tests__/core/sync-module.test.ts @@ -0,0 +1,119 @@ +import fs from 'fs'; +import path from 'path'; +import { TestFixture } from '../../test-utils'; + +let fixture: TestFixture; + +beforeAll(() => { + fixture = new TestFixture('sqitch'); +}); + +afterAll(() => { + fixture.cleanup(); +}); + +describe('LaunchQLPackage.syncModule', () => { + it('syncs module artifacts with package.json version', () => { + const project = fixture.getModuleProject(['simple'], 'my-first'); + const modPath = project.getModulePath()!; + const info = project.getModuleInfo(); + + const result = project.syncModule(); + + expect(result.success).toBe(true); + expect(result.message).toContain('0.0.1'); + expect(result.files.length).toBeGreaterThan(0); + + const controlContent = fs.readFileSync(info.controlFile, 'utf8'); + expect(controlContent).toContain("default_version = '0.0.1'"); + + const sqlFile = path.join(modPath, 'sql', `${info.extname}--0.0.1.sql`); + expect(fs.existsSync(sqlFile)).toBe(true); + + const sqlContent = fs.readFileSync(sqlFile, 'utf8'); + expect(sqlContent).toContain('my-first extension version 0.0.1'); + }); + + it('syncs with explicit version parameter', () => { + const project = fixture.getModuleProject(['simple'], 'my-first'); + const modPath = project.getModulePath()!; + const info = project.getModuleInfo(); + + const result = project.syncModule('2.5.0'); + + expect(result.success).toBe(true); + expect(result.message).toContain('2.5.0'); + + const controlContent = fs.readFileSync(info.controlFile, 'utf8'); + expect(controlContent).toContain("default_version = '2.5.0'"); + + const sqlFile = path.join(modPath, 'sql', `${info.extname}--2.5.0.sql`); + expect(fs.existsSync(sqlFile)).toBe(true); + }); + + it('does not overwrite existing SQL migration files', () => { + const project = fixture.getModuleProject(['simple'], 'my-first'); + const modPath = project.getModulePath()!; + const info = project.getModuleInfo(); + + const sqlDir = path.join(modPath, 'sql'); + const sqlFile = path.join(sqlDir, `${info.extname}--0.0.1.sql`); + fs.mkdirSync(sqlDir, { recursive: true }); + fs.writeFileSync(sqlFile, '-- custom existing content'); + + const result = project.syncModule(); + + expect(result.success).toBe(true); + + const sqlContent = fs.readFileSync(sqlFile, 'utf8'); + expect(sqlContent).toBe('-- custom existing content'); + }); + + it('fails when not run in a module directory', () => { + const project = fixture.getWorkspaceProject(['simple']); + + const result = project.syncModule(); + + expect(result.success).toBe(false); + expect(result.message).toContain('must be run inside a LaunchQL module'); + expect(result.files.length).toBe(0); + }); + + it('fails when package.json is missing', () => { + const project = fixture.getModuleProject(['simple'], 'my-first'); + const modPath = project.getModulePath()!; + const pkgJsonPath = path.join(modPath, 'package.json'); + const backupPath = path.join(modPath, 'package.json.bak'); + + fs.renameSync(pkgJsonPath, backupPath); + + try { + const result = project.syncModule(); + + expect(result.success).toBe(false); + expect(result.message).toContain('package.json not found'); + } finally { + fs.renameSync(backupPath, pkgJsonPath); + } + }); + + it('fails when package.json lacks version', () => { + const project = fixture.getModuleProject(['simple'], 'my-first'); + const modPath = project.getModulePath()!; + const pkgJsonPath = path.join(modPath, 'package.json'); + + const original = fs.readFileSync(pkgJsonPath, 'utf8'); + const pkgJson = JSON.parse(original); + delete pkgJson.version; + fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2)); + + try { + const result = project.syncModule(); + + expect(result.success).toBe(false); + expect(result.message).toContain('No version found in package.json'); + } finally { + fs.writeFileSync(pkgJsonPath, original); + } + }); +}); diff --git a/packages/core/__tests__/core/validate-module.test.ts b/packages/core/__tests__/core/validate-module.test.ts new file mode 100644 index 000000000..20d5a0756 --- /dev/null +++ b/packages/core/__tests__/core/validate-module.test.ts @@ -0,0 +1,138 @@ +import fs from 'fs'; +import path from 'path'; +import { TestFixture } from '../../test-utils'; + +let fixture: TestFixture; + +beforeAll(() => { + fixture = new TestFixture('sqitch'); +}); + +afterAll(() => { + fixture.cleanup(); +}); + +describe('LaunchQLPackage.validateModule', () => { + it('validates a consistent package successfully', () => { + const project = fixture.getModuleProject(['simple'], 'my-first'); + const modPath = project.getModulePath()!; + const info = project.getModuleInfo(); + + const pkgJson = JSON.parse(fs.readFileSync(path.join(modPath, 'package.json'), 'utf8')); + const version = pkgJson.version; + const sqlDir = path.join(modPath, 'sql'); + const sqlFile = path.join(sqlDir, `${info.extname}--${version}.sql`); + + fs.mkdirSync(sqlDir, { recursive: true }); + fs.writeFileSync(sqlFile, '-- combined sql'); + + const result = project.validateModule(); + + expect(result.ok).toBe(true); + expect(result.issues.length).toBe(0); + }); + + it('detects version mismatch between package.json and control file', () => { + const project = fixture.getModuleProject(['simple'], 'my-first'); + const modPath = project.getModulePath()!; + const info = project.getModuleInfo(); + + const pkgJsonPath = path.join(modPath, 'package.json'); + const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')); + pkgJson.version = '2.0.0'; + fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2)); + + const result = project.validateModule(); + + expect(result.ok).toBe(false); + const codes = result.issues.map(i => i.code); + expect(codes).toContain('version_mismatch'); + + const versionIssue = result.issues.find(i => i.code === 'version_mismatch'); + expect(versionIssue?.message).toContain('0.0.1'); + expect(versionIssue?.message).toContain('2.0.0'); + }); + + it('detects missing SQL migration file', () => { + const project = fixture.getModuleProject(['simple'], 'my-first'); + + const result = project.validateModule(); + + expect(result.ok).toBe(false); + const codes = result.issues.map(i => i.code); + expect(codes).toContain('missing_sql_migration'); + + const sqlIssue = result.issues.find(i => i.code === 'missing_sql_migration'); + expect(sqlIssue?.message).toContain('my-first--0.0.1.sql'); + }); + + it('detects missing package.json', () => { + const project = fixture.getModuleProject(['simple'], 'my-first'); + const modPath = project.getModulePath()!; + const pkgJsonPath = path.join(modPath, 'package.json'); + const backupPath = path.join(modPath, 'package.json.bak'); + + fs.renameSync(pkgJsonPath, backupPath); + + try { + const result = project.validateModule(); + + expect(result.ok).toBe(false); + const codes = result.issues.map(i => i.code); + expect(codes).toContain('missing_package_json'); + } finally { + fs.renameSync(backupPath, pkgJsonPath); + } + }); + + it('detects missing version in package.json', () => { + const project = fixture.getModuleProject(['simple'], 'my-first'); + const modPath = project.getModulePath()!; + const pkgJsonPath = path.join(modPath, 'package.json'); + + const original = fs.readFileSync(pkgJsonPath, 'utf8'); + const pkgJson = JSON.parse(original); + delete pkgJson.version; + fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2)); + + try { + const result = project.validateModule(); + + expect(result.ok).toBe(false); + const codes = result.issues.map(i => i.code); + expect(codes).toContain('missing_version'); + } finally { + fs.writeFileSync(pkgJsonPath, original); + } + }); + + it('fails when not run in a module directory', () => { + const project = fixture.getWorkspaceProject(['simple']); + + const result = project.validateModule(); + + expect(result.ok).toBe(false); + const codes = result.issues.map(i => i.code); + expect(codes).toContain('not_in_module'); + }); + + it('warns about missing version tag in plan file', () => { + const project = fixture.getModuleProject(['simple'], 'my-first'); + const modPath = project.getModulePath()!; + const info = project.getModuleInfo(); + + const pkgJson = JSON.parse(fs.readFileSync(path.join(modPath, 'package.json'), 'utf8')); + const version = pkgJson.version; + const sqlDir = path.join(modPath, 'sql'); + const sqlFile = path.join(sqlDir, `${info.extname}--${version}.sql`); + + fs.mkdirSync(sqlDir, { recursive: true }); + fs.writeFileSync(sqlFile, '-- combined sql'); + + const result = project.validateModule(); + + expect(result.ok).toBe(true); + const codes = result.issues.map(i => i.code); + expect(codes).toContain('missing_version_tag'); + }); +}); diff --git a/packages/core/__tests__/core/version-workspace.test.ts b/packages/core/__tests__/core/version-workspace.test.ts new file mode 100644 index 000000000..9f415097d --- /dev/null +++ b/packages/core/__tests__/core/version-workspace.test.ts @@ -0,0 +1,140 @@ +import fs from 'fs'; +import path from 'path'; +import { execSync } from 'child_process'; +import { TestFixture } from '../../test-utils'; + +let fixture: TestFixture; + +beforeAll(() => { + fixture = new TestFixture('sqitch'); +}); + +afterAll(() => { + fixture.cleanup(); +}); + +describe('LaunchQLPackage.versionWorkspace', () => { + it('detects and versions changed packages in dry-run mode', () => { + const project = fixture.getWorkspaceProject(['simple']); + + const result = project.versionWorkspace({ dryRun: true }); + + expect(result.success).toBe(true); + expect(result.packages.length).toBeGreaterThan(0); + expect(result.message).toContain('Dry run mode'); + + const packageNames = result.packages.map(p => p.name); + expect(packageNames).toContain('my-first'); + expect(packageNames).toContain('my-second'); + + const firstPackage = result.packages.find(p => p.name === 'my-first'); + expect(firstPackage?.oldVersion).toBe('0.0.1'); + expect(firstPackage?.newVersion).toBe('0.0.2'); // default patch bump + }); + + it('versions packages with different bump types', () => { + const project = fixture.getWorkspaceProject(['simple']); + + const result = project.versionWorkspace({ + bump: 'minor', + dryRun: true + }); + + expect(result.success).toBe(true); + + const firstPackage = result.packages.find(p => p.name === 'my-first'); + expect(firstPackage?.oldVersion).toBe('0.0.1'); + expect(firstPackage?.newVersion).toBe('0.1.0'); // minor bump + }); + + it('versions packages with exact version', () => { + const project = fixture.getWorkspaceProject(['simple']); + + const result = project.versionWorkspace({ + bump: 'exact', + exact: '3.0.0', + dryRun: true + }); + + expect(result.success).toBe(true); + + const firstPackage = result.packages.find(p => p.name === 'my-first'); + expect(firstPackage?.newVersion).toBe('3.0.0'); + }); + + it('filters packages by pattern', () => { + const project = fixture.getWorkspaceProject(['simple']); + + const result = project.versionWorkspace({ + filter: 'my-first', + dryRun: true + }); + + expect(result.success).toBe(true); + expect(result.packages.length).toBe(1); + expect(result.packages[0].name).toBe('my-first'); + }); + + it('handles no changed packages scenario', () => { + const project = fixture.getWorkspaceProject(['simple']); + const workspacePath = project.getWorkspacePath()!; + + try { + execSync('git init', { cwd: workspacePath }); + execSync('git config user.email "test@example.com"', { cwd: workspacePath }); + execSync('git config user.name "Test User"', { cwd: workspacePath }); + execSync('git add .', { cwd: workspacePath }); + execSync('git commit -m "initial"', { cwd: workspacePath }); + execSync('git tag "my-first@0.0.1"', { cwd: workspacePath }); + execSync('git tag "my-second@0.0.1"', { cwd: workspacePath }); + + const result = project.versionWorkspace({ dryRun: true }); + + expect(result.success).toBe(true); + expect(result.packages.length).toBe(0); + expect(result.message).toContain('No packages have changed'); + } catch (error) { + console.warn('Skipping git-dependent test:', error); + } + }); + + it('fails when not run from workspace root', () => { + const project = fixture.getModuleProject(['simple'], 'my-first'); + + const result = project.versionWorkspace(); + + expect(result.success).toBe(false); + expect(result.message).toContain('must be run from a workspace root'); + expect(result.packages.length).toBe(0); + }); + + it('actually updates files when not in dry-run mode', () => { + const project = fixture.getWorkspaceProject(['simple']); + const workspacePath = project.getWorkspacePath()!; + + try { + execSync('git init', { cwd: workspacePath }); + execSync('git config user.email "test@example.com"', { cwd: workspacePath }); + execSync('git config user.name "Test User"', { cwd: workspacePath }); + + const result = project.versionWorkspace({ bump: 'patch' }); + + expect(result.success).toBe(true); + expect(result.packages.length).toBeGreaterThan(0); + + const firstPackagePath = path.join(workspacePath, 'packages', 'my-first', 'package.json'); + const updatedPkg = JSON.parse(fs.readFileSync(firstPackagePath, 'utf8')); + expect(updatedPkg.version).toBe('0.0.2'); + + const controlPath = path.join(workspacePath, 'packages', 'my-first', 'my-first.control'); + const controlContent = fs.readFileSync(controlPath, 'utf8'); + expect(controlContent).toContain("default_version = '0.0.2'"); + + const sqlFile = path.join(workspacePath, 'packages', 'my-first', 'sql', 'my-first--0.0.2.sql'); + expect(fs.existsSync(sqlFile)).toBe(true); + + } catch (error) { + console.warn('Skipping git-dependent test:', error); + } + }); +}); diff --git a/packages/core/src/core/class/launchql.ts b/packages/core/src/core/class/launchql.ts index 4e57caec6..4b53afedd 100644 --- a/packages/core/src/core/class/launchql.ts +++ b/packages/core/src/core/class/launchql.ts @@ -1424,4 +1424,407 @@ export class LaunchQLPackage { this.clearCache(); return { changed, warnings }; } + + /** + * Validate module consistency before bumping + * Returns validation result with issues if any + */ + validateModule(): { ok: boolean; issues: Array<{ code: string; message: string; file?: string }> } { + const analysisResult = this.analyzeModule(); + const issues = [...analysisResult.issues]; + + if (!this.isInModule()) { + issues.push({ + code: 'not_in_module', + message: 'This command must be run inside a LaunchQL module.' + }); + return { ok: false, issues }; + } + + const modPath = this.getModulePath()!; + const info = this.getModuleInfo(); + + try { + const pkgJsonPath = path.join(modPath, 'package.json'); + if (!fs.existsSync(pkgJsonPath)) { + issues.push({ + code: 'missing_package_json', + message: 'package.json not found', + file: pkgJsonPath + }); + } else { + const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')); + const pkgVersion = pkg.version; + + if (!pkgVersion) { + issues.push({ + code: 'missing_version', + message: 'No version found in package.json', + file: pkgJsonPath + }); + } else { + const controlContent = this.getModuleControlFile(); + const defaultVersionMatch = controlContent.match(/^default_version\s*=\s*'([^']+)'/m); + + if (!defaultVersionMatch) { + issues.push({ + code: 'missing_default_version', + message: 'Control file missing default_version', + file: info.controlFile + }); + } else { + const controlVersion = defaultVersionMatch[1]; + if (controlVersion !== pkgVersion) { + issues.push({ + code: 'version_mismatch', + message: `Version mismatch: control file default_version '${controlVersion}' !== package.json version '${pkgVersion}'`, + file: info.controlFile + }); + } + } + + const sqlFile = path.join(modPath, 'sql', `${info.extname}--${pkgVersion}.sql`); + if (!fs.existsSync(sqlFile)) { + issues.push({ + code: 'missing_sql_migration', + message: `SQL migration file missing: sql/${info.extname}--${pkgVersion}.sql`, + file: sqlFile + }); + } + + const planPath = path.join(modPath, 'launchql.plan'); + if (fs.existsSync(planPath)) { + try { + const planContent = fs.readFileSync(planPath, 'utf8'); + const hasVersionTag = planContent.includes(`@v${pkgVersion}`) || planContent.includes(`@${pkgVersion}`); + if (!hasVersionTag) { + issues.push({ + code: 'missing_version_tag', + message: `launchql.plan missing tag for version ${pkgVersion}`, + file: planPath + }); + } + } catch (e) { + issues.push({ + code: 'plan_read_error', + message: `Failed to read launchql.plan: ${e}`, + file: planPath + }); + } + } + } + } + } catch (error) { + issues.push({ + code: 'validation_error', + message: `Validation failed: ${error}` + }); + } + + return { ok: issues.length === 0, issues }; + } + + /** + * Synchronize artifacts with the new bumped version + * Updates control file and creates SQL migration file + */ + syncModule(version?: string): { success: boolean; message: string; files: string[] } { + if (!this.isInModule()) { + return { + success: false, + message: 'This command must be run inside a LaunchQL module.', + files: [] + }; + } + + const modPath = this.getModulePath()!; + const info = this.getModuleInfo(); + const files: string[] = []; + + try { + let targetVersion = version; + + if (!targetVersion) { + const pkgJsonPath = path.join(modPath, 'package.json'); + if (!fs.existsSync(pkgJsonPath)) { + return { + success: false, + message: 'package.json not found', + files: [] + }; + } + + const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')); + targetVersion = pkg.version; + + if (!targetVersion) { + return { + success: false, + message: 'No version found in package.json', + files: [] + }; + } + } + + // Update control file + const controlPath = info.controlFile; + const requires = this.getRequiredModules(); + + const controlContent = generateControlFileContent({ + name: info.extname, + version: targetVersion, + requires + }); + + fs.writeFileSync(controlPath, controlContent); + files.push(path.relative(modPath, controlPath)); + + // Create SQL migration file if it doesn't exist + const sqlDir = path.join(modPath, 'sql'); + if (!fs.existsSync(sqlDir)) { + fs.mkdirSync(sqlDir, { recursive: true }); + } + + const sqlFile = path.join(sqlDir, `${info.extname}--${targetVersion}.sql`); + if (!fs.existsSync(sqlFile)) { + const sqlContent = `-- ${info.extname} extension version ${targetVersion} +-- This file contains the SQL commands to create the extension + +-- Add your SQL commands here +`; + fs.writeFileSync(sqlFile, sqlContent); + files.push(`sql/${info.extname}--${targetVersion}.sql`); + } + + return { + success: true, + message: `Sync completed successfully for version ${targetVersion}`, + files + }; + + } catch (error) { + return { + success: false, + message: `Sync failed: ${error}`, + files + }; + } + } + + /** + * Version workspace packages like lerna version + * Detects changed packages, bumps versions, updates dependencies + */ + versionWorkspace(options: { + filter?: string; + bump?: 'patch' | 'minor' | 'major' | 'prerelease' | 'exact'; + exact?: string; + dryRun?: boolean; + } = {}): { success: boolean; message: string; packages: Array<{ name: string; oldVersion: string; newVersion: string }> } { + + if (!this.isInWorkspace()) { + return { + success: false, + message: 'This command must be run from a workspace root.', + packages: [] + }; + } + + try { + const workspacePath = this.getWorkspacePath()!; + const moduleMap = this.getModuleMap(); + const changedPackages: Array<{ name: string; path: string; oldVersion: string; newVersion: string }> = []; + + // Helper function to bump version + const bumpVersion = (version: string, bumpType: string, exactVersion?: string): string => { + if (bumpType === 'exact' && exactVersion) { + return exactVersion; + } + + const parts = version.split('.').map(Number); + const [major, minor, patch] = parts; + + switch (bumpType) { + case 'major': + return `${major + 1}.0.0`; + case 'minor': + return `${major}.${minor + 1}.0`; + case 'patch': + return `${major}.${minor}.${patch + 1}`; + case 'prerelease': + return `${major}.${minor}.${patch + 1}-alpha.0`; + default: + return version; + } + }; + + for (const [moduleName, moduleInfo] of Object.entries(moduleMap)) { + if (options.filter && !moduleName.includes(options.filter)) { + continue; + } + + const modulePath = path.join(workspacePath, moduleInfo.path); + const pkgJsonPath = path.join(modulePath, 'package.json'); + + if (!fs.existsSync(pkgJsonPath)) { + continue; + } + + const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')); + const currentVersion = pkg.version; + + if (!currentVersion) { + continue; + } + + let changed = true; + + try { + const lastTag = execSync(`git describe --tags --abbrev=0 --match="${moduleName}@*" 2>/dev/null || echo ""`, { + cwd: workspacePath, + encoding: 'utf8' + }).trim(); + + if (lastTag) { + const commitsSince = execSync(`git rev-list --count ${lastTag}..HEAD -- ${moduleInfo.path}`, { + cwd: workspacePath, + encoding: 'utf8' + }).trim(); + + changed = parseInt(commitsSince) > 0; + } + } catch (error) { + } + + if (changed) { + const bumpType = options.bump || 'patch'; + const newVersion = bumpVersion(currentVersion, bumpType, options.exact); + + changedPackages.push({ + name: moduleName, + path: modulePath, + oldVersion: currentVersion, + newVersion + }); + } + } + + if (changedPackages.length === 0) { + return { + success: true, + message: 'No packages have changed since last release', + packages: [] + }; + } + + if (options.dryRun) { + return { + success: true, + message: `Dry run mode - would update ${changedPackages.length} packages`, + packages: changedPackages.map(pkg => ({ + name: pkg.name, + oldVersion: pkg.oldVersion, + newVersion: pkg.newVersion + })) + }; + } + + // Update package versions and dependencies + const packageUpdates = new Map(); + for (const pkg of changedPackages) { + packageUpdates.set(pkg.name, pkg.newVersion); + } + + // Helper function to update dependency ranges + const updateDependencyRanges = (pkgJson: any, packageUpdates: Map): boolean => { + let updated = false; + + const updateDeps = (deps: Record | undefined) => { + if (!deps) return; + + for (const [depName, depVersion] of Object.entries(deps)) { + if (packageUpdates.has(depName)) { + const newVersion = packageUpdates.get(depName)!; + + if (depVersion.startsWith('workspace:')) { + continue; + } + + if (depVersion.startsWith('^')) { + deps[depName] = `^${newVersion}`; + updated = true; + } else if (depVersion.startsWith('~')) { + deps[depName] = `~${newVersion}`; + updated = true; + } + } + } + }; + + updateDeps(pkgJson.dependencies); + updateDeps(pkgJson.devDependencies); + updateDeps(pkgJson.peerDependencies); + + return updated; + }; + + // Update each package + for (const pkg of changedPackages) { + const pkgJsonPath = path.join(pkg.path, 'package.json'); + const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')); + + pkgJson.version = pkg.newVersion; + updateDependencyRanges(pkgJson, packageUpdates); + + fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2) + '\n'); + + try { + const moduleProject = new LaunchQLPackage(pkg.path); + moduleProject.syncModule(pkg.newVersion); + } catch (error) { + } + } + + // Git operations + const filesToAdd = []; + for (const pkg of changedPackages) { + filesToAdd.push(path.relative(workspacePath, path.join(pkg.path, 'package.json'))); + filesToAdd.push(path.relative(workspacePath, path.join(pkg.path, `${pkg.name}.control`))); + filesToAdd.push(path.relative(workspacePath, path.join(pkg.path, 'sql', `${pkg.name}--${pkg.newVersion}.sql`))); + } + + for (const file of filesToAdd) { + try { + execSync(`git add "${file}"`, { cwd: workspacePath }); + } catch (error) { + // Continue if file doesn't exist + } + } + + const commitMessage = 'chore(release): publish'; + execSync(`git commit -m "${commitMessage}"`, { cwd: workspacePath }); + + for (const pkg of changedPackages) { + const tag = `${pkg.name}@${pkg.newVersion}`; + execSync(`git tag "${tag}"`, { cwd: workspacePath }); + } + + return { + success: true, + message: `Successfully versioned ${changedPackages.length} packages`, + packages: changedPackages.map(pkg => ({ + name: pkg.name, + oldVersion: pkg.oldVersion, + newVersion: pkg.newVersion + })) + }; + + } catch (error) { + return { + success: false, + message: `Version command failed: ${error}`, + packages: [] + }; + } + } } diff --git a/packages/core/test-utils/TestFixture.ts b/packages/core/test-utils/TestFixture.ts index d05829e8b..01f980c75 100644 --- a/packages/core/test-utils/TestFixture.ts +++ b/packages/core/test-utils/TestFixture.ts @@ -12,6 +12,7 @@ export class TestFixture { readonly tempFixtureDir: string; readonly getFixturePath: (...paths: string[]) => string; readonly getModuleProject: (workspacePath: string[], moduleName: string) => LaunchQLPackage; + readonly getWorkspaceProject: (workspacePath: string[]) => LaunchQLPackage; constructor(...fixturePath: string[]) { const originalFixtureDir = getFixturePath(...fixturePath); @@ -30,6 +31,10 @@ export class TestFixture { if (!meta) throw new Error(`Module ${moduleName} not found in workspace`); return new LaunchQLPackage(this.getFixturePath(...workspacePath, meta.path)); }; + + this.getWorkspaceProject = (workspacePath: string[]): LaunchQLPackage => { + return new LaunchQLPackage(this.getFixturePath(...workspacePath)); + }; } fixturePath(...paths: string[]) {