From b341b020dc8dea42a41345a3d44f200eba363b8e Mon Sep 17 00:00:00 2001 From: omermorad Date: Fri, 13 Mar 2026 18:46:46 +0200 Subject: [PATCH 1/3] feat: major refactor for commands --- .github/workflows/e2e-release.yml | 23 +- README.md | 154 +++--- local-e2e.docker-compose.yaml | 13 + logo.png | Bin 0 -> 74748 bytes packages/changesets/index.ts | 3 + packages/changesets/versioning/manager.ts | 168 ++++++- packages/cli/package.json | 1 + packages/cli/src/commands.ts | 39 ++ packages/cli/src/commands/contract.command.ts | 370 ++++++++++++++ packages/cli/src/commands/init.command.ts | 213 +++++++- packages/cli/src/commands/pre.command.ts | 172 +++++++ packages/cli/src/commands/version.command.ts | 246 ++++++++-- packages/cli/src/config/schema.json | 13 + packages/cli/src/index.ts | 3 + packages/cli/src/utils/prompts.ts | 160 +++++++ packages/types/config.ts | 18 + packages/types/versioning.ts | 23 + pnpm-lock.yaml | 278 +++++++++++ tests/e2e/01-init.test.ts | 17 +- tests/e2e/17-pre-release.test.ts | 403 ++++++++++++++++ tests/e2e/18-contract-management.test.ts | 312 ++++++++++++ tests/e2e/19-init-enhanced.test.ts | 240 ++++++++++ tests/e2e/20-version-enhanced.test.ts | 453 ++++++++++++++++++ 23 files changed, 3170 insertions(+), 152 deletions(-) create mode 100644 logo.png create mode 100644 packages/cli/src/commands/contract.command.ts create mode 100644 packages/cli/src/commands/pre.command.ts create mode 100644 packages/cli/src/utils/prompts.ts create mode 100644 tests/e2e/17-pre-release.test.ts create mode 100644 tests/e2e/18-contract-management.test.ts create mode 100644 tests/e2e/19-init-enhanced.test.ts create mode 100644 tests/e2e/20-version-enhanced.test.ts diff --git a/.github/workflows/e2e-release.yml b/.github/workflows/e2e-release.yml index 4c357d1..c195bbe 100644 --- a/.github/workflows/e2e-release.yml +++ b/.github/workflows/e2e-release.yml @@ -50,12 +50,8 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Run Verdaccio Docker - run: | - docker run -d --name verdaccio \ - -p 4873:4873 \ - -v ${{ github.workspace }}/e2e/config.yaml:/verdaccio/conf/config.yaml \ - verdaccio/verdaccio + - name: Start Verdaccio + run: docker compose -f local-e2e.docker-compose.yaml up -d verdaccio - name: Wait for Verdaccio run: | @@ -80,24 +76,13 @@ jobs: always-auth: false - name: Install jq - run: sudo apt-get install jq - - - name: Remove provenance from package.json files - run: | - find packages -name 'package.json' | while read filename; do - jq 'del(.publishConfig.provenance)' "$filename" > temp.json && mv temp.json "$filename" - done + run: sudo apt-get install -y jq - name: Config Git run: | git config --global user.email "e2e@contractual.dev" git config --global user.name "Contractual e2e" - - name: Commit Provenance Change - run: | - git add . - git commit -am "remove provenance" - - name: Version Packages run: | BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD) @@ -135,6 +120,8 @@ jobs: --no-git-reset \ --exact \ --dist-tag e2e + env: + NPM_CONFIG_PROVENANCE: false - name: Clean Source (simulate fresh install) run: | diff --git a/README.md b/README.md index fdf186c..f21b5d8 100644 --- a/README.md +++ b/README.md @@ -1,107 +1,109 @@ -# Contractual +

+ Contractual +

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

Contractual

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

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

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

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

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

+Supported Formats: OpenAPI, JSON Schema, AsyncAPI +

-### Other package managers +## Features -```sh -pnpm add -g contractual -yarn global add contractual -bun add -g contractual -``` +- **Structural Breaking Change Detection** - Compares specs against versioned snapshots using structural diffing, not string comparison. Catches removed fields, type changes, and endpoint deletions. -## Usage +- **Automated Versioning** - Changesets declare bump levels (major/minor/patch). `contractual version` consumes them, bumps versions, updates snapshots, and generates changelogs. -The CLI provides command summaries and flags: +- **CI Integration** - GitHub Action posts diff tables on PRs, auto-generates changesets, and opens Version PRs for release automation. -```sh -contractual --help -``` +- **Format Agnostic** - Works with OpenAPI, JSON Schema, and AsyncAPI. Custom linters and differs can be configured per contract. -For usage details, see the documentation, especially: +## Quick Example -- [`contractual breaking`][breaking-docs] -- [`contractual lint`][lint-docs] -- [`contractual changeset`][changeset-docs] -- [`contractual version`][version-docs] -- [GitHub Action setup][action-docs] +### Detect changes -## CLI breaking change policy +```bash +$ contractual diff + +orders-api: 3 changes (2 breaking, 1 non-breaking) — suggested bump: major + + BREAKING Removed endpoint GET /orders/{id}/details + BREAKING Changed type of field 'amount': string → number + non-breaking Added optional field 'tracking_url' +``` -Breaking changes are documented in release notes for the npm package. +### Generate a changeset -## Goals for schema contracts +```bash +$ contractual changeset -Schema contracts are a compatibility boundary between producers and consumers. Contractual standardizes linting, breaking change detection, versioning, and changelog generation across OpenAPI, JSON Schema, and AsyncAPI. +? Bump type for orders-api: major +? Summary: Remove deprecated endpoint, change amount type -Contractual wraps existing tooling where possible and adds missing lifecycle steps, including built-in JSON Schema diffing where production-grade tooling is limited. +Wrote .contractual/changesets/fuzzy-lion-dances.md +``` -## The Contractual workflow +### Bump versions -Contractual uses a repository state directory at `.contractual/` to store versions, snapshots, and pending changesets. The GitHub Action can post diff tables on pull requests and open a Version Contracts PR for release automation. +```bash +$ contractual version -The GitHub Action is optional. The CLI can run locally or in CI. +orders-api 1.4.2 → 2.0.0 (major) -## More advanced CLI features +Updated .contractual/versions.json +Updated CHANGELOG.md +``` -- Custom linters and differs via `contractual.yaml` -- Custom outputs for code generation -- JSON output formats for CI systems -- Base snapshot selection with `--base` -- Monorepo support with multiple configs -- Optional AI explanations with `ANTHROPIC_API_KEY` +## Installation -## Next steps +```bash +npm install -g @contractual/cli +``` -After installation, follow the CLI quickstart: +Or with other package managers: -- [Quickstart][quickstart-docs] -- [Configuration reference][config-docs] -- [Breaking change detection][breaking-overview] +```bash +pnpm add -g @contractual/cli +yarn global add @contractual/cli +``` -## Builds +## Getting Started -The CLI is distributed via npm and requires Node.js 18 or later. +1. **Initialize** - `contractual init` scans for specs and creates `contractual.yaml` +2. **Lint** - `contractual lint` validates specs +3. **Detect changes** - `contractual diff` shows all changes classified +4. **CI gate** - `contractual breaking` fails if breaking changes exist +5. **Version** - `contractual changeset` + `contractual version` for releases -| Platform | Support | -|----------|---------| -| macOS | Node.js 18+ | -| Linux | Node.js 18+ | -| Windows | Node.js 18+ | +[→ Full Quickstart Guide](https://contractual.dev/getting-started/quickstart) ## Community -Issues and feature requests: -- [GitHub issues][issues] - -Documentation: -- [contractual.dev][docs] - -License: -- [MIT](LICENSE) - -[docs]: https://contractual.dev -[issues]: https://github.com/contractual-dev/contractual/issues -[quickstart-docs]: https://contractual.dev/getting-started/quickstart -[config-docs]: https://contractual.dev/reference/configuration -[breaking-docs]: https://contractual.dev/breaking/usage -[breaking-overview]: https://contractual.dev/breaking/overview -[lint-docs]: https://contractual.dev/linting/usage -[changeset-docs]: https://contractual.dev/versioning/usage -[version-docs]: https://contractual.dev/versioning/usage -[action-docs]: https://contractual.dev/github-action/setup +- [Documentation](https://contractual.dev) +- [GitHub Issues](https://github.com/contractual-dev/contractual/issues) + +## License + +[MIT](LICENSE) diff --git a/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 0000000000000000000000000000000000000000..cde1ab0a793a2584c186fbb42a5bc69f07714cb3 GIT binary patch literal 74748 zcmeFa2{aV!8#jE9vG2-OWJyTLQj(pb>{Kc$!(=J4G?>H`4T)qeiX=;xQ3*+6kU^nr znJKbmUnWbI$=dSX)3eq8`JeN>?>XQ5e&6}Nah|7gFW2?EujO8@>lz{tS{d_jZsY_2 z@R*ty9R`434F2E4j)W~vZ}xS-zoeW^4qF0%lLG)B2EZz8!cPEjUIlOToN!f+Iu9Rd+I}lz}Efz z%zMA{$;|d!VYeh^`A=Ag$gMgR2Cs6!NsKRs1Q-%vNZ|j25|EnT1;AIy_k3P>!|zN0 z|08QPlLuq+?KLaFTfd{XezH~v;L`nIH>CLC%f$#Vu>Omo*g*Vh0uKP`d6L~A;$6BY z0Lw<--m+?Be|JFwlKiI#b&~WKd3dhq9nvpB@IKJ*0$?_hL7E{DhJ`R(hOvh*HdV$c z!#Eommj%Y{iE*7|+^-oA4#qQy@or>1r2j7>3vsKXwgBCr{DtED?%Zs6|75z}vdAav z#XS8_6-e+SQ~wiz?cnO6%U1>*^DgW(69ahJZeT%SEz9`$daD-zx80B`mSO{;IQgs+ z1hBi$LsIj?bA7rOO=bn?NOeFdK`1dMGK9mh9ENu=whqSj$T&L~=Og3tz_=hWZjX!` zGUNHccp@?0kBm1mBY?mNk}x7jj40Xvb5Sz2;92X-9eXz9@L={GoK(U>5n-~tka0rG&FdTw$W%?u1#<*-sGOp>2 zH^e`cD-4HVI0WNS{Yxnq!yy6e@U!yyFA`D0*R(3o&Y$etuAvQGmcGKwuOgFbWVD1qh4+ zgg*<48NowF@Q@KaWCRZx!9&L9AsC+|VSLPm@d2T~e^wMDc*qDIGJ=PU;2|S;_mh@5V@+`i~bv0#yFPn*2^WFfVb%Vzx zds0uT%OKnn&q-d$R)D{J0J~YN(WEoTpg4rP3kYS8o7%u%WiVl+9)F`?4ex^7P>k4S z(w2}5e+uQ>NKYvl)Dq$~>rNNsk@z%uOZdAGGS+t)93ajhZ}PY_vgS$YTBqFP0S5BL zA<@`Awk=|f@jM6j;?mS)p){-@H7b$ML_J<37r`I5VCij5ibG70P%nGXfBd zNC_kM#E9lG!j6nUE+gX1h~YDm4gWjRH*ck$Jpup^OpW#)VWe{Y-;^9Pnxg!dl$nt| z;lCvJuEq0Qvi{GM+h!MJf5Lxe?AW_VOaDsv?NguC{m*>i|9?rAnf{kdDgUXOFTcpB za2q{+`hS1ph+GwI;&tfc#eXuj4oAV;*XME6_5_yBUsPB5@Ml^!?0-jNzcbx%M5BJq z|AzgaYBVBF$^0wPd=1-W{I5i_0JS*oU-7cdWLL=>|Cwku%5Kuz{4WbPNeVLhN6vp{ z#4-1j)~@}_L-wlAvi|3MlX#xWz5nTS>s*lM;{Vg>n7c?~{}UvA?>gX0T&lkO!s#=# zvn^Hg6R&ZTJpVxb2u;EiH`<=?rQ;X6m5)G10_{zf{N&?3Fya1Z`aaMWVIWVvWo+GX z@)uz91x$D=^7gp2{O2E~{!ZYJqFBJaY+DK470pFDh#Hf>pP;6NI=FeiDLRNNH~uaw zxakl`8yWvV2f-@ycU{3xz->?HAh-|wU06T|Y(xBH_`7apA|PS{wkzUy?H>3l5e3y_ zQt$N3eq^eYyHj~oz!B!*LKanWKkdmbEu$K|8tFH=KqaZ=xE2u z=vRN0OgJ0^ZyST)DsIB&f7Dr-4>E4VK?$4RK6ws4*Z!=Km@j6+A8=LtRbzAN zeLl0_rIRfpu_Gr+Z|m=PZ#o2EvA2{~Gk)jS;0GDDpC^90^1G=@2FJ2AvVJ_Ra{8Ad z7uPV`)mi2vGHfql;=`?t^1i*D#csrTc4Uvy9}Ts6-$WxwRD->hP_%t~2jBhgAE z=*VxHo-kC$1Qv%}_-E^&M_>55NTYq(QF_~c6CF)jtW3w_@h@Wo9T5hS^o1C9ocR3D z1~~tM39m<)-rtI9$Lc z7OWKguBmWGXSi;@Cbz$YPZx&z*NqjGli2nMSG8XiB^DfmO7k4hJ>dMyj#4t%`C(KS zvQy0ZTUD?$sk_X&PfP6uk55vJV92Sm+XJjx0_7Wlv^X;lM9d|x9Q*8^Z{H~`&&Qa*zoU5 z6ltTn8XHnm_$+?&EYz~9zt0-Bh3&M)ufi4yjz;yKCypU^iSPQQj4X;kE6z%ffWzf} zvtK2>+@ww4?A)sUxx>EM&IO3^Jodfg{~qmv6dAwpVGT;Ke#_58=fnsw5b1;f_1pf9 zu#r_s2{nJ?54q0XOxvnUw`;C0QqD9*R`Sjt8g{T1pr8Lks!%2kT9a&lXeXY>DBujj zK!keKSf7u2GwzSIGj$n+K_elW6l$&dNWSCy!(pVv;Efp=tdF2Q%u0taejl7Ndva>% zEB6{=ShX%A`%el#8`CJeCGX57!)m!-TgkGLDZe!?Cp(-kS-7dR^7e0NmAR9=z#gic zd?|DV?%i~|hMiO*5+p&}Q?nKQe(f(BuzC-WTy?TVHcj^u%=Og%7+qNi%ktQOQr(4Q z^M&nG#&GrlS)l+uSU~tEWq;~TzLswNx>qSj23BBpRvTb8Jg4qA-m`{%)o^+`9kz(q zElA6tE`WiZWRF;Xdz)Fqj_(jeovM!HAJk>4#@xo`P;*a?ubqK}8)h%TTEtDD>>3K! z(jL2=`*LHqJ|v(KK5qCE=Kf1&g__-gW=WSkXSO!Mp|R5R>P0vMK3;O%*?$<$w9vo* zyZPE0EhXz7_7$7_&vEqj@NRj@NVx;G)VJYga%tNd?xh%+l=VW(YE_(r!YGHO9ZUBDX*0S`eF(C$pS= zQ6lEn~7Y)E>Ssm%fg;60~6vbAhA^D+KRVmbbMRSTMjQb zTlcE-7n|EkfDaD1n)aA(Sk_qo6yUq~GzAp&H*|0t zmBn~Z8})jem0vS=rqJ%}GiJ8X<&x9u?uy)2TAo#FXfB#3gwrgjW;k}RaM4T|!m$#$ z6!3G?+FhAVFtf4RgdlxQoq3TD@kc;q!|UD^`Jam&lNT$?Z=;6tDWftAT#)a6-f02p zD>k@7v6>Vt2V0{p&O%l6=5X-=&sMF;V*fu!^HOW;4tbMpaulk3AD1r@>ijpAbi|F-Ty4gzt8zWw zC7#zmnhnRN2>_J}+S#l-C`idXDR28^@I#ZfTLr1wO!%%GTd}Gs%BYeQDhtZ=$P;Vp z+w>A)70pfQ%5S@~I@zZYo_s-2NsVfgK$*kyt04PPxSf)W0MH~`*$?-)J?dDP{)M|rhv^D_l15I*c{GU~Mc{Xo ztIXUdj~38ZoWH72cX9hm8zzvus9DwF_Nw+1J9YDUSIpsXPbpct3d~!*9Bx8FXWmP0 z7R%taj!gVX!=3Z8sWn^y`n}}+CQ;WZ^3$9hd+srjn$@mWZ<&0U?)OIa<+RJY8)-Yd zg3RcM;&W$3N~~=wmlC(OPO~X+f5j!f&3f5DWr*C>T=+t@;&7jle8HS0@#}`0bythU(%esXk_6lW3 zy}Rv+*Mh3xtw)evss&eMJi^~`-44aNFsh4PBR@9XBK0?Y%glHd}Wy>OZBk;p#dqA|WM!gjufg;bOFLn2`8 zmcyBn`$B^P`74Gr(JcP9NWq?dfId+{)5=Onf}AmLWR27fq+2C&db7=;3%p{Q49$N4 zw{Z4$g@k>T#KU3NfXd@F`tUaD*`)Y_!zW8QZ5pL@9lLQBbk5fe?}}s#4XTolo;6oD zkfpAWZHe=MbWDQW6m3O*rBrilP_XAj-WFZQ)pfo|Qoi`i3-QP7anfQYPlgEVJ{Jfk zi87J$$&aF~X6U08k_5ZJtwz?vsd`L!Q+P^*&QRVf!Dqg&Ns@$cnW=ShseKs|Bov3N zg??Ss3NH+y3u4{jmp|!b>k(`>#7t3M6~Gxc3v?7Ao4}ti!$;qb>SRZu42c$AsGRM5 z<{xOn2}-)4h)2?0xk1RMK_k2A-R+p##LZ&Y+Diq&7%j!YMQuJ+$)7{`s{%YmG_%iG zI4Y?!<27?SMek*Mpsqk26nx0OO^c>B$%X@!979}8`?a=W<93A1D_r^s@~&KE`q~mb z2VRKlSH<2(9=MJ$+mR-u5b6z*Z}RjeWd%_VVM!*c1`qbD^Zx|HB37m>kD7!rluuBY zJN8f&D4j~EivW$(tF&)6CwA->C!hZ`8DF5QxVbx4U<(r|8d4WU4a7m}7DQT6w5QaV z-p>hCp#w7BVS3kjMOCTfx}BjfMPtkn=zVSUc3wA$Q6&_iv_yg++GWseNL&P@)%?dw zW7{Bai0B5-!DeaQGpq9@0MDUW7^u33dP2nKuF{c{8=VeX;#$>i(g6Mxp5oRF*B!j3>|6{u8-ij~F<8=|Al)gK&46!p7X z0NJWXR49*WLZA+mtDm-`aCY$7C1cKv@3K3NdZUN>w2gfAQxkuVIMqLPmkCf2vlVr? zUU#R#=Z$PVZ0$0Lb@qj=kjQ?U@nM}DuJ=ivcwc|&amoiv;xZt;k)Sk1sZUkw#M&sS z4sHKr+Llru770`~NTQVFAaw~y9_mJSjaK5WNS8E<*TdnuwW6`F<=*JWJKhc3*~eB~ z0BEIjN>r5kr3Br>#Dg(#Gk^yZT)7@7+SVy(f4v{o;A6QrcJj@G25m*-As*m2*D|`# ze$(abHEhmC^G=D|luES*Q%#T@<-w^mB8{SM%cnUzke@}2rbaadT8Kyjy9C%X>PLKc z^uz0Q!!lAVRD#%u=sM8+viQ?sHLTvvdx}tR-F3bWPJ6p20Lj9$p)&pvHs?`Mf~>K# zD(8G7`+PIN-|5Xx5=Jd=xwu6V;b(7hU_UjGaz{@KsH}$~df8SWoVAN=3 zZWJHL-&$4dHXL7o72>0s?aq~F6$(7foI~#zk9V-cX7-VuI8buVLeXektvaw*T?|3e zfj%E!fz4?$v3>X?h`5O1+;sj*>TM)GaCyL2+L~%+EB*KlylY5W4Q2{#S!BfrmgNrY z^M~y|=>Z0n_$4`p{ngI8ynmhb}^YQ*AyD@KG&L3 zob2N_E2&C)sUxzw=e7J;_(1beyu&eUX41kF@~uzJTXWggIQr=(@V>yVw;3es^|~pruAbkmB~QQnsRpZ_DMH7EEA5GXw>*&>}J)f1{1`sjd2ZG;`Ptd&ZKz<4h=MuGcx8k z>y+P(t*gsxqpl*sc+r)$b@tpBDzT4olryQ_E>Va0{%-F@2dIr-&GO{PEsdZFewtQA zT?PZex-EXTxlbj~lYO%eR3v*>~E`KV^-p0VEf+qe6?(_gp(M%0Q%tY&d z9LGMv`DL=cN9D*L@+;6n;E8JIUjY5G*fRzP+_xM!uXWp{>moBa-6B#h6nJW>y=4*)< zeJxT~ISVqj^(ZS#p+?N;tw)kq$xrjNt1ciiw&}kr9X~n6)XihGGGrX2Om%

wyCR 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 s+{(I$Zr$X+Gw@>K_9gNn { + 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..9bc1857 --- /dev/null +++ b/packages/cli/src/commands/contract.command.ts @@ -0,0 +1,370 @@ +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..058bc65 100644 --- a/packages/cli/src/commands/init.command.ts +++ b/packages/cli/src/commands/init.command.ts @@ -1,11 +1,42 @@ -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 +93,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 +161,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 +195,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 +205,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 +228,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 +243,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 +264,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 +274,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 +293,78 @@ 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..732d9b1 --- /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(); + } + }); + }); +}); From 7a6055134c013f00ef95505b66c1f24c8e5d0b1f Mon Sep 17 00:00:00 2001 From: omermorad Date: Sun, 15 Mar 2026 00:18:23 +0200 Subject: [PATCH 2/3] feat: major refactor for commands --- e2e/cli-basic/cli-install.test.ts | 37 ++++ e2e/cli-lifecycle/full-lifecycle.test.ts | 171 ++++++++++++++++++ packages/cli/src/commands/contract.command.ts | 7 +- packages/cli/src/commands/init.command.ts | 23 ++- packages/cli/src/commands/pre.command.ts | 8 +- 5 files changed, 235 insertions(+), 11 deletions(-) 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/packages/cli/src/commands/contract.command.ts b/packages/cli/src/commands/contract.command.ts index 9bc1857..2b68c53 100644 --- a/packages/cli/src/commands/contract.command.ts +++ b/packages/cli/src/commands/contract.command.ts @@ -133,7 +133,9 @@ export async function contractAddCommand(options: ContractAddOptions = {}): Prom // Print summary const snapshotExt = extname(specPath) || '.yaml'; console.log(); - console.log(chalk.green('✓') + ` Added ${chalk.cyan(contractName)} (${contractType}) at v${version}`); + 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`); @@ -177,7 +179,8 @@ async function getContractName( // 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' + chalk.red('Invalid name:') + + ' Must start with letter, contain only letters, numbers, hyphens, underscores' ); process.exitCode = 1; return null; diff --git a/packages/cli/src/commands/init.command.ts b/packages/cli/src/commands/init.command.ts index 058bc65..376ba3e 100644 --- a/packages/cli/src/commands/init.command.ts +++ b/packages/cli/src/commands/init.command.ts @@ -5,7 +5,12 @@ import chalk from 'chalk'; import ora from 'ora'; 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 { + ensureContractualDir, + detectSpecType, + CONTRACTUAL_DIR, + getSnapshotPath, +} from '../utils/files.js'; import { promptSelect, promptVersion, @@ -334,9 +339,13 @@ async function handleExistingProject( } // Show uninitialized contracts - console.log(chalk.yellow(`Found ${uninitializedContracts.length} contract(s) without version history:`)); + 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( + ` ${chalk.dim('-')} ${chalk.cyan(contract.name)} ${chalk.dim(`(${contract.type})`)}` + ); } console.log(); @@ -357,11 +366,15 @@ async function handleExistingProject( 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}`)); + 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}`); + console.log( + chalk.green('✓') + ` Initialized ${chalk.cyan(contract.name)} at v${DEFAULT_VERSION}` + ); } } diff --git a/packages/cli/src/commands/pre.command.ts b/packages/cli/src/commands/pre.command.ts index 732d9b1..e962aa6 100644 --- a/packages/cli/src/commands/pre.command.ts +++ b/packages/cli/src/commands/pre.command.ts @@ -36,7 +36,9 @@ export async function preEnterCommand(tag: string): Promise { 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.')); + 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); @@ -89,9 +91,7 @@ export async function preExitCommand(): Promise { } } console.log(); - console.log( - chalk.dim('Run `contractual version` after exiting to finalize versions.') - ); + console.log(chalk.dim('Run `contractual version` after exiting to finalize versions.')); } preManager.exit(); From ed8f6dd736dbc47b46441a35a0989bb1e55d0e8e Mon Sep 17 00:00:00 2001 From: omermorad Date: Sun, 15 Mar 2026 00:19:45 +0200 Subject: [PATCH 3/3] fix eslint --- .eslintrc | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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": [