diff --git a/.eslintrc b/.eslintrc index 413ced2..adb1e42 100644 --- a/.eslintrc +++ b/.eslintrc @@ -62,8 +62,7 @@ { "devDependencies": true, "optionalDependencies": false, - "peerDependencies": false, - "packageDir": "./" + "peerDependencies": false } ], "@typescript-eslint/consistent-type-imports": [ diff --git a/.github/workflows/e2e-release.yml b/.github/workflows/e2e-release.yml index 4c357d1..c195bbe 100644 --- a/.github/workflows/e2e-release.yml +++ b/.github/workflows/e2e-release.yml @@ -50,12 +50,8 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Run Verdaccio Docker - run: | - docker run -d --name verdaccio \ - -p 4873:4873 \ - -v ${{ github.workspace }}/e2e/config.yaml:/verdaccio/conf/config.yaml \ - verdaccio/verdaccio + - name: Start Verdaccio + run: docker compose -f local-e2e.docker-compose.yaml up -d verdaccio - name: Wait for Verdaccio run: | @@ -80,24 +76,13 @@ jobs: always-auth: false - name: Install jq - run: sudo apt-get install jq - - - name: Remove provenance from package.json files - run: | - find packages -name 'package.json' | while read filename; do - jq 'del(.publishConfig.provenance)' "$filename" > temp.json && mv temp.json "$filename" - done + run: sudo apt-get install -y jq - name: Config Git run: | git config --global user.email "e2e@contractual.dev" git config --global user.name "Contractual e2e" - - name: Commit Provenance Change - run: | - git add . - git commit -am "remove provenance" - - name: Version Packages run: | BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD) @@ -135,6 +120,8 @@ jobs: --no-git-reset \ --exact \ --dist-tag e2e + env: + NPM_CONFIG_PROVENANCE: false - name: Clean Source (simulate fresh install) run: | diff --git a/README.md b/README.md index fdf186c..f21b5d8 100644 --- a/README.md +++ b/README.md @@ -1,107 +1,109 @@ -# Contractual +

+ Contractual +

-The `contractual` CLI and GitHub Action manage schema contract lifecycle for OpenAPI, JSON Schema, and AsyncAPI. +

Contractual

-It provides: -- Linting of specs -- Structural breaking change detection against snapshots -- Changeset generation and versioning -- Changelog generation -- GitHub Action integration for PR checks and release automation +

+Schema contract lifecycle for OpenAPI, JSON Schema, and AsyncAPI +
+Linting • Breaking change detection • Versioning • Release automation +

-## Installation +
+ license + PRs welcome + npm downloads +
-### npm +

+ Docs +   •   + Quickstart +   •   + Breaking Detection +   •   + GitHub Action +

-```sh -npm install -g contractual -``` +

+Supported Formats: OpenAPI, JSON Schema, AsyncAPI +

-### Other package managers +## Features -```sh -pnpm add -g contractual -yarn global add contractual -bun add -g contractual -``` +- **Structural Breaking Change Detection** - Compares specs against versioned snapshots using structural diffing, not string comparison. Catches removed fields, type changes, and endpoint deletions. -## Usage +- **Automated Versioning** - Changesets declare bump levels (major/minor/patch). `contractual version` consumes them, bumps versions, updates snapshots, and generates changelogs. -The CLI provides command summaries and flags: +- **CI Integration** - GitHub Action posts diff tables on PRs, auto-generates changesets, and opens Version PRs for release automation. -```sh -contractual --help -``` +- **Format Agnostic** - Works with OpenAPI, JSON Schema, and AsyncAPI. Custom linters and differs can be configured per contract. -For usage details, see the documentation, especially: +## Quick Example -- [`contractual breaking`][breaking-docs] -- [`contractual lint`][lint-docs] -- [`contractual changeset`][changeset-docs] -- [`contractual version`][version-docs] -- [GitHub Action setup][action-docs] +### Detect changes -## CLI breaking change policy +```bash +$ contractual diff + +orders-api: 3 changes (2 breaking, 1 non-breaking) — suggested bump: major + + BREAKING Removed endpoint GET /orders/{id}/details + BREAKING Changed type of field 'amount': string → number + non-breaking Added optional field 'tracking_url' +``` -Breaking changes are documented in release notes for the npm package. +### Generate a changeset -## Goals for schema contracts +```bash +$ contractual changeset -Schema contracts are a compatibility boundary between producers and consumers. Contractual standardizes linting, breaking change detection, versioning, and changelog generation across OpenAPI, JSON Schema, and AsyncAPI. +? Bump type for orders-api: major +? Summary: Remove deprecated endpoint, change amount type -Contractual wraps existing tooling where possible and adds missing lifecycle steps, including built-in JSON Schema diffing where production-grade tooling is limited. +Wrote .contractual/changesets/fuzzy-lion-dances.md +``` -## The Contractual workflow +### Bump versions -Contractual uses a repository state directory at `.contractual/` to store versions, snapshots, and pending changesets. The GitHub Action can post diff tables on pull requests and open a Version Contracts PR for release automation. +```bash +$ contractual version -The GitHub Action is optional. The CLI can run locally or in CI. +orders-api 1.4.2 → 2.0.0 (major) -## More advanced CLI features +Updated .contractual/versions.json +Updated CHANGELOG.md +``` -- Custom linters and differs via `contractual.yaml` -- Custom outputs for code generation -- JSON output formats for CI systems -- Base snapshot selection with `--base` -- Monorepo support with multiple configs -- Optional AI explanations with `ANTHROPIC_API_KEY` +## Installation -## Next steps +```bash +npm install -g @contractual/cli +``` -After installation, follow the CLI quickstart: +Or with other package managers: -- [Quickstart][quickstart-docs] -- [Configuration reference][config-docs] -- [Breaking change detection][breaking-overview] +```bash +pnpm add -g @contractual/cli +yarn global add @contractual/cli +``` -## Builds +## Getting Started -The CLI is distributed via npm and requires Node.js 18 or later. +1. **Initialize** - `contractual init` scans for specs and creates `contractual.yaml` +2. **Lint** - `contractual lint` validates specs +3. **Detect changes** - `contractual diff` shows all changes classified +4. **CI gate** - `contractual breaking` fails if breaking changes exist +5. **Version** - `contractual changeset` + `contractual version` for releases -| Platform | Support | -|----------|---------| -| macOS | Node.js 18+ | -| Linux | Node.js 18+ | -| Windows | Node.js 18+ | +[→ Full Quickstart Guide](https://contractual.dev/getting-started/quickstart) ## Community -Issues and feature requests: -- [GitHub issues][issues] - -Documentation: -- [contractual.dev][docs] - -License: -- [MIT](LICENSE) - -[docs]: https://contractual.dev -[issues]: https://github.com/contractual-dev/contractual/issues -[quickstart-docs]: https://contractual.dev/getting-started/quickstart -[config-docs]: https://contractual.dev/reference/configuration -[breaking-docs]: https://contractual.dev/breaking/usage -[breaking-overview]: https://contractual.dev/breaking/overview -[lint-docs]: https://contractual.dev/linting/usage -[changeset-docs]: https://contractual.dev/versioning/usage -[version-docs]: https://contractual.dev/versioning/usage -[action-docs]: https://contractual.dev/github-action/setup +- [Documentation](https://contractual.dev) +- [GitHub Issues](https://github.com/contractual-dev/contractual/issues) + +## License + +[MIT](LICENSE) diff --git a/e2e/cli-basic/cli-install.test.ts b/e2e/cli-basic/cli-install.test.ts index f4e976f..fedc1cb 100644 --- a/e2e/cli-basic/cli-install.test.ts +++ b/e2e/cli-basic/cli-install.test.ts @@ -19,10 +19,13 @@ describe('CLI Installation and Basic Commands', () => { const result = run('npx contractual --help'); expect(result).toContain('init'); expect(result).toContain('lint'); + expect(result).toContain('diff'); expect(result).toContain('breaking'); expect(result).toContain('changeset'); expect(result).toContain('version'); expect(result).toContain('status'); + expect(result).toContain('contract'); + expect(result).toContain('pre'); }); test('contractual init --help shows init options', () => { @@ -39,4 +42,38 @@ describe('CLI Installation and Basic Commands', () => { const result = run('npx contractual breaking --help'); expect(result).toContain('--format'); }); + + test('contractual diff --help shows diff options', () => { + const result = run('npx contractual diff --help'); + expect(result).toContain('--format'); + expect(result).toContain('--severity'); + expect(result).toContain('--verbose'); + }); + + test('contractual contract --help shows subcommands', () => { + const result = run('npx contractual contract --help'); + expect(result).toContain('add'); + expect(result).toContain('list'); + }); + + test('contractual contract add --help shows add options', () => { + const result = run('npx contractual contract add --help'); + expect(result).toContain('--name'); + expect(result).toContain('--type'); + expect(result).toContain('--path'); + }); + + test('contractual pre --help shows subcommands', () => { + const result = run('npx contractual pre --help'); + expect(result).toContain('enter'); + expect(result).toContain('exit'); + expect(result).toContain('status'); + }); + + test('contractual version --help shows version options', () => { + const result = run('npx contractual version --help'); + expect(result).toContain('--dry-run'); + expect(result).toContain('--json'); + expect(result).toContain('--yes'); + }); }); diff --git a/e2e/cli-lifecycle/full-lifecycle.test.ts b/e2e/cli-lifecycle/full-lifecycle.test.ts index 8f0ddbd..63189ee 100644 --- a/e2e/cli-lifecycle/full-lifecycle.test.ts +++ b/e2e/cli-lifecycle/full-lifecycle.test.ts @@ -132,4 +132,175 @@ describe('Full CLI Lifecycle (installed from Verdaccio)', () => { >; expect(versions['order'].version).toBe('2.0.0'); }); + + test('diff shows changes between spec and snapshot', () => { + repo = createTempRepo(); + const { dir } = repo; + + setupRepoWithConfig(dir, [{ name: 'order', type: 'json-schema', path: 'schemas/order.json' }]); + copyFixture('json-schema/order-base.json', path.join(dir, '.contractual/snapshots/order.json')); + copyFixture('json-schema/order-field-removed.json', path.join(dir, 'schemas/order.json')); + writeFile( + dir, + '.contractual/versions.json', + JSON.stringify({ + order: { version: '1.0.0', released: '2026-01-01T00:00:00Z' }, + }) + ); + + const result = run('diff --format json', dir); + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout); + expect(parsed.contracts).toBeDefined(); + expect(parsed.contracts.order).toBeDefined(); + expect(parsed.contracts.order.changes.length).toBeGreaterThan(0); + }); + + test('contract list shows configured contracts', () => { + repo = createTempRepo(); + const { dir } = repo; + + setupRepoWithConfig(dir, [{ name: 'order', type: 'json-schema', path: 'schemas/order.json' }]); + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/order.json')); + writeFile( + dir, + '.contractual/versions.json', + JSON.stringify({ + order: { version: '1.0.0', released: '2026-01-01T00:00:00Z' }, + }) + ); + + const result = run('contract list --json', dir); + expect(result.exitCode).toBe(0); + const contracts = JSON.parse(result.stdout); + expect(Array.isArray(contracts)).toBe(true); + expect(contracts[0].name).toBe('order'); + }); + + test('contract add adds new contract to config', () => { + repo = createTempRepo(); + const { dir } = repo; + + setupRepoWithConfig(dir, [{ name: 'order', type: 'json-schema', path: 'schemas/order.json' }]); + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/order.json')); + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/user.json')); + writeFile( + dir, + '.contractual/versions.json', + JSON.stringify({ + order: { version: '1.0.0', released: '2026-01-01T00:00:00Z' }, + }) + ); + + const result = run('contract add --name user --type json-schema --path schemas/user.json -y', dir); + expect(result.exitCode).toBe(0); + + const listResult = run('contract list --json', dir); + const contracts = JSON.parse(listResult.stdout); + expect(contracts.length).toBe(2); + expect(contracts.find((c: { name: string }) => c.name === 'user')).toBeDefined(); + }); + + test('pre enter/exit manages pre-release mode', () => { + repo = createTempRepo(); + const { dir } = repo; + + setupRepoWithConfig(dir, [{ name: 'order', type: 'json-schema', path: 'schemas/order.json' }]); + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/order.json')); + writeFile( + dir, + '.contractual/versions.json', + JSON.stringify({ + order: { version: '1.0.0', released: '2026-01-01T00:00:00Z' }, + }) + ); + + // Enter pre-release mode + const enterResult = run('pre enter beta', dir); + expect(enterResult.exitCode).toBe(0); + expect(fileExists(dir, '.contractual/pre.json')).toBe(true); + + // Check status + const statusResult = run('pre status', dir); + expect(statusResult.exitCode).toBe(0); + expect(statusResult.stdout).toMatch(/beta/i); + + // Exit pre-release mode + const exitResult = run('pre exit', dir); + expect(exitResult.exitCode).toBe(0); + expect(fileExists(dir, '.contractual/pre.json')).toBe(false); + }); + + test('version --dry-run shows preview without changes', () => { + repo = createTempRepo(); + const { dir } = repo; + + setupRepoWithConfig(dir, [{ name: 'order', type: 'json-schema', path: 'schemas/order.json' }]); + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/order.json')); + copyFixture('json-schema/order-base.json', path.join(dir, '.contractual/snapshots/order.json')); + writeFile( + dir, + '.contractual/versions.json', + JSON.stringify({ + order: { version: '1.0.0', released: '2026-01-01T00:00:00Z' }, + }) + ); + + // Create changeset + writeFile( + dir, + '.contractual/changesets/test.md', + `--- +"order": minor +--- + +Test change +` + ); + + const result = run('version --dry-run', dir); + expect(result.exitCode).toBe(0); + + // Verify version NOT changed + const versions = readJSON(dir, '.contractual/versions.json') as Record< + string, + { version: string } + >; + expect(versions['order'].version).toBe('1.0.0'); + }); + + test('version --json outputs structured result', () => { + repo = createTempRepo(); + const { dir } = repo; + + setupRepoWithConfig(dir, [{ name: 'order', type: 'json-schema', path: 'schemas/order.json' }]); + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/order.json')); + copyFixture('json-schema/order-base.json', path.join(dir, '.contractual/snapshots/order.json')); + writeFile( + dir, + '.contractual/versions.json', + JSON.stringify({ + order: { version: '1.0.0', released: '2026-01-01T00:00:00Z' }, + }) + ); + + // Create changeset + writeFile( + dir, + '.contractual/changesets/test.md', + `--- +"order": patch +--- + +Bug fix +` + ); + + const result = run('version --json', dir); + expect(result.exitCode).toBe(0); + + const output = JSON.parse(result.stdout); + expect(output.bumps).toBeDefined(); + expect(Array.isArray(output.bumps)).toBe(true); + }); }); diff --git a/local-e2e.docker-compose.yaml b/local-e2e.docker-compose.yaml index 8f05a64..c205944 100644 --- a/local-e2e.docker-compose.yaml +++ b/local-e2e.docker-compose.yaml @@ -11,6 +11,19 @@ services: networks: - e2e-network + executor: + image: node:22-alpine + working_dir: /workspace + volumes: + - .:/workspace + command: ['tail', '-f', '/dev/null'] + networks: + - e2e-network + depends_on: + - verdaccio + environment: + - NPM_CONFIG_PROVENANCE=false + networks: e2e-network: driver: bridge diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..cde1ab0 Binary files /dev/null and b/logo.png differ diff --git a/packages/changesets/index.ts b/packages/changesets/index.ts index 9c99ed9..d65796d 100644 --- a/packages/changesets/index.ts +++ b/packages/changesets/index.ts @@ -15,12 +15,15 @@ export { aggregateBumps, extractContractChanges } from './changesets/consume.js' // Versioning export { VersionManager, + PreReleaseManager, VERSIONS_FILE, SNAPSHOTS_DIR, CHANGESETS_DIR, + PRE_RELEASE_FILE, DEFAULT_VERSION, SPEC_EXTENSIONS, incrementVersion, + incrementVersionWithPreRelease, VersionError, type BumpOperationResult, } from './versioning/manager.js'; diff --git a/packages/changesets/versioning/manager.ts b/packages/changesets/versioning/manager.ts index ad5b39e..9656d0c 100644 --- a/packages/changesets/versioning/manager.ts +++ b/packages/changesets/versioning/manager.ts @@ -1,7 +1,7 @@ -import { existsSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs'; +import { existsSync, readFileSync, writeFileSync, copyFileSync, unlinkSync } from 'node:fs'; import { join, extname } from 'node:path'; import * as semver from 'semver'; -import type { VersionsFile, SimpleVersionEntry, BumpType } from '@contractual/types'; +import type { VersionsFile, SimpleVersionEntry, BumpType, PreReleaseState } from '@contractual/types'; /** * Default version for new contracts @@ -74,6 +74,11 @@ export const SNAPSHOTS_DIR = 'snapshots' as const; */ export const CHANGESETS_DIR = 'changesets' as const; +/** + * Filename for pre-release state + */ +export const PRE_RELEASE_FILE = 'pre.json' as const; + /** * Manages contract versions and snapshots */ @@ -197,6 +202,32 @@ export class VersionManager { return { oldVersion, newVersion }; } + /** + * Set version for a contract (used for initial version setup) + * @param contractName - The contract name + * @param version - The version to set + * @param specPath - Path to the spec file to snapshot + */ + setVersion(contractName: string, version: string, specPath: string): void { + if (!semver.valid(version)) { + throw new VersionError(`Invalid semver version: ${version}`, version); + } + + // Update versions entry + this.versions[contractName] = { + version, + released: new Date().toISOString(), + }; + + // Copy spec to snapshots directory + const ext = extname(specPath) || '.yaml'; + const snapshotPath = join(this.snapshotsDir, `${contractName}${ext}`); + copyFileSync(specPath, snapshotPath); + + // Save versions.json + this.save(); + } + /** * Save versions.json to disk */ @@ -204,4 +235,137 @@ export class VersionManager { const content = JSON.stringify(this.versions, null, 2); writeFileSync(this.versionsPath, content, 'utf-8'); } + + /** + * Get all contract versions + * @returns Map of contract names to versions + */ + getAllVersions(): Record { + const result: Record = {}; + for (const [name, entry] of Object.entries(this.versions)) { + result[name] = entry.version; + } + return result; + } +} + +/** + * Manages pre-release state + */ +export class PreReleaseManager { + private readonly prePath: string; + + constructor(contractualDir: string) { + this.prePath = join(contractualDir, PRE_RELEASE_FILE); + } + + /** + * Check if pre-release mode is active + */ + isActive(): boolean { + return existsSync(this.prePath); + } + + /** + * Get current pre-release state + * @returns The pre-release state, or null if not in pre-release mode + */ + getState(): PreReleaseState | null { + if (!this.isActive()) { + return null; + } + + try { + const content = readFileSync(this.prePath, 'utf-8'); + return JSON.parse(content) as PreReleaseState; + } catch { + return null; + } + } + + /** + * Enter pre-release mode + * @param tag - The pre-release tag (e.g., "alpha", "beta", "rc") + * @param versionManager - VersionManager to get current versions + */ + enter(tag: string, versionManager: VersionManager): void { + if (this.isActive()) { + throw new VersionError(`Already in pre-release mode. Run 'pre exit' first.`); + } + + // Validate tag + if (!/^[a-zA-Z][a-zA-Z0-9-]*$/.test(tag)) { + throw new VersionError( + `Invalid pre-release tag: ${tag}. Must start with letter, contain only letters, numbers, and hyphens.` + ); + } + + const state: PreReleaseState = { + tag, + enteredAt: new Date().toISOString(), + initialVersions: versionManager.getAllVersions(), + }; + + writeFileSync(this.prePath, JSON.stringify(state, null, 2), 'utf-8'); + } + + /** + * Exit pre-release mode + */ + exit(): void { + if (!this.isActive()) { + throw new VersionError('Not in pre-release mode.'); + } + + unlinkSync(this.prePath); + } + + /** + * Get the pre-release tag + * @returns The tag, or null if not in pre-release mode + */ + getTag(): string | null { + const state = this.getState(); + return state?.tag ?? null; + } +} + +/** + * Increment a version with pre-release support + * @param version - The current version string + * @param bumpType - The type of version bump + * @param preReleaseTag - Optional pre-release tag (e.g., "beta") + * @returns The new version string + */ +export function incrementVersionWithPreRelease( + version: string, + bumpType: BumpType, + preReleaseTag?: string +): string { + if (!semver.valid(version)) { + throw new VersionError(`Invalid semver version: ${version}`, version, bumpType); + } + + const parsed = semver.parse(version); + if (!parsed) { + throw new VersionError(`Failed to parse version: ${version}`, version, bumpType); + } + + // If no pre-release tag, use normal increment + if (!preReleaseTag) { + return incrementVersion(version, bumpType); + } + + // If already a pre-release with the same tag, just bump the pre-release number + if (parsed.prerelease.length > 0 && parsed.prerelease[0] === preReleaseTag) { + const newVersion = semver.inc(version, 'prerelease', preReleaseTag); + if (!newVersion) { + throw new VersionError(`Failed to increment pre-release version`, version, bumpType); + } + return newVersion; + } + + // Otherwise, apply the bump type and start a new pre-release + const baseVersion = incrementVersion(version, bumpType); + return `${baseVersion}-${preReleaseTag}.0`; } diff --git a/packages/cli/package.json b/packages/cli/package.json index 6fd3977..fcb36e3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -60,6 +60,7 @@ "@contractual/changesets": "workspace:*", "@contractual/governance": "workspace:*", "@contractual/types": "workspace:*", + "@inquirer/prompts": "^8.1.0", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "chalk": "^5.4.1", diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index dafce66..763420e 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -1,10 +1,12 @@ import { Command } from 'commander'; import { initCommand } from './commands/init.command.js'; +import { contractAddCommand, contractListCommand } from './commands/contract.command.js'; import { lintCommand } from './commands/lint.command.js'; import { diffCommand } from './commands/diff.command.js'; import { breakingCommand } from './commands/breaking.command.js'; import { changesetCommand } from './commands/changeset.command.js'; import { versionCommand } from './commands/version.command.js'; +import { preEnterCommand, preExitCommand, preStatusCommand } from './commands/pre.command.js'; import { statusCommand } from './commands/status.command.js'; const program = new Command(); @@ -14,8 +16,31 @@ program.name('contractual').description('Schema contract lifecycle orchestrator' program .command('init') .description('Initialize Contractual in this repository') + .option('-V, --initial-version ', 'Initial version for contracts') + .option('--versioning ', 'Versioning mode: independent, fixed') + .option('-y, --yes', 'Skip prompts and use defaults') + .option('--force', 'Reinitialize existing project') .action(initCommand); +const contractCmd = program.command('contract').description('Manage contracts'); + +contractCmd + .command('add') + .description('Add a new contract to the configuration') + .option('-n, --name ', 'Contract name') + .option('-t, --type ', 'Contract type: openapi, asyncapi, json-schema, odcs') + .option('-p, --path ', 'Path to spec file') + .option('--initial-version ', 'Initial version (default: 0.0.0)') + .option('--skip-validation', 'Skip spec validation') + .option('-y, --yes', 'Skip prompts and use defaults') + .action(contractAddCommand); + +contractCmd + .command('list [name]') + .description('List contracts (optionally filter by name)') + .option('--json', 'Output as JSON') + .action(contractListCommand); + program .command('lint') .description('Lint all configured contracts') @@ -49,8 +74,22 @@ program program .command('version') .description('Consume changesets and bump versions') + .option('-y, --yes', 'Skip confirmation prompt') + .option('--dry-run', 'Preview without applying') + .option('--json', 'Output JSON (implies --yes)') .action(versionCommand); +const preCmd = program.command('pre').description('Manage pre-release versions'); + +preCmd + .command('enter ') + .description('Enter pre-release mode (e.g., alpha, beta, rc)') + .action(preEnterCommand); + +preCmd.command('exit').description('Exit pre-release mode').action(preExitCommand); + +preCmd.command('status').description('Show pre-release status').action(preStatusCommand); + program .command('status') .description('Show current versions and pending changesets') diff --git a/packages/cli/src/commands/contract.command.ts b/packages/cli/src/commands/contract.command.ts new file mode 100644 index 0000000..2b68c53 --- /dev/null +++ b/packages/cli/src/commands/contract.command.ts @@ -0,0 +1,373 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve, extname } from 'node:path'; +import chalk from 'chalk'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import { VersionManager } from '@contractual/changesets'; +import { loadConfig } from '../config/index.js'; +import { + ensureContractualDir, + detectSpecType, + CONTRACTUAL_DIR, + findContractualDir, +} from '../utils/files.js'; +import { + promptInput, + promptSelect, + promptVersion, + CONTRACT_TYPE_CHOICES, + type PromptOptions, +} from '../utils/prompts.js'; +import type { ContractDefinition, ContractType } from '@contractual/types'; + +/** + * Default version for new contracts + */ +const DEFAULT_VERSION = '0.0.0'; + +/** + * Options for the contract add command + */ +interface ContractAddOptions extends PromptOptions { + /** Contract name */ + name?: string; + /** Contract type */ + type?: ContractType; + /** Path to spec file */ + path?: string; + /** Initial version */ + initialVersion?: string; + /** Skip validation */ + skipValidation?: boolean; +} + +/** + * Add a new contract to the configuration + */ +export async function contractAddCommand(options: ContractAddOptions = {}): Promise { + const cwd = process.cwd(); + const configPath = join(cwd, 'contractual.yaml'); + + // Check if initialized + if (!existsSync(configPath)) { + console.log(chalk.red('Not initialized:') + ' contractual.yaml not found'); + console.log(chalk.dim('Run `contractual init` first')); + process.exitCode = 1; + return; + } + + // Read existing config + const configContent = readFileSync(configPath, 'utf-8'); + const config = parseYaml(configContent) as { + contracts?: ContractDefinition[]; + changeset?: unknown; + versioning?: unknown; + ai?: unknown; + }; + + if (!config.contracts) { + config.contracts = []; + } + + // Get contract details through prompts or options + const contractName = await getContractName(config.contracts, options); + if (!contractName) return; + + const specPath = await getSpecPath(cwd, options); + if (!specPath) return; + + const contractType = await getContractType(cwd, specPath, options); + if (!contractType) return; + + const version = await getVersion(options); + + // Validate spec file + if (!options.skipValidation) { + const absolutePath = resolve(cwd, specPath); + const detectedType = detectSpecType(absolutePath); + + if (!detectedType) { + console.log(chalk.red('Invalid spec file:') + ' Could not detect spec type'); + console.log(chalk.dim(`Expected: ${contractType}`)); + process.exitCode = 1; + return; + } + + if (detectedType !== contractType) { + console.log( + chalk.yellow('Type mismatch:') + + ` Detected ${chalk.cyan(detectedType)}, specified ${chalk.cyan(contractType)}` + ); + console.log(chalk.dim('Use --skip-validation to override')); + process.exitCode = 1; + return; + } + + console.log(chalk.green('✓') + ` Valid ${contractType} spec`); + } + + // Create contract definition + const contract: ContractDefinition = { + name: contractName, + type: contractType, + path: specPath, + }; + + // Add to config + config.contracts.push(contract); + + // Write updated config + const yamlContent = stringifyYaml(config, { + lineWidth: 100, + singleQuote: true, + }); + writeFileSync(configPath, yamlContent, 'utf-8'); + + // Ensure .contractual directory exists and create snapshot + const contractualDir = findContractualDir(cwd) ?? join(cwd, CONTRACTUAL_DIR); + ensureContractualDir(cwd); + + const versionManager = new VersionManager(contractualDir); + const absolutePath = resolve(cwd, specPath); + versionManager.setVersion(contractName, version, absolutePath); + + // Print summary + const snapshotExt = extname(specPath) || '.yaml'; + console.log(); + console.log( + chalk.green('✓') + ` Added ${chalk.cyan(contractName)} (${contractType}) at v${version}` + ); + console.log(); + console.log(chalk.bold('Updated:')); + console.log(` ${chalk.yellow('~')} contractual.yaml`); + console.log(chalk.bold('Created:')); + console.log(` ${chalk.green('+')} .contractual/snapshots/${contractName}${snapshotExt}`); + console.log(` ${chalk.green('+')} .contractual/versions.json (updated)`); +} + +/** + * Get contract name through prompts or options + */ +async function getContractName( + existingContracts: ContractDefinition[], + options: ContractAddOptions +): Promise { + const existingNames = new Set(existingContracts.map((c) => c.name)); + + if (options.name) { + if (existingNames.has(options.name)) { + console.log(chalk.red('Contract exists:') + ` ${options.name} already defined`); + process.exitCode = 1; + return null; + } + return options.name; + } + + const name = await promptInput('Contract name:', '', options); + + if (!name) { + console.log(chalk.red('Contract name is required')); + process.exitCode = 1; + return null; + } + + if (existingNames.has(name)) { + console.log(chalk.red('Contract exists:') + ` ${name} already defined`); + process.exitCode = 1; + return null; + } + + // Validate name format (alphanumeric, hyphens, underscores) + if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(name)) { + console.log( + chalk.red('Invalid name:') + + ' Must start with letter, contain only letters, numbers, hyphens, underscores' + ); + process.exitCode = 1; + return null; + } + + return name; +} + +/** + * Get spec path through prompts or options + */ +async function getSpecPath(cwd: string, options: ContractAddOptions): Promise { + if (options.path) { + const absolutePath = resolve(cwd, options.path); + if (!existsSync(absolutePath)) { + console.log(chalk.red('File not found:') + ` ${options.path}`); + process.exitCode = 1; + return null; + } + return options.path; + } + + const path = await promptInput('Path to spec file:', '', options); + + if (!path) { + console.log(chalk.red('Spec path is required')); + process.exitCode = 1; + return null; + } + + const absolutePath = resolve(cwd, path); + if (!existsSync(absolutePath)) { + console.log(chalk.red('File not found:') + ` ${path}`); + process.exitCode = 1; + return null; + } + + return path; +} + +/** + * Get contract type through prompts or options + */ +async function getContractType( + cwd: string, + specPath: string, + options: ContractAddOptions +): Promise { + if (options.type) { + return options.type; + } + + // Try to auto-detect type + const absolutePath = resolve(cwd, specPath); + const detectedType = detectSpecType(absolutePath); + + if (detectedType && options.yes) { + return detectedType; + } + + const typeChoices = CONTRACT_TYPE_CHOICES.map((c) => ({ + ...c, + name: detectedType === c.value ? `${c.name} (detected)` : c.name, + })); + + return promptSelect('Contract type:', [...typeChoices], detectedType ?? 'openapi', options); +} + +/** + * Get version through prompts or options + */ +async function getVersion(options: ContractAddOptions): Promise { + if (options.initialVersion) { + return options.initialVersion; + } + + return promptVersion('Initial version:', DEFAULT_VERSION, options); +} + +/** + * Options for the contract list command + */ +interface ContractListOptions { + /** Output as JSON */ + json?: boolean; +} + +/** + * Contract info for list output + */ +interface ContractInfo { + name: string; + type: ContractType; + version: string; + path: string; +} + +/** + * List contracts + */ +export async function contractListCommand( + name: string | undefined, + options: ContractListOptions = {} +): Promise { + let config; + try { + config = loadConfig(); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error(chalk.red('Failed to load configuration:'), message); + process.exitCode = 1; + return; + } + + const contractualDir = findContractualDir(config.configDir); + const versionManager = contractualDir ? new VersionManager(contractualDir) : null; + + // Build contract info list + let contracts: ContractInfo[] = config.contracts.map((c) => ({ + name: c.name, + type: c.type, + version: versionManager?.getVersion(c.name) ?? '0.0.0', + path: c.path, + })); + + // Filter by name if provided + if (name) { + contracts = contracts.filter((c) => c.name === name); + if (contracts.length === 0) { + console.error(chalk.red(`Contract not found: ${name}`)); + process.exitCode = 1; + return; + } + } + + // Output + if (options.json) { + console.log(JSON.stringify(contracts, null, 2)); + return; + } + + // Table output + if (contracts.length === 0) { + console.log(chalk.dim('No contracts configured.')); + return; + } + + // Calculate column widths + const maxNameLen = Math.max(4, ...contracts.map((c) => c.name.length)); + const maxTypeLen = Math.max(4, ...contracts.map((c) => c.type.length)); + const maxVersionLen = Math.max(7, ...contracts.map((c) => c.version.length)); + + // Header + const header = + `${'Name'.padEnd(maxNameLen)} ` + + `${'Type'.padEnd(maxTypeLen)} ` + + `${'Version'.padEnd(maxVersionLen)} ` + + `Path`; + console.log(chalk.dim(header)); + console.log(chalk.dim('─'.repeat(header.length + 10))); + + // Rows + for (const contract of contracts) { + const typeColor = getTypeColor(contract.type); + console.log( + `${chalk.cyan(contract.name.padEnd(maxNameLen))} ` + + `${typeColor(contract.type.padEnd(maxTypeLen))} ` + + `${chalk.green(contract.version.padEnd(maxVersionLen))} ` + + `${chalk.dim(contract.path)}` + ); + } +} + +/** + * Get chalk color function for contract type + */ +function getTypeColor(type: ContractType): (text: string) => string { + switch (type) { + case 'openapi': + return chalk.green; + case 'asyncapi': + return chalk.magenta; + case 'json-schema': + return chalk.blue; + case 'odcs': + return chalk.yellow; + default: + return chalk.white; + } +} diff --git a/packages/cli/src/commands/init.command.ts b/packages/cli/src/commands/init.command.ts index 29ca827..376ba3e 100644 --- a/packages/cli/src/commands/init.command.ts +++ b/packages/cli/src/commands/init.command.ts @@ -1,11 +1,47 @@ -import { existsSync, writeFileSync } from 'node:fs'; +import { existsSync, writeFileSync, readFileSync } from 'node:fs'; import { join, basename } from 'node:path'; import fg from 'fast-glob'; import chalk from 'chalk'; import ora from 'ora'; -import { stringify as stringifyYaml } from 'yaml'; -import { ensureContractualDir, detectSpecType } from '../utils/files.js'; -import type { ContractDefinition, ContractType } from '@contractual/types'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import { VersionManager } from '@contractual/changesets'; +import { + ensureContractualDir, + detectSpecType, + CONTRACTUAL_DIR, + getSnapshotPath, +} from '../utils/files.js'; +import { + promptSelect, + promptVersion, + promptConfirm, + VERSION_CHOICES, + VERSIONING_MODE_CHOICES, + type PromptOptions, +} from '../utils/prompts.js'; +import type { ContractDefinition, ContractType, VersioningMode } from '@contractual/types'; + +/** + * Default starting version for new contracts + */ +const DEFAULT_VERSION = '0.0.0'; + +/** + * Default versioning mode + */ +const DEFAULT_VERSIONING_MODE: VersioningMode = 'independent'; + +/** + * Options for the init command + */ +interface InitOptions extends PromptOptions { + /** Initial version for contracts */ + initialVersion?: string; + /** Versioning mode */ + versioning?: VersioningMode; + /** Force reinitialize */ + force?: boolean; +} /** * Glob patterns to find spec files @@ -62,20 +98,60 @@ function extractContractName(filePath: string): string { return name; } +/** + * Get initial version through prompts or options + */ +async function getInitialVersion(options: InitOptions): Promise { + // If version provided via CLI, use it + if (options.initialVersion) { + return options.initialVersion; + } + + // Prompt for version + const versionChoice = await promptSelect( + 'Initial version for contracts:', + [...VERSION_CHOICES], + '0.0.0', + options + ); + + if (versionChoice === 'custom') { + return promptVersion('Enter version:', DEFAULT_VERSION, options); + } + + return versionChoice; +} + +/** + * Get versioning mode through prompts or options + */ +async function getVersioningMode(options: InitOptions): Promise { + if (options.versioning) { + return options.versioning; + } + + return promptSelect( + 'Versioning mode:', + [...VERSIONING_MODE_CHOICES], + DEFAULT_VERSIONING_MODE, + options + ); +} + /** * Initialize Contractual in a repository * * Scans for spec files and generates contractual.yaml configuration */ -export async function initCommand(): Promise { +export async function initCommand(options: InitOptions = {}): Promise { const cwd = process.cwd(); const configPath = join(cwd, 'contractual.yaml'); + const contractualDir = join(cwd, CONTRACTUAL_DIR); // Check if already initialized - if (existsSync(configPath)) { - console.log(chalk.red('Already initialized:') + ' contractual.yaml exists'); - console.log(chalk.dim('Use `contractual status` to see current state')); - process.exitCode = 1; + if (existsSync(configPath) && !options.force) { + // Try to handle existing project with uninitialized contracts + await handleExistingProject(cwd, configPath, contractualDir, options); return; } @@ -90,7 +166,7 @@ export async function initCommand(): Promise { onlyFiles: true, }); - spinner.text = `Found ${files.length} potential spec file(s)`; + spinner.succeed(`Found ${files.length} potential spec file(s)`); // Build contract definitions const contracts: ContractDefinition[] = []; @@ -124,7 +200,7 @@ export async function initCommand(): Promise { } if (contracts.length === 0) { - spinner.warn('No spec files found'); + console.log(chalk.yellow('\nNo spec files found')); console.log(chalk.dim('\nSupported file patterns:')); console.log(chalk.dim(' - *.openapi.yaml/json')); console.log(chalk.dim(' - *.asyncapi.yaml/json')); @@ -134,8 +210,22 @@ export async function initCommand(): Promise { return; } + // Show found contracts + console.log(); + for (const contract of contracts) { + const typeColor = getTypeColor(contract.type); + console.log( + ` ${chalk.dim('Found:')} ${contract.path} ${chalk.dim('(')}${typeColor(contract.type)}${chalk.dim(')')}` + ); + } + console.log(); + + // Get version and mode through prompts + const initialVersion = await getInitialVersion(options); + const versioningMode = await getVersioningMode(options); + // Generate config - const config = { + const config: Record = { contracts, changeset: { autoDetect: true, @@ -143,6 +233,13 @@ export async function initCommand(): Promise { }, }; + // Only add versioning section if not using defaults + if (versioningMode !== 'independent') { + config.versioning = { + mode: versioningMode, + }; + } + // Write contractual.yaml const yamlContent = stringifyYaml(config, { lineWidth: 100, @@ -151,12 +248,19 @@ export async function initCommand(): Promise { writeFileSync(configPath, yamlContent, 'utf-8'); // Create .contractual directory structure - ensureContractualDir(cwd); + const createdDir = ensureContractualDir(cwd); - spinner.succeed('Initialized Contractual'); + // Create snapshots and set initial versions + const versionManager = new VersionManager(createdDir); + for (const contract of contracts) { + const absolutePath = join(cwd, contract.path); + versionManager.setVersion(contract.name, initialVersion, absolutePath); + } // Print summary console.log(); + console.log(chalk.green('✓') + ' Initialized Contractual'); + console.log(); console.log(chalk.bold('Created:')); console.log(` ${chalk.green('+')} contractual.yaml`); console.log(` ${chalk.green('+')} .contractual/`); @@ -165,7 +269,7 @@ export async function initCommand(): Promise { console.log(` ${chalk.green('+')} .contractual/versions.json`); console.log(); - console.log(chalk.bold(`Detected ${contracts.length} contract(s):`)); + console.log(chalk.bold(`Detected ${contracts.length} contract(s) at v${initialVersion}:`)); for (const contract of contracts) { const typeColor = getTypeColor(contract.type); @@ -175,11 +279,17 @@ export async function initCommand(): Promise { console.log(` ${chalk.dim(contract.path)}`); } + if (versioningMode !== 'independent') { + console.log(); + console.log(chalk.dim(`Versioning mode: ${versioningMode}`)); + } + console.log(); console.log(chalk.dim('Next steps:')); - console.log(chalk.dim(' 1. Review contractual.yaml and adjust as needed')); - console.log(chalk.dim(' 2. Run `contractual status` to check current state')); - console.log(chalk.dim(' 3. Run `contractual lint` to validate specs')); + console.log(chalk.dim(' 1. Run `contractual lint` to validate your specs')); + console.log(chalk.dim(' 2. Make changes to your specs')); + console.log(chalk.dim(' 3. Run `contractual diff` to see changes')); + console.log(chalk.dim(' 4. Run `contractual changeset` to record changes')); } catch (error) { spinner.fail('Initialization failed'); const message = error instanceof Error ? error.message : 'Unknown error'; @@ -188,6 +298,86 @@ export async function initCommand(): Promise { } } +/** + * Handle existing project - initialize uninitialized contracts + */ +async function handleExistingProject( + cwd: string, + configPath: string, + contractualDir: string, + options: InitOptions +): Promise { + // Read existing config + const configContent = readFileSync(configPath, 'utf-8'); + const config = parseYaml(configContent) as { contracts?: ContractDefinition[] }; + + if (!config.contracts || config.contracts.length === 0) { + console.log(chalk.red('Already initialized:') + ' contractual.yaml exists'); + console.log(chalk.dim('Use `contractual status` to see current state')); + process.exitCode = 1; + return; + } + + // Ensure .contractual directory exists + ensureContractualDir(cwd); + + // Find contracts without snapshots + const uninitializedContracts: ContractDefinition[] = []; + for (const contract of config.contracts) { + const snapshotPath = getSnapshotPath(contract.name, contractualDir); + if (!snapshotPath) { + uninitializedContracts.push(contract); + } + } + + if (uninitializedContracts.length === 0) { + console.log(chalk.yellow('Already initialized:') + ' contractual.yaml exists'); + console.log(chalk.dim('All contracts have snapshots.')); + console.log(chalk.dim('Use `contractual status` to see current state')); + console.log(chalk.dim('Use `--force` to reinitialize')); + return; + } + + // Show uninitialized contracts + console.log( + chalk.yellow(`Found ${uninitializedContracts.length} contract(s) without version history:`) + ); + for (const contract of uninitializedContracts) { + console.log( + ` ${chalk.dim('-')} ${chalk.cyan(contract.name)} ${chalk.dim(`(${contract.type})`)}` + ); + } + console.log(); + + // Confirm initialization + const shouldInitialize = await promptConfirm( + `Initialize with version ${DEFAULT_VERSION}?`, + true, + options + ); + + if (!shouldInitialize) { + console.log(chalk.dim('Skipped initialization')); + return; + } + + // Initialize uninitialized contracts + const versionManager = new VersionManager(contractualDir); + for (const contract of uninitializedContracts) { + const absolutePath = join(cwd, contract.path); + if (!existsSync(absolutePath)) { + console.log( + chalk.yellow(` Skipped ${contract.name}: spec file not found at ${contract.path}`) + ); + continue; + } + versionManager.setVersion(contract.name, DEFAULT_VERSION, absolutePath); + console.log( + chalk.green('✓') + ` Initialized ${chalk.cyan(contract.name)} at v${DEFAULT_VERSION}` + ); + } +} + /** * Get chalk color function for contract type */ diff --git a/packages/cli/src/commands/pre.command.ts b/packages/cli/src/commands/pre.command.ts new file mode 100644 index 0000000..e962aa6 --- /dev/null +++ b/packages/cli/src/commands/pre.command.ts @@ -0,0 +1,172 @@ +import chalk from 'chalk'; +import { VersionManager, PreReleaseManager } from '@contractual/changesets'; +import { findContractualDir } from '../utils/files.js'; + +/** + * Enter pre-release mode + * @param tag - The pre-release tag (e.g., "alpha", "beta", "rc") + */ +export async function preEnterCommand(tag: string): Promise { + const cwd = process.cwd(); + const contractualDir = findContractualDir(cwd); + + if (!contractualDir) { + console.error(chalk.red('No .contractual directory found. Run `contractual init` first.')); + process.exitCode = 1; + return; + } + + try { + const versionManager = new VersionManager(contractualDir); + const preManager = new PreReleaseManager(contractualDir); + + if (preManager.isActive()) { + const state = preManager.getState(); + console.log(chalk.yellow('Already in pre-release mode:') + ` ${state?.tag}`); + console.log(chalk.dim('Run `contractual pre exit` to leave pre-release mode first.')); + process.exitCode = 1; + return; + } + + preManager.enter(tag, versionManager); + + console.log(chalk.green('✓') + ` Entered pre-release mode: ${chalk.cyan(tag)}`); + console.log(); + console.log(chalk.bold('Created:')); + console.log(` ${chalk.green('+')} .contractual/pre.json`); + console.log(); + console.log(chalk.dim(`Next versions will use ${tag} identifier (e.g., 2.0.0-${tag}.0)`)); + console.log( + chalk.dim('Run `contractual version` to apply changesets with pre-release versions.') + ); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error(chalk.red('Failed to enter pre-release mode:'), message); + process.exitCode = 1; + } +} + +/** + * Exit pre-release mode + */ +export async function preExitCommand(): Promise { + const cwd = process.cwd(); + const contractualDir = findContractualDir(cwd); + + if (!contractualDir) { + console.error(chalk.red('No .contractual directory found. Run `contractual init` first.')); + process.exitCode = 1; + return; + } + + try { + const versionManager = new VersionManager(contractualDir); + const preManager = new PreReleaseManager(contractualDir); + + if (!preManager.isActive()) { + console.log(chalk.yellow('Not in pre-release mode.')); + return; + } + + const state = preManager.getState(); + const currentVersions = versionManager.getAllVersions(); + + // Show what will change + console.log(chalk.bold('Exiting pre-release mode')); + console.log(); + + const hasPreReleaseVersions = Object.entries(currentVersions).some(([_, version]) => + version.includes('-') + ); + + if (hasPreReleaseVersions) { + console.log(chalk.dim('Current pre-release versions:')); + for (const [contract, version] of Object.entries(currentVersions)) { + if (version.includes('-')) { + // Extract base version + const baseVersion = version.split('-')[0]; + console.log( + ` ${chalk.cyan(contract)}: ${chalk.gray(version)} → ${chalk.green(baseVersion)}` + ); + } + } + console.log(); + console.log(chalk.dim('Run `contractual version` after exiting to finalize versions.')); + } + + preManager.exit(); + + console.log(); + console.log(chalk.green('✓') + ' Exited pre-release mode'); + console.log(); + console.log(chalk.bold('Removed:')); + console.log(` ${chalk.red('-')} .contractual/pre.json`); + + if (state) { + console.log(); + console.log(chalk.dim(`Pre-release tag was: ${state.tag}`)); + console.log(chalk.dim(`Entered at: ${new Date(state.enteredAt).toLocaleString()}`)); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error(chalk.red('Failed to exit pre-release mode:'), message); + process.exitCode = 1; + } +} + +/** + * Show pre-release status + */ +export async function preStatusCommand(): Promise { + const cwd = process.cwd(); + const contractualDir = findContractualDir(cwd); + + if (!contractualDir) { + console.error(chalk.red('No .contractual directory found. Run `contractual init` first.')); + process.exitCode = 1; + return; + } + + const preManager = new PreReleaseManager(contractualDir); + + if (!preManager.isActive()) { + console.log(chalk.dim('Not in pre-release mode.')); + console.log(chalk.dim('Run `contractual pre enter ` to enter pre-release mode.')); + return; + } + + const state = preManager.getState(); + if (!state) { + console.log(chalk.yellow('Pre-release state file is corrupted.')); + console.log(chalk.dim('Run `contractual pre exit` to reset.')); + return; + } + + const versionManager = new VersionManager(contractualDir); + const currentVersions = versionManager.getAllVersions(); + + console.log(chalk.bold('Pre-release Status')); + console.log(); + console.log(` ${chalk.dim('Tag:')} ${chalk.cyan(state.tag)}`); + console.log(` ${chalk.dim('Since:')} ${new Date(state.enteredAt).toLocaleString()}`); + + // Show version changes since entering pre-release + const changedContracts = Object.entries(currentVersions).filter(([name, version]) => { + const initial = state.initialVersions[name]; + return initial && initial !== version; + }); + + if (changedContracts.length > 0) { + console.log(); + console.log(chalk.bold('Version changes since entering pre-release:')); + for (const [name, version] of changedContracts) { + const initial = state.initialVersions[name]; + console.log(` ${chalk.cyan(name)}: ${chalk.gray(initial)} → ${chalk.green(version)}`); + } + } + + console.log(); + console.log(chalk.dim('Commands:')); + console.log(chalk.dim(' contractual version Apply changesets with pre-release versions')); + console.log(chalk.dim(' contractual pre exit Exit pre-release mode')); +} diff --git a/packages/cli/src/commands/version.command.ts b/packages/cli/src/commands/version.command.ts index 3cfe9d5..8baf00f 100644 --- a/packages/cli/src/commands/version.command.ts +++ b/packages/cli/src/commands/version.command.ts @@ -4,19 +4,48 @@ import chalk from 'chalk'; import ora from 'ora'; import { loadConfig } from '../config/index.js'; import { findContractualDir, CHANGESETS_DIR } from '../utils/files.js'; +import { promptConfirm, type PromptOptions } from '../utils/prompts.js'; import { VersionManager, + PreReleaseManager, readChangesets, aggregateBumps, extractContractChanges, appendChangelog, + incrementVersion, + incrementVersionWithPreRelease, } from '@contractual/changesets'; -import type { BumpResult } from '@contractual/types'; +import type { BumpResult, BumpType } from '@contractual/types'; + +/** + * Options for the version command + */ +interface VersionOptions extends PromptOptions { + /** Preview without applying */ + dryRun?: boolean; + /** Output JSON (implies --yes) */ + json?: boolean; +} + +/** + * Pending version bump info + */ +interface PendingBump { + contract: string; + currentVersion: string; + nextVersion: string; + bumpType: BumpType; +} /** * Consume changesets and bump versions */ -export async function versionCommand(): Promise { +export async function versionCommand(options: VersionOptions = {}): Promise { + // JSON output implies --yes (no prompts) + if (options.json) { + options.yes = true; + } + const spinner = ora('Loading configuration...').start(); let config; @@ -43,7 +72,11 @@ export async function versionCommand(): Promise { if (changesets.length === 0) { readSpinner.succeed('No pending changesets'); - console.log(chalk.gray('Nothing to version.')); + if (options.json) { + console.log(JSON.stringify({ bumps: [], changesets: 0 }, null, 2)); + } else { + console.log(chalk.gray('Nothing to version.')); + } process.exit(0); } @@ -53,34 +86,115 @@ export async function versionCommand(): Promise { const aggregatedBumps = aggregateBumps(changesets); if (Object.keys(aggregatedBumps).length === 0) { - console.log(chalk.gray('No version bumps required.')); + if (options.json) { + console.log(JSON.stringify({ bumps: [], changesets: changesets.length }, null, 2)); + } else { + console.log(chalk.gray('No version bumps required.')); + } process.exit(0); } - // Initialize version manager + // Initialize version manager for reading current versions const versionManager = new VersionManager(contractualDir); + const preManager = new PreReleaseManager(contractualDir); + const preReleaseTag = preManager.getTag(); - // Process each contract bump - const bumpSpinner = ora('Applying version bumps...').start(); + // Calculate pending bumps (preview) + const pendingBumps: PendingBump[] = []; + + for (const [contractName, bumpType] of Object.entries(aggregatedBumps)) { + const contract = config.contracts.find((c) => c.name === contractName); + if (!contract) { + continue; + } + + const currentVersion = versionManager.getVersion(contractName) ?? '0.0.0'; + const nextVersion = preReleaseTag + ? incrementVersionWithPreRelease(currentVersion, bumpType, preReleaseTag) + : incrementVersion(currentVersion, bumpType); + + pendingBumps.push({ + contract: contractName, + currentVersion, + nextVersion, + bumpType, + }); + } + + // Show pre-release mode notice + if (preReleaseTag && !options.json) { + console.log(chalk.cyan(`Pre-release mode: ${preReleaseTag}`)); + } + + // Show preview + if (options.json) { + if (options.dryRun) { + console.log( + JSON.stringify( + { + dryRun: true, + bumps: pendingBumps.map((b) => ({ + contract: b.contract, + current: b.currentVersion, + next: b.nextVersion, + type: b.bumpType, + })), + changesets: changesets.length, + }, + null, + 2 + ) + ); + return; + } + } else { + printPreviewTable(pendingBumps); + + if (options.dryRun) { + console.log(); + console.log(chalk.dim('Dry run - no changes applied')); + return; + } + } + + // Confirm before applying (unless --yes) + if (!options.json) { + const shouldApply = await promptConfirm('Apply these version bumps?', true, options); + + if (!shouldApply) { + console.log(chalk.dim('Cancelled')); + return; + } + } + + // Apply version bumps + const bumpSpinner = options.json ? null : ora('Applying version bumps...').start(); const bumpResults: BumpResult[] = []; const consumedChangesetPaths: string[] = []; for (const [contractName, bumpType] of Object.entries(aggregatedBumps)) { - // Find the contract in config const contract = config.contracts.find((c) => c.name === contractName); if (!contract) { - console.warn( - chalk.yellow(`Warning: Contract "${contractName}" not found in config, skipping.`) - ); + if (!options.json) { + console.warn( + chalk.yellow(`Warning: Contract "${contractName}" not found in config, skipping.`) + ); + } continue; } - // Apply semver bump and update snapshot - const { oldVersion, newVersion } = versionManager.bump( - contractName, - bumpType, - contract.absolutePath - ); + const oldVersion = versionManager.getVersion(contractName) ?? '0.0.0'; + let newVersion: string; + + if (preReleaseTag) { + // Use pre-release version increment + newVersion = incrementVersionWithPreRelease(oldVersion, bumpType, preReleaseTag); + versionManager.setVersion(contractName, newVersion, contract.absolutePath); + } else { + // Normal bump + const result = versionManager.bump(contractName, bumpType, contract.absolutePath); + newVersion = result.newVersion; + } // Extract changes text from changesets for this contract const changes = extractContractChanges(changesets, contractName); @@ -94,21 +208,21 @@ export async function versionCommand(): Promise { }); } - bumpSpinner.succeed('Version bumps applied'); + bumpSpinner?.succeed('Version bumps applied'); // Append to CHANGELOG.md - const changelogSpinner = ora('Updating changelog...').start(); + const changelogSpinner = options.json ? null : ora('Updating changelog...').start(); const changelogPath = join(config.configDir, 'CHANGELOG.md'); try { appendChangelog(changelogPath, bumpResults); - changelogSpinner.succeed('Changelog updated'); + changelogSpinner?.succeed('Changelog updated'); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; - changelogSpinner.warn(`Failed to update changelog: ${message}`); + changelogSpinner?.warn(`Failed to update changelog: ${message}`); } // Delete consumed changeset files - const cleanupSpinner = ora('Cleaning up changesets...').start(); + const cleanupSpinner = options.json ? null : ora('Cleaning up changesets...').start(); for (const changeset of changesets) { const changesetPath = join(changesetsDir, changeset.filename); @@ -117,25 +231,95 @@ export async function versionCommand(): Promise { unlinkSync(changesetPath); consumedChangesetPaths.push(changeset.filename); } - } catch (error) { + } catch { // Ignore cleanup errors } } - cleanupSpinner.succeed(`Removed ${consumedChangesetPaths.length} changeset(s)`); + cleanupSpinner?.succeed(`Removed ${consumedChangesetPaths.length} changeset(s)`); // Print summary + if (options.json) { + console.log( + JSON.stringify( + { + bumps: bumpResults.map((r) => ({ + contract: r.contract, + old: r.oldVersion, + new: r.newVersion, + type: r.bumpType, + })), + changesets: consumedChangesetPaths.length, + }, + null, + 2 + ) + ); + } else { + console.log(); + console.log(chalk.bold('Version Summary:')); + console.log(); + + for (const result of bumpResults) { + console.log( + ` ${chalk.cyan(result.contract)}: ` + + `${chalk.gray(result.oldVersion)} -> ${chalk.green(result.newVersion)} ` + + `(${result.bumpType})` + ); + } + + console.log(); + console.log(chalk.green('Done!'), `${bumpResults.length} contract(s) versioned.`); + } +} + +/** + * Print a preview table of pending version bumps + */ +function printPreviewTable(bumps: PendingBump[]): void { console.log(); - console.log(chalk.bold('Version Summary:')); + console.log(chalk.bold('Pending version bumps:')); console.log(); - for (const result of bumpResults) { + // Calculate column widths + const maxContractLen = Math.max(8, ...bumps.map((b) => b.contract.length)); + const maxCurrentLen = Math.max(7, ...bumps.map((b) => b.currentVersion.length)); + const maxNextLen = Math.max(4, ...bumps.map((b) => b.nextVersion.length)); + + // Header + const header = + ` ${'Contract'.padEnd(maxContractLen)} ` + + `${'Current'.padEnd(maxCurrentLen)} ` + + `${'→'} ` + + `${'Next'.padEnd(maxNextLen)} ` + + `Reason`; + console.log(chalk.dim(header)); + console.log(chalk.dim(' ' + '─'.repeat(header.length - 2))); + + // Rows + for (const bump of bumps) { + const reason = getBumpReason(bump.bumpType); console.log( - ` ${chalk.cyan(result.contract)}: ` + - `${chalk.gray(result.oldVersion)} -> ${chalk.green(result.newVersion)} ` + - `(${result.bumpType})` + ` ${chalk.cyan(bump.contract.padEnd(maxContractLen))} ` + + `${chalk.gray(bump.currentVersion.padEnd(maxCurrentLen))} ` + + `${chalk.dim('→')} ` + + `${chalk.green(bump.nextVersion.padEnd(maxNextLen))} ` + + `${reason}` ); } +} - console.log(); - console.log(chalk.green('Done!'), 'Run `contractual status` to verify changes.'); +/** + * Get human-readable reason for bump type + */ +function getBumpReason(bumpType: BumpType): string { + switch (bumpType) { + case 'major': + return chalk.red('major (breaking)'); + case 'minor': + return chalk.yellow('minor (feature)'); + case 'patch': + return chalk.dim('patch (fix)'); + default: + return bumpType; + } } diff --git a/packages/cli/src/config/schema.json b/packages/cli/src/config/schema.json index 1f5b8dd..891c8d3 100644 --- a/packages/cli/src/config/schema.json +++ b/packages/cli/src/config/schema.json @@ -49,6 +49,19 @@ "additionalProperties": false } }, + "versioning": { + "type": "object", + "description": "Versioning configuration", + "properties": { + "mode": { + "type": "string", + "enum": ["independent", "fixed"], + "description": "Versioning mode: independent (each contract separate) or fixed (all share same version)", + "default": "independent" + } + }, + "additionalProperties": false + }, "changeset": { "type": "object", "description": "Changeset behavior configuration", diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index ba5dc92..492e7e3 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -17,15 +17,18 @@ export { // Re-export from @contractual/changesets export { VersionManager, + PreReleaseManager, createChangeset, readChangesets, aggregateBumps, generateChangesetName, extractContractChanges, appendChangelog, + incrementVersionWithPreRelease, VERSIONS_FILE, SNAPSHOTS_DIR, CHANGESETS_DIR, + PRE_RELEASE_FILE, } from '@contractual/changesets'; export type { BumpOperationResult } from '@contractual/changesets'; diff --git a/packages/cli/src/utils/prompts.ts b/packages/cli/src/utils/prompts.ts new file mode 100644 index 0000000..5d135f7 --- /dev/null +++ b/packages/cli/src/utils/prompts.ts @@ -0,0 +1,160 @@ +import { select, input, confirm } from '@inquirer/prompts'; + +/** + * Check if running in interactive mode (TTY available) + */ +export function isInteractive(): boolean { + return process.stdin.isTTY === true && process.stdout.isTTY === true; +} + +/** + * Check if running in CI environment + */ +export function isCI(): boolean { + return ( + process.env.CI === 'true' || + process.env.CI === '1' || + process.env.CONTINUOUS_INTEGRATION === 'true' || + process.env.GITHUB_ACTIONS === 'true' || + process.env.GITLAB_CI === 'true' || + process.env.CIRCLECI === 'true' + ); +} + +/** + * Options for prompt functions + */ +export interface PromptOptions { + /** Skip prompts and use defaults (--yes flag) */ + yes?: boolean; + /** Force interactive mode even in CI */ + interactive?: boolean; +} + +/** + * Determine if prompts should be shown + */ +export function shouldPrompt(options: PromptOptions): boolean { + if (options.yes) return false; + if (options.interactive) return true; + if (isCI()) return false; + return isInteractive(); +} + +/** + * Prompt for a selection from a list of choices + */ +export async function promptSelect( + message: string, + choices: Array<{ value: T; name: string; description?: string }>, + defaultValue: T, + options: PromptOptions = {} +): Promise { + if (!shouldPrompt(options)) { + return defaultValue; + } + + return select({ + message, + choices: choices.map((c) => ({ + value: c.value, + name: c.name, + description: c.description, + })), + default: defaultValue, + }); +} + +/** + * Prompt for text input + */ +export async function promptInput( + message: string, + defaultValue: string, + options: PromptOptions = {} +): Promise { + if (!shouldPrompt(options)) { + return defaultValue; + } + + return input({ + message, + default: defaultValue, + }); +} + +/** + * Prompt for confirmation (yes/no) + */ +export async function promptConfirm( + message: string, + defaultValue: boolean, + options: PromptOptions = {} +): Promise { + if (!shouldPrompt(options)) { + return defaultValue; + } + + return confirm({ + message, + default: defaultValue, + }); +} + +/** + * Prompt for version input with validation + */ +export async function promptVersion( + message: string, + defaultValue: string, + options: PromptOptions = {} +): Promise { + if (!shouldPrompt(options)) { + return defaultValue; + } + + const result = await input({ + message, + default: defaultValue, + validate: (value) => { + const semverRegex = /^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/; + if (semverRegex.test(value)) { + return true; + } + return 'Please enter a valid semver version (e.g., 0.0.0, 1.2.3-beta.1)'; + }, + }); + + return result; +} + +/** + * Common version choices for init + */ +export const VERSION_CHOICES = [ + { value: '0.0.0', name: '0.0.0', description: 'Start fresh (recommended for new projects)' }, + { value: '1.0.0', name: '1.0.0', description: 'Production-ready' }, + { value: 'custom', name: 'Custom', description: 'Enter a custom version' }, +] as const; + +/** + * Common versioning mode choices + */ +export const VERSIONING_MODE_CHOICES = [ + { + value: 'independent', + name: 'Independent', + description: 'Each contract versioned separately', + }, + { value: 'fixed', name: 'Fixed', description: 'All contracts share same version' }, +] as const; + +/** + * Contract type choices + */ +export const CONTRACT_TYPE_CHOICES = [ + { value: 'openapi', name: 'OpenAPI', description: 'OpenAPI/Swagger specification' }, + { value: 'asyncapi', name: 'AsyncAPI', description: 'AsyncAPI specification' }, + { value: 'json-schema', name: 'JSON Schema', description: 'JSON Schema definition' }, + { value: 'odcs', name: 'ODCS', description: 'Open Data Contract Standard' }, +] as const; diff --git a/packages/types/config.ts b/packages/types/config.ts index 897432d..e5d2adb 100644 --- a/packages/types/config.ts +++ b/packages/types/config.ts @@ -88,6 +88,22 @@ export interface AIConfig { features?: AIFeatures; } +/** + * Versioning mode for contracts. + * + * - `independent` - Each contract has its own version (like Lerna independent mode) + * - `fixed` - All contracts share the same version + */ +export type VersioningMode = 'independent' | 'fixed'; + +/** + * Versioning configuration. + */ +export interface VersioningConfig { + /** Versioning mode (default: 'independent') */ + mode: VersioningMode; +} + /** * Root configuration for contractual.yaml. * @@ -107,6 +123,8 @@ export interface AIConfig { export interface ContractualConfig { /** List of contract definitions */ contracts: ContractDefinition[]; + /** Versioning configuration */ + versioning?: VersioningConfig; /** Changeset behavior configuration */ changeset?: ChangesetConfig; /** AI/LLM integration configuration */ diff --git a/packages/types/versioning.ts b/packages/types/versioning.ts index c581c9c..8afb1cc 100644 --- a/packages/types/versioning.ts +++ b/packages/types/versioning.ts @@ -181,3 +181,26 @@ export function isBumpType(value: unknown): value is BumpType { ['major', 'minor', 'patch'].includes(value) ); } + +/** + * Pre-release state stored in .contractual/pre.json + * + * @example + * ```json + * { + * "tag": "beta", + * "enteredAt": "2026-03-10T10:00:00Z", + * "initialVersions": { + * "orders-api": "1.2.0" + * } + * } + * ``` + */ +export interface PreReleaseState { + /** Pre-release tag (e.g., "alpha", "beta", "rc") */ + tag: string; + /** ISO 8601 timestamp when pre-release mode was entered */ + enteredAt: string; + /** Versions of contracts when pre-release mode was entered */ + initialVersions: Record; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9def316..db94fa0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,6 +90,9 @@ importers: '@contractual/types': specifier: workspace:* version: link:../types + '@inquirer/prompts': + specifier: ^8.1.0 + version: 8.1.0(@types/node@22.19.11) ajv: specifier: ^8.17.1 version: 8.18.0 @@ -401,6 +404,55 @@ packages: resolution: {integrity: sha512-H9XAx3hc0BQHY6l+IFSWHDySypcXsvsuLhgYLUGywmJ5pswRVQJUHpOsobnLYp2ZUaUlKiKDrgWWhosOwAEM8Q==} engines: {node: '>=6.9.0'} + '@inquirer/ansi@2.0.2': + resolution: {integrity: sha512-SYLX05PwJVnW+WVegZt1T4Ip1qba1ik+pNJPDiqvk6zS5Y/i8PhRzLpGEtVd7sW0G8cMtkD8t4AZYhQwm8vnww==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + + '@inquirer/checkbox@5.0.3': + resolution: {integrity: sha512-xtQP2eXMFlOcAhZ4ReKP2KZvDIBb1AnCfZ81wWXG3DXLVH0f0g4obE0XDPH+ukAEMRcZT0kdX2AS1jrWGXbpxw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@6.0.3': + resolution: {integrity: sha512-lyEvibDFL+NA5R4xl8FUmNhmu81B+LDL9L/MpKkZlQDJZXzG8InxiqYxiAlQYa9cqLLhYqKLQwZqXmSTqCLjyw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@11.1.0': + resolution: {integrity: sha512-+jD/34T1pK8M5QmZD/ENhOfXdl9Zr+BrQAUc5h2anWgi7gggRq15ZbiBeLoObj0TLbdgW7TAIQRU2boMc9uOKQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@5.0.3': + resolution: {integrity: sha512-wYyQo96TsAqIciP/r5D3cFeV8h4WqKQ/YOvTg5yOfP2sqEbVVpbxPpfV3LM5D0EP4zUI3EZVHyIUIllnoIa8OQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@5.0.3': + resolution: {integrity: sha512-2oINvuL27ujjxd95f6K2K909uZOU2x1WiAl7Wb1X/xOtL8CgQ1kSxzykIr7u4xTkXkXOAkCuF45T588/YKee7w==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} @@ -410,6 +462,91 @@ packages: '@types/node': optional: true + '@inquirer/external-editor@2.0.2': + resolution: {integrity: sha512-X/fMXK7vXomRWEex1j8mnj7s1mpnTeP4CO/h2gysJhHLT2WjBnLv4ZQEGpm/kcYI8QfLZ2fgW+9kTKD+jeopLg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@2.0.2': + resolution: {integrity: sha512-qXm6EVvQx/FmnSrCWCIGtMHwqeLgxABP8XgcaAoywsL0NFga9gD5kfG0gXiv80GjK9Hsoz4pgGwF/+CjygyV9A==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + + '@inquirer/input@5.0.3': + resolution: {integrity: sha512-4R0TdWl53dtp79Vs6Df2OHAtA2FVNqya1hND1f5wjHWxZJxwDMSNB1X5ADZJSsQKYAJ5JHCTO+GpJZ42mK0Otw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@4.0.3': + resolution: {integrity: sha512-TjQLe93GGo5snRlu83JxE38ZPqj5ZVggL+QqqAF2oBA5JOJoxx25GG3EGH/XN/Os5WOmKfO8iLVdCXQxXRZIMQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@5.0.3': + resolution: {integrity: sha512-rCozGbUMAHedTeYWEN8sgZH4lRCdgG/WinFkit6ZPsp8JaNg2T0g3QslPBS5XbpORyKP/I+xyBO81kFEvhBmjA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@8.1.0': + resolution: {integrity: sha512-LsZMdKcmRNF5LyTRuZE5nWeOjganzmN3zwbtNfcs6GPh3I2TsTtF1UYZlbxVfhxd+EuUqLGs/Lm3Xt4v6Az1wA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@5.1.0': + resolution: {integrity: sha512-yUCuVh0jW026Gr2tZlG3kHignxcrLKDR3KBp+eUgNz+BAdSeZk0e18yt2gyBr+giYhj/WSIHCmPDOgp1mT2niQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@4.0.3': + resolution: {integrity: sha512-lzqVw0YwuKYetk5VwJ81Ba+dyVlhseHPx9YnRKQgwXdFS0kEavCz2gngnNhnMIxg8+j1N/rUl1t5s1npwa7bqg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@5.0.3': + resolution: {integrity: sha512-M+ynbwS0ecQFDYMFrQrybA0qL8DV0snpc4kKevCCNaTpfghsRowRY7SlQBeIYNzHqXtiiz4RG9vTOeb/udew7w==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@4.0.2': + resolution: {integrity: sha512-cae7mzluplsjSdgFA6ACLygb5jC8alO0UUnFPyu0E7tNRPrL+q/f8VcSXp+cjZQ7l5CMpDpi2G1+IQvkOiL1Lw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -1364,6 +1501,10 @@ packages: resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==} engines: {node: '>= 10'} + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} @@ -2837,6 +2978,10 @@ packages: resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + mute-stream@3.0.0: + resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} + engines: {node: ^20.17.0 || >=22.9.0} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -3998,6 +4143,10 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -4252,6 +4401,51 @@ snapshots: '@hutson/parse-repository-url@3.0.2': {} + '@inquirer/ansi@2.0.2': {} + + '@inquirer/checkbox@5.0.3(@types/node@22.19.11)': + dependencies: + '@inquirer/ansi': 2.0.2 + '@inquirer/core': 11.1.0(@types/node@22.19.11) + '@inquirer/figures': 2.0.2 + '@inquirer/type': 4.0.2(@types/node@22.19.11) + optionalDependencies: + '@types/node': 22.19.11 + + '@inquirer/confirm@6.0.3(@types/node@22.19.11)': + dependencies: + '@inquirer/core': 11.1.0(@types/node@22.19.11) + '@inquirer/type': 4.0.2(@types/node@22.19.11) + optionalDependencies: + '@types/node': 22.19.11 + + '@inquirer/core@11.1.0(@types/node@22.19.11)': + dependencies: + '@inquirer/ansi': 2.0.2 + '@inquirer/figures': 2.0.2 + '@inquirer/type': 4.0.2(@types/node@22.19.11) + cli-width: 4.1.0 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + wrap-ansi: 9.0.2 + optionalDependencies: + '@types/node': 22.19.11 + + '@inquirer/editor@5.0.3(@types/node@22.19.11)': + dependencies: + '@inquirer/core': 11.1.0(@types/node@22.19.11) + '@inquirer/external-editor': 2.0.2(@types/node@22.19.11) + '@inquirer/type': 4.0.2(@types/node@22.19.11) + optionalDependencies: + '@types/node': 22.19.11 + + '@inquirer/expand@5.0.3(@types/node@22.19.11)': + dependencies: + '@inquirer/core': 11.1.0(@types/node@22.19.11) + '@inquirer/type': 4.0.2(@types/node@22.19.11) + optionalDependencies: + '@types/node': 22.19.11 + '@inquirer/external-editor@1.0.3(@types/node@22.19.11)': dependencies: chardet: 2.1.1 @@ -4259,6 +4453,80 @@ snapshots: optionalDependencies: '@types/node': 22.19.11 + '@inquirer/external-editor@2.0.2(@types/node@22.19.11)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 22.19.11 + + '@inquirer/figures@2.0.2': {} + + '@inquirer/input@5.0.3(@types/node@22.19.11)': + dependencies: + '@inquirer/core': 11.1.0(@types/node@22.19.11) + '@inquirer/type': 4.0.2(@types/node@22.19.11) + optionalDependencies: + '@types/node': 22.19.11 + + '@inquirer/number@4.0.3(@types/node@22.19.11)': + dependencies: + '@inquirer/core': 11.1.0(@types/node@22.19.11) + '@inquirer/type': 4.0.2(@types/node@22.19.11) + optionalDependencies: + '@types/node': 22.19.11 + + '@inquirer/password@5.0.3(@types/node@22.19.11)': + dependencies: + '@inquirer/ansi': 2.0.2 + '@inquirer/core': 11.1.0(@types/node@22.19.11) + '@inquirer/type': 4.0.2(@types/node@22.19.11) + optionalDependencies: + '@types/node': 22.19.11 + + '@inquirer/prompts@8.1.0(@types/node@22.19.11)': + dependencies: + '@inquirer/checkbox': 5.0.3(@types/node@22.19.11) + '@inquirer/confirm': 6.0.3(@types/node@22.19.11) + '@inquirer/editor': 5.0.3(@types/node@22.19.11) + '@inquirer/expand': 5.0.3(@types/node@22.19.11) + '@inquirer/input': 5.0.3(@types/node@22.19.11) + '@inquirer/number': 4.0.3(@types/node@22.19.11) + '@inquirer/password': 5.0.3(@types/node@22.19.11) + '@inquirer/rawlist': 5.1.0(@types/node@22.19.11) + '@inquirer/search': 4.0.3(@types/node@22.19.11) + '@inquirer/select': 5.0.3(@types/node@22.19.11) + optionalDependencies: + '@types/node': 22.19.11 + + '@inquirer/rawlist@5.1.0(@types/node@22.19.11)': + dependencies: + '@inquirer/core': 11.1.0(@types/node@22.19.11) + '@inquirer/type': 4.0.2(@types/node@22.19.11) + optionalDependencies: + '@types/node': 22.19.11 + + '@inquirer/search@4.0.3(@types/node@22.19.11)': + dependencies: + '@inquirer/core': 11.1.0(@types/node@22.19.11) + '@inquirer/figures': 2.0.2 + '@inquirer/type': 4.0.2(@types/node@22.19.11) + optionalDependencies: + '@types/node': 22.19.11 + + '@inquirer/select@5.0.3(@types/node@22.19.11)': + dependencies: + '@inquirer/ansi': 2.0.2 + '@inquirer/core': 11.1.0(@types/node@22.19.11) + '@inquirer/figures': 2.0.2 + '@inquirer/type': 4.0.2(@types/node@22.19.11) + optionalDependencies: + '@types/node': 22.19.11 + + '@inquirer/type@4.0.2(@types/node@22.19.11)': + optionalDependencies: + '@types/node': 22.19.11 + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -5339,6 +5607,8 @@ snapshots: cli-width@3.0.0: {} + cli-width@4.1.0: {} + cliui@7.0.4: dependencies: string-width: 4.2.3 @@ -7021,6 +7291,8 @@ snapshots: mute-stream@1.0.0: {} + mute-stream@3.0.0: {} + nanoid@3.3.11: {} napi-postinstall@0.3.4: {} @@ -8357,6 +8629,12 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.2 + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.1.2 + wrappy@1.0.2: {} write-file-atomic@2.4.3: diff --git a/tests/e2e/01-init.test.ts b/tests/e2e/01-init.test.ts index f717fb0..5ba98ae 100644 --- a/tests/e2e/01-init.test.ts +++ b/tests/e2e/01-init.test.ts @@ -34,8 +34,11 @@ describe('contractual init', () => { expect(fileExists(dir, '.contractual/changesets')).toBe(true); expect(fileExists(dir, '.contractual/snapshots')).toBe(true); - // Assert: versions.json is empty object - expect(readJSON(dir, '.contractual/versions.json')).toEqual({}); + // Assert: versions.json is populated with initial version + const versions = readJSON(dir, '.contractual/versions.json') as Record; + const contractName = Object.keys(versions)[0]; + expect(contractName).toBeDefined(); + expect(versions[contractName].version).toBe('0.0.0'); // Assert: stdout confirms detection expect(result.stdout).toMatch(/found|detected|initialized/i); @@ -77,17 +80,17 @@ describe('contractual init', () => { } }); - test('aborts if already initialized', () => { + test('handles already initialized gracefully', () => { const { dir, cleanup } = createTempRepo(); try { copyFixture('openapi/petstore-base.yaml', path.join(dir, 'specs/api.openapi.yaml')); run('init', dir); - // Second init should fail - const result = run('init', dir, { expectFail: true }); - expect(result.exitCode).not.toBe(0); - expect(result.stdout + result.stderr).toMatch(/already initialized|exists/i); + // Second init should succeed with informational message (use --force to reinitialize) + const result = run('init', dir); + expect(result.exitCode).toBe(0); + expect(result.stdout + result.stderr).toMatch(/already initialized|all contracts have snapshots/i); } finally { cleanup(); } diff --git a/tests/e2e/17-pre-release.test.ts b/tests/e2e/17-pre-release.test.ts new file mode 100644 index 0000000..76069b2 --- /dev/null +++ b/tests/e2e/17-pre-release.test.ts @@ -0,0 +1,403 @@ +import { describe, test, expect, beforeAll } from 'vitest'; +import path from 'node:path'; +import { + createTempRepo, + copyFixture, + run, + setupRepoWithConfig, + writeFile, + readFile, + readJSON, + fileExists, + listFiles, + ensureCliBuilt, +} from './helpers.js'; + +beforeAll(() => { + ensureCliBuilt(); +}); + +describe('contractual pre', () => { + describe('pre enter', () => { + test('enters pre-release mode with tag', () => { + const { dir, cleanup } = createTempRepo(); + try { + setupRepoWithConfig(dir, [ + { + name: 'order-schema', + type: 'json-schema', + path: 'schemas/order.json', + }, + ]); + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/order.json')); + + writeFile( + dir, + '.contractual/versions.json', + JSON.stringify({ + 'order-schema': { version: '1.0.0', released: '2026-01-01T00:00:00Z' }, + }) + ); + + const result = run('pre enter beta', dir); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/entered pre-release mode/i); + expect(result.stdout).toMatch(/beta/i); + expect(fileExists(dir, '.contractual/pre.json')).toBe(true); + + const preState = readJSON(dir, '.contractual/pre.json') as { + tag: string; + enteredAt: string; + initialVersions: Record; + }; + expect(preState.tag).toBe('beta'); + expect(preState.initialVersions['order-schema']).toBe('1.0.0'); + } finally { + cleanup(); + } + }); + + test('creates pre.json with correct structure', () => { + const { dir, cleanup } = createTempRepo(); + try { + setupRepoWithConfig(dir, [ + { + name: 'api', + type: 'json-schema', + path: 'schemas/api.json', + }, + ]); + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/api.json')); + + writeFile( + dir, + '.contractual/versions.json', + JSON.stringify({ + api: { version: '2.5.0', released: '2026-01-01T00:00:00Z' }, + }) + ); + + run('pre enter alpha', dir); + + const preState = readJSON(dir, '.contractual/pre.json') as { + tag: string; + enteredAt: string; + initialVersions: Record; + }; + + expect(preState.tag).toBe('alpha'); + expect(preState.enteredAt).toBeDefined(); + expect(new Date(preState.enteredAt).getTime()).not.toBeNaN(); + expect(preState.initialVersions).toEqual({ api: '2.5.0' }); + } finally { + cleanup(); + } + }); + + test('fails if already in pre-release mode', () => { + const { dir, cleanup } = createTempRepo(); + try { + setupRepoWithConfig(dir, [ + { + name: 'order-schema', + type: 'json-schema', + path: 'schemas/order.json', + }, + ]); + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/order.json')); + + writeFile( + dir, + '.contractual/versions.json', + JSON.stringify({ + 'order-schema': { version: '1.0.0', released: '2026-01-01T00:00:00Z' }, + }) + ); + + // Enter pre-release mode first + run('pre enter beta', dir); + + // Try to enter again + const result = run('pre enter alpha', dir, { expectFail: true }); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toMatch(/already in pre-release mode/i); + } finally { + cleanup(); + } + }); + + test('fails if not initialized', () => { + const { dir, cleanup } = createTempRepo(); + try { + // No contractual.yaml or .contractual directory + + const result = run('pre enter beta', dir, { expectFail: true }); + + expect(result.exitCode).toBe(1); + expect(result.stdout + result.stderr).toMatch(/no .contractual directory|init/i); + } finally { + cleanup(); + } + }); + }); + + describe('pre exit', () => { + test('exits pre-release mode', () => { + const { dir, cleanup } = createTempRepo(); + try { + setupRepoWithConfig(dir, [ + { + name: 'order-schema', + type: 'json-schema', + path: 'schemas/order.json', + }, + ]); + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/order.json')); + + writeFile( + dir, + '.contractual/versions.json', + JSON.stringify({ + 'order-schema': { version: '1.0.0', released: '2026-01-01T00:00:00Z' }, + }) + ); + + // Enter and then exit + run('pre enter beta', dir); + expect(fileExists(dir, '.contractual/pre.json')).toBe(true); + + const result = run('pre exit', dir); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/exited pre-release mode/i); + expect(fileExists(dir, '.contractual/pre.json')).toBe(false); + } finally { + cleanup(); + } + }); + + test('shows message if not in pre-release mode', () => { + const { dir, cleanup } = createTempRepo(); + try { + setupRepoWithConfig(dir, [ + { + name: 'order-schema', + type: 'json-schema', + path: 'schemas/order.json', + }, + ]); + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/order.json')); + + const result = run('pre exit', dir); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/not in pre-release mode/i); + } finally { + cleanup(); + } + }); + + test('fails if not initialized', () => { + const { dir, cleanup } = createTempRepo(); + try { + const result = run('pre exit', dir, { expectFail: true }); + + expect(result.exitCode).toBe(1); + expect(result.stdout + result.stderr).toMatch(/no .contractual directory|init/i); + } finally { + cleanup(); + } + }); + }); + + describe('pre status', () => { + test('shows current pre-release tag and entry time', () => { + const { dir, cleanup } = createTempRepo(); + try { + setupRepoWithConfig(dir, [ + { + name: 'order-schema', + type: 'json-schema', + path: 'schemas/order.json', + }, + ]); + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/order.json')); + + writeFile( + dir, + '.contractual/versions.json', + JSON.stringify({ + 'order-schema': { version: '1.0.0', released: '2026-01-01T00:00:00Z' }, + }) + ); + + run('pre enter rc', dir); + + const result = run('pre status', dir); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/rc/i); + expect(result.stdout).toMatch(/tag|pre-release/i); + } finally { + cleanup(); + } + }); + + test('shows "not in pre-release mode" when inactive', () => { + const { dir, cleanup } = createTempRepo(); + try { + setupRepoWithConfig(dir, [ + { + name: 'order-schema', + type: 'json-schema', + path: 'schemas/order.json', + }, + ]); + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/order.json')); + + const result = run('pre status', dir); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/not in pre-release mode/i); + } finally { + cleanup(); + } + }); + + test('fails if not initialized', () => { + const { dir, cleanup } = createTempRepo(); + try { + const result = run('pre status', dir, { expectFail: true }); + + expect(result.exitCode).toBe(1); + expect(result.stdout + result.stderr).toMatch(/no .contractual directory|init/i); + } finally { + cleanup(); + } + }); + }); + + describe('version with pre-release', () => { + test('bumps to pre-release version', () => { + const { dir, cleanup } = createTempRepo(); + try { + setupRepoWithConfig(dir, [ + { + name: 'order-schema', + type: 'json-schema', + path: 'schemas/order.json', + }, + ]); + + // Setup initial state + copyFixture( + 'json-schema/order-base.json', + path.join(dir, '.contractual/snapshots/order-schema.json') + ); + copyFixture('json-schema/order-field-removed.json', path.join(dir, 'schemas/order.json')); + + writeFile( + dir, + '.contractual/versions.json', + JSON.stringify({ + 'order-schema': { version: '1.0.0', released: '2026-01-01T00:00:00Z' }, + }) + ); + + // Enter pre-release mode + run('pre enter beta', dir); + + // Create changeset + const changeset = `--- +"order-schema": major +--- + +Breaking change for beta testing +`; + writeFile(dir, '.contractual/changesets/breaking.md', changeset); + + // Run version + const result = run('version --yes', dir); + + expect(result.exitCode).toBe(0); + + // Check version is pre-release format + const versions = readJSON(dir, '.contractual/versions.json') as Record< + string, + { version: string } + >; + expect(versions['order-schema'].version).toMatch(/2\.0\.0-beta/); + } finally { + cleanup(); + } + }); + + test('increments pre-release number on subsequent versions', () => { + const { dir, cleanup } = createTempRepo(); + try { + setupRepoWithConfig(dir, [ + { + name: 'order-schema', + type: 'json-schema', + path: 'schemas/order.json', + }, + ]); + + // Start with a pre-release version already + copyFixture( + 'json-schema/order-base.json', + path.join(dir, '.contractual/snapshots/order-schema.json') + ); + copyFixture( + 'json-schema/order-optional-field-added.json', + path.join(dir, 'schemas/order.json') + ); + + writeFile( + dir, + '.contractual/versions.json', + JSON.stringify({ + 'order-schema': { version: '2.0.0-beta.0', released: '2026-01-01T00:00:00Z' }, + }) + ); + + // Create pre.json to simulate being in pre-release mode + writeFile( + dir, + '.contractual/pre.json', + JSON.stringify({ + tag: 'beta', + enteredAt: '2026-01-01T00:00:00Z', + initialVersions: { 'order-schema': '1.0.0' }, + }) + ); + + // Create changeset + const changeset = `--- +"order-schema": minor +--- + +Another beta change +`; + writeFile(dir, '.contractual/changesets/minor.md', changeset); + + // Run version + const result = run('version --yes', dir); + + expect(result.exitCode).toBe(0); + + // Check version incremented pre-release number + const versions = readJSON(dir, '.contractual/versions.json') as Record< + string, + { version: string } + >; + // Should be 2.1.0-beta.0 or 2.0.0-beta.1 depending on implementation + expect(versions['order-schema'].version).toMatch(/beta/); + } finally { + cleanup(); + } + }); + }); +}); diff --git a/tests/e2e/18-contract-management.test.ts b/tests/e2e/18-contract-management.test.ts new file mode 100644 index 0000000..c7305a4 --- /dev/null +++ b/tests/e2e/18-contract-management.test.ts @@ -0,0 +1,312 @@ +import { describe, test, expect, beforeAll } from 'vitest'; +import path from 'node:path'; +import { + createTempRepo, + copyFixture, + run, + setupRepoWithConfig, + writeFile, + readFile, + readJSON, + readYAML, + fileExists, + ensureCliBuilt, +} from './helpers.js'; + +beforeAll(() => { + ensureCliBuilt(); +}); + +describe('contractual contract', () => { + describe('contract add', () => { + test('adds contract to existing config', () => { + const { dir, cleanup } = createTempRepo(); + try { + // Initialize with one contract + setupRepoWithConfig(dir, [ + { + name: 'order-schema', + type: 'json-schema', + path: 'schemas/order.json', + }, + ]); + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/order.json')); + + // Add a new spec file + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/user.json')); + + // Add contract using CLI + const result = run( + 'contract add --name user-schema --type json-schema --path schemas/user.json --initial-version 0.0.0 -y', + dir + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/added.*user-schema/i); + + // Verify config was updated + const config = readYAML(dir, 'contractual.yaml') as { + contracts: Array<{ name: string; type: string; path: string }>; + }; + expect(config.contracts).toHaveLength(2); + expect(config.contracts.find((c) => c.name === 'user-schema')).toBeDefined(); + } finally { + cleanup(); + } + }); + + test('creates snapshot and updates versions.json', () => { + const { dir, cleanup } = createTempRepo(); + try { + setupRepoWithConfig(dir, [ + { + name: 'order-schema', + type: 'json-schema', + path: 'schemas/order.json', + }, + ]); + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/order.json')); + + writeFile( + dir, + '.contractual/versions.json', + JSON.stringify({ + 'order-schema': { version: '1.0.0', released: '2026-01-01T00:00:00Z' }, + }) + ); + + // Add new contract + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/new-api.json')); + + const result = run( + 'contract add --name new-api --type json-schema --path schemas/new-api.json --initial-version 0.5.0 -y', + dir + ); + + expect(result.exitCode).toBe(0); + + // Verify snapshot created + expect(fileExists(dir, '.contractual/snapshots/new-api.json')).toBe(true); + + // Verify versions.json updated + const versions = readJSON(dir, '.contractual/versions.json') as Record< + string, + { version: string } + >; + expect(versions['new-api'].version).toBe('0.5.0'); + expect(versions['order-schema'].version).toBe('1.0.0'); + } finally { + cleanup(); + } + }); + + test('validates spec file type', () => { + const { dir, cleanup } = createTempRepo(); + try { + setupRepoWithConfig(dir, [ + { + name: 'order-schema', + type: 'json-schema', + path: 'schemas/order.json', + }, + ]); + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/order.json')); + + // Create a non-matching spec file (JSON Schema file but claim it's OpenAPI) + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/fake-api.json')); + + const result = run( + 'contract add --name fake-api --type openapi --path schemas/fake-api.json -y', + dir, + { expectFail: true } + ); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toMatch(/type mismatch|invalid|detected/i); + } finally { + cleanup(); + } + }); + + test('--skip-validation bypasses type check', () => { + const { dir, cleanup } = createTempRepo(); + try { + setupRepoWithConfig(dir, [ + { + name: 'order-schema', + type: 'json-schema', + path: 'schemas/order.json', + }, + ]); + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/order.json')); + + // Create file + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/custom.json')); + + const result = run( + 'contract add --name custom-api --type openapi --path schemas/custom.json --skip-validation -y', + dir + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/added.*custom-api/i); + } finally { + cleanup(); + } + }); + + test('fails if not initialized', () => { + const { dir, cleanup } = createTempRepo(); + try { + // No contractual.yaml + + const result = run( + 'contract add --name test --type json-schema --path test.json -y', + dir, + { expectFail: true } + ); + + expect(result.exitCode).toBe(1); + expect(result.stdout + result.stderr).toMatch(/not initialized|init/i); + } finally { + cleanup(); + } + }); + + test('fails if contract name already exists', () => { + const { dir, cleanup } = createTempRepo(); + try { + setupRepoWithConfig(dir, [ + { + name: 'order-schema', + type: 'json-schema', + path: 'schemas/order.json', + }, + ]); + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/order.json')); + + // Try to add with same name + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/other.json')); + + const result = run( + 'contract add --name order-schema --type json-schema --path schemas/other.json -y', + dir, + { expectFail: true } + ); + + expect(result.exitCode).toBe(1); + expect(result.stdout + result.stderr).toMatch(/contract exists|already defined|duplicate/i); + } finally { + cleanup(); + } + }); + }); + + describe('contract list', () => { + test('lists all contracts', () => { + const { dir, cleanup } = createTempRepo(); + try { + setupRepoWithConfig(dir, [ + { + name: 'order-schema', + type: 'json-schema', + path: 'schemas/order.json', + }, + { + name: 'user-api', + type: 'openapi', + path: 'specs/user.yaml', + }, + ]); + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/order.json')); + copyFixture('openapi/petstore-base.yaml', path.join(dir, 'specs/user.yaml')); + + const result = run('contract list', dir); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/order-schema/); + expect(result.stdout).toMatch(/user-api/); + expect(result.stdout).toMatch(/json-schema/); + expect(result.stdout).toMatch(/openapi/); + } finally { + cleanup(); + } + }); + + test('filters by exact name', () => { + const { dir, cleanup } = createTempRepo(); + try { + setupRepoWithConfig(dir, [ + { + name: 'order-schema', + type: 'json-schema', + path: 'schemas/order.json', + }, + { + name: 'user-schema', + type: 'json-schema', + path: 'schemas/user.json', + }, + { + name: 'product-api', + type: 'openapi', + path: 'specs/product.yaml', + }, + ]); + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/order.json')); + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/user.json')); + copyFixture('openapi/petstore-base.yaml', path.join(dir, 'specs/product.yaml')); + + const result = run('contract list order-schema', dir); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/order-schema/); + expect(result.stdout).not.toMatch(/user-schema/); + expect(result.stdout).not.toMatch(/product-api/); + } finally { + cleanup(); + } + }); + + test('--json outputs structured JSON', () => { + const { dir, cleanup } = createTempRepo(); + try { + setupRepoWithConfig(dir, [ + { + name: 'order-schema', + type: 'json-schema', + path: 'schemas/order.json', + }, + ]); + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/order.json')); + + const result = run('contract list --json', dir); + + expect(result.exitCode).toBe(0); + + const output = JSON.parse(result.stdout); + expect(Array.isArray(output)).toBe(true); + expect(output[0].name).toBe('order-schema'); + expect(output[0].type).toBe('json-schema'); + } finally { + cleanup(); + } + }); + + test('shows message when no contracts (schema requires at least one)', () => { + const { dir, cleanup } = createTempRepo(); + try { + // Schema validation requires at least one contract, so empty array fails + writeFile(dir, 'contractual.yaml', 'contracts: []\n'); + + const result = run('contract list', dir, { expectFail: true }); + + // Schema validation fails on empty contracts array + expect(result.exitCode).toBeGreaterThan(0); + expect(result.stdout + result.stderr).toMatch(/must NOT have fewer than 1 items|invalid|contracts/i); + } finally { + cleanup(); + } + }); + }); +}); diff --git a/tests/e2e/19-init-enhanced.test.ts b/tests/e2e/19-init-enhanced.test.ts new file mode 100644 index 0000000..074ebc4 --- /dev/null +++ b/tests/e2e/19-init-enhanced.test.ts @@ -0,0 +1,240 @@ +import { describe, test, expect, beforeAll } from 'vitest'; +import path from 'node:path'; +import { + createTempRepo, + copyFixture, + run, + writeFile, + readJSON, + readYAML, + fileExists, + ensureCliBuilt, +} from './helpers.js'; + +beforeAll(() => { + ensureCliBuilt(); +}); + +describe('contractual init (enhanced)', () => { + describe('version options', () => { + test('--initial-version sets starting version', () => { + const { dir, cleanup } = createTempRepo(); + try { + // Create a spec file + copyFixture('json-schema/order-base.json', path.join(dir, 'order.schema.json')); + + const result = run('init --initial-version 1.5.0 -y', dir); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/v1\.5\.0/); + + // Verify versions.json + const versions = readJSON(dir, '.contractual/versions.json') as Record< + string, + { version: string } + >; + const contractName = Object.keys(versions)[0]; + expect(versions[contractName].version).toBe('1.5.0'); + } finally { + cleanup(); + } + }); + + test('-y uses default version (0.0.0)', () => { + const { dir, cleanup } = createTempRepo(); + try { + copyFixture('json-schema/order-base.json', path.join(dir, 'order.schema.json')); + + const result = run('init -y', dir); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/v0\.0\.0/); + + const versions = readJSON(dir, '.contractual/versions.json') as Record< + string, + { version: string } + >; + const contractName = Object.keys(versions)[0]; + expect(versions[contractName].version).toBe('0.0.0'); + } finally { + cleanup(); + } + }); + + test('creates snapshots at initial version', () => { + const { dir, cleanup } = createTempRepo(); + try { + copyFixture('json-schema/order-base.json', path.join(dir, 'order.schema.json')); + + run('init --initial-version 2.0.0 -y', dir); + + // Verify snapshot was created + expect(fileExists(dir, '.contractual/snapshots')).toBe(true); + + // Find snapshot file + const config = readYAML(dir, 'contractual.yaml') as { + contracts: Array<{ name: string }>; + }; + const contractName = config.contracts[0].name; + + // Snapshot should exist (extension may vary) + const snapshotExists = + fileExists(dir, `.contractual/snapshots/${contractName}.json`) || + fileExists(dir, `.contractual/snapshots/${contractName}.yaml`); + expect(snapshotExists).toBe(true); + } finally { + cleanup(); + } + }); + }); + + describe('versioning modes', () => { + test('--versioning independent (default)', () => { + const { dir, cleanup } = createTempRepo(); + try { + copyFixture('json-schema/order-base.json', path.join(dir, 'order.schema.json')); + + const result = run('init --versioning independent -y', dir); + + expect(result.exitCode).toBe(0); + + // Independent mode should not add versioning section (it's the default) + const config = readYAML(dir, 'contractual.yaml') as { + versioning?: { mode: string }; + }; + // Default mode doesn't need explicit config + expect(config.versioning?.mode).toBeUndefined(); + } finally { + cleanup(); + } + }); + + test('--versioning fixed', () => { + const { dir, cleanup } = createTempRepo(); + try { + copyFixture('json-schema/order-base.json', path.join(dir, 'order.schema.json')); + + const result = run('init --versioning fixed -y', dir); + + expect(result.exitCode).toBe(0); + + const config = readYAML(dir, 'contractual.yaml') as { + versioning?: { mode: string }; + }; + expect(config.versioning?.mode).toBe('fixed'); + } finally { + cleanup(); + } + }); + }); + + describe('--force flag', () => { + test('reinitializes existing project', () => { + const { dir, cleanup } = createTempRepo(); + try { + copyFixture('json-schema/order-base.json', path.join(dir, 'order.schema.json')); + + // First init + run('init --initial-version 1.0.0 -y', dir); + + // Verify first init + let versions = readJSON(dir, '.contractual/versions.json') as Record< + string, + { version: string } + >; + const contractName = Object.keys(versions)[0]; + expect(versions[contractName].version).toBe('1.0.0'); + + // Force reinit with different version + const result = run('init --initial-version 2.0.0 --force -y', dir); + + expect(result.exitCode).toBe(0); + + // Verify reinit + versions = readJSON(dir, '.contractual/versions.json') as Record< + string, + { version: string } + >; + expect(versions[contractName].version).toBe('2.0.0'); + } finally { + cleanup(); + } + }); + }); + + describe('existing project handling', () => { + test('initializes unversioned contracts in existing project', () => { + const { dir, cleanup } = createTempRepo(); + try { + // Create config manually with two contracts + writeFile( + dir, + 'contractual.yaml', + `contracts: + - name: order-schema + type: json-schema + path: schemas/order.json + - name: user-schema + type: json-schema + path: schemas/user.json +` + ); + + // Create spec files + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/order.json')); + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/user.json')); + + // Create .contractual dir with only one contract versioned + writeFile(dir, '.contractual/versions.json', '{}'); + + // Run init - should detect unversioned contracts + const result = run('init -y', dir); + + expect(result.exitCode).toBe(0); + // Should mention finding unversioned contracts + expect(result.stdout).toMatch(/without version|uninitialized|initialized/i); + } finally { + cleanup(); + } + }); + + test('skips already versioned contracts', () => { + const { dir, cleanup } = createTempRepo(); + try { + // Create config + writeFile( + dir, + 'contractual.yaml', + `contracts: + - name: order-schema + type: json-schema + path: schemas/order.json +` + ); + + // Create spec and snapshot + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/order.json')); + copyFixture( + 'json-schema/order-base.json', + path.join(dir, '.contractual/snapshots/order-schema.json') + ); + + writeFile( + dir, + '.contractual/versions.json', + JSON.stringify({ + 'order-schema': { version: '1.0.0', released: '2026-01-01T00:00:00Z' }, + }) + ); + + // Run init - should recognize all contracts have snapshots + const result = run('init -y', dir); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/already initialized|all contracts have snapshots/i); + } finally { + cleanup(); + } + }); + }); +}); diff --git a/tests/e2e/20-version-enhanced.test.ts b/tests/e2e/20-version-enhanced.test.ts new file mode 100644 index 0000000..7cd984b --- /dev/null +++ b/tests/e2e/20-version-enhanced.test.ts @@ -0,0 +1,453 @@ +import { describe, test, expect, beforeAll } from 'vitest'; +import path from 'node:path'; +import { + createTempRepo, + copyFixture, + run, + setupRepoWithConfig, + writeFile, + readJSON, + fileExists, + listFiles, + ensureCliBuilt, +} from './helpers.js'; + +beforeAll(() => { + ensureCliBuilt(); +}); + +describe('contractual version (enhanced)', () => { + describe('--dry-run', () => { + test('shows preview without applying changes', () => { + const { dir, cleanup } = createTempRepo(); + try { + setupRepoWithConfig(dir, [ + { + name: 'order-schema', + type: 'json-schema', + path: 'schemas/order.json', + }, + ]); + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/order.json')); + copyFixture( + 'json-schema/order-base.json', + path.join(dir, '.contractual/snapshots/order-schema.json') + ); + + writeFile( + dir, + '.contractual/versions.json', + JSON.stringify({ + 'order-schema': { version: '1.0.0', released: '2026-01-01T00:00:00Z' }, + }) + ); + + // Create changeset + const changeset = `--- +"order-schema": minor +--- + +Added new feature +`; + writeFile(dir, '.contractual/changesets/feature.md', changeset); + + const result = run('version --dry-run', dir); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/dry run|preview/i); + expect(result.stdout).toMatch(/order-schema/); + expect(result.stdout).toMatch(/1\.0\.0/); + expect(result.stdout).toMatch(/1\.1\.0|minor/); + } finally { + cleanup(); + } + }); + + test('does not modify versions.json', () => { + const { dir, cleanup } = createTempRepo(); + try { + setupRepoWithConfig(dir, [ + { + name: 'order-schema', + type: 'json-schema', + path: 'schemas/order.json', + }, + ]); + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/order.json')); + copyFixture( + 'json-schema/order-base.json', + path.join(dir, '.contractual/snapshots/order-schema.json') + ); + + writeFile( + dir, + '.contractual/versions.json', + JSON.stringify({ + 'order-schema': { version: '1.0.0', released: '2026-01-01T00:00:00Z' }, + }) + ); + + const changeset = `--- +"order-schema": major +--- + +Breaking change +`; + writeFile(dir, '.contractual/changesets/breaking.md', changeset); + + run('version --dry-run', dir); + + // Verify version unchanged + const versions = readJSON(dir, '.contractual/versions.json') as Record< + string, + { version: string } + >; + expect(versions['order-schema'].version).toBe('1.0.0'); + } finally { + cleanup(); + } + }); + + test('does not delete changesets', () => { + const { dir, cleanup } = createTempRepo(); + try { + setupRepoWithConfig(dir, [ + { + name: 'order-schema', + type: 'json-schema', + path: 'schemas/order.json', + }, + ]); + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/order.json')); + copyFixture( + 'json-schema/order-base.json', + path.join(dir, '.contractual/snapshots/order-schema.json') + ); + + writeFile( + dir, + '.contractual/versions.json', + JSON.stringify({ + 'order-schema': { version: '1.0.0', released: '2026-01-01T00:00:00Z' }, + }) + ); + + const changeset = `--- +"order-schema": patch +--- + +Bug fix +`; + writeFile(dir, '.contractual/changesets/fix.md', changeset); + + run('version --dry-run', dir); + + // Verify changeset still exists + const changesets = listFiles(dir, '.contractual/changesets'); + expect(changesets).toContain('fix.md'); + } finally { + cleanup(); + } + }); + }); + + describe('--json', () => { + test('outputs structured JSON', () => { + const { dir, cleanup } = createTempRepo(); + try { + setupRepoWithConfig(dir, [ + { + name: 'order-schema', + type: 'json-schema', + path: 'schemas/order.json', + }, + ]); + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/order.json')); + copyFixture( + 'json-schema/order-base.json', + path.join(dir, '.contractual/snapshots/order-schema.json') + ); + + writeFile( + dir, + '.contractual/versions.json', + JSON.stringify({ + 'order-schema': { version: '1.0.0', released: '2026-01-01T00:00:00Z' }, + }) + ); + + const changeset = `--- +"order-schema": minor +--- + +New feature +`; + writeFile(dir, '.contractual/changesets/feature.md', changeset); + + const result = run('version --json', dir); + + expect(result.exitCode).toBe(0); + + const output = JSON.parse(result.stdout); + expect(output.bumps).toBeDefined(); + expect(Array.isArray(output.bumps)).toBe(true); + + if (output.bumps.length > 0) { + expect(output.bumps[0].contract).toBe('order-schema'); + expect(output.bumps[0].old).toBe('1.0.0'); + expect(output.bumps[0].new).toBe('1.1.0'); + expect(output.bumps[0].type).toBe('minor'); + } + } finally { + cleanup(); + } + }); + + test('implies --yes (no prompts)', () => { + const { dir, cleanup } = createTempRepo(); + try { + setupRepoWithConfig(dir, [ + { + name: 'order-schema', + type: 'json-schema', + path: 'schemas/order.json', + }, + ]); + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/order.json')); + copyFixture( + 'json-schema/order-base.json', + path.join(dir, '.contractual/snapshots/order-schema.json') + ); + + writeFile( + dir, + '.contractual/versions.json', + JSON.stringify({ + 'order-schema': { version: '1.0.0', released: '2026-01-01T00:00:00Z' }, + }) + ); + + const changeset = `--- +"order-schema": minor +--- + +Feature +`; + writeFile(dir, '.contractual/changesets/feat.md', changeset); + + // --json should apply without prompting (implies --yes) + const result = run('version --json', dir); + + expect(result.exitCode).toBe(0); + + // Verify version was bumped + const versions = readJSON(dir, '.contractual/versions.json') as Record< + string, + { version: string } + >; + expect(versions['order-schema'].version).toBe('1.1.0'); + } finally { + cleanup(); + } + }); + + test('works with --dry-run', () => { + const { dir, cleanup } = createTempRepo(); + try { + setupRepoWithConfig(dir, [ + { + name: 'order-schema', + type: 'json-schema', + path: 'schemas/order.json', + }, + ]); + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/order.json')); + copyFixture( + 'json-schema/order-base.json', + path.join(dir, '.contractual/snapshots/order-schema.json') + ); + + writeFile( + dir, + '.contractual/versions.json', + JSON.stringify({ + 'order-schema': { version: '1.0.0', released: '2026-01-01T00:00:00Z' }, + }) + ); + + const changeset = `--- +"order-schema": major +--- + +Breaking +`; + writeFile(dir, '.contractual/changesets/breaking.md', changeset); + + const result = run('version --json --dry-run', dir); + + expect(result.exitCode).toBe(0); + + const output = JSON.parse(result.stdout); + expect(output.dryRun).toBe(true); + expect(output.bumps).toBeDefined(); + + // Verify version NOT changed + const versions = readJSON(dir, '.contractual/versions.json') as Record< + string, + { version: string } + >; + expect(versions['order-schema'].version).toBe('1.0.0'); + } finally { + cleanup(); + } + }); + }); + + describe('-y/--yes', () => { + test('skips confirmation prompt', () => { + const { dir, cleanup } = createTempRepo(); + try { + setupRepoWithConfig(dir, [ + { + name: 'order-schema', + type: 'json-schema', + path: 'schemas/order.json', + }, + ]); + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/order.json')); + copyFixture( + 'json-schema/order-base.json', + path.join(dir, '.contractual/snapshots/order-schema.json') + ); + + writeFile( + dir, + '.contractual/versions.json', + JSON.stringify({ + 'order-schema': { version: '1.0.0', released: '2026-01-01T00:00:00Z' }, + }) + ); + + const changeset = `--- +"order-schema": patch +--- + +Fix +`; + writeFile(dir, '.contractual/changesets/fix.md', changeset); + + const result = run('version --yes', dir); + + expect(result.exitCode).toBe(0); + + // Verify version was bumped + const versions = readJSON(dir, '.contractual/versions.json') as Record< + string, + { version: string } + >; + expect(versions['order-schema'].version).toBe('1.0.1'); + } finally { + cleanup(); + } + }); + + test('applies changes immediately', () => { + const { dir, cleanup } = createTempRepo(); + try { + setupRepoWithConfig(dir, [ + { + name: 'api-1', + type: 'json-schema', + path: 'schemas/api1.json', + }, + { + name: 'api-2', + type: 'json-schema', + path: 'schemas/api2.json', + }, + ]); + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/api1.json')); + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/api2.json')); + copyFixture( + 'json-schema/order-base.json', + path.join(dir, '.contractual/snapshots/api-1.json') + ); + copyFixture( + 'json-schema/order-base.json', + path.join(dir, '.contractual/snapshots/api-2.json') + ); + + writeFile( + dir, + '.contractual/versions.json', + JSON.stringify({ + 'api-1': { version: '1.0.0', released: '2026-01-01T00:00:00Z' }, + 'api-2': { version: '2.0.0', released: '2026-01-01T00:00:00Z' }, + }) + ); + + const changeset = `--- +"api-1": minor +"api-2": major +--- + +Multiple changes +`; + writeFile(dir, '.contractual/changesets/multi.md', changeset); + + const result = run('version -y', dir); + + expect(result.exitCode).toBe(0); + + const versions = readJSON(dir, '.contractual/versions.json') as Record< + string, + { version: string } + >; + expect(versions['api-1'].version).toBe('1.1.0'); + expect(versions['api-2'].version).toBe('3.0.0'); + + // Verify changeset was consumed + const changesets = listFiles(dir, '.contractual/changesets'); + expect(changesets.filter((f) => f.endsWith('.md'))).toHaveLength(0); + } finally { + cleanup(); + } + }); + }); + + describe('no changesets', () => { + test('shows message when no changesets with --json', () => { + const { dir, cleanup } = createTempRepo(); + try { + setupRepoWithConfig(dir, [ + { + name: 'order-schema', + type: 'json-schema', + path: 'schemas/order.json', + }, + ]); + copyFixture('json-schema/order-base.json', path.join(dir, 'schemas/order.json')); + + writeFile( + dir, + '.contractual/versions.json', + JSON.stringify({ + 'order-schema': { version: '1.0.0', released: '2026-01-01T00:00:00Z' }, + }) + ); + + // No changesets + + const result = run('version --json', dir); + + expect(result.exitCode).toBe(0); + + const output = JSON.parse(result.stdout); + expect(output.bumps).toHaveLength(0); + expect(output.changesets).toBe(0); + } finally { + cleanup(); + } + }); + }); +});