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
+
+
+
-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
+
-### npm
+
-```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();
+ }
+ });
+ });
+});