diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bb04b81..9e0345b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,13 +16,6 @@ pnpm execute # run the built CLI locally pnpm generate # interactive flow via tsx (source) ``` -Generate specific variants (output defaults to `generated/` unless `--out` is given): - -```bash -pnpm generate:sdk -- react --name my-app --client pjs -pnpm generate:xcm-api -- vue --name my-api --package-manager npm -``` - ## Project layout - `src/` — the TypeScript CLI (entry `src/index.ts`, bundled to `dist/`) diff --git a/README.md b/README.md index 109b117..a5a4bdf 100644 --- a/README.md +++ b/README.md @@ -92,12 +92,12 @@ For the **Node.js** framework it additionally (optionally) prompts for a **Subst
For Agents & CI
-Use `sdk` or `api` as the first argument (or `--type`), plus `--name`. SDK projects also accept `--client` (defaults to `pjs`; on a TTY you're prompted for it if omitted). +Use `sdk` or `api` as the subcommand, plus `--name`. SDK projects also accept `--client` (defaults to `pjs`; on a TTY you're prompted for it if omitted). ```bash npx create-paraspell@latest sdk react --name my-app --client pjs --package-manager pnpm npx create-paraspell@latest api vue --name my-api --package-manager npm -npx create-paraspell@latest --type sdk --framework node --name my-node --client dedot --evm +npx create-paraspell@latest sdk node --name my-node --client dedot --evm ``` ```bash @@ -110,10 +110,9 @@ On a TTY, omitting `--name` or `--client` (SDK) opens prompts. Without a TTY, se | Flag | Values | Default | |------|--------|---------| -| `--type` | `sdk`, `api` | required when not using `sdk`/`api` subcommand | -| `--framework` | `react`, `vue`, `node` | `react` | +| `` positional / `--framework` | `react`, `vue`, `node` | `react` | | `--client` (SDK only) | `papi`, `pjs`, `dedot` | `pjs` | -| `--evm`, `--swap`, `--snowbridge` | bare flag enables feature | `false` | +| `--evm`, `--swap`, `--snowbridge` | bare flag enables; `--no-evm` etc. disables | `false` | | `--package-manager` | `npm`, `yarn`, `pnpm`, `bun` | `pnpm` | | `--name`, `--out` | | `./` in the current directory | | `--substrate-mnemonic` | (Node only) seed the generated `.env` | — | @@ -137,23 +136,19 @@ Found a vulnerability? See [SECURITY.md](SECURITY.md).
Repository development
-Clone this repo and use the same flags via dev scripts. Output defaults to `generated/` unless you pass `--out`: +Clone this repo and run the CLI from source — it takes the same `sdk` / `api` subcommands and flags as `create-paraspell` (pass `--out` to choose where the app is written): ```bash pnpm install pnpm build pnpm execute # run the built CLI locally pnpm generate # interactive flow via tsx (source) - -pnpm generate:sdk -- react --name my-app --client pjs --package-manager pnpm -pnpm generate:xcm-api -- vue --name my-api --package-manager npm ``` **Package layout:** ```text -├── index.js # starting point -├── dist/ # built CLI +├── dist/ # built CLI (bin entry: dist/index.js) ├── assets/ # bundled static files ├── _templates/ # Hygen generators │ ├── shared/ # shared EJS partials (evm, xcm) diff --git a/index.js b/index.js deleted file mode 100644 index a771b98..0000000 --- a/index.js +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env node - -import './dist/index.js' diff --git a/package.json b/package.json index 6024733..df59cfa 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,9 @@ "license": "MIT", "author": "ParaSpell", "bin": { - "create-paraspell": "index.js" + "create-paraspell": "dist/index.js" }, "files": [ - "index.js", "dist", "_templates", "assets", @@ -40,25 +39,18 @@ "dev": "tsdown --watch", "build": "tsdown", "typecheck": "tsc -p tsconfig.json", - "prepublishOnly": "npm run build", - "execute": "node index.js", + "execute": "node dist/index.js", "generate": "tsx src/index.ts", - "generate:sdk": "tsx src/generate-sdk.ts", - "generate:xcm-api": "tsx src/generate-xcm-api.ts", - "generate:sdk:examples": "tsx src/generate-sdk-examples.ts", - "generate:xcm-api:examples": "tsx src/generate-xcm-api-examples.ts", "test": "vitest run --project structure", "test:typecheck": "vitest run --project typecheck", "test:build": "vitest run --project build", "test:all": "vitest run", "test:watch": "vitest --project structure", - "test:generate": "tsx src/tests/generate-all.ts" + "test:generate": "tsx src/generate-examples.ts" }, "dependencies": { - "@inquirer/ansi": "^2.0.7", - "@inquirer/core": "^11.2.1", - "@inquirer/figures": "^2.0.7", - "@inquirer/prompts": "^8.4.3", + "@clack/prompts": "^1.7.0", + "@stricli/core": "^1.3.0", "ejs": "^6.0.1", "hygen": "^6.2.11", "terminal-image": "^4.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5bddb0b..d8528ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,18 +8,12 @@ importers: .: dependencies: - '@inquirer/ansi': - specifier: ^2.0.7 - version: 2.0.7 - '@inquirer/core': - specifier: ^11.2.1 - version: 11.2.1(@types/node@24.13.2) - '@inquirer/figures': - specifier: ^2.0.7 - version: 2.0.7 - '@inquirer/prompts': - specifier: ^8.4.3 - version: 8.5.2(@types/node@24.13.2) + '@clack/prompts': + specifier: ^1.7.0 + version: 1.7.0 + '@stricli/core': + specifier: ^1.3.0 + version: 1.3.0 ejs: specifier: ^6.0.1 version: 6.0.1 @@ -81,6 +75,14 @@ packages: '@borewit/text-codec@0.2.2': resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + '@clack/core@1.4.3': + resolution: {integrity: sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.7.0': + resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==} + engines: {node: '>= 20.12.0'} + '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} @@ -402,140 +404,6 @@ packages: cpu: [x64] os: [win32] - '@inquirer/ansi@2.0.7': - resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - - '@inquirer/checkbox@5.2.1': - resolution: {integrity: sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/confirm@6.1.1': - resolution: {integrity: sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/core@11.2.1': - resolution: {integrity: sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/editor@5.2.2': - resolution: {integrity: sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/expand@5.1.1': - resolution: {integrity: sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/external-editor@3.0.3': - resolution: {integrity: sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/figures@2.0.7': - resolution: {integrity: sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - - '@inquirer/input@5.1.2': - resolution: {integrity: sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/number@4.1.1': - resolution: {integrity: sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/password@5.1.1': - resolution: {integrity: sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/prompts@8.5.2': - resolution: {integrity: sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/rawlist@5.3.1': - resolution: {integrity: sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/search@4.2.1': - resolution: {integrity: sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/select@5.2.1': - resolution: {integrity: sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/type@4.0.7': - resolution: {integrity: sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - '@jimp/core@1.6.1': resolution: {integrity: sha512-+BoKC5G6hkrSy501zcJ2EpfnllP+avPevcBfRcZe/CW+EwEfY6X1EZ8QWyT7NpDIvEEJb1fdJnMMfUnFkxmw9A==} engines: {node: '>=18'} @@ -893,6 +761,9 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@stricli/core@1.3.0': + resolution: {integrity: sha512-LnBe2HntygaLDU5trtjiC3J4C/YkmIZuM0XB52IF4qaLqJH09kgD1fnZiS2gaYUnt0nAN2a+1b4PSOBcfPrN4Q==} + '@tokenizer/inflate@0.4.1': resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} engines: {node: '>=18'} @@ -1062,9 +933,6 @@ packages: change-case@3.1.0: resolution: {integrity: sha512-2AZp7uJZbYEzRPsFoa+ijKdvp9zsrnnt6+yFokfwEpeJm0xuJDVoxiRCAaTzyJND8GJkofo2IcKWaUZ/OECVzw==} - chardet@2.1.1: - resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} - cli-cursor@3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} @@ -1077,10 +945,6 @@ packages: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} - cli-width@4.1.0: - resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} - engines: {node: '>= 12'} - clone@1.0.4: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} @@ -1264,10 +1128,6 @@ packages: resolution: {integrity: sha512-t6/zLI2XozP5gvV74nnl8LZSbwpVNFUkUs/O9DwuOdiiBbws5k4AQNVwKZ9FGzcKjdJ5EBBYkVzlcUHkLyY0FQ==} hasBin: true - iconv-lite@0.7.2: - resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} - engines: {node: '>=0.10.0'} - ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -1390,10 +1250,6 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - mute-stream@3.0.0: - resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} - engines: {node: ^20.17.0 || >=22.9.0} - nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -1530,9 +1386,6 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - sax@1.6.0: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} @@ -1567,6 +1420,9 @@ packages: resolution: {integrity: sha512-mz9VXphOxQWX3eQ/uXCtm6upltoN0DLx8Zb5T4TFC4FHB7S9FDPGre8CfLWqPWQQH/GrQYd2AXhhVM5LDpYx6Q==} engines: {node: '>=20.12.2'} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + slice-ansi@9.0.0: resolution: {integrity: sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==} engines: {node: '>=22'} @@ -1881,6 +1737,18 @@ snapshots: '@borewit/text-codec@0.2.2': {} + '@clack/core@1.4.3': + dependencies: + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@clack/prompts@1.7.0': + dependencies: + '@clack/core': 1.4.3 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + '@emnapi/core@1.11.1': dependencies: '@emnapi/wasi-threads': 1.2.2 @@ -2053,125 +1921,6 @@ snapshots: '@esbuild/win32-x64@0.28.0': optional: true - '@inquirer/ansi@2.0.7': {} - - '@inquirer/checkbox@5.2.1(@types/node@24.13.2)': - dependencies: - '@inquirer/ansi': 2.0.7 - '@inquirer/core': 11.2.1(@types/node@24.13.2) - '@inquirer/figures': 2.0.7 - '@inquirer/type': 4.0.7(@types/node@24.13.2) - optionalDependencies: - '@types/node': 24.13.2 - - '@inquirer/confirm@6.1.1(@types/node@24.13.2)': - dependencies: - '@inquirer/core': 11.2.1(@types/node@24.13.2) - '@inquirer/type': 4.0.7(@types/node@24.13.2) - optionalDependencies: - '@types/node': 24.13.2 - - '@inquirer/core@11.2.1(@types/node@24.13.2)': - dependencies: - '@inquirer/ansi': 2.0.7 - '@inquirer/figures': 2.0.7 - '@inquirer/type': 4.0.7(@types/node@24.13.2) - cli-width: 4.1.0 - fast-wrap-ansi: 0.2.2 - mute-stream: 3.0.0 - signal-exit: 4.1.0 - optionalDependencies: - '@types/node': 24.13.2 - - '@inquirer/editor@5.2.2(@types/node@24.13.2)': - dependencies: - '@inquirer/core': 11.2.1(@types/node@24.13.2) - '@inquirer/external-editor': 3.0.3(@types/node@24.13.2) - '@inquirer/type': 4.0.7(@types/node@24.13.2) - optionalDependencies: - '@types/node': 24.13.2 - - '@inquirer/expand@5.1.1(@types/node@24.13.2)': - dependencies: - '@inquirer/core': 11.2.1(@types/node@24.13.2) - '@inquirer/type': 4.0.7(@types/node@24.13.2) - optionalDependencies: - '@types/node': 24.13.2 - - '@inquirer/external-editor@3.0.3(@types/node@24.13.2)': - dependencies: - chardet: 2.1.1 - iconv-lite: 0.7.2 - optionalDependencies: - '@types/node': 24.13.2 - - '@inquirer/figures@2.0.7': {} - - '@inquirer/input@5.1.2(@types/node@24.13.2)': - dependencies: - '@inquirer/core': 11.2.1(@types/node@24.13.2) - '@inquirer/type': 4.0.7(@types/node@24.13.2) - optionalDependencies: - '@types/node': 24.13.2 - - '@inquirer/number@4.1.1(@types/node@24.13.2)': - dependencies: - '@inquirer/core': 11.2.1(@types/node@24.13.2) - '@inquirer/type': 4.0.7(@types/node@24.13.2) - optionalDependencies: - '@types/node': 24.13.2 - - '@inquirer/password@5.1.1(@types/node@24.13.2)': - dependencies: - '@inquirer/ansi': 2.0.7 - '@inquirer/core': 11.2.1(@types/node@24.13.2) - '@inquirer/type': 4.0.7(@types/node@24.13.2) - optionalDependencies: - '@types/node': 24.13.2 - - '@inquirer/prompts@8.5.2(@types/node@24.13.2)': - dependencies: - '@inquirer/checkbox': 5.2.1(@types/node@24.13.2) - '@inquirer/confirm': 6.1.1(@types/node@24.13.2) - '@inquirer/editor': 5.2.2(@types/node@24.13.2) - '@inquirer/expand': 5.1.1(@types/node@24.13.2) - '@inquirer/input': 5.1.2(@types/node@24.13.2) - '@inquirer/number': 4.1.1(@types/node@24.13.2) - '@inquirer/password': 5.1.1(@types/node@24.13.2) - '@inquirer/rawlist': 5.3.1(@types/node@24.13.2) - '@inquirer/search': 4.2.1(@types/node@24.13.2) - '@inquirer/select': 5.2.1(@types/node@24.13.2) - optionalDependencies: - '@types/node': 24.13.2 - - '@inquirer/rawlist@5.3.1(@types/node@24.13.2)': - dependencies: - '@inquirer/core': 11.2.1(@types/node@24.13.2) - '@inquirer/type': 4.0.7(@types/node@24.13.2) - optionalDependencies: - '@types/node': 24.13.2 - - '@inquirer/search@4.2.1(@types/node@24.13.2)': - dependencies: - '@inquirer/core': 11.2.1(@types/node@24.13.2) - '@inquirer/figures': 2.0.7 - '@inquirer/type': 4.0.7(@types/node@24.13.2) - optionalDependencies: - '@types/node': 24.13.2 - - '@inquirer/select@5.2.1(@types/node@24.13.2)': - dependencies: - '@inquirer/ansi': 2.0.7 - '@inquirer/core': 11.2.1(@types/node@24.13.2) - '@inquirer/figures': 2.0.7 - '@inquirer/type': 4.0.7(@types/node@24.13.2) - optionalDependencies: - '@types/node': 24.13.2 - - '@inquirer/type@4.0.7(@types/node@24.13.2)': - optionalDependencies: - '@types/node': 24.13.2 - '@jimp/core@1.6.1': dependencies: '@jimp/file-ops': 1.6.1 @@ -2550,6 +2299,8 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@stricli/core@1.3.0': {} + '@tokenizer/inflate@0.4.1': dependencies: debug: 4.4.3 @@ -2735,8 +2486,6 @@ snapshots: upper-case: 1.1.3 upper-case-first: 1.1.2 - chardet@2.1.1: {} - cli-cursor@3.1.0: dependencies: restore-cursor: 3.1.0 @@ -2747,8 +2496,6 @@ snapshots: cli-spinners@2.9.2: {} - cli-width@4.1.0: {} - clone@1.0.4: {} color-convert@2.0.1: @@ -2972,10 +2719,6 @@ snapshots: transitivePeerDependencies: - supports-color - iconv-lite@0.7.2: - dependencies: - safer-buffer: 2.1.2 - ieee754@1.2.1: {} ignore-walk@4.0.1: @@ -3114,8 +2857,6 @@ snapshots: ms@2.1.3: {} - mute-stream@3.0.0: {} - nanoid@3.3.12: {} no-case@2.3.2: @@ -3292,8 +3033,6 @@ snapshots: safe-buffer@5.2.1: {} - safer-buffer@2.1.2: {} - sax@1.6.0: {} semver@7.8.5: {} @@ -3317,6 +3056,8 @@ snapshots: simple-xml-to-json@1.2.7: {} + sisteransi@1.0.5: {} + slice-ansi@9.0.0: dependencies: ansi-styles: 6.2.3 diff --git a/shared/feature-flags.cjs b/shared/feature-flags.cjs index e3f8b59..ab7b1fd 100644 --- a/shared/feature-flags.cjs +++ b/shared/feature-flags.cjs @@ -1,27 +1,23 @@ -/** @param {unknown} value @param {boolean} [defaultValue] */ -function parseBool(value, defaultValue = false) { - if (value === undefined || value === null || value === '') return defaultValue; - if (typeof value === 'boolean') return value; - return value === 'true' || value === '1' || value === 'yes'; -} +/** @param {unknown} value */ +const isEnabled = (value) => + value === true || value === "true" || value === "1" || value === "yes"; /** * @param {{ evm: unknown, swap: unknown, snowbridge: unknown }} input * @returns {{ evm: boolean, swap: boolean, snowbridge: boolean, evmWallet: boolean }} */ -function resolveFeatureFlags(input) { - const evm = parseBool(input.evm, false); - const swap = parseBool(input.swap, false); - const snowbridge = parseBool(input.snowbridge, false); +const resolveFeatureFlags = (input) => { + const evm = isEnabled(input.evm); + const swap = isEnabled(input.swap); + const snowbridge = isEnabled(input.snowbridge); return { evm, swap, snowbridge, evmWallet: evm || snowbridge, }; -} +}; module.exports = { - parseBool, resolveFeatureFlags, }; diff --git a/shared/feature-flags.d.cts b/shared/feature-flags.d.cts index e3c3d84..ba9939b 100644 --- a/shared/feature-flags.d.cts +++ b/shared/feature-flags.d.cts @@ -1,5 +1,3 @@ -export function parseBool(value: unknown, defaultValue?: boolean): boolean; - export function resolveFeatureFlags(input: { evm: unknown; swap: unknown; diff --git a/shared/package-manager.cjs b/shared/package-manager.cjs index fc61b3e..7587049 100644 --- a/shared/package-manager.cjs +++ b/shared/package-manager.cjs @@ -1,18 +1,18 @@ /** @typedef {'npm' | 'yarn' | 'pnpm' | 'bun'} PackageManager */ -const PACKAGE_MANAGERS = /** @type {const} */ (['npm', 'yarn', 'pnpm', 'bun']); +const PACKAGE_MANAGERS = /** @type {const} */ (["npm", "yarn", "pnpm", "bun"]); /** @param {string | undefined} value @returns {PackageManager} */ -function normalizePackageManager(value) { +const normalizePackageManager = (value) => { const key = value?.toLowerCase(); if (key && PACKAGE_MANAGERS.includes(/** @type {PackageManager} */ (key))) { return /** @type {PackageManager} */ (key); } - return 'pnpm'; -} + return "pnpm"; +}; /** @param {string | undefined} input */ -function resolvePackageManager(input) { +const resolvePackageManager = (input) => { const packageManager = normalizePackageManager(input); return { packageManager, @@ -20,10 +20,9 @@ function resolvePackageManager(input) { devCmd: `${packageManager} run dev`, startCmd: `${packageManager} start`, }; -} +}; module.exports = { - PACKAGE_MANAGERS, normalizePackageManager, resolvePackageManager, }; diff --git a/shared/package-manager.d.cts b/shared/package-manager.d.cts index c6e3256..cb3935c 100644 --- a/shared/package-manager.d.cts +++ b/shared/package-manager.d.cts @@ -1,7 +1,5 @@ export type PackageManager = 'npm' | 'yarn' | 'pnpm' | 'bun'; -export const PACKAGE_MANAGERS: readonly PackageManager[]; - export function normalizePackageManager( value: string | undefined, ): PackageManager; diff --git a/shared/package-manager.d.ts b/shared/package-manager.d.ts deleted file mode 100644 index 131bb6e..0000000 --- a/shared/package-manager.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -export type PackageManager = 'npm' | 'yarn' | 'pnpm' | 'bun'; - -export declare const PACKAGE_MANAGERS: readonly PackageManager[]; - -export declare function normalizePackageManager( - value: string | undefined, -): PackageManager; - -export declare function resolvePackageManager(input: string | undefined): { - packageManager: PackageManager; - installCmd: string; - devCmd: string; - startCmd: string; -}; diff --git a/src/examples/api-examples.ts b/src/examples/api-examples.ts deleted file mode 100644 index 0a78543..0000000 --- a/src/examples/api-examples.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { FeatureFlags } from '../shared/types.js'; -import { FEATURE_COMBOS, formatApiExampleName } from './feature-combos.js'; - -export interface ApiExample extends FeatureFlags { - name: string; -} - -export const API_EXAMPLES: ApiExample[] = FEATURE_COMBOS.map((combo) => ({ - name: formatApiExampleName(combo), - ...combo, -})); - -export const API_VARIANTS_PER_FRAMEWORK = FEATURE_COMBOS.length; diff --git a/src/examples/feature-combos.test.ts b/src/examples/feature-combos.test.ts deleted file mode 100644 index 5c1ea54..0000000 --- a/src/examples/feature-combos.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - FEATURE_COMBOS, - formatApiExampleName, - formatSdkExampleDir, - SDK_CLIENTS, -} from './feature-combos.js'; - -describe('feature combos', () => { - it('defines 8 valid combinations', () => { - expect(FEATURE_COMBOS).toHaveLength(8); - }); - - it('uses unique API example names', () => { - const names = FEATURE_COMBOS.map(formatApiExampleName); - expect(new Set(names).size).toBe(names.length); - }); - - it('uses unique SDK dirs per client', () => { - for (const client of SDK_CLIENTS) { - const dirs = FEATURE_COMBOS.map((combo) => formatSdkExampleDir(client, combo)); - expect(new Set(dirs).size).toBe(dirs.length); - } - }); - - it('names snowbridge-only API examples', () => { - expect(formatApiExampleName(FEATURE_COMBOS[2]!)).toBe('snowbridge'); - expect(formatApiExampleName(FEATURE_COMBOS[3]!)).toBe('swap-snowbridge'); - }); - - it('names the full API feature set', () => { - expect(formatApiExampleName(FEATURE_COMBOS[7]!)).toBe('evm-swap-snowbridge'); - }); - - it('names snowbridge-only SDK dirs', () => { - expect(formatSdkExampleDir('pjs', FEATURE_COMBOS[2]!)).toBe('pjs-snowbridge'); - }); - - it('names the full SDK feature set', () => { - expect(formatSdkExampleDir('pjs', FEATURE_COMBOS[7]!)).toBe('pjs-evm-swap-snowbridge'); - }); -}); diff --git a/src/examples/feature-combos.ts b/src/examples/feature-combos.ts deleted file mode 100644 index fe7615b..0000000 --- a/src/examples/feature-combos.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { FeatureFlags, SdkClient } from '../shared/types.js'; - -/** All valid feature flag combinations (EVM and Snowbridge are independent). */ -export const FEATURE_COMBOS: readonly FeatureFlags[] = [ - { evm: false, swap: false, snowbridge: false }, - { evm: false, swap: true, snowbridge: false }, - { evm: false, swap: false, snowbridge: true }, - { evm: false, swap: true, snowbridge: true }, - { evm: true, swap: false, snowbridge: false }, - { evm: true, swap: true, snowbridge: false }, - { evm: true, swap: false, snowbridge: true }, - { evm: true, swap: true, snowbridge: true }, -]; - -export const SDK_CLIENTS: readonly SdkClient[] = ['pjs', 'papi', 'dedot']; - -export function formatApiExampleName(combo: FeatureFlags): string { - if (!combo.evm && !combo.swap && !combo.snowbridge) { - return 'base'; - } - const parts: string[] = []; - if (combo.evm) parts.push('evm'); - if (combo.swap) parts.push('swap'); - if (combo.snowbridge) parts.push('snowbridge'); - return parts.join('-'); -} - -export function formatSdkExampleDir(client: SdkClient, combo: FeatureFlags): string { - if (!combo.evm && !combo.swap && !combo.snowbridge) { - return client; - } - const parts: string[] = [client]; - if (combo.evm) { - parts.push('evm'); - if (combo.swap) parts.push('swap'); - if (combo.snowbridge) parts.push('snowbridge'); - } else if (combo.swap) { - parts.push('swap'); - if (combo.snowbridge) parts.push('snowbridge'); - } else if (combo.snowbridge) { - parts.push('snowbridge'); - } - return parts.join('-'); -} diff --git a/src/examples/sdk-examples.ts b/src/examples/sdk-examples.ts deleted file mode 100644 index bcb3711..0000000 --- a/src/examples/sdk-examples.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { FeatureFlags, Framework, SdkClient } from '../shared/types.js'; -import { - FEATURE_COMBOS, - formatSdkExampleDir, - SDK_CLIENTS, -} from './feature-combos.js'; - -export interface SdkExample extends FeatureFlags { - dir: string; - client: SdkClient; -} - -function buildSdkExamplesForFramework(): SdkExample[] { - return SDK_CLIENTS.flatMap((client) => - FEATURE_COMBOS.map((combo) => ({ - dir: formatSdkExampleDir(client, combo), - client, - ...combo, - })), - ); -} - -const FRAMEWORKS: Framework[] = ['react', 'vue', 'node']; - -export const SDK_EXAMPLES: Record = Object.fromEntries( - FRAMEWORKS.map((framework) => [framework, buildSdkExamplesForFramework()]), -) as Record; - -export const SDK_VARIANTS_PER_FRAMEWORK = SDK_CLIENTS.length * FEATURE_COMBOS.length; diff --git a/src/generate-examples.test.ts b/src/generate-examples.test.ts new file mode 100644 index 0000000..94eae0d --- /dev/null +++ b/src/generate-examples.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { FEATURE_COMBOS, apiExampleName, sdkExampleDir } from './generate-examples.js'; +import { SDK_CLIENTS } from './shared/types.js'; + +describe('feature combos', () => { + it('defines 8 valid combinations', () => { + expect(FEATURE_COMBOS).toHaveLength(8); + }); + + it('uses unique API example names', () => { + const names = FEATURE_COMBOS.map(apiExampleName); + expect(new Set(names).size).toBe(names.length); + }); + + it('uses unique SDK dirs per client', () => { + for (const client of SDK_CLIENTS) { + const dirs = FEATURE_COMBOS.map((combo) => sdkExampleDir(client, combo)); + expect(new Set(dirs).size).toBe(dirs.length); + } + }); + + it('names the base API example', () => { + expect(apiExampleName(FEATURE_COMBOS[0]!)).toBe('base'); + }); + + it('names snowbridge-only API examples', () => { + expect(apiExampleName(FEATURE_COMBOS[2]!)).toBe('snowbridge'); + expect(apiExampleName(FEATURE_COMBOS[3]!)).toBe('swap-snowbridge'); + }); + + it('names the full API feature set', () => { + expect(apiExampleName(FEATURE_COMBOS[7]!)).toBe('evm-swap-snowbridge'); + }); + + it('names snowbridge-only SDK dirs', () => { + expect(sdkExampleDir('pjs', FEATURE_COMBOS[2]!)).toBe('pjs-snowbridge'); + }); + + it('names the full SDK feature set', () => { + expect(sdkExampleDir('pjs', FEATURE_COMBOS[7]!)).toBe('pjs-evm-swap-snowbridge'); + }); +}); diff --git a/src/generate-examples.ts b/src/generate-examples.ts new file mode 100644 index 0000000..f73d878 --- /dev/null +++ b/src/generate-examples.ts @@ -0,0 +1,135 @@ +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { buildApplication, buildCommand, run } from "@stricli/core"; +import { getPackageRoot } from "./package-root.js"; +import { + frameworkPositional, + packageManagerFlag, +} from "./shared/cli-params.js"; +import { API_FRAMEWORKS, SDK_FRAMEWORKS } from "./shared/frameworks.js"; +import { generateApiApp, generateSdkApp } from "./shared/hygen-runner.js"; +import { normalizePackageManager } from "./shared/package-manager.js"; +import { + FEATURE_KEYS, + FRAMEWORKS, + SDK_CLIENTS, + type FeatureFlags, + type Framework, + type PackageManager, + type ProjectType, + type SdkClient, +} from "./shared/types.js"; + +export const FEATURE_COMBOS: readonly FeatureFlags[] = [false, true].flatMap( + (evm) => + [false, true].flatMap((snowbridge) => + [false, true].map((swap) => ({ evm, swap, snowbridge })), + ), +); + +export const featureSuffix = (combo: FeatureFlags): string => { + return FEATURE_KEYS.filter((key) => combo[key]).join("-"); +}; + +export const apiExampleName = (combo: FeatureFlags): string => { + return featureSuffix(combo) || "base"; +}; + +export const sdkExampleDir = (client: SdkClient, combo: FeatureFlags): string => { + const suffix = featureSuffix(combo); + return suffix ? `${client}-${suffix}` : client; +}; + +const cliRoot = getPackageRoot(); +const templatesRoot = path.join(cliRoot, "_templates"); + +const generateExamples = async ( + kind: ProjectType | undefined, + framework: Framework | undefined, + packageManagerRaw: PackageManager | undefined, +): Promise => { + const packageManager = normalizePackageManager(packageManagerRaw); + const frameworks: readonly Framework[] = framework ? [framework] : FRAMEWORKS; + + if (!kind || kind === "sdk") { + for (const fw of frameworks) { + const meta = SDK_FRAMEWORKS[fw]; + for (const client of SDK_CLIENTS) { + for (const combo of FEATURE_COMBOS) { + const name = sdkExampleDir(client, combo); + await generateSdkApp({ + meta, + templatesRoot, + opts: { + framework: fw, + name, + client, + evm: combo.evm, + swap: combo.swap, + snowbridge: combo.snowbridge, + packageManager, + out: path.join(cliRoot, "generated", "xcm-sdk", fw, name), + }, + }); + } + } + } + } + + if (!kind || kind === "api") { + for (const fw of frameworks) { + const meta = API_FRAMEWORKS[fw]; + for (const combo of FEATURE_COMBOS) { + const name = apiExampleName(combo); + await generateApiApp({ + meta, + templatesRoot, + opts: { + framework: fw, + name: `xcm-api-${name}`, + out: path.join(cliRoot, "generated", "xcm-api", fw, name), + evm: combo.evm, + swap: combo.swap, + snowbridge: combo.snowbridge, + packageManager, + }, + }); + } + } + } +}; + +const command = buildCommand< + { packageManager?: PackageManager; kind?: ProjectType }, + [Framework?] +>({ + docs: { brief: "Generate ParaSpell XCM SDK and API example apps" }, + parameters: { + positional: frameworkPositional, + flags: { + packageManager: packageManagerFlag, + kind: { + kind: "enum", + values: ["sdk", "api"], + brief: "Which examples to generate (defaults to both)", + optional: true, + }, + }, + }, + func: async (flags, framework) => { + try { + await generateExamples(flags.kind, framework, flags.packageManager); + } catch (error) { + return error instanceof Error ? error : new Error(String(error)); + } + }, +}); + +const app = buildApplication(command, { + name: "generate-examples", + scanner: { caseStyle: "allow-kebab-for-camel" }, +}); + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + await run(app, process.argv.slice(2), { process }); +} diff --git a/src/generate-sdk-examples.ts b/src/generate-sdk-examples.ts deleted file mode 100644 index 87f4970..0000000 --- a/src/generate-sdk-examples.ts +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env node -import path from 'node:path'; -import { getPackageRoot } from './package-root.js'; -import { SDK_EXAMPLES } from './examples/sdk-examples.js'; -import { SDK_FRAMEWORKS } from './shared/frameworks.js'; -import { generateSdkApp } from './shared/hygen-runner.js'; -import { shiftPositionalFramework } from './shared/parse-cli-args.js'; -import { normalizePackageManager } from './shared/package-manager.js'; -import { runEntry } from './shared/run-entry.js'; -import type { Framework } from './shared/types.js'; - -const cliRoot = getPackageRoot(); -const templatesRoot = path.join(cliRoot, '_templates'); - -const { argv, framework: positional } = shiftPositionalFramework( - process.argv.slice(2), -); - -const pmFlag = - argv.find((a) => a.startsWith('--package-manager='))?.split('=')[1] ?? - (argv.includes('--package-manager') - ? argv[argv.indexOf('--package-manager') + 1] - : undefined); - -const packageManager = normalizePackageManager(pmFlag); - -const frameworks: Framework[] = positional - ? [positional] - : ['react', 'vue', 'node']; - -await runEntry(async () => { - for (const framework of frameworks) { - const meta = SDK_FRAMEWORKS[framework]; - for (const ex of SDK_EXAMPLES[framework]) { - await generateSdkApp({ - meta, - templatesRoot, - opts: { - framework, - name: ex.dir, - client: ex.client, - evm: ex.evm, - swap: ex.swap, - snowbridge: ex.snowbridge, - packageManager, - out: path.join(cliRoot, 'generated', 'xcm-sdk', framework, ex.dir), - }, - }); - } - console.log(`Generated ${SDK_EXAMPLES[framework].length} ${meta.label} examples`); - } -}); diff --git a/src/generate-sdk.ts b/src/generate-sdk.ts deleted file mode 100644 index 13b3430..0000000 --- a/src/generate-sdk.ts +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env node -import path from 'node:path'; -import { getPackageRoot } from './package-root.js'; -import { runEntry } from './shared/run-entry.js'; -import { runSdkFromArgv } from './run-cli.js'; - -const cliRoot = getPackageRoot(); -const templatesRoot = path.join(cliRoot, '_templates'); - -await runEntry(() => - runSdkFromArgv(process.argv.slice(2), { - root: cliRoot, - templatesRoot, - }), -); diff --git a/src/generate-xcm-api-examples.ts b/src/generate-xcm-api-examples.ts deleted file mode 100644 index 03b324c..0000000 --- a/src/generate-xcm-api-examples.ts +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env node -import path from 'node:path'; -import { getPackageRoot } from './package-root.js'; -import { API_EXAMPLES } from './examples/api-examples.js'; -import { API_FRAMEWORKS } from './shared/frameworks.js'; -import { generateApiApp } from './shared/hygen-runner.js'; -import { shiftPositionalFramework } from './shared/parse-cli-args.js'; -import { normalizePackageManager } from './shared/package-manager.js'; -import { runEntry } from './shared/run-entry.js'; -import type { Framework } from './shared/types.js'; - -const cliRoot = getPackageRoot(); -const templatesRoot = path.join(cliRoot, '_templates'); -const examplesRoot = path.join(cliRoot, 'generated/xcm-api'); - -const { argv, framework: positional } = shiftPositionalFramework( - process.argv.slice(2), -); - -const pmFlag = - argv.find((a) => a.startsWith('--package-manager='))?.split('=')[1] ?? - (argv.includes('--package-manager') - ? argv[argv.indexOf('--package-manager') + 1] - : undefined); - -const packageManager = normalizePackageManager(pmFlag); - -const frameworks: Framework[] = positional - ? [positional] - : ['react', 'vue', 'node']; - -await runEntry(async () => { - for (const framework of frameworks) { - const meta = API_FRAMEWORKS[framework]; - for (const variant of API_EXAMPLES) { - await generateApiApp({ - meta, - templatesRoot, - opts: { - framework, - name: `xcm-api-${variant.name}`, - out: path.join(examplesRoot, framework, variant.name), - evm: variant.evm, - swap: variant.swap, - snowbridge: variant.snowbridge, - packageManager, - }, - }); - } - } - - console.log(`Done. Examples at ${examplesRoot}`); -}); diff --git a/src/generate-xcm-api.ts b/src/generate-xcm-api.ts deleted file mode 100644 index d809eb4..0000000 --- a/src/generate-xcm-api.ts +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env node -import path from 'node:path'; -import { getPackageRoot } from './package-root.js'; -import { runEntry } from './shared/run-entry.js'; -import { runApiFromArgv } from './run-cli.js'; - -const cliRoot = getPackageRoot(); -const templatesRoot = path.join(cliRoot, '_templates'); - -await runEntry(() => - runApiFromArgv(process.argv.slice(2), { - root: cliRoot, - templatesRoot, - }), -); diff --git a/src/index.ts b/src/index.ts index e743fc6..ba7e612 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,9 @@ #!/usr/bin/env node import path from 'node:path'; import { getPackageRoot } from './package-root.js'; -import { runEntry } from './shared/run-entry.js'; import { runCli } from './run-cli.js'; const packageRoot = getPackageRoot(); const templatesRoot = path.join(packageRoot, '_templates'); -await runEntry(() => runCli(process.argv.slice(2), templatesRoot)); +await runCli(process.argv.slice(2), templatesRoot); diff --git a/src/interactive.test.ts b/src/interactive.test.ts index ec73f32..b076ec2 100644 --- a/src/interactive.test.ts +++ b/src/interactive.test.ts @@ -1,218 +1,132 @@ -import fs from 'node:fs'; -import os from 'node:os'; import path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { input, select } from '@inquirer/prompts'; -import { - EVM_EXTENSION, - promptFeatureExtensions, -} from './shared/feature-extensions-checkbox.js'; -import { promptEvmPrivateKey } from './shared/prompt-evm-private-key.js'; -import { promptSubstrateMnemonic } from './shared/prompt-substrate-mnemonic.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { select, text } from '@clack/prompts'; type GenerateSdkApp = typeof import('./shared/hygen-runner.js').generateSdkApp; type GenerateApiApp = typeof import('./shared/hygen-runner.js').generateApiApp; +const VALID_PRIVATE_KEY = + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; + const generateSdkApp = vi.fn(); const generateApiApp = vi.fn(); +const promptFeatureExtensions = vi.fn(async (_defaults?: unknown) => [] as string[]); +const promptSubstrateMnemonic = vi.fn(async () => undefined as string | undefined); +const promptEvmPrivateKey = vi.fn(async () => undefined as string | undefined); vi.mock('terminal-image', () => ({ - default: { - buffer: vi.fn().mockResolvedValue(''), - }, + default: { buffer: vi.fn(async () => '') }, })); -vi.mock('@inquirer/prompts', () => ({ - input: vi.fn(), +vi.mock('@clack/prompts', () => ({ + intro: vi.fn(), + outro: vi.fn(), + text: vi.fn(), select: vi.fn(), - Separator: class Separator {}, + cancel: vi.fn(), + isCancel: vi.fn(() => false), })); -vi.mock('./shared/feature-extensions-checkbox.js', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - promptFeatureExtensions: vi.fn(), - }; -}); - -vi.mock('./shared/prompt-substrate-mnemonic.js', () => ({ - promptSubstrateMnemonic: vi.fn(), +vi.mock('./shared/hygen-runner.js', () => ({ + generateSdkApp: (params: Parameters[0]) => + generateSdkApp(params), + generateApiApp: (params: Parameters[0]) => + generateApiApp(params), })); -vi.mock('./shared/prompt-evm-private-key.js', () => ({ - promptEvmPrivateKey: vi.fn(), +vi.mock('./shared/feature-extensions-checkbox.js', () => ({ + EVM_EXTENSION: 'evm-extension', + SWAP_EXTENSION: 'swap-extension', + SNOWBRIDGE_EXTENSION: 'snowbridge-extension', + promptFeatureExtensions: (defaults?: unknown) => + promptFeatureExtensions(defaults), })); -vi.mock('./shared/hygen-runner.js', () => ({ - generateSdkApp: (params: Parameters[0]) => generateSdkApp(params), - generateApiApp: (params: Parameters[0]) => generateApiApp(params), +vi.mock('./shared/prompt-secrets.js', () => ({ + promptSubstrateMnemonic: () => promptSubstrateMnemonic(), + promptEvmPrivateKey: () => promptEvmPrivateKey(), })); const { runInteractiveGenerate } = await import('./interactive.js'); -const mockedInput = vi.mocked(input); +const mockedText = vi.mocked(text); const mockedSelect = vi.mocked(select); -const mockedFeatureExtensions = vi.mocked(promptFeatureExtensions); -const mockedSubstrateMnemonic = vi.mocked(promptSubstrateMnemonic); -const mockedEvmPrivateKey = vi.mocked(promptEvmPrivateKey); - -const PROMPTS = { - name: 'Enter the project name', - packageManager: 'Select the desired package manager', - framework: 'Select the desired framework', - projectType: 'Select the desired project type', - client: 'Select the desired JS client type', -} as const; - const TEMPLATES_ROOT = path.join(process.cwd(), '_templates'); -function selectMessages(): string[] { - return mockedSelect.mock.calls.map((call) => call[0]?.message); -} - -function inputMessages(): string[] { - return mockedInput.mock.calls.map((call) => call[0]?.message); -} - describe('runInteractiveGenerate', () => { - let tmpRoot: string; - let cwd: ReturnType; - beforeEach(() => { vi.clearAllMocks(); - tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'paraspell-interactive-')); - cwd = vi.spyOn(process, 'cwd').mockReturnValue(tmpRoot); - vi.spyOn(fs, 'existsSync').mockReturnValue(false); generateSdkApp.mockResolvedValue(undefined); generateApiApp.mockResolvedValue(undefined); - mockedInput.mockResolvedValue('my-wizard-app'); - mockedFeatureExtensions.mockResolvedValue([]); - mockedSubstrateMnemonic.mockResolvedValue(undefined); - mockedEvmPrivateKey.mockResolvedValue(undefined); - }); - - afterEach(() => { - fs.rmSync(tmpRoot, { recursive: true, force: true }); - vi.restoreAllMocks(); + promptFeatureExtensions.mockResolvedValue([]); + promptSubstrateMnemonic.mockResolvedValue(undefined); + promptEvmPrivateKey.mockResolvedValue(undefined); + vi.spyOn(console, 'log').mockImplementation(() => {}); }); - it('runs the sdk wizard prompts in order and generates the project', async () => { + it('scaffolds an sdk app from wizard answers', async () => { + mockedText.mockResolvedValue('wizard-app'); mockedSelect - .mockResolvedValueOnce('pnpm') + .mockResolvedValueOnce('npm') .mockResolvedValueOnce('react') .mockResolvedValueOnce('sdk') .mockResolvedValueOnce('papi'); - const log = vi.spyOn(console, 'log').mockImplementation(() => {}); await runInteractiveGenerate(TEMPLATES_ROOT); - expect(inputMessages()).toEqual([PROMPTS.name]); - expect(selectMessages()).toEqual([ - PROMPTS.packageManager, - PROMPTS.framework, - PROMPTS.projectType, - PROMPTS.client, - ]); - expect(mockedFeatureExtensions).toHaveBeenCalledOnce(); - expect(mockedSubstrateMnemonic).not.toHaveBeenCalled(); - expect(mockedEvmPrivateKey).not.toHaveBeenCalled(); - expect(generateSdkApp).toHaveBeenCalledOnce(); expect(generateApiApp).not.toHaveBeenCalled(); + expect(generateSdkApp).toHaveBeenCalledOnce(); expect(generateSdkApp.mock.calls[0]?.[0]).toMatchObject({ templatesRoot: TEMPLATES_ROOT, - opts: { + opts: expect.objectContaining({ framework: 'react', - name: 'my-wizard-app', + name: 'wizard-app', client: 'papi', - packageManager: 'pnpm', - out: path.join(tmpRoot, 'my-wizard-app'), - evm: false, - swap: false, - snowbridge: false, - }, + packageManager: 'npm', + out: path.join(process.cwd(), 'wizard-app'), + }), }); - expect(log.mock.calls.some(([line]) => String(line).includes('Welcome to the Paraspell CLI'))).toBe( - true, - ); - expect(log.mock.calls.some(([line]) => String(line).includes('Next steps:'))).toBe(true); }); - it('skips the client prompt for api projects', async () => { + it('scaffolds an api app without prompting for a client', async () => { + mockedText.mockResolvedValue('api-wizard'); mockedSelect - .mockResolvedValueOnce('npm') + .mockResolvedValueOnce('pnpm') .mockResolvedValueOnce('vue') .mockResolvedValueOnce('api'); - vi.spyOn(console, 'log').mockImplementation(() => {}); await runInteractiveGenerate(TEMPLATES_ROOT); - expect(selectMessages()).toEqual([ - PROMPTS.packageManager, - PROMPTS.framework, - PROMPTS.projectType, - ]); - expect(generateApiApp).toHaveBeenCalledOnce(); + expect(mockedSelect).toHaveBeenCalledTimes(3); expect(generateSdkApp).not.toHaveBeenCalled(); + expect(generateApiApp).toHaveBeenCalledOnce(); expect(generateApiApp.mock.calls[0]?.[0]).toMatchObject({ - opts: { - framework: 'vue', - name: 'my-wizard-app', - packageManager: 'npm', - out: path.join(tmpRoot, 'my-wizard-app'), - }, + opts: expect.objectContaining({ framework: 'vue', name: 'api-wizard' }), }); }); - it('prompts for node secrets after feature selection when wallet origins are enabled', async () => { + it('prompts for node secrets when EVM features are enabled', async () => { + mockedText.mockResolvedValue('node-app'); mockedSelect .mockResolvedValueOnce('pnpm') .mockResolvedValueOnce('node') .mockResolvedValueOnce('sdk') - .mockResolvedValueOnce('pjs'); - mockedFeatureExtensions.mockResolvedValue([EVM_EXTENSION]); - mockedSubstrateMnemonic.mockResolvedValue('//Alice'); - mockedEvmPrivateKey.mockResolvedValue('0xabc'); + .mockResolvedValueOnce('papi'); + promptFeatureExtensions.mockResolvedValue(['evm-extension']); + promptSubstrateMnemonic.mockResolvedValue('//Alice'); + promptEvmPrivateKey.mockResolvedValue(VALID_PRIVATE_KEY); - vi.spyOn(console, 'log').mockImplementation(() => {}); await runInteractiveGenerate(TEMPLATES_ROOT); - expect(selectMessages()).toEqual([ - PROMPTS.packageManager, - PROMPTS.framework, - PROMPTS.projectType, - PROMPTS.client, - ]); - - const stepNames = [ - ...inputMessages(), - ...selectMessages(), - 'feature-extensions', - 'substrate-mnemonic', - 'evm-private-key', - ]; - expect(stepNames).toEqual([ - PROMPTS.name, - PROMPTS.packageManager, - PROMPTS.framework, - PROMPTS.projectType, - PROMPTS.client, - 'feature-extensions', - 'substrate-mnemonic', - 'evm-private-key', - ]); - expect(mockedFeatureExtensions).toHaveBeenCalledOnce(); - expect(mockedSubstrateMnemonic).toHaveBeenCalledOnce(); - expect(mockedEvmPrivateKey).toHaveBeenCalledOnce(); + expect(promptSubstrateMnemonic).toHaveBeenCalledOnce(); + expect(promptEvmPrivateKey).toHaveBeenCalledOnce(); expect(generateSdkApp.mock.calls[0]?.[0]).toMatchObject({ - opts: { + opts: expect.objectContaining({ framework: 'node', evm: true, - evmWallet: true, substrateMnemonic: '//Alice', - privateKey: '0xabc', - }, + privateKey: VALID_PRIVATE_KEY, + }), }); }); }); diff --git a/src/interactive.ts b/src/interactive.ts index 2ed105f..9ea8456 100644 --- a/src/interactive.ts +++ b/src/interactive.ts @@ -1,6 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; -import { input, select, Separator } from '@inquirer/prompts'; +import { intro, outro } from '@clack/prompts'; import terminalImage from 'terminal-image'; import { getPackageRoot } from './package-root.js'; import { applyFeatureFlags } from './shared/feature-flags.js'; @@ -10,26 +10,28 @@ import { SNOWBRIDGE_EXTENSION, SWAP_EXTENSION, } from './shared/feature-extensions-checkbox.js'; -import { API_FRAMEWORKS, SDK_FRAMEWORKS } from './shared/frameworks.js'; -import { generateApiApp, generateSdkApp } from './shared/hygen-runner.js'; +import { generateApp } from './shared/generate-dispatch.js'; import { printNextSteps } from './shared/next-steps.js'; -import { PACKAGE_MANAGERS } from './shared/package-manager.js'; -import { promptEvmPrivateKey } from './shared/prompt-evm-private-key.js'; -import { promptSubstrateMnemonic } from './shared/prompt-substrate-mnemonic.js'; -import type { - Framework, - PackageManager, - ProjectType, - SdkClient, -} from './shared/types.js'; +import { + promptEvmPrivateKey, + promptSubstrateMnemonic, +} from './shared/prompt-secrets.js'; +import { + promptClient, + promptFramework, + promptName, + promptPackageManager, + promptProjectType, +} from './shared/prompts.js'; +import type { SdkClient } from './shared/types.js'; import { validateNameInput } from './shared/validate.js'; -function preferNativeTerminalImage(): boolean { +const preferNativeTerminalImage = (): boolean => { const program = process.env.TERM_PROGRAM?.toLowerCase() ?? ''; return program !== 'vscode' && program !== 'cursor'; -} +}; -async function renderBanner(): Promise { +const renderBanner = async (): Promise => { try { const iconPath = path.join( getPackageRoot(), @@ -46,66 +48,31 @@ async function renderBanner(): Promise { } catch { // Decorative banner unavailable; continue without it. } -} +}; -export async function runInteractiveGenerate( +export const runInteractiveGenerate = async ( templatesRoot: string, -): Promise { +): Promise => { await renderBanner(); - console.log('Welcome to the Paraspell CLI\n'); + intro('Welcome to the Paraspell CLI'); - const projectName = await input({ - message: 'Enter the project name', - default: 'my-app', - validate: (name) => { - const base = validateNameInput(name); - if (base !== true) return base; - const target = path.join(process.cwd(), name.trim()); - if (fs.existsSync(target)) return `Project already exists: ${target}`; - return true; - }, + const projectName = await promptName('my-app', (name) => { + const base = validateNameInput(name); + if (base !== true) return base; + const target = path.join(process.cwd(), name.trim()); + if (fs.existsSync(target)) return `Project already exists: ${target}`; + return true; }); const projectPath = path.join(process.cwd(), projectName); - const packageManager = await select({ - message: 'Select the desired package manager', - choices: [ - new Separator(), - ...PACKAGE_MANAGERS.map((pm) => ({ name: pm, value: pm })), - ], - }); - - const framework = await select({ - message: 'Select the desired framework', - choices: [ - new Separator(), - { name: 'Vite - React', value: 'react' }, - { name: 'Vite - Vue', value: 'vue' }, - { name: 'NodeJS', value: 'node' }, - ], - }); - - const projectType = await select({ - message: 'Select the desired project type', - choices: [ - new Separator(), - { name: 'XCM SDK', value: 'sdk' }, - { name: 'XCM API', value: 'api' }, - ], - }); + const packageManager = await promptPackageManager('pnpm'); + const framework = await promptFramework(); + const projectType = await promptProjectType(); let client: SdkClient = 'pjs'; if (projectType === 'sdk') { - client = await select({ - message: 'Select the desired JS client type', - choices: [ - new Separator(), - { name: 'Polkadot API', value: 'papi' }, - { name: 'Polkadot JS', value: 'pjs' }, - { name: 'Dedot', value: 'dedot' }, - ], - }); + client = await promptClient('pjs'); } const additionalFeatures = await promptFeatureExtensions(); @@ -124,36 +91,22 @@ export async function runInteractiveGenerate( ? await promptEvmPrivateKey() : undefined; - if (projectType === 'sdk') { - await generateSdkApp({ - meta: SDK_FRAMEWORKS[framework], - templatesRoot, - opts: { - framework, - name: projectName, - client, - ...featureFlags, - packageManager, - out: projectPath, - privateKey, - substrateMnemonic, - }, - }); - } else { - await generateApiApp({ - meta: API_FRAMEWORKS[framework], - templatesRoot, - opts: { - framework, - name: projectName, - ...featureFlags, - packageManager, - out: projectPath, - privateKey, - substrateMnemonic, - }, - }); - } + const opts = { + framework, + name: projectName, + ...featureFlags, + packageManager, + out: projectPath, + privateKey, + substrateMnemonic, + }; + + await generateApp( + projectType === 'sdk' + ? { kind: projectType, framework, templatesRoot, opts: { ...opts, client } } + : { kind: projectType, framework, templatesRoot, opts }, + ); + outro(`Scaffolded ${projectName}`); printNextSteps(projectName, packageManager, framework); -} +}; diff --git a/src/package-root.ts b/src/package-root.ts index dcfbbd5..f69a8cf 100644 --- a/src/package-root.ts +++ b/src/package-root.ts @@ -2,14 +2,14 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -function isPackageRoot(dir: string): boolean { +const isPackageRoot = (dir: string): boolean => { return ( fs.existsSync(path.join(dir, '_templates')) && fs.existsSync(path.join(dir, 'shared', 'feature-flags.cjs')) ); -} +}; -export function getPackageRoot(fromModuleUrl = import.meta.url): string { +export const getPackageRoot = (fromModuleUrl = import.meta.url): string => { let dir = path.dirname(fileURLToPath(fromModuleUrl)); while (true) { @@ -25,4 +25,4 @@ export function getPackageRoot(fromModuleUrl = import.meta.url): string { } dir = parent; } -} +}; diff --git a/src/run-cli.test.ts b/src/run-cli.test.ts index 310ceb1..710fce0 100644 --- a/src/run-cli.test.ts +++ b/src/run-cli.test.ts @@ -2,63 +2,60 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { UserError } from './shared/errors.js'; type GenerateSdkApp = typeof import('./shared/hygen-runner.js').generateSdkApp; type GenerateApiApp = typeof import('./shared/hygen-runner.js').generateApiApp; -type RunInteractiveGenerate = typeof import('./interactive.js').runInteractiveGenerate; -type PromptSdkOptions = typeof import('./shared/prompt-sdk.js').promptSdkOptions; -type PromptApiOptions = typeof import('./shared/prompt-api.js').promptApiOptions; +type RunInteractive = + typeof import('./interactive.js').runInteractiveGenerate; +type PromptGenerateOptions = + typeof import('./shared/prompt-options.js').promptGenerateOptions; const generateSdkApp = vi.fn(); const generateApiApp = vi.fn(); -const runInteractiveGenerate = vi.fn(); -const promptSdkOptions = vi.fn(); -const promptApiOptions = vi.fn(); +const runInteractiveGenerate = vi.fn(); +const promptGenerateOptions = vi.fn(); vi.mock('./shared/hygen-runner.js', () => ({ - generateSdkApp: (params: Parameters[0]) => generateSdkApp(params), - generateApiApp: (params: Parameters[0]) => generateApiApp(params), + generateSdkApp: (params: Parameters[0]) => + generateSdkApp(params), + generateApiApp: (params: Parameters[0]) => + generateApiApp(params), })); vi.mock('./interactive.js', () => ({ - runInteractiveGenerate: (templatesRoot: Parameters[0]) => + runInteractiveGenerate: (templatesRoot: string) => runInteractiveGenerate(templatesRoot), })); -vi.mock('./shared/prompt-sdk.js', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('./shared/prompt-options.js', async (importOriginal) => { + const actual = + await importOriginal(); return { ...actual, - promptSdkOptions: ( - partial: Parameters[0], - options?: Parameters[1], - ) => promptSdkOptions(partial, options), + promptGenerateOptions: ( + input: Parameters[0], + options?: Parameters[1], + ) => promptGenerateOptions(input, options), }; }); -vi.mock('./shared/prompt-api.js', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - promptApiOptions: ( - partial: Parameters[0], - options?: Parameters[1], - ) => promptApiOptions(partial, options), - }; -}); +const { runCli, runFromArgv } = await import('./run-cli.js'); -const { runApiFromArgv, runCli, runSdkFromArgv } = await import('./run-cli.js'); +type ArgvContext = { root: string; templatesRoot: string; consumer?: boolean }; +const runSdkFromArgv = (argv: string[], ctx: ArgvContext) => + runFromArgv(['sdk', ...argv], ctx); +const runApiFromArgv = (argv: string[], ctx: ArgvContext) => + runFromArgv(['api', ...argv], ctx); const TEMPLATES_ROOT = path.join(process.cwd(), '_templates'); -function consumerCtx(root: string) { +const consumerCtx = (root: string) => { return { root, templatesRoot: TEMPLATES_ROOT, consumer: true as const }; -} +}; -function devCtx(root: string) { +const devCtx = (root: string) => { return { root, templatesRoot: TEMPLATES_ROOT }; -} +}; const SDK_FLAGS = [ 'react', @@ -71,27 +68,14 @@ const SDK_FLAGS = [ '--evm', ] as const; -const defaultSdkPromptAnswers: Awaited> = { - name: 'my-app', - client: 'pjs', - evm: false, - swap: false, - snowbridge: false, - packageManager: 'pnpm', +const stubTty = (isTTY: boolean): void => { + vi.stubGlobal('process', { ...process, stdin: { isTTY }, exitCode: undefined }); }; -const defaultApiPromptAnswers: Awaited> = { - name: 'my-api-app', - evm: false, - swap: false, - snowbridge: false, - packageManager: 'pnpm', +const capturedText = (mock: { mock: { calls: unknown[][] } }): string => { + return mock.mock.calls.map((call) => String(call[0])).join(''); }; -function stubTty(isTTY: boolean): void { - vi.stubGlobal('process', { ...process, stdin: { isTTY } }); -} - describe('runSdkFromArgv', () => { let tmpRoot: string; @@ -100,8 +84,6 @@ describe('runSdkFromArgv', () => { vi.clearAllMocks(); generateSdkApp.mockResolvedValue(undefined); generateApiApp.mockResolvedValue(undefined); - promptSdkOptions.mockResolvedValue(defaultSdkPromptAnswers); - promptApiOptions.mockResolvedValue(defaultApiPromptAnswers); vi.spyOn(fs, 'existsSync').mockReturnValue(false); stubTty(false); }); @@ -112,10 +94,10 @@ describe('runSdkFromArgv', () => { vi.unstubAllGlobals(); }); - it('generates a sdk app from positional framework and flags without prompting', async () => { + it('generates a sdk app from positional framework and flags', async () => { await runSdkFromArgv([...SDK_FLAGS], consumerCtx(tmpRoot)); - expect(promptSdkOptions).not.toHaveBeenCalled(); + expect(promptGenerateOptions).not.toHaveBeenCalled(); expect(generateSdkApp).toHaveBeenCalledOnce(); expect(generateSdkApp.mock.calls[0]?.[0]).toMatchObject({ templatesRoot: TEMPLATES_ROOT, @@ -132,23 +114,20 @@ describe('runSdkFromArgv', () => { it('uses --out instead of cwd/name in consumer mode', async () => { const outDir = path.join(tmpRoot, 'custom-out'); - await runSdkFromArgv( - [...SDK_FLAGS, '--out', outDir], - consumerCtx(tmpRoot), - ); + await runSdkFromArgv([...SDK_FLAGS, '--out', outDir], consumerCtx(tmpRoot)); expect(generateSdkApp.mock.calls[0]?.[0]).toMatchObject({ opts: expect.objectContaining({ out: outDir }), }); }); - it('keeps the dev default out path when consumer mode is disabled', async () => { + it('uses the dev default out path when consumer mode is disabled', async () => { await runSdkFromArgv([...SDK_FLAGS], devCtx(tmpRoot)); expect(generateSdkApp.mock.calls[0]?.[0]).toMatchObject({ opts: expect.objectContaining({ name: 'my-app', - out: path.join(tmpRoot, 'generated', 'xcm-sdk', 'react', 'my-xcm-app'), + out: path.join(tmpRoot, 'generated', 'xcm-sdk', 'react', 'my-app'), }), }); }); @@ -157,115 +136,94 @@ describe('runSdkFromArgv', () => { const log = vi.spyOn(console, 'log').mockImplementation(() => {}); await runSdkFromArgv([...SDK_FLAGS], consumerCtx(tmpRoot)); - expect(log.mock.calls.some(([line]) => String(line).includes('Next steps:'))).toBe( - true, - ); - expect(log.mock.calls.some(([line]) => String(line).includes('npm install'))).toBe( - true, - ); + const printed = log.mock.calls.map(([line]) => String(line)).join('\n'); + expect(printed).toContain('Next steps:'); + expect(printed).toContain('npm install'); }); - it('throws when the consumer target directory already exists', async () => { + it('reports an error and skips generation when the target exists', async () => { vi.mocked(fs.existsSync).mockReturnValue(true); + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + + await runSdkFromArgv([...SDK_FLAGS], consumerCtx(tmpRoot)); - await expect( - runSdkFromArgv([...SDK_FLAGS], consumerCtx(tmpRoot)), - ).rejects.toThrow(UserError); - await expect( - runSdkFromArgv([...SDK_FLAGS], consumerCtx(tmpRoot)), - ).rejects.toThrow(/Project already exists/); expect(generateSdkApp).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(capturedText(stderr)).toContain('Project already exists'); }); - it('prints sdk help and skips generation when --help is passed', async () => { - const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + it('prints sdk help and skips generation for --help', async () => { + const stdout = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + await runSdkFromArgv(['react', '--help'], consumerCtx(tmpRoot)); - expect(log.mock.calls[0]?.[0]).toContain('create-paraspell sdk'); + expect(capturedText(stdout)).toContain('create-paraspell sdk'); expect(generateSdkApp).not.toHaveBeenCalled(); }); - it('prompts for missing flags when stdin is a TTY', async () => { + it('prompts for missing options when stdin is a TTY', async () => { stubTty(true); - promptSdkOptions.mockResolvedValue({ + promptGenerateOptions.mockResolvedValue({ name: 'prompted-app', - packageManager: 'pnpm', client: 'pjs', evm: false, swap: false, snowbridge: false, + packageManager: 'pnpm', }); await runSdkFromArgv(['react'], consumerCtx(tmpRoot)); - expect(promptSdkOptions).toHaveBeenCalledOnce(); + expect(promptGenerateOptions).toHaveBeenCalledOnce(); expect(generateSdkApp.mock.calls[0]?.[0]).toMatchObject({ opts: expect.objectContaining({ name: 'prompted-app' }), }); }); - it('prompts for name when an invalid --name is passed on a TTY', async () => { - stubTty(true); - promptSdkOptions.mockResolvedValue({ - name: 'fixed-app', - packageManager: 'npm', - client: 'pjs', - evm: true, - swap: false, - snowbridge: false, - }); + it('reports invalid CLI secrets and skips generation without a TTY', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); await runSdkFromArgv( [ - 'react', + 'node', '--name', - 'Invalid Name', + 'node-app', '--package-manager', - 'npm', + 'pnpm', '--client', - 'pjs', + 'papi', '--evm', + '--private-key', + 'incorrect', + '--substrate-mnemonic', + 'bad-mnemonic', ], consumerCtx(tmpRoot), ); - expect(promptSdkOptions).toHaveBeenCalledOnce(); - expect(generateSdkApp.mock.calls[0]?.[0]).toMatchObject({ - opts: expect.objectContaining({ name: 'fixed-app' }), - }); - }); - - it('throws on invalid CLI secrets when stdin is not a TTY', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const argv = [ - 'node', - '--name', - 'node-app', - '--package-manager', - 'pnpm', - '--client', - 'papi', - '--evm', - '--private-key', - 'incorrect', - '--substrate-mnemonic', - 'bad-mnemonic', - ]; - - await expect(runSdkFromArgv(argv, consumerCtx(tmpRoot))).rejects.toThrow(UserError); - await expect(runSdkFromArgv(argv, consumerCtx(tmpRoot))).rejects.toThrow( - /Invalid --private-key or --substrate-mnemonic/, - ); - expect(promptSdkOptions).not.toHaveBeenCalled(); + expect(promptGenerateOptions).not.toHaveBeenCalled(); expect(generateSdkApp).not.toHaveBeenCalled(); - expect(warn).toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(capturedText(stderr)).toContain( + 'Invalid --private-key or --substrate-mnemonic', + ); }); it('prompts for secrets when invalid CLI secrets are passed on a TTY', async () => { stubTty(true); - promptSdkOptions.mockResolvedValue({ - ...defaultSdkPromptAnswers, + promptGenerateOptions.mockResolvedValue({ name: 'node-app', + client: 'papi', + evm: true, + swap: false, + snowbridge: false, + packageManager: 'pnpm', substrateMnemonic: '//Alice', privateKey: '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', @@ -289,7 +247,7 @@ describe('runSdkFromArgv', () => { consumerCtx(tmpRoot), ); - expect(promptSdkOptions).toHaveBeenCalledOnce(); + expect(promptGenerateOptions).toHaveBeenCalledOnce(); expect(generateSdkApp).toHaveBeenCalledOnce(); }); }); @@ -301,7 +259,6 @@ describe('runApiFromArgv', () => { tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'paraspell-run-cli-')); vi.clearAllMocks(); generateApiApp.mockResolvedValue(undefined); - promptApiOptions.mockResolvedValue(defaultApiPromptAnswers); vi.spyOn(fs, 'existsSync').mockReturnValue(false); stubTty(false); }); @@ -348,33 +305,33 @@ describe('runCli', () => { expect(runInteractiveGenerate).toHaveBeenCalledWith(TEMPLATES_ROOT); }); - it('prints main help for bare --help', async () => { - const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + it('prints root help to stdout for --help', async () => { + const stdout = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + await runCli(['--help'], TEMPLATES_ROOT); - expect(log.mock.calls[0]?.[0]).toContain('create-paraspell sdk'); + + expect(capturedText(stdout)).toContain('create-paraspell'); expect(runInteractiveGenerate).not.toHaveBeenCalled(); }); - it('exits with code 1 when orphan flags are passed without sdk|api', async () => { - const error = vi.spyOn(console, 'error').mockImplementation(() => {}); - const log = vi.spyOn(console, 'log').mockImplementation(() => {}); - const exit = vi.spyOn(process, 'exit').mockImplementation((() => { - throw new Error('process.exit'); - }) as typeof process.exit); + it('reports an unknown command for orphan flags', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); - await expect(runCli(['--name', 'orphan'], TEMPLATES_ROOT)).rejects.toThrow( - 'process.exit', - ); - expect(exit).toHaveBeenCalledWith(1); - expect(error.mock.calls[0]?.[0]).toContain( - 'Non-interactive mode requires --type sdk|api', - ); - expect(log).toHaveBeenCalled(); + await runCli(['--name', 'orphan'], TEMPLATES_ROOT); + + expect(generateSdkApp).not.toHaveBeenCalled(); expect(runInteractiveGenerate).not.toHaveBeenCalled(); + expect(capturedText(stderr)).toContain('No command registered'); }); it('routes sdk subcommands through consumer generation', async () => { - const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'paraspell-run-cli-')); + const tmpRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'paraspell-run-cli-'), + ); const cwd = vi.spyOn(process, 'cwd').mockReturnValue(tmpRoot); try { diff --git a/src/run-cli.ts b/src/run-cli.ts index 9938341..13648fa 100644 --- a/src/run-cli.ts +++ b/src/run-cli.ts @@ -1,211 +1,325 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { runInteractiveGenerate } from './interactive.js'; -import { API_FRAMEWORKS, SDK_FRAMEWORKS } from './shared/frameworks.js'; -import { generateApiApp, generateSdkApp } from './shared/hygen-runner.js'; -import { printNextSteps } from './shared/next-steps.js'; +import fs from "node:fs"; +import path from "node:path"; import { - assertNoStrayPositional, - argvHasFlag, - getArgvFlag, - hasRejectedCliSecrets, - parseApiArgv, - parseSdkArgv, - printApiHelp, - printMainHelp, - printSdkHelp, - shiftPositionalFramework, - shiftPositionalType, -} from './shared/parse-cli-args.js'; -import { apiNeedsInteractive, promptApiOptions } from './shared/prompt-api.js'; -import { promptSdkOptions, sdkNeedsInteractive } from './shared/prompt-sdk.js'; + type Application, + buildApplication, + buildCommand, + buildRouteMap, + type CommandContext, + type FlagParametersForType, + help, + run, +} from "@stricli/core"; +import { runInteractiveGenerate } from "./interactive.js"; +import { + frameworkPositional, + parseClientArg, + parseFrameworkArg, + parsePackageManagerArg, +} from "./shared/cli-params.js"; +import { generateApp } from "./shared/generate-dispatch.js"; +import { printNextSteps } from "./shared/next-steps.js"; +import { + applyGenerateDefaults, + generateNeedsInteractive, + hasRejectedSecrets, + type NameValidator, + promptGenerateOptions, +} from "./shared/prompt-options.js"; import type { - ApiGenerateOptions, Framework, + PackageManager, ProjectType, - SdkGenerateOptions, -} from './shared/types.js'; -import { UserError } from './shared/errors.js'; -import { validateNameInput } from './shared/validate.js'; + ResolveInput, + SdkClient, +} from "./shared/types.js"; +import { validateNameInput } from "./shared/validate.js"; -function consumerNameValidator( - argv: string[], - root: string, - parsedOut: string, -): (name: string) => true | string { - return (name) => { - const base = validateNameInput(name); - if (base !== true) return base; - const out = resolveConsumerOut(argv, root, name, parsedOut); - if (fs.existsSync(out)) return `Project already exists: ${out}`; - return true; - }; +interface AppContext extends CommandContext { + root: string; + templatesRoot: string; + consumer: boolean; } -function argvHasOut(argv: string[]): boolean { - return argv.some((a) => a === '--out' || a.startsWith('--out=')); -} +export type RunContext = { + root: string; + templatesRoot: string; + consumer?: boolean; +}; + +type SharedFlags = { + name?: string; + framework?: Framework; + evm?: boolean; + swap?: boolean; + snowbridge?: boolean; + packageManager?: PackageManager; + out?: string; + privateKey?: string; + substrateMnemonic?: string; +}; + +type SdkFlags = SharedFlags & { client?: SdkClient }; +type ApiFlags = SharedFlags; + +const identity = (value: string): string => value; + +const parseNameArg = (value: string): string => { + const result = validateNameInput(value); + if (result !== true) throw new Error(result); + return value; +}; + +const sharedFlagParams = { + name: { + kind: "parsed", + parse: parseNameArg, + brief: "Project name", + optional: true, + }, + framework: { + kind: "parsed", + parse: parseFrameworkArg, + brief: "Target framework: react | vue | node", + optional: true, + }, + evm: { kind: "boolean", brief: "Enable EVM origin chains", optional: true }, + swap: { + kind: "boolean", + brief: "Enable cross-chain swaps (@paraspell/swap)", + optional: true, + }, + snowbridge: { + kind: "boolean", + brief: "Enable Snowbridge transfers", + optional: true, + }, + packageManager: { + kind: "parsed", + parse: parsePackageManagerArg, + brief: "Package manager: npm | yarn | pnpm | bun", + optional: true, + }, + out: { + kind: "parsed", + parse: identity, + brief: "Output directory", + optional: true, + }, + privateKey: { + kind: "parsed", + parse: identity, + brief: "EVM wallet key for node when using EVM or Snowbridge origins", + optional: true, + }, + substrateMnemonic: { + kind: "parsed", + parse: identity, + brief: "Substrate mnemonic or //Dev URI for node", + optional: true, + }, +} as const satisfies FlagParametersForType; + +const sdkFlagParams = { + ...sharedFlagParams, + client: { + kind: "parsed", + parse: parseClientArg, + brief: "JS client: papi | pjs | dedot", + optional: true, + }, +} as const satisfies FlagParametersForType; + +const resolveOut = (root: string, out: string): string => + path.isAbsolute(out) ? out : path.join(root, out); -function resolveConsumerOut( - argv: string[], +const defaultInternalOut = ( root: string, + kind: ProjectType, + framework: Framework, name: string, - parsedOut: string, -): string { - if (argvHasOut(argv)) return parsedOut; - return path.join(root, name); -} +): string => + path.join( + root, + "generated", + kind === "sdk" ? "xcm-sdk" : "xcm-api", + framework, + name, + ); -function assertConsumerProject(name: string, outDir: string): void { +const assertConsumerProject = (name: string, outDir: string): void => { const nameError = validateNameInput(name); - if (nameError !== true) { - throw new UserError(nameError); - } + if (nameError !== true) throw new Error(nameError); if (fs.existsSync(outDir)) { - throw new UserError(`Project already exists: ${outDir}`); + throw new Error(`Project already exists: ${outDir}`); } -} - -function wantsNonInteractive(argv: string[]): boolean { - const type = shiftPositionalType([...argv]).type ?? parseProjectTypeFlag(argv); - return type !== null; -} - -function parseProjectTypeFlag(argv: string[]): ProjectType | null { - const raw = getArgvFlag(argv, 'type'); - if (raw === 'sdk' || raw === 'api') return raw; - return null; -} - -type RunContext = { root: string; templatesRoot: string; consumer?: boolean }; +}; -async function runFromArgv( - kind: ProjectType, - rawArgv: string[], - ctx: RunContext, -): Promise { - const { argv, framework: positional } = shiftPositionalFramework(rawArgv); - assertNoStrayPositional(argv, positional); - const parseCtx = { - root: ctx.root, - framework: positional ?? 'react', - frameworkFlag: true, +const makeConsumerNameValidator = ( + root: string, + outFlag: string | undefined, +): NameValidator => { + return (name) => { + const base = validateNameInput(name); + if (base !== true) return base; + const out = + outFlag !== undefined ? resolveOut(root, outFlag) : path.join(root, name); + if (fs.existsSync(out)) return `Project already exists: ${out}`; + return true; }; +}; - let opts: SdkGenerateOptions | ApiGenerateOptions = - kind === 'sdk' ? parseSdkArgv(argv, parseCtx) : parseApiArgv(argv, parseCtx); +const runGenerate = async ( + kind: ProjectType, + ctx: AppContext, + flags: SdkFlags, + positionalFramework?: Framework, +): Promise => { + try { + const framework = flags.framework ?? positionalFramework ?? "react"; + const input: ResolveInput = { + kind, + framework, + name: flags.name, + client: kind === "sdk" ? flags.client : undefined, + evm: flags.evm, + swap: flags.swap, + snowbridge: flags.snowbridge, + packageManager: flags.packageManager, + privateKey: flags.privateKey, + substrateMnemonic: flags.substrateMnemonic, + }; - if (opts.help) { - const helpCommand = ctx.consumer ? `create-paraspell ${kind}` : undefined; - if (kind === 'sdk') printSdkHelp(helpCommand); - else printApiHelp(helpCommand); - return; - } + const rejectedSecrets = hasRejectedSecrets(input); + if (rejectedSecrets && !process.stdin.isTTY) { + throw new Error( + "Invalid --private-key or --substrate-mnemonic value. Fix the flag value, or omit it and run on a TTY to enter secrets interactively.", + ); + } - const rejectedSecrets = hasRejectedCliSecrets(argv, opts); - if (rejectedSecrets && !process.stdin.isTTY) { - throw new UserError( - 'Invalid --private-key or --substrate-mnemonic value. Fix the flag value, or omit it and run on a TTY to enter secrets interactively.', - ); - } + const interactive = + generateNeedsInteractive(input) || + (rejectedSecrets && Boolean(process.stdin.isTTY)); - const needsInteractive = - rejectedSecrets || - (kind === 'sdk' - ? sdkNeedsInteractive(argv, opts as SdkGenerateOptions) - : apiNeedsInteractive(argv, opts)); - if (needsInteractive) { const validateName = ctx.consumer - ? consumerNameValidator(argv, ctx.root, opts.out) + ? makeConsumerNameValidator(ctx.root, flags.out) : undefined; - const provided = { - framework: positional !== null || argvHasFlag(argv, 'framework'), + + const resolved = interactive + ? await promptGenerateOptions(input, { validateName }) + : applyGenerateDefaults(input); + + const out = + flags.out !== undefined + ? resolveOut(ctx.root, flags.out) + : ctx.consumer + ? path.join(ctx.root, resolved.name) + : defaultInternalOut(ctx.root, kind, framework, resolved.name); + + if (ctx.consumer) assertConsumerProject(resolved.name, out); + + const opts = { + framework, + name: resolved.name, + evm: resolved.evm, + swap: resolved.swap, + snowbridge: resolved.snowbridge, + packageManager: resolved.packageManager, + out, + privateKey: resolved.privateKey, + substrateMnemonic: resolved.substrateMnemonic, }; - const answers = - kind === 'sdk' - ? await promptSdkOptions(opts as SdkGenerateOptions, { - validateName, - argv, - provided, - }) - : await promptApiOptions(opts, { validateName, argv, provided }); - opts = { ...opts, ...answers }; - } - if (ctx.consumer) { - opts.out = resolveConsumerOut(argv, ctx.root, opts.name, opts.out); - assertConsumerProject(opts.name, opts.out); - } + await generateApp( + kind === "sdk" + ? { + kind, + framework, + templatesRoot: ctx.templatesRoot, + opts: { ...opts, client: resolved.client ?? "pjs" }, + } + : { kind, framework, templatesRoot: ctx.templatesRoot, opts }, + ); - if (kind === 'sdk') { - await generateSdkApp({ - meta: SDK_FRAMEWORKS[opts.framework], - templatesRoot: ctx.templatesRoot, - opts: opts as SdkGenerateOptions, - }); - } else { - await generateApiApp({ - meta: API_FRAMEWORKS[opts.framework], - templatesRoot: ctx.templatesRoot, - opts, - }); + if (ctx.consumer) { + printNextSteps(out, resolved.packageManager, framework); + } + } catch (error) { + return error instanceof Error ? error : new Error(String(error)); } +}; - if (ctx.consumer) { - printNextSteps(opts.out, opts.packageManager, opts.framework); - } -} +const createApp = (): Application => { + const sdk = buildCommand({ + docs: { brief: "Scaffold a ParaSpell XCM SDK starter app" }, + parameters: { positional: frameworkPositional, flags: sdkFlagParams }, + func(flags, framework) { + return runGenerate("sdk", this, flags, framework); + }, + }); -export async function runSdkFromArgv( - rawArgv: string[], - ctx: RunContext, -): Promise { - return runFromArgv('sdk', rawArgv, ctx); -} + const api = buildCommand({ + docs: { brief: "Scaffold a ParaSpell XCM API starter app" }, + parameters: { positional: frameworkPositional, flags: sharedFlagParams }, + func(flags, framework) { + return runGenerate("api", this, flags, framework); + }, + }); -export async function runApiFromArgv( - rawArgv: string[], - ctx: RunContext, -): Promise { - return runFromArgv('api', rawArgv, ctx); -} + const routes = buildRouteMap({ + routes: { sdk, api }, + docs: { + brief: "Scaffold ParaSpell XCM SDK and XCM API starter apps", + }, + }); -export async function runCli( - rawArgv: string[], - templatesRoot: string, -): Promise { - const cwd = process.cwd(); + return buildApplication( + routes, + { + name: "create-paraspell", + scanner: { caseStyle: "allow-kebab-for-camel" }, + }, + { + help: help({ + alias: "h", + brief: "Print help information and exit", + formatting: { + useAliasInUsageLine: false, + onlyRequiredInUsageLine: false, + caseStyle: "convert-camel-to-kebab", + }, + }), + }, + ); +}; - if (rawArgv.length === 0) { - await runInteractiveGenerate(templatesRoot); - return; - } +const app = createApp(); - if (getArgvFlag(rawArgv, 'help') === true && !wantsNonInteractive(rawArgv)) { - printMainHelp(); - return; - } +const toContext = ( + root: string, + templatesRoot: string, + consumer: boolean, +): AppContext => { + return { process, root, templatesRoot, consumer }; +}; - const { argv, type: positionalType } = shiftPositionalType(rawArgv); - const projectType = positionalType ?? parseProjectTypeFlag(argv); +export const runFromArgv = (rawArgv: string[], ctx: RunContext) => { + return run( + app, + rawArgv, + toContext(ctx.root, ctx.templatesRoot, ctx.consumer ?? false), + ); +}; - if (!projectType) { - if (rawArgv.some((a) => a.startsWith('--'))) { - console.error( - 'Non-interactive mode requires --type sdk|api or a leading sdk|api subcommand.\n', - ); - printMainHelp(); - process.exit(1); - } +export const runCli = async (rawArgv: string[], templatesRoot: string) => { + if (rawArgv.length === 0) { await runInteractiveGenerate(templatesRoot); return; } - const ctx: RunContext = { root: cwd, templatesRoot, consumer: true }; - - if (projectType === 'sdk') { - await runSdkFromArgv(argv, ctx); - } else { - await runApiFromArgv(argv, ctx); - } -} + await runFromArgv(rawArgv, { + root: process.cwd(), + templatesRoot, + consumer: true, + }); +}; diff --git a/src/shared/clack.ts b/src/shared/clack.ts new file mode 100644 index 0000000..cfefc71 --- /dev/null +++ b/src/shared/clack.ts @@ -0,0 +1,19 @@ +import { cancel, isCancel } from "@clack/prompts"; + +export const ask = async (prompt: Promise): Promise => { + const value = await prompt; + if (isCancel(value)) { + cancel("Operation cancelled."); + process.exit(0); + } + return value as T; +} + +export const toClackValidate = ( + validate: (value: string) => true | string, +): ((value: string | undefined) => string | undefined) => { + return (value) => { + const result = validate(value ?? ""); + return result === true ? undefined : result; + }; +} diff --git a/src/shared/cli-params.ts b/src/shared/cli-params.ts new file mode 100644 index 0000000..44e168f --- /dev/null +++ b/src/shared/cli-params.ts @@ -0,0 +1,42 @@ +import { FRAMEWORKS, PACKAGE_MANAGERS, SDK_CLIENTS } from "./types.js"; + +const choiceParser = ( + label: string, + values: readonly T[], +): ((input: string) => T) => { + return (input) => { + const match = values.find((value) => value === input); + if (match === undefined) { + throw new Error( + `Unknown ${label} "${input}". Expected one of: ${values.join(", ")}.`, + ); + } + return match; + }; +} + +export const parseFrameworkArg = choiceParser("framework", FRAMEWORKS); +export const parseClientArg = choiceParser("client", SDK_CLIENTS); +export const parsePackageManagerArg = choiceParser( + "package manager", + PACKAGE_MANAGERS, +); + +export const frameworkPositional = { + kind: "tuple", + parameters: [ + { + brief: "Target framework (react | vue | node)", + parse: parseFrameworkArg, + optional: true, + placeholder: "framework", + }, + ], +} as const; + +export const packageManagerFlag = { + kind: "parsed", + parse: parsePackageManagerArg, + brief: "Package manager: npm | yarn | pnpm | bun", + optional: true, +} as const; diff --git a/src/shared/errors.ts b/src/shared/errors.ts deleted file mode 100644 index 6871dd3..0000000 --- a/src/shared/errors.ts +++ /dev/null @@ -1,10 +0,0 @@ -export class UserError extends Error { - constructor(message: string) { - super(message); - this.name = 'UserError'; - } -} - -export function isUserError(error: unknown): error is UserError { - return error instanceof UserError; -} diff --git a/src/shared/feature-extensions-checkbox.ts b/src/shared/feature-extensions-checkbox.ts index c1c5a83..7be73e8 100644 --- a/src/shared/feature-extensions-checkbox.ts +++ b/src/shared/feature-extensions-checkbox.ts @@ -1,277 +1,51 @@ -/** - * Checkbox prompt for optional XCM feature extensions. - */ -import { - createPrompt, - isDownKey, - isEnterKey, - isNumberKey, - isSpaceKey, - isUpKey, - makeTheme, - Separator, - useKeypress, - useMemo, - usePagination, - usePrefix, - useState, - ValidationError, -} from '@inquirer/core'; -import { cursorHide } from '@inquirer/ansi'; -import figures from '@inquirer/figures'; -import { styleText } from 'node:util'; +import { multiselect } from '@clack/prompts'; +import { ask } from './clack.js'; export const SWAP_EXTENSION = 'swap-extension'; export const EVM_EXTENSION = 'evm-extension'; export const SNOWBRIDGE_EXTENSION = 'snowbridge-extension'; -type ExtensionValue = +export type ExtensionValue = | typeof SWAP_EXTENSION | typeof EVM_EXTENSION | typeof SNOWBRIDGE_EXTENSION; -type ExtensionChoice = { - value: ExtensionValue; - name: string; - checkedName: string; - short: string; - disabled: boolean | string; - checked: boolean; - description?: string; -}; - -const checkboxTheme = { - icon: { - checked: styleText('green', figures.circleFilled), - unchecked: figures.circle, - cursor: figures.pointer, - disabledChecked: styleText('green', figures.circleDouble), - disabledUnchecked: '-', - }, - style: { - disabled: (text: string) => styleText('dim', text), - renderSelectedChoices: (selected: ReadonlyArray) => - selected.map((c) => c.short).join(', '), - description: (text: string) => styleText('cyan', text), - keysHelpTip: (keys: [string, string][]) => - keys.map(([key, action]) => `${styleText('bold', key)} ${styleText('dim', action)}`).join(styleText('dim', ' • ')), - }, - i18n: { disabledError: 'This option is disabled and cannot be toggled.' }, -}; - -function isSelectable(item: ExtensionChoice | Separator): item is ExtensionChoice { - return !Separator.isSeparator(item) && !item.disabled; -} - -function isNavigable(item: ExtensionChoice | Separator): item is ExtensionChoice { - return !Separator.isSeparator(item); -} - -function isChecked(item: ExtensionChoice | Separator): item is ExtensionChoice { - return !Separator.isSeparator(item) && item.checked; -} - -function toggle(item: ExtensionChoice | Separator): ExtensionChoice | Separator { - return isSelectable(item) ? { ...item, checked: !item.checked } : item; -} - -function toggleAt( - items: Array, - toggledIndex: number, -): Array { - return items.map((choice, i) => (i === toggledIndex ? toggle(choice) : choice)); -} - export type FeatureExtensionsDefaults = { evm?: boolean; swap?: boolean; snowbridge?: boolean; }; -function defaultChoices( +export const promptFeatureExtensions = async ( defaults: FeatureExtensionsDefaults = {}, -): Array { - return [ - new Separator(), - { - value: SWAP_EXTENSION, - name: 'Swap extension', - checkedName: 'Swap extension', - short: 'Swap', - disabled: false, - checked: defaults.swap ?? false, - description: 'Cross-chain swaps via @paraspell/swap', - }, - { - value: EVM_EXTENSION, - name: 'EVM extension', - checkedName: 'EVM extension', - short: 'EVM', - disabled: false, - checked: defaults.evm ?? false, - description: 'Enables EVM chains to be used as origin chains', - }, - { - value: SNOWBRIDGE_EXTENSION, - name: 'Snowbridge extension', - checkedName: 'Snowbridge extension', - short: 'Snowbridge', - disabled: false, - checked: defaults.snowbridge ?? false, - description: 'Snowbridge cross-chain transfers', - }, - ]; -} - -type FeatureExtensionsConfig = { - message: string; - pageSize?: number; - loop?: boolean; - defaults?: FeatureExtensionsDefaults; -}; - -const featureExtensionsCheckbox = createPrompt( - (config, done) => { - const { message, pageSize = 7, loop = true, defaults } = config; - const theme = makeTheme(checkboxTheme); - const { keybindings } = theme; - - const [status, setStatus] = useState<'idle' | 'done'>('idle'); - const prefix = usePrefix({ status, theme }); - const [items, setItems] = useState(defaultChoices(defaults)); - - const bounds = useMemo(() => { - const first = items.findIndex(isNavigable); - let last = -1; - for (let i = items.length - 1; i >= 0; i--) { - if (isNavigable(items[i])) { - last = i; - break; - } - } - if (first === -1) { - throw new ValidationError('[feature-extensions] No selectable choices.'); - } - return { first, last }; - }, [items]); - - const [active, setActive] = useState(bounds.first); - const [errorMsg, setError] = useState(); - - useKeypress((key) => { - if (isEnterKey(key)) { - setStatus('done'); - done(items.filter(isChecked).map((c) => c.value)); - return; - } - - if (isUpKey(key, keybindings) || isDownKey(key, keybindings)) { - if (errorMsg) setError(undefined); - if ( - loop || - (isUpKey(key, keybindings) && active !== bounds.first) || - (isDownKey(key, keybindings) && active !== bounds.last) - ) { - const offset = isUpKey(key, keybindings) ? -1 : 1; - let next = active; - do { - next = (next + offset + items.length) % items.length; - } while (!isNavigable(items[next])); - setActive(next); - } - return; - } - - if (isSpaceKey(key)) { - const activeItem = items[active]; - if (activeItem && !Separator.isSeparator(activeItem)) { - if (activeItem.disabled) { - setError(theme.i18n.disabledError); - } else { - setError(undefined); - setItems(toggleAt(items, active)); - } - } - return; - } - - if (isNumberKey(key)) { - const selectedIndex = Number(key.name) - 1; - let selectableIndex = -1; - const position = items.findIndex((item) => { - if (Separator.isSeparator(item)) return false; - selectableIndex++; - return selectableIndex === selectedIndex; - }); - const selectedItem = items[position]; - if (selectedItem && isSelectable(selectedItem)) { - setActive(position); - setItems(toggleAt(items, position)); - } - } - }); - - const messageLine = theme.style.message(message, status); - let description: string | undefined; - - const page = usePagination({ - items, - active, - renderItem({ item, isActive }) { - if (Separator.isSeparator(item)) { - return ` ${item.separator}`; - } - const cursor = isActive ? theme.icon.cursor : ' '; - if (item.disabled) { - const disabledLabel = typeof item.disabled === 'string' ? item.disabled : '(disabled)'; - const checkbox = item.checked ? theme.icon.disabledChecked : theme.icon.disabledUnchecked; - return theme.style.disabled(`${cursor}${checkbox} ${item.name} ${disabledLabel}`); - } - if (isActive) { - description = item.description; - } - const checkbox = item.checked ? theme.icon.checked : theme.icon.unchecked; - const name = item.checked ? item.checkedName : item.name; - const color = isActive ? theme.style.highlight : (x: string) => x; - return color(`${cursor}${checkbox} ${name}`); - }, - pageSize, - loop, - }); - - if (status === 'done') { - const selection = items.filter(isChecked); - const answer = theme.style.answer(theme.style.renderSelectedChoices(selection)); - return [prefix, messageLine, answer].filter(Boolean).join(' '); - } - - const helpLine = theme.style.keysHelpTip([ - ['↑↓', 'navigate'], - ['space', 'select'], - ['⏎', 'submit'], - ]); - - const lines = [ - [prefix, messageLine].filter(Boolean).join(' '), - page, - ' ', - description ? theme.style.description(description) : '', - errorMsg ? theme.style.error(errorMsg) : '', - helpLine, - ] - .filter(Boolean) - .join('\n') - .trimEnd(); - - return `${lines}${cursorHide}`; - }, -); - -export async function promptFeatureExtensions( - defaults?: FeatureExtensionsDefaults, -): Promise { - return featureExtensionsCheckbox({ - message: 'Select the desired additional features', - defaults, - }) as Promise; +): Promise => { + const initialValues: ExtensionValue[] = []; + if (defaults.swap) initialValues.push(SWAP_EXTENSION); + if (defaults.evm) initialValues.push(EVM_EXTENSION); + if (defaults.snowbridge) initialValues.push(SNOWBRIDGE_EXTENSION); + + return ask( + multiselect({ + message: 'Select the desired additional features', + options: [ + { + value: SWAP_EXTENSION, + label: 'Swap extension', + hint: 'Cross-chain swaps via @paraspell/swap', + }, + { + value: EVM_EXTENSION, + label: 'EVM extension', + hint: 'Enables EVM chains to be used as origin chains', + }, + { + value: SNOWBRIDGE_EXTENSION, + label: 'Snowbridge extension', + hint: 'Snowbridge cross-chain transfers', + }, + ], + initialValues, + required: false, + }), + ); } diff --git a/src/shared/feature-flags.ts b/src/shared/feature-flags.ts index fc15e89..97b7343 100644 --- a/src/shared/feature-flags.ts +++ b/src/shared/feature-flags.ts @@ -15,9 +15,9 @@ export { resolveFeatureFlags }; export type ResolvedFeatureFlags = FeatureFlags & { evmWallet: boolean }; -export function applyFeatureFlags( +export const applyFeatureFlags = ( opts: T, -): T & { evmWallet: boolean } { +): T & { evmWallet: boolean } => { const flags = resolveFeatureFlags(opts); return { ...opts, ...flags }; } diff --git a/src/shared/frameworks.ts b/src/shared/frameworks.ts index a473970..cfdb77e 100644 --- a/src/shared/frameworks.ts +++ b/src/shared/frameworks.ts @@ -21,11 +21,3 @@ export const API_FRAMEWORKS: Record = { }, node: { generator: 'xcm-api-node', label: 'Node.js', examplesSubdir: 'node' }, }; - -export function parseFramework(value: string | undefined): Framework | null { - const key = value?.toLowerCase(); - if (key === 'react' || key === 'vue' || key === 'node' || key === 'nodejs') { - return key === 'nodejs' ? 'node' : key; - } - return null; -} diff --git a/src/shared/generate-dispatch.ts b/src/shared/generate-dispatch.ts new file mode 100644 index 0000000..04d7e16 --- /dev/null +++ b/src/shared/generate-dispatch.ts @@ -0,0 +1,36 @@ +import { API_FRAMEWORKS, SDK_FRAMEWORKS } from "./frameworks.js"; +import { generateApiApp, generateSdkApp } from "./hygen-runner.js"; +import type { + ApiGenerateOptions, + Framework, + SdkGenerateOptions, +} from "./types.js"; + +type GenerateAppParams = + | { + kind: "sdk"; + framework: Framework; + templatesRoot: string; + opts: SdkGenerateOptions; + } + | { + kind: "api"; + framework: Framework; + templatesRoot: string; + opts: ApiGenerateOptions; + }; + +export const generateApp = (params: GenerateAppParams): Promise => { + if (params.kind === "sdk") { + return generateSdkApp({ + meta: SDK_FRAMEWORKS[params.framework], + templatesRoot: params.templatesRoot, + opts: params.opts, + }); + } + return generateApiApp({ + meta: API_FRAMEWORKS[params.framework], + templatesRoot: params.templatesRoot, + opts: params.opts, + }); +} diff --git a/src/shared/hygen-runner.ts b/src/shared/hygen-runner.ts index 837fd4c..875021e 100644 --- a/src/shared/hygen-runner.ts +++ b/src/shared/hygen-runner.ts @@ -1,27 +1,24 @@ -import path from 'node:path'; -import fs from 'node:fs'; -import ejs from 'ejs'; -import { runner, Logger } from 'hygen'; -import contextModule from 'hygen/dist/context.js'; -import { applyFeatureFlags } from './feature-flags.js'; -import { UserError } from './errors.js'; -import { createInquirerPrompter } from './inquirer-prompter.js'; -import { normalizeBlankLinesInDir } from './normalize-blank-lines.js'; -import { shouldWriteNodeEnv, writeNodeEnv } from './write-node-env.js'; +import path from "node:path"; +import fs from "node:fs"; +import ejs from "ejs"; +import { runner, Logger } from "hygen"; +import contextModule from "hygen/dist/context.js"; +import { applyFeatureFlags } from "./feature-flags.js"; +import { shouldWriteNodeEnv, writeNodeEnv } from "./write-node-env.js"; import type { ApiGenerateOptions, FrameworkMeta, ProjectType, SdkGenerateOptions, -} from './types.js'; -import type { RunnerConfig } from 'hygen/dist/types.js'; +} from "./types.js"; +import type { RunnerConfig } from "hygen/dist/types.js"; const hygenContext = contextModule.default; -function createHygenHelpers(): ( +const createHygenHelpers = (): (( locals: Record, config: Record, -) => { includeShared: (relativePath: string) => string } { +) => { includeShared: (relativePath: string) => string }) => { const helpers = ( locals: Record, config: Record, @@ -32,7 +29,7 @@ function createHygenHelpers(): ( if (!fs.existsSync(filePath)) { throw new Error(`Missing shared template: ${filePath}`); } - const template = fs.readFileSync(filePath, 'utf8'); + const template = fs.readFileSync(filePath, "utf8"); const ctx = hygenContext( { ...locals, templates: templatesRoot }, { ...config, helpers }, @@ -41,54 +38,51 @@ function createHygenHelpers(): ( }, }); return helpers; -} +}; -async function runHygen( +const runHygen = async ( generator: string, templatesRoot: string, cwd: string, hygenArgs: string[], - interactive: boolean, -): Promise { - const result = await runner([generator, 'new', ...hygenArgs], { +): Promise => { + const result = await runner([generator, "new", ...hygenArgs], { templates: templatesRoot, cwd, - createPrompter: () => - interactive ? createInquirerPrompter() : { prompt: async () => ({}) }, + createPrompter: () => ({ prompt: async () => ({}) }), logger: new Logger(console.log.bind(console)), debug: false, helpers: createHygenHelpers(), } as RunnerConfig); return result.success; -} +}; -async function copyLogo( +const copyLogo = async ( meta: FrameworkMeta, templatesRoot: string, generator: string, outDir: string, -): Promise { - const logoFile = meta.logoFile ?? 'paraspell.png'; - const logoSrc = path.join(templatesRoot, generator, 'new/public', logoFile); - const logoDest = path.join(outDir, 'public', logoFile); +): Promise => { + const logoFile = meta.logoFile ?? "paraspell.png"; + const logoSrc = path.join(templatesRoot, generator, "new/public", logoFile); + const logoDest = path.join(outDir, "public", logoFile); if (fs.existsSync(logoSrc)) { await fs.promises.mkdir(path.dirname(logoDest), { recursive: true }); await fs.promises.copyFile(logoSrc, logoDest); } -} +}; -async function generateApp(params: { +const generateApp = async (params: { kind: ProjectType; meta: FrameworkMeta; templatesRoot: string; opts: SdkGenerateOptions | ApiGenerateOptions; - interactive: boolean; -}): Promise { - const { kind, meta, templatesRoot, opts, interactive } = params; +}): Promise => { + const { kind, meta, templatesRoot, opts } = params; const flags = applyFeatureFlags(opts); - const templateDir = path.join(templatesRoot, meta.generator, 'new'); + const templateDir = path.join(templatesRoot, meta.generator, "new"); if (!fs.existsSync(templateDir)) { - throw new UserError(`Missing Hygen templates at ${templateDir}`); + throw new Error(`Missing Hygen templates at ${templateDir}`); } if (fs.existsSync(flags.out)) { @@ -98,7 +92,7 @@ async function generateApp(params: { const hygenArgs = [ `--name=${flags.name}`, - ...(kind === 'sdk' + ...(kind === "sdk" ? [`--client=${(flags as SdkGenerateOptions).client}`] : []), `--evm=${flags.evm}`, @@ -112,11 +106,10 @@ async function generateApp(params: { templatesRoot, flags.out, hygenArgs, - interactive, ); if (!ok) { - throw new UserError('Hygen generation failed'); + throw new Error("Hygen generation failed"); } await copyLogo(meta, templatesRoot, meta.generator, flags.out); @@ -129,38 +122,32 @@ async function generateApp(params: { }); } - await normalizeBlankLinesInDir(flags.out); - - const label = kind === 'sdk' ? 'XCM SDK' : 'XCM API'; + const label = kind === "sdk" ? "XCM SDK" : "XCM API"; console.log(`\nGenerated ${meta.label} ${label} app at ${flags.out}`); -} +}; -export async function generateSdkApp(params: { +export const generateSdkApp = async (params: { meta: FrameworkMeta; templatesRoot: string; opts: SdkGenerateOptions; - interactive?: boolean; -}): Promise { +}) => { return generateApp({ - kind: 'sdk', + kind: "sdk", meta: params.meta, templatesRoot: params.templatesRoot, opts: params.opts, - interactive: params.interactive ?? false, }); -} +}; -export async function generateApiApp(params: { +export const generateApiApp = async (params: { meta: FrameworkMeta; templatesRoot: string; opts: ApiGenerateOptions; - interactive?: boolean; -}): Promise { +}): Promise => { return generateApp({ - kind: 'api', + kind: "api", meta: params.meta, templatesRoot: params.templatesRoot, opts: params.opts, - interactive: params.interactive ?? false, }); -} +}; diff --git a/src/shared/inquirer-prompter.ts b/src/shared/inquirer-prompter.ts deleted file mode 100644 index f90edd4..0000000 --- a/src/shared/inquirer-prompter.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { confirm, input, select } from '@inquirer/prompts'; - -interface PrompterChoice { - name: string; - value: string; -} - -interface PrompterQuestion { - type: string; - name: string; - message: string; - initial?: string | boolean; - choices?: PrompterChoice[]; -} - -/** Hygen enquirer-shaped API → @inquirer/prompts */ -export function createInquirerPrompter() { - return { - prompt: async ( - questions: PrompterQuestion | PrompterQuestion[], - ): Promise> => { - const list = Array.isArray(questions) ? questions : [questions]; - const result: Record = {}; - for (const q of list) { - if (q.type === 'input') { - result[q.name] = await input({ - message: q.message, - default: typeof q.initial === 'string' ? q.initial : undefined, - }); - } else if (q.type === 'confirm') { - result[q.name] = await confirm({ - message: q.message, - default: typeof q.initial === 'boolean' ? q.initial : false, - }); - } else if (q.type === 'select' && q.choices) { - result[q.name] = await select({ - message: q.message, - choices: q.choices.map((c) => ({ name: c.name, value: c.value })), - default: typeof q.initial === 'string' ? q.initial : undefined, - }); - } - } - return result; - }, - }; -} diff --git a/src/shared/log-resolved-prompt.test.ts b/src/shared/log-resolved-prompt.test.ts index 382f1c2..9b97dab 100644 --- a/src/shared/log-resolved-prompt.test.ts +++ b/src/shared/log-resolved-prompt.test.ts @@ -1,398 +1,109 @@ -import figures from '@inquirer/figures'; -import { styleText } from 'node:util'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { - buildArgvResolvedLogs, + buildResolvedLogs, formatClientLabel, formatFeatureFlags, - formatFrameworkLabel, - logArgvResolvedPrompts, - logResolvedSecret, + logResolvedPrompts, } from './log-resolved-prompt.js'; +import type { ResolveInput } from './types.js'; -const SECRET_ANSWER = styleText('dim', '(provided via CLI)'); +vi.mock('@clack/prompts', () => ({ + log: { success: vi.fn() }, +})); -const PROMPTS = { - name: 'Enter the project name', - framework: 'Select the desired framework', - packageManager: 'Select the desired package manager', - client: 'Select the desired JS client type', - features: 'Select the desired additional features', - substrateMnemonic: 'Your Substrate wallet mnemonic for setup', - privateKey: 'Your EVM wallet private key for setup', -} as const; +const VALID_PRIVATE_KEY = + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; -type BuildInput = Parameters[0]; -type ResolvedLine = { message: string; answer: string }; +const answersByMessage = (input: ResolveInput): Record => { + return Object.fromEntries( + buildResolvedLogs(input).map((line) => [line.message, line.answer]), + ); +}; -function sdkInput(overrides: Partial = {}): BuildInput { - return { - argv: [], - partial: { framework: 'react' }, - kind: 'sdk', - defaultName: 'my-xcm-app', - ...overrides, - }; -} - -function expectResolvedLogs( - input: BuildInput, - expected: ResolvedLine[], -): void { - expect(buildArgvResolvedLogs(input)).toEqual(expected); -} - -function formatResolvedLine(message: string, answer: string): string { - return `${styleText('green', figures.tick)} ${message} ${styleText('cyan', answer)}`; -} - -describe('formatFrameworkLabel', () => { - it.each([ - ['react', 'Vite - React'], - ['vue', 'Vite - Vue'], - ['node', 'NodeJS'], - ] as const)('maps %s to %s', (framework, label) => { - expect(formatFrameworkLabel(framework)).toBe(label); - }); -}); - -describe('formatClientLabel', () => { - it.each([ - ['papi', 'Polkadot API'], - ['pjs', 'Polkadot JS'], - ['dedot', 'Dedot'], - ] as const)('maps %s to %s', (client, label) => { - expect(formatClientLabel(client)).toBe(label); +describe('formatters', () => { + it('formats client labels', () => { + expect(formatClientLabel('papi')).toBe('Polkadot API'); + expect(formatClientLabel('pjs')).toBe('Polkadot JS'); + expect(formatClientLabel('dedot')).toBe('Dedot'); }); -}); -describe('formatFeatureFlags', () => { - it.each([ - [{ evm: true, swap: true, snowbridge: true }, 'EVM, Swap, Snowbridge'], - [{ evm: true, swap: false, snowbridge: true }, 'EVM, Snowbridge'], - [{ evm: false, swap: true, snowbridge: false }, 'Swap'], - [{ evm: false, swap: false, snowbridge: false }, 'none'], - ] as const)('formats %j as %s', (flags, label) => { - expect(formatFeatureFlags(flags)).toBe(label); + it('formats feature flags, or "none"', () => { + expect( + formatFeatureFlags({ evm: true, swap: false, snowbridge: true }), + ).toBe('EVM, Snowbridge'); + expect( + formatFeatureFlags({ evm: false, swap: false, snowbridge: false }), + ).toBe('none'); }); }); -describe('buildArgvResolvedLogs', () => { - describe('project name', () => { - it('logs the parsed name when --name is provided', () => { - expectResolvedLogs( - sdkInput({ - argv: ['--name', 'my-app'], - partial: { name: 'my-app' }, - }), - [{ message: PROMPTS.name, answer: 'my-app' }], - ); - }); - - it('logs the parsed name from --name=value', () => { - expectResolvedLogs( - sdkInput({ - argv: ['--name=my-app'], - partial: { name: 'my-app' }, - }), - [{ message: PROMPTS.name, answer: 'my-app' }], - ); - }); - - it('does not log name when the flag is present without a value', () => { - expectResolvedLogs(sdkInput({ argv: ['--name'], partial: {} }), []); - expectResolvedLogs( - { - argv: ['--name'], - partial: {}, - kind: 'api', - defaultName: 'my-xcm-api-app', - }, - [], - ); - }); - - it('does not log name when the flag has an invalid value', () => { - expectResolvedLogs( - sdkInput({ - argv: ['--name', 'Invalid Name'], - partial: { name: 'Invalid Name' }, - }), - [], - ); - }); - }); - - describe('framework', () => { - it('logs the formatted label only when marked as CLI-provided', () => { - expectResolvedLogs( - sdkInput({ - argv: ['--name=my-app'], - partial: { framework: 'react', name: 'my-app' }, - provided: { framework: true }, - }), - [ - { message: PROMPTS.name, answer: 'my-app' }, - { message: PROMPTS.framework, answer: 'Vite - React' }, - ], - ); - }); - - it('skips the prompt when framework is not marked as CLI-provided', () => { - expectResolvedLogs( - sdkInput({ - argv: ['--name=my-app'], - partial: { framework: 'react', name: 'my-app' }, - provided: { framework: false }, - }), - [{ message: PROMPTS.name, answer: 'my-app' }], - ); +describe('buildResolvedLogs', () => { + it('logs every provided sdk option', () => { + const answers = answersByMessage({ + kind: 'sdk', + framework: 'react', + name: 'my-app', + packageManager: 'npm', + client: 'pjs', + evm: true, }); + expect(answers['Project name']).toBe('my-app'); + expect(answers['Package manager']).toBe('npm'); + expect(answers['JS client']).toBe('Polkadot JS'); + expect(answers['Additional features']).toBe('EVM'); }); - describe('package manager', () => { - it('logs the parsed package manager', () => { - expectResolvedLogs( - sdkInput({ - argv: ['--package-manager', 'npm'], - partial: { packageManager: 'npm' }, - }), - [{ message: PROMPTS.packageManager, answer: 'npm' }], - ); - }); - - it('does not log package manager when the flag is present without a value', () => { - expectResolvedLogs(sdkInput({ argv: ['--package-manager'], partial: {} }), []); + it('omits an invalid --name', () => { + const lines = buildResolvedLogs({ + kind: 'sdk', + framework: 'react', + name: 'Invalid Name', }); + expect(lines.some((line) => line.message === 'Project name')).toBe(false); }); - describe('client', () => { - it('logs the formatted client for sdk projects', () => { - expectResolvedLogs( - sdkInput({ - argv: ['--client', 'pjs'], - partial: { client: 'pjs' }, - }), - [{ message: PROMPTS.client, answer: 'Polkadot JS' }], - ); - }); - - it('does not log client when the flag is present without a value', () => { - expectResolvedLogs(sdkInput({ argv: ['--client'], partial: {} }), []); - }); - - it('does not log the client prompt for api projects', () => { - expectResolvedLogs( - { - argv: ['--client', 'papi'], - partial: { client: 'papi' }, - kind: 'api', - defaultName: 'my-xcm-api-app', - }, - [], - ); + it('never logs a client for api projects', () => { + const lines = buildResolvedLogs({ + kind: 'api', + framework: 'react', + client: 'pjs', }); + expect(lines.some((line) => line.message === 'JS client')).toBe(false); }); - describe('feature flags', () => { - it('logs enabled flags in stable order', () => { - expectResolvedLogs( - sdkInput({ - argv: ['--evm', '--swap', '--snowbridge'], - partial: { evm: true, swap: true, snowbridge: true }, - }), - [{ message: PROMPTS.features, answer: 'EVM, Swap, Snowbridge' }], - ); - }); - - it('logs none when every feature flag is explicitly disabled', () => { - expectResolvedLogs( - sdkInput({ - argv: ['--evm=false', '--swap=false'], - partial: { evm: false, swap: false }, - }), - [{ message: PROMPTS.features, answer: 'none' }], - ); - }); - - it('omits the prompt when no feature flag appears in argv', () => { - expectResolvedLogs(sdkInput({ argv: ['--name', 'my-app'], partial: { name: 'my-app' } }), [ - { message: PROMPTS.name, answer: 'my-app' }, - ]); + it('logs node secrets when valid and EVM wallet origins are enabled', () => { + const answers = answersByMessage({ + kind: 'sdk', + framework: 'node', + evm: true, + substrateMnemonic: '//Alice', + privateKey: VALID_PRIVATE_KEY, }); + expect(answers['Substrate mnemonic']).toBe('(provided via CLI)'); + expect(answers['EVM private key']).toBe('(provided via CLI)'); }); - describe('prompt order', () => { - it('preserves sdk prompt order for mixed CLI-provided values', () => { - expectResolvedLogs( - sdkInput({ - argv: ['--name', 'my-app', '--evm', '--client', 'pjs'], - partial: { - framework: 'react', - name: 'my-app', - client: 'pjs', - evm: true, - }, - provided: { framework: true }, - }), - [ - { message: PROMPTS.name, answer: 'my-app' }, - { message: PROMPTS.framework, answer: 'Vite - React' }, - { message: PROMPTS.client, answer: 'Polkadot JS' }, - { message: PROMPTS.features, answer: 'EVM' }, - ], - ); - }); - - it('preserves sdk prompt order for framework, package manager, and features', () => { - expectResolvedLogs( - sdkInput({ - argv: ['--package-manager', 'npm', '--evm', '--swap', '--snowbridge'], - partial: { - framework: 'vue', - packageManager: 'npm', - evm: true, - swap: true, - snowbridge: true, - }, - provided: { framework: true }, - }), - [ - { message: PROMPTS.framework, answer: 'Vite - Vue' }, - { message: PROMPTS.packageManager, answer: 'npm' }, - { message: PROMPTS.features, answer: 'EVM, Swap, Snowbridge' }, - ], - ); + it('skips invalid node secrets', () => { + const lines = buildResolvedLogs({ + kind: 'sdk', + framework: 'node', + evm: true, + substrateMnemonic: 'not-a-mnemonic', + privateKey: 'nope', }); - }); - - describe('node secrets', () => { - it('masks substrate mnemonic and private key for node projects', () => { - expectResolvedLogs( - sdkInput({ - argv: ['--evm', '--substrate-mnemonic', 'seed', '--private-key', '0xabc'], - partial: { - framework: 'node', - evm: true, - substrateMnemonic: 'seed', - privateKey: '0xabc', - }, - provided: { framework: true }, - }), - [ - { message: PROMPTS.framework, answer: 'NodeJS' }, - { message: PROMPTS.features, answer: 'EVM' }, - { message: PROMPTS.substrateMnemonic, answer: SECRET_ANSWER }, - { message: PROMPTS.privateKey, answer: SECRET_ANSWER }, - ], - ); - }); - - it('masks the private key when snowbridge enables the EVM wallet flow', () => { - expectResolvedLogs( - sdkInput({ - argv: ['--snowbridge', '--private-key', '0xabc'], - partial: { - framework: 'node', - snowbridge: true, - privateKey: '0xabc', - }, - provided: { framework: true }, - }), - [ - { message: PROMPTS.framework, answer: 'NodeJS' }, - { message: PROMPTS.features, answer: 'Snowbridge' }, - { message: PROMPTS.privateKey, answer: SECRET_ANSWER }, - ], - ); - }); - - it('does not log node secrets for non-node frameworks', () => { - expectResolvedLogs( - sdkInput({ - argv: ['--evm', '--substrate-mnemonic', 'seed', '--private-key', '0xabc'], - partial: { framework: 'react', evm: true }, - }), - [{ message: PROMPTS.features, answer: 'EVM' }], - ); - }); - - it('does not log the private key when no wallet origin feature is enabled', () => { - expectResolvedLogs( - sdkInput({ - argv: ['--private-key', '0xabc'], - partial: { - framework: 'node', - evm: false, - snowbridge: false, - privateKey: '0xabc', - }, - provided: { framework: true }, - }), - [{ message: PROMPTS.framework, answer: 'NodeJS' }], - ); - }); - }); - - describe('empty output', () => { - it('returns no lines when nothing was provided via CLI', () => { - expectResolvedLogs( - { - argv: [], - partial: { framework: 'react' }, - kind: 'api', - defaultName: 'my-xcm-api-app', - }, - [], - ); - }); - }); -}); - -describe('logArgvResolvedPrompts', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('prints each resolved line in order and ends with a blank line', () => { - const log = vi.spyOn(console, 'log').mockImplementation(() => {}); - - logArgvResolvedPrompts( - sdkInput({ - argv: ['--name', 'my-app', '--package-manager', 'npm'], - partial: { name: 'my-app', packageManager: 'npm' }, - }), + expect(lines.some((line) => line.message === 'Substrate mnemonic')).toBe( + false, + ); + expect(lines.some((line) => line.message === 'EVM private key')).toBe( + false, ); - - expect(log.mock.calls).toEqual([ - [formatResolvedLine(PROMPTS.name, 'my-app')], - [formatResolvedLine(PROMPTS.packageManager, 'npm')], - [], - ]); - }); - - it('prints nothing when there are no CLI-provided values', () => { - const log = vi.spyOn(console, 'log').mockImplementation(() => {}); - - logArgvResolvedPrompts(sdkInput()); - - expect(log).not.toHaveBeenCalled(); }); }); -describe('logResolvedSecret', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('prints the prompt with a masked CLI-provided answer', () => { - const log = vi.spyOn(console, 'log').mockImplementation(() => {}); - - logResolvedSecret(PROMPTS.privateKey); - - expect(log).toHaveBeenCalledOnce(); - expect(log).toHaveBeenCalledWith( - formatResolvedLine(PROMPTS.privateKey, SECRET_ANSWER), - ); +describe('logResolvedPrompts', () => { + it('does not throw for an empty input', () => { + expect(() => + logResolvedPrompts({ kind: 'sdk', framework: 'react' }), + ).not.toThrow(); }); }); diff --git a/src/shared/log-resolved-prompt.ts b/src/shared/log-resolved-prompt.ts index 95ff1f3..2c46404 100644 --- a/src/shared/log-resolved-prompt.ts +++ b/src/shared/log-resolved-prompt.ts @@ -1,139 +1,96 @@ -import figures from '@inquirer/figures'; +import { log } from '@clack/prompts'; import { styleText } from 'node:util'; import { applyFeatureFlags } from './feature-flags.js'; +import { SDK_CLIENT_LABELS } from './types.js'; +import type { FeatureFlags, ResolveInput, SdkClient } from './types.js'; import { - argvHasAnyFeatureFlag, - argvHasAcceptedName, - argvHasFlag, -} from './parse-cli-args.js'; -import type { ApiGenerateOptions, FeatureFlags, Framework, SdkClient, SdkGenerateOptions } from './types.js'; + validateEvmPrivateKey, + validateNameInput, + validateSubstrateMnemonic, +} from './validate.js'; -const FRAMEWORK_LABELS: Record = { - react: 'Vite - React', - vue: 'Vite - Vue', - node: 'NodeJS', -}; +const SECRET_ANSWER = '(provided via CLI)'; -const CLIENT_LABELS: Record = { - papi: 'Polkadot API', - pjs: 'Polkadot JS', - dedot: 'Dedot', +export const formatClientLabel = (client: SdkClient): string => { + return SDK_CLIENT_LABELS[client]; }; -const SECRET_ANSWER = styleText('dim', '(provided via CLI)'); - -export function formatFrameworkLabel(framework: Framework): string { - return FRAMEWORK_LABELS[framework]; -} - -export function formatClientLabel(client: SdkClient): string { - return CLIENT_LABELS[client]; -} - -export function formatFeatureFlags(flags: FeatureFlags): string { +export const formatFeatureFlags = (flags: FeatureFlags): string => { const parts: string[] = []; if (flags.evm) parts.push('EVM'); if (flags.swap) parts.push('Swap'); if (flags.snowbridge) parts.push('Snowbridge'); return parts.length > 0 ? parts.join(', ') : 'none'; -} - -export function logResolvedPrompt(message: string, answer: string): void { - console.log( - `${styleText('green', figures.tick)} ${message} ${styleText('cyan', answer)}`, - ); -} - -export function logResolvedSecret(message: string): void { - logResolvedPrompt(message, SECRET_ANSWER); -} +}; type ResolvedLogLine = { message: string; answer: string }; -type LogArgvResolvedInput = { - argv: string[]; - partial: Partial; - provided?: { framework?: boolean }; - kind: 'sdk' | 'api'; - defaultName: string; +const featuresProvided = (input: ResolveInput): boolean => { + return ( + input.evm !== undefined || + input.swap !== undefined || + input.snowbridge !== undefined + ); }; -export function buildArgvResolvedLogs(input: LogArgvResolvedInput): ResolvedLogLine[] { - const { argv, partial, provided, kind, defaultName } = input; +export const buildResolvedLogs = (input: ResolveInput): ResolvedLogLine[] => { const lines: ResolvedLogLine[] = []; - if (argvHasAcceptedName(argv, partial.name)) { - lines.push({ - message: 'Enter the project name', - answer: partial.name ?? defaultName, - }); - } - - if (provided?.framework && partial.framework) { - lines.push({ - message: 'Select the desired framework', - answer: formatFrameworkLabel(partial.framework), - }); + if (input.name !== undefined && validateNameInput(input.name) === true) { + lines.push({ message: 'Project name', answer: input.name }); } - if (argvHasFlag(argv, 'package-manager')) { - lines.push({ - message: 'Select the desired package manager', - answer: partial.packageManager ?? 'pnpm', - }); + if (input.packageManager !== undefined) { + lines.push({ message: 'Package manager', answer: input.packageManager }); } - if (kind === 'sdk' && argvHasFlag(argv, 'client')) { - const client = (partial as Partial).client ?? 'pjs'; + if (input.kind === 'sdk' && input.client !== undefined) { lines.push({ - message: 'Select the desired JS client type', - answer: formatClientLabel(client), + message: 'JS client', + answer: formatClientLabel(input.client), }); } - if (argvHasAnyFeatureFlag(argv)) { - const featureFlags = applyFeatureFlags({ - evm: partial.evm ?? false, - swap: partial.swap ?? false, - snowbridge: partial.snowbridge ?? false, + if (featuresProvided(input)) { + const flags = applyFeatureFlags({ + evm: input.evm ?? false, + swap: input.swap ?? false, + snowbridge: input.snowbridge ?? false, }); lines.push({ - message: 'Select the desired additional features', - answer: formatFeatureFlags(featureFlags), + message: 'Additional features', + answer: formatFeatureFlags(flags), }); } - if (partial.framework === 'node' && partial.substrateMnemonic !== undefined) { - lines.push({ - message: 'Your Substrate wallet mnemonic for setup', - answer: SECRET_ANSWER, - }); + const flags = applyFeatureFlags({ + evm: input.evm ?? false, + swap: input.swap ?? false, + snowbridge: input.snowbridge ?? false, + }); + + if ( + input.framework === 'node' && + input.substrateMnemonic !== undefined && + validateSubstrateMnemonic(input.substrateMnemonic) === true + ) { + lines.push({ message: 'Substrate mnemonic', answer: SECRET_ANSWER }); } - const featureFlags = applyFeatureFlags({ - evm: partial.evm ?? false, - swap: partial.swap ?? false, - snowbridge: partial.snowbridge ?? false, - }); if ( - partial.framework === 'node' && - featureFlags.evmWallet && - partial.privateKey !== undefined + input.framework === 'node' && + flags.evmWallet && + input.privateKey !== undefined && + validateEvmPrivateKey(input.privateKey) === true ) { - lines.push({ - message: 'Your EVM wallet private key for setup', - answer: SECRET_ANSWER, - }); + lines.push({ message: 'EVM private key', answer: SECRET_ANSWER }); } return lines; -} +}; -export function logArgvResolvedPrompts(input: LogArgvResolvedInput): void { - const lines = buildArgvResolvedLogs(input); - if (lines.length === 0) return; - for (const line of lines) { - logResolvedPrompt(line.message, line.answer); +export const logResolvedPrompts = (input: ResolveInput): void => { + for (const line of buildResolvedLogs(input)) { + log.success(`${line.message}: ${styleText('cyan', line.answer)}`); } - console.log(); -} +}; diff --git a/src/shared/next-steps.ts b/src/shared/next-steps.ts index d2a6db1..6c1e45a 100644 --- a/src/shared/next-steps.ts +++ b/src/shared/next-steps.ts @@ -1,11 +1,11 @@ import path from 'node:path'; import type { Framework, PackageManager } from './types.js'; -export function printNextSteps( +export const printNextSteps = ( outDir: string, pm: PackageManager, framework: Framework, -): void { +): void => { const cdPath = path.isAbsolute(outDir) ? outDir : path.relative(process.cwd(), outDir) || path.basename(outDir); diff --git a/src/shared/normalize-blank-lines.test.ts b/src/shared/normalize-blank-lines.test.ts deleted file mode 100644 index 5ff211b..0000000 --- a/src/shared/normalize-blank-lines.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { collapseExtraBlankLines } from './normalize-blank-lines.js'; - -describe('collapseExtraBlankLines', () => { - it('leaves single blank lines unchanged', () => { - const input = 'a\n\nb'; - expect(collapseExtraBlankLines(input)).toBe(input); - }); - - it('collapses multiple consecutive blank lines to one', () => { - expect(collapseExtraBlankLines('a\n\n\nb')).toBe('a\n\nb'); - expect(collapseExtraBlankLines('a\n\n\n\nb')).toBe('a\n\nb'); - }); - - it('collapses blank lines that include whitespace-only lines', () => { - expect(collapseExtraBlankLines('a\n\n \nb')).toBe('a\n\nb'); - }); - - it('handles CRLF line endings', () => { - expect(collapseExtraBlankLines('a\r\n\r\n\r\nb')).toBe('a\r\n\r\nb'); - }); - - it('collapses every run of extra blank lines in the file', () => { - const input = 'first\n\n\nsecond\n\n\n\nthird'; - expect(collapseExtraBlankLines(input)).toBe('first\n\nsecond\n\nthird'); - }); -}); diff --git a/src/shared/normalize-blank-lines.ts b/src/shared/normalize-blank-lines.ts deleted file mode 100644 index 549c86f..0000000 --- a/src/shared/normalize-blank-lines.ts +++ /dev/null @@ -1,44 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; - -const TEXT_FILE_PATTERN = /\.(ts|tsx|vue|js|jsx|json|html|css|md)$/; - -const EXTRA_BLANK_LINES = /(\r?\n)(?:[ \t]*\r?\n){2,}/; - -export function hasExtraBlankLines(content: string): boolean { - return EXTRA_BLANK_LINES.test(content); -} - -export function collapseExtraBlankLines(content: string): string { - return content.replace(new RegExp(EXTRA_BLANK_LINES.source, 'g'), '$1$1'); -} - -async function walkTextFiles( - dir: string, - files: string[] = [], -): Promise { - const entries = await fs.promises.readdir(dir, { withFileTypes: true }); - for (const entry of entries) { - const full = path.join(dir, entry.name); - if (entry.name === 'node_modules' || entry.name === 'dist') continue; - if (entry.isDirectory()) { - await walkTextFiles(full, files); - } else if (TEXT_FILE_PATTERN.test(entry.name)) { - files.push(full); - } - } - return files; -} - -export async function normalizeBlankLinesInDir(dir: string): Promise { - const files = await walkTextFiles(dir); - await Promise.all( - files.map(async (file) => { - const content = await fs.promises.readFile(file, 'utf8'); - const normalized = collapseExtraBlankLines(content); - if (normalized !== content) { - await fs.promises.writeFile(file, normalized); - } - }), - ); -} diff --git a/src/shared/package-manager.ts b/src/shared/package-manager.ts index f874797..f855c71 100644 --- a/src/shared/package-manager.ts +++ b/src/shared/package-manager.ts @@ -1,14 +1,14 @@ import path from 'node:path'; import { createRequire } from 'node:module'; import { getPackageRoot } from '../package-root.js'; -export type { PackageManager } from './types.js'; +export { PACKAGE_MANAGERS } from './types.js'; type PackageManagerModule = typeof import('../../shared/package-manager.cjs'); const require = createRequire(import.meta.url); const packageRoot = getPackageRoot(); -const { PACKAGE_MANAGERS, normalizePackageManager }: PackageManagerModule = +const { normalizePackageManager }: PackageManagerModule = require(path.join(packageRoot, 'shared/package-manager.cjs')); -export { PACKAGE_MANAGERS, normalizePackageManager }; +export { normalizePackageManager }; diff --git a/src/shared/parse-bool.ts b/src/shared/parse-bool.ts deleted file mode 100644 index c2079d3..0000000 --- a/src/shared/parse-bool.ts +++ /dev/null @@ -1,5 +0,0 @@ -export function parseBool(value: unknown, defaultValue = false): boolean { - if (value === undefined || value === null || value === '') return defaultValue; - if (typeof value === 'boolean') return value; - return value === 'true' || value === '1' || value === 'yes'; -} diff --git a/src/shared/parse-cli-args.test.ts b/src/shared/parse-cli-args.test.ts deleted file mode 100644 index e308399..0000000 --- a/src/shared/parse-cli-args.test.ts +++ /dev/null @@ -1,306 +0,0 @@ -import path from 'node:path'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { UserError } from './errors.js'; -import { - assertNoStrayPositional, - argvHasAcceptedName, - argvHasAnyFeatureFlag, - argvHasFlag, - argvNameRejected, - hasRejectedCliSecrets, - parseApiArgv, - parseSdkArgv, - printApiHelp, - printMainHelp, - printSdkHelp, - shiftPositionalFramework, - shiftPositionalType, -} from './parse-cli-args.js'; - -const ROOT = '/tmp/paraspell-test'; -const ctx = { root: ROOT, framework: 'react' as const, frameworkFlag: true }; - -describe('argvHasFlag', () => { - it('detects kebab-case and camelCase aliases', () => { - expect(argvHasFlag(['--package-manager', 'npm'], 'package-manager')).toBe(true); - expect(argvHasFlag(['--packageManager=npm'], 'package-manager')).toBe(true); - expect(argvHasFlag(['--client', 'pjs'], 'client')).toBe(true); - expect(argvHasFlag(['--name=my-app'], 'name')).toBe(true); - expect(argvHasFlag(['--package-manager'], 'package-manager')).toBe(false); - expect(argvHasFlag(['--name'], 'name')).toBe(false); - expect(argvHasFlag(['--name='], 'name')).toBe(false); - expect(argvHasFlag(['--client', 'pjs'], 'package-manager')).toBe(false); - }); -}); - -describe('argvHasAcceptedName', () => { - it('accepts valid CLI names and rejects invalid ones', () => { - expect(argvHasAcceptedName(['--name', 'my-app'], 'my-app')).toBe(true); - expect(argvHasAcceptedName(['--name=my-app'], 'my-app')).toBe(true); - expect(argvHasAcceptedName(['--name', 'Invalid Name'], 'Invalid Name')).toBe( - false, - ); - expect(argvHasAcceptedName(['--name'], 'my-xcm-app')).toBe(false); - expect(argvHasAcceptedName([], 'my-app')).toBe(false); - }); - - it('detects rejected names', () => { - expect(argvNameRejected(['--name', 'Invalid Name'], 'Invalid Name')).toBe(true); - expect(argvNameRejected(['--name', 'my-app'], 'my-app')).toBe(false); - expect(argvNameRejected([], 'my-app')).toBe(false); - }); -}); - -describe('argvHasAnyFeatureFlag', () => { - it('detects any feature flag', () => { - expect(argvHasAnyFeatureFlag(['--evm'])).toBe(true); - expect(argvHasAnyFeatureFlag(['--swap=false'])).toBe(true); - expect(argvHasAnyFeatureFlag(['--name', 'x'])).toBe(false); - }); -}); - -describe('shiftPositionalType', () => { - it.each([ - [['sdk', 'react', '--name', 'x'], { argv: ['react', '--name', 'x'], type: 'sdk' }], - [['api', 'node', '--evm'], { argv: ['node', '--evm'], type: 'api' }], - [['--type', 'sdk'], { argv: ['--type', 'sdk'], type: null }], - [[], { argv: [], type: null }], - ] as const)('shifts %j', (argv, expected) => { - expect(shiftPositionalType([...argv])).toEqual(expected); - }); -}); - -describe('shiftPositionalFramework', () => { - it.each([ - [['react', '--name', 'x'], { argv: ['--name', 'x'], framework: 'react' }], - [['vue', '--evm'], { argv: ['--evm'], framework: 'vue' }], - [['node'], { argv: [], framework: 'node' }], - [['--name', 'x'], { argv: ['--name', 'x'], framework: null }], - [['not-a-framework', '--name', 'x'], { argv: ['not-a-framework', '--name', 'x'], framework: null }], - ] as const)('shifts %j', (argv, expected) => { - expect(shiftPositionalFramework([...argv])).toEqual(expected); - }); -}); - -describe('assertNoStrayPositional', () => { - it('allows argv when a framework positional was already consumed', () => { - expect(() => assertNoStrayPositional(['--name', 'x'], 'react')).not.toThrow(); - }); - - it('throws for an unknown leading positional', () => { - expect(() => assertNoStrayPositional(['bogus'], null)).toThrow(UserError); - expect(() => assertNoStrayPositional(['bogus'], null)).toThrow( - /Unknown argument "bogus"/, - ); - }); -}); - -const VALID_PRIVATE_KEY = - '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; - -describe('parseSdkArgv', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('applies defaults for an empty argv', () => { - expect(parseSdkArgv([], ctx)).toMatchObject({ - framework: 'react', - name: 'my-xcm-app', - client: 'pjs', - evm: false, - swap: false, - snowbridge: false, - packageManager: 'pnpm', - out: path.join(ROOT, 'generated', 'xcm-sdk', 'react', 'my-xcm-app'), - }); - }); - - it('parses name, client, framework, package manager, and out', () => { - expect( - parseSdkArgv( - [ - '--name', - 'my-app', - '--client', - 'papi', - '--framework', - 'vue', - '--package-manager', - 'npm', - '--out', - 'dist/my-app', - ], - ctx, - ), - ).toMatchObject({ - name: 'my-app', - client: 'papi', - framework: 'vue', - packageManager: 'npm', - out: path.join(ROOT, 'dist/my-app'), - }); - }); - - it('accepts client aliases and camelCase flags', () => { - expect(parseSdkArgv(['--client', 'polkadot-api'], ctx).client).toBe('papi'); - expect(parseSdkArgv(['--packageManager=yarn'], ctx).packageManager).toBe('yarn'); - expect(parseSdkArgv(['--name=my-app'], ctx).name).toBe('my-app'); - }); - - it('parses node secrets', () => { - expect( - parseSdkArgv( - ['--substrate-mnemonic', '//Alice', '--private-key', VALID_PRIVATE_KEY], - { ...ctx, framework: 'node' }, - ), - ).toMatchObject({ - substrateMnemonic: '//Alice', - privateKey: VALID_PRIVATE_KEY, - }); - }); - - it('ignores invalid node secrets and warns', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - expect( - parseSdkArgv(['--private-key', '0xabc'], { ...ctx, framework: 'node' }), - ).not.toHaveProperty('privateKey'); - expect( - parseSdkArgv(['--substrate-mnemonic', 'seed'], { ...ctx, framework: 'node' }), - ).not.toHaveProperty('substrateMnemonic'); - expect(warn).toHaveBeenCalled(); - vi.restoreAllMocks(); - }); - - it('detects rejected CLI secrets', () => { - const argv = [ - '--substrate-mnemonic', - 'seed', - '--private-key', - '0xabc', - ]; - const opts = parseSdkArgv(argv, { ...ctx, framework: 'node' }); - expect(hasRejectedCliSecrets(argv, opts)).toBe(true); - expect( - hasRejectedCliSecrets( - ['--substrate-mnemonic', '//Alice', '--private-key', VALID_PRIVATE_KEY], - { - substrateMnemonic: '//Alice', - privateKey: VALID_PRIVATE_KEY, - }, - ), - ).toBe(false); - }); - - it('sets help when --help is present', () => { - expect(parseSdkArgv(['--help'], ctx).help).toBe(true); - }); - - it('enables a feature when the bare flag is present', () => { - expect(parseSdkArgv(['--evm'], ctx).evm).toBe(true); - expect(parseSdkArgv(['--swap'], ctx).swap).toBe(true); - expect(parseSdkArgv(['--snowbridge'], ctx).snowbridge).toBe(true); - }); - - it('still accepts explicit true|false values', () => { - expect(parseSdkArgv(['--evm', 'true'], ctx).evm).toBe(true); - expect(parseSdkArgv(['--evm', 'false'], ctx).evm).toBe(false); - expect(parseSdkArgv(['--evm=false'], ctx).evm).toBe(false); - }); - - it('defaults features to false when omitted', () => { - expect(parseSdkArgv([], ctx)).toMatchObject({ - evm: false, - swap: false, - snowbridge: false, - }); - }); - - it('rejects unknown framework, client, and package manager values', () => { - expect(() => parseSdkArgv(['--framework', 'angular'], ctx)).toThrow(/Unknown --framework/); - expect(() => parseSdkArgv(['--client', 'substrate'], ctx)).toThrow(/Unknown --client/); - expect(() => parseSdkArgv(['--package-manager', 'deno'], ctx)).toThrow( - /Unknown --package-manager/, - ); - }); - - it('ignores value flags without values and keeps defaults', () => { - expect(parseSdkArgv(['--name'], ctx)).toMatchObject({ - name: 'my-xcm-app', - packageManager: 'pnpm', - }); - expect(parseSdkArgv(['--client', '--evm'], ctx)).toMatchObject({ - client: 'pjs', - evm: true, - }); - expect(parseSdkArgv(['--name=', '--package-manager='], ctx)).toMatchObject({ - name: 'my-xcm-app', - packageManager: 'pnpm', - }); - }); - - it('warns on unknown options instead of failing', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - parseSdkArgv(['--bogus'], ctx); - expect(warn).toHaveBeenCalledWith('Warning: unknown option --bogus ignored.'); - }); -}); - -describe('parseApiArgv', () => { - it('applies api defaults and ignores client flags as unknown', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - expect(parseApiArgv([], ctx)).toMatchObject({ - framework: 'react', - name: 'my-xcm-api-app', - out: path.join(ROOT, 'generated', 'xcm-api', 'react', 'my-xcm-api-app'), - }); - parseApiArgv(['--client', 'pjs'], ctx); - expect(warn).toHaveBeenCalledWith('Warning: unknown option --client ignored.'); - vi.restoreAllMocks(); - }); - - it('parses shared flags', () => { - expect( - parseApiArgv( - ['--name', 'api-app', '--framework', 'node', '--evm', '--package-manager', 'bun'], - ctx, - ), - ).toMatchObject({ - name: 'api-app', - framework: 'node', - evm: true, - packageManager: 'bun', - }); - }); -}); - -describe('help printers', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('prints main help', () => { - const log = vi.spyOn(console, 'log').mockImplementation(() => {}); - printMainHelp(); - expect(log.mock.calls[0]?.[0]).toContain('create-paraspell sdk'); - expect(log.mock.calls[0]?.[0]).toContain('--type sdk|api'); - }); - - it('prints sdk help with a custom command label', () => { - const log = vi.spyOn(console, 'log').mockImplementation(() => {}); - printSdkHelp('create-paraspell sdk'); - expect(log.mock.calls[0]?.[0]).toContain('create-paraspell sdk'); - expect(log.mock.calls[0]?.[0]).toContain('--client'); - expect(log.mock.calls[0]?.[0]).toContain('--substrate-mnemonic'); - expect(log.mock.calls[0]?.[0]).toContain('--private-key'); - }); - - it('prints api help with a custom command label', () => { - const log = vi.spyOn(console, 'log').mockImplementation(() => {}); - printApiHelp('create-paraspell api'); - expect(log.mock.calls[0]?.[0]).toContain('create-paraspell api'); - expect(log.mock.calls[0]?.[0]).not.toContain('--client'); - expect(log.mock.calls[0]?.[0]).toContain('--substrate-mnemonic'); - expect(log.mock.calls[0]?.[0]).toContain('--private-key'); - }); -}); diff --git a/src/shared/parse-cli-args.ts b/src/shared/parse-cli-args.ts deleted file mode 100644 index 44d2eff..0000000 --- a/src/shared/parse-cli-args.ts +++ /dev/null @@ -1,408 +0,0 @@ -import path from 'node:path'; -import { applyFeatureFlags } from './feature-flags.js'; -import { UserError } from './errors.js'; -import { parseBool } from './parse-bool.js'; -import { - normalizePackageManager, - PACKAGE_MANAGERS, - type PackageManager, -} from './package-manager.js'; -import { parseFramework } from './frameworks.js'; -import { - parseSecretFlag, - validateEvmPrivateKey, - validateNameInput, - validateSubstrateMnemonic, -} from './validate.js'; -import type { - ApiGenerateOptions, - Framework, - ProjectType, - SdkClient, - SdkGenerateOptions, -} from './types.js'; - -type ArgRecord = Record; - -export function getArgvFlag( - argv: string[], - key: string, -): string | boolean | undefined { - return parseArgv(argv)[key]; -} - -const FLAG_ALIASES: Readonly> = { - 'package-manager': ['package-manager', 'packageManager'], - 'private-key': ['private-key', 'privateKey'], - 'substrate-mnemonic': ['substrate-mnemonic', 'substrateMnemonic'], -}; - -const VALUE_FLAGS = new Set([ - 'name', - 'framework', - 'client', - 'package-manager', - 'packageManager', - 'out', - 'private-key', - 'privateKey', - 'substrate-mnemonic', - 'substrateMnemonic', -]); - -function isValueFlag(flag: string): boolean { - return VALUE_FLAGS.has(flag) || flag in FLAG_ALIASES; -} - -function argvEntryHasValue(argv: string[], index: number): boolean { - const arg = argv[index]!; - const [, inlineValue] = arg.slice(2).split('=', 2); - if (inlineValue !== undefined) return inlineValue !== ''; - const next = argv[index + 1]; - return next !== undefined && !next.startsWith('--'); -} - -export function argvHasFlag(argv: string[], flag: string): boolean { - const names = new Set(FLAG_ALIASES[flag] ?? [flag]); - const needsValue = isValueFlag(flag); - - for (let i = 0; i < argv.length; i++) { - const arg = argv[i]; - if (!arg.startsWith('--')) continue; - const flagKey = arg.slice(2).split('=')[0]!; - if (!names.has(flagKey)) continue; - if (needsValue) return argvEntryHasValue(argv, i); - return true; - } - return false; -} - -export function argvHasAnyFeatureFlag(argv: string[]): boolean { - return ( - argvHasFlag(argv, 'evm') || - argvHasFlag(argv, 'swap') || - argvHasFlag(argv, 'snowbridge') - ); -} - -export function argvHasAcceptedName( - argv: string[], - name: string | undefined, -): boolean { - if (!argvHasFlag(argv, 'name')) return false; - const resolved = - name ?? - (() => { - const value = getArgvFlag(argv, 'name'); - return typeof value === 'string' ? value : undefined; - })(); - return validateNameInput(resolved ?? '') === true; -} - -export function argvNameRejected( - argv: string[], - name: string | undefined, -): boolean { - return argvHasFlag(argv, 'name') && !argvHasAcceptedName(argv, name); -} - -export function argvSecretRejected( - argv: string[], - flag: 'private-key' | 'substrate-mnemonic', - value: string | undefined, -): boolean { - return argvHasFlag(argv, flag) && value === undefined; -} - -export function hasRejectedCliSecrets( - argv: string[], - opts: Pick, -): boolean { - return ( - argvSecretRejected(argv, 'private-key', opts.privateKey) || - argvSecretRejected(argv, 'substrate-mnemonic', opts.substrateMnemonic) - ); -} - -function parseArgv(argv: string[]): ArgRecord { - const opts: ArgRecord = {}; - for (let i = 0; i < argv.length; i++) { - const arg = argv[i]; - if (!arg.startsWith('--')) continue; - const raw = arg.slice(2); - const [key, inlineValue] = raw.split('=', 2); - if (inlineValue !== undefined) { - opts[key] = inlineValue; - continue; - } - const next = argv[i + 1]; - if (next && !next.startsWith('--')) { - opts[key] = next; - i++; - continue; - } - opts[key] = true; - } - return opts; -} - -function resolveOut(root: string, out: string): string { - return path.isAbsolute(out) ? out : path.join(root, out); -} - -const CLIENT_ALIASES: Record = { - papi: 'papi', - pjs: 'pjs', - dedot: 'dedot', - 'polkadot-api': 'papi', - 'polkadot-js': 'pjs', -}; - -const FRAMEWORKS_ALLOWED = ['react', 'vue', 'node']; -const CLIENTS_ALLOWED = ['papi', 'pjs', 'dedot']; - -function parseClient(value: string): SdkClient | null { - return CLIENT_ALIASES[value.toLowerCase()] ?? null; -} - -function valueFlag(flags: ArgRecord, key: string): string | undefined { - const value = flags[key]; - if (value === undefined || value === true) return undefined; - if (typeof value !== 'string') { - throw new UserError(`Option --${key} requires a value.`); - } - if (value === '') return undefined; - return value; -} - -function requireKnown( - raw: string, - parsed: T | null, - flag: string, - allowed: readonly string[], -): T { - if (parsed === null) { - throw new UserError( - `Unknown ${flag} "${raw}". Expected one of: ${allowed.join(', ')}.`, - ); - } - return parsed; -} - -function parsePackageManagerFlag( - flags: ArgRecord, -): PackageManager | undefined { - const raw = valueFlag(flags, 'package-manager') ?? valueFlag(flags, 'packageManager'); - if (raw === undefined) return undefined; - if (!(PACKAGE_MANAGERS as readonly string[]).includes(raw.toLowerCase())) { - throw new UserError( - `Unknown --package-manager "${raw}". Expected one of: ${PACKAGE_MANAGERS.join(', ')}.`, - ); - } - return normalizePackageManager(raw); -} - -const KNOWN_FLAGS_COMMON = [ - 'help', 'type', 'framework', 'name', - 'evm', 'swap', 'snowbridge', - 'package-manager', 'packageManager', 'out', - 'private-key', 'privateKey', - 'substrate-mnemonic', 'substrateMnemonic', -]; - -function warnUnknownFlags(flags: ArgRecord, extra: readonly string[]): void { - const known = new Set([...KNOWN_FLAGS_COMMON, ...extra]); - for (const key of Object.keys(flags)) { - if (!known.has(key)) { - console.warn(`Warning: unknown option --${key} ignored.`); - } - } -} - -export function assertNoStrayPositional( - argv: string[], - framework: Framework | null, -): void { - if (!framework && argv[0] !== undefined && !argv[0].startsWith('--')) { - throw new UserError( - `Unknown argument "${argv[0]}". Expected a framework ` + - `(${FRAMEWORKS_ALLOWED.join(', ')}) or options like --name.`, - ); - } -} - -type ParseCtx = { root: string; framework: Framework; frameworkFlag?: boolean }; - -function applyCommonFlags( - opts: ApiGenerateOptions, - flags: ArgRecord, - ctx: ParseCtx, -): void { - if (flags.help === true) opts.help = true; - - if (ctx.frameworkFlag) { - const framework = valueFlag(flags, 'framework'); - if (framework !== undefined) { - opts.framework = requireKnown( - framework, parseFramework(framework), '--framework', FRAMEWORKS_ALLOWED, - ); - } - } - - const name = valueFlag(flags, 'name'); - if (name !== undefined) opts.name = name; - - opts.evm = parseBool(flags.evm, opts.evm); - opts.swap = parseBool(flags.swap, opts.swap); - opts.snowbridge = parseBool(flags.snowbridge, opts.snowbridge); - - const packageManager = parsePackageManagerFlag(flags); - if (packageManager !== undefined) opts.packageManager = packageManager; - - const out = valueFlag(flags, 'out'); - if (out !== undefined) opts.out = resolveOut(ctx.root, out); - - const privateKey = valueFlag(flags, 'private-key') ?? valueFlag(flags, 'privateKey'); - if (privateKey !== undefined) { - const parsed = parseSecretFlag('--private-key', privateKey, validateEvmPrivateKey); - if (parsed !== undefined) opts.privateKey = parsed; - } - - const substrateMnemonic = - valueFlag(flags, 'substrate-mnemonic') ?? valueFlag(flags, 'substrateMnemonic'); - if (substrateMnemonic !== undefined) { - const parsed = parseSecretFlag( - '--substrate-mnemonic', - substrateMnemonic, - validateSubstrateMnemonic, - ); - if (parsed !== undefined) opts.substrateMnemonic = parsed; - } -} - -export function parseSdkArgv(argv: string[], ctx: ParseCtx): SdkGenerateOptions { - const flags = parseArgv(argv); - warnUnknownFlags(flags, ['client']); - const opts: SdkGenerateOptions = { - framework: ctx.framework, - name: 'my-xcm-app', - client: 'pjs', - evm: false, - swap: false, - snowbridge: false, - packageManager: 'pnpm', - out: path.join(ctx.root, 'generated', 'xcm-sdk', ctx.framework, 'my-xcm-app'), - }; - - applyCommonFlags(opts, flags, ctx); - - const client = valueFlag(flags, 'client'); - if (client !== undefined) { - opts.client = requireKnown(client, parseClient(client), '--client', CLIENTS_ALLOWED); - } - - return applyFeatureFlags(opts); -} - -export function parseApiArgv(argv: string[], ctx: ParseCtx): ApiGenerateOptions { - const flags = parseArgv(argv); - warnUnknownFlags(flags, []); - const opts: ApiGenerateOptions = { - framework: ctx.framework, - name: 'my-xcm-api-app', - evm: false, - swap: false, - snowbridge: false, - packageManager: 'pnpm', - out: path.join(ctx.root, 'generated', 'xcm-api', ctx.framework, 'my-xcm-api-app'), - }; - - applyCommonFlags(opts, flags, ctx); - - return applyFeatureFlags(opts); -} - -export function shiftPositionalType(argv: string[]): { - argv: string[]; - type: ProjectType | null; -} { - const rest = [...argv]; - if (rest[0] === 'sdk' || rest[0] === 'api') { - const type = rest[0]; - rest.shift(); - return { argv: rest, type }; - } - return { argv: rest, type: null }; -} - -export function shiftPositionalFramework(argv: string[]): { - argv: string[]; - framework: Framework | null; -} { - const rest = [...argv]; - if (rest[0] && !rest[0].startsWith('--')) { - const parsed = parseFramework(rest[0]); - if (parsed) { - rest.shift(); - return { argv: rest, framework: parsed }; - } - } - return { argv: rest, framework: null }; -} - -const SECRET_OPTIONS = ` --substrate-mnemonic optional Substrate mnemonic or //Dev URI for node (non-interactive) - --private-key optional wallet key for node when using EVM or Snowbridge origins`; - -const SHARED_OPTIONS = ` --framework react | vue | node - --name - --evm, --swap, --snowbridge include flag to enable (omit for false; explicit true|false also accepted) -${SECRET_OPTIONS} - --package-manager npm | yarn | pnpm | bun - --out default: ./ in cwd - --help`; - -export function printMainHelp(): void { - console.log(`Usage: create-paraspell - create-paraspell sdk [framework] [options] - create-paraspell api [framework] [options] - -Run without arguments for interactive mode. - -Non-interactive: - --type sdk|api same as sdk|api subcommand - -SDK-only: - --client papi | pjs | dedot - -${SHARED_OPTIONS} -`); -} - -export function printSdkHelp(command = 'npm run generate:sdk'): void { - console.log(`Usage: ${command} -- [framework] [options] - -Options: - --framework react | vue | node - --name - --client papi | pjs | dedot - --evm, --swap, --snowbridge include flag to enable (omit for false; explicit true|false also accepted) -${SECRET_OPTIONS} - --package-manager npm | yarn | pnpm | bun - --out - --help -`); -} - -export function printApiHelp(command = 'npm run generate:xcm-api'): void { - console.log(`Usage: ${command} -- [framework] [options] - -Options: - --framework react | vue | node - --name - --evm, --swap, --snowbridge include flag to enable (omit for false; explicit true|false also accepted) -${SECRET_OPTIONS} - --package-manager npm | yarn | pnpm | bun - --out - --help -`); -} diff --git a/src/shared/prompt-api.ts b/src/shared/prompt-api.ts deleted file mode 100644 index 16708dd..0000000 --- a/src/shared/prompt-api.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { Separator, input, select } from '@inquirer/prompts'; -import { applyFeatureFlags } from './feature-flags.js'; -import { - EVM_EXTENSION, - promptFeatureExtensions, - SNOWBRIDGE_EXTENSION, - SWAP_EXTENSION, -} from './feature-extensions-checkbox.js'; -import { logArgvResolvedPrompts } from './log-resolved-prompt.js'; -import { - argvHasAnyFeatureFlag, - argvHasAcceptedName, - argvHasFlag, - argvNameRejected, -} from './parse-cli-args.js'; -import { promptEvmPrivateKey } from './prompt-evm-private-key.js'; -import { promptSubstrateMnemonic } from './prompt-substrate-mnemonic.js'; -import type { ApiGenerateOptions } from './types.js'; -import { PACKAGE_MANAGERS } from './package-manager.js'; -import { validateNameInput } from './validate.js'; - -type NameValidator = (name: string) => true | string | Promise; - -type PromptProvided = { - framework?: boolean; -}; - -type PromptApiOptions = { - validateName?: NameValidator; - argv?: string[]; - provided?: PromptProvided; -}; - -export async function promptApiOptions( - partial: Partial, - options: PromptApiOptions = {}, -): Promise< - Pick< - ApiGenerateOptions, - | 'name' - | 'evm' - | 'swap' - | 'snowbridge' - | 'packageManager' - | 'privateKey' - | 'substrateMnemonic' - > -> { - const argv = options.argv ?? []; - - logArgvResolvedPrompts({ - argv, - partial, - provided: options.provided, - kind: 'api', - defaultName: 'my-xcm-api-app', - }); - - const defaultName = 'my-xcm-api-app'; - - if (argvNameRejected(argv, partial.name)) { - const reason = validateNameInput(partial.name ?? ''); - if (reason !== true) { - console.warn(`Warning: ignoring invalid --name. ${reason}`); - } - } - - const name = argvHasAcceptedName(argv, partial.name) - ? (partial.name ?? defaultName) - : await input({ - message: 'Enter the project name', - default: defaultName, - validate: options.validateName ?? validateNameInput, - }); - - const packageManager = argvHasFlag(argv, 'package-manager') - ? (partial.packageManager ?? 'pnpm') - : await select({ - message: 'Select the desired package manager', - choices: [ - new Separator(), - ...PACKAGE_MANAGERS.map((packageManager) => ({ - name: packageManager, - value: packageManager, - })), - ], - default: partial.packageManager ?? 'pnpm', - }); - - let featureFlags: ReturnType; - if (argvHasAnyFeatureFlag(argv)) { - featureFlags = applyFeatureFlags({ - evm: partial.evm ?? false, - swap: partial.swap ?? false, - snowbridge: partial.snowbridge ?? false, - }); - } else { - const additionalFeatures = await promptFeatureExtensions({ - evm: partial.evm, - swap: partial.swap, - snowbridge: partial.snowbridge, - }); - featureFlags = applyFeatureFlags({ - evm: additionalFeatures.includes(EVM_EXTENSION), - swap: additionalFeatures.includes(SWAP_EXTENSION), - snowbridge: additionalFeatures.includes(SNOWBRIDGE_EXTENSION), - }); - } - - const substrateMnemonic = - partial.framework !== 'node' - ? undefined - : partial.substrateMnemonic !== undefined - ? partial.substrateMnemonic - : await promptSubstrateMnemonic(); - - const privateKey = - partial.framework !== 'node' || !featureFlags.evmWallet - ? undefined - : partial.privateKey !== undefined - ? partial.privateKey - : await promptEvmPrivateKey(); - - return { - name, - ...featureFlags, - packageManager, - privateKey, - substrateMnemonic, - }; -} - -export function apiNeedsInteractive( - argv: string[], - partial: Partial, -): boolean { - if (!process.stdin.isTTY) return false; - if (!argvHasFlag(argv, 'package-manager')) return true; - if (!argvHasAnyFeatureFlag(argv)) return true; - if (!argvHasAcceptedName(argv, partial.name)) return true; - if (partial.framework === 'node' && partial.substrateMnemonic === undefined) { - return true; - } - const featureFlags = applyFeatureFlags({ - evm: partial.evm ?? false, - swap: partial.swap ?? false, - snowbridge: partial.snowbridge ?? false, - }); - if ( - partial.framework === 'node' && - featureFlags.evmWallet && - partial.privateKey === undefined - ) { - return true; - } - return false; -} diff --git a/src/shared/prompt-evm-private-key.ts b/src/shared/prompt-evm-private-key.ts deleted file mode 100644 index 4953523..0000000 --- a/src/shared/prompt-evm-private-key.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { password } from '@inquirer/prompts'; -import { validateEvmPrivateKey } from './validate.js'; - -export async function promptEvmPrivateKey(): Promise { - const value = await password({ - message: - 'Your EVM wallet private key for setup (optional, press Enter to skip)', - mask: '*', - validate: validateEvmPrivateKey, - }); - - const trimmed = value.trim(); - return trimmed || undefined; -} diff --git a/src/shared/prompt-needs-interactive.test.ts b/src/shared/prompt-needs-interactive.test.ts index f7b63a5..e445ac0 100644 --- a/src/shared/prompt-needs-interactive.test.ts +++ b/src/shared/prompt-needs-interactive.test.ts @@ -1,185 +1,127 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { apiNeedsInteractive } from './prompt-api.js'; -import { sdkNeedsInteractive } from './prompt-sdk.js'; +import { generateNeedsInteractive } from './prompt-options.js'; +import type { ResolveInput } from './types.js'; const VALID_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; -function withTty(isTTY: boolean, run: () => void): void { +const withTty = (isTTY: boolean, run: () => void): void => { vi.stubGlobal('process', { ...process, stdin: { isTTY } }); run(); vi.unstubAllGlobals(); -} - -describe('sdkNeedsInteractive', () => { +}; + +const sdkComplete: ResolveInput = { + kind: 'sdk', + framework: 'react', + name: 'my-app', + packageManager: 'npm', + client: 'pjs', + evm: true, +}; + +describe('generateNeedsInteractive (sdk)', () => { afterEach(() => { vi.unstubAllGlobals(); }); - it('returns false when all sdk flags are provided on a TTY', () => { + it('is false when every sdk option is provided on a TTY', () => { withTty(true, () => { - expect( - sdkNeedsInteractive( - [ - '--package-manager', - 'npm', - '--client', - 'pjs', - '--evm', - '--name', - 'my-app', - ], - { framework: 'react' }, - ), - ).toBe(false); + expect(generateNeedsInteractive(sdkComplete)).toBe(false); }); }); - it('returns true when a required flag is missing on a TTY', () => { + it('is true when a required option is missing on a TTY', () => { withTty(true, () => { expect( - sdkNeedsInteractive(['--package-manager', 'npm', '--evm'], { + generateNeedsInteractive({ + kind: 'sdk', framework: 'react', + packageManager: 'npm', + evm: true, }), ).toBe(true); }); }); - it('returns true when --name has an invalid value on a TTY', () => { + it('is true when --name is invalid on a TTY', () => { withTty(true, () => { expect( - sdkNeedsInteractive( - [ - '--package-manager', - 'npm', - '--client', - 'pjs', - '--evm', - '--name', - 'Invalid Name', - ], - { framework: 'react', name: 'Invalid Name' }, - ), + generateNeedsInteractive({ ...sdkComplete, name: 'Invalid Name' }), ).toBe(true); }); }); - it('returns false when stdin is not a TTY even if flags are missing', () => { + it('is false when stdin is not a TTY even if options are missing', () => { withTty(false, () => { - expect(sdkNeedsInteractive([], { framework: 'react' })).toBe(false); + expect( + generateNeedsInteractive({ kind: 'sdk', framework: 'react' }), + ).toBe(false); }); }); it('requires node secrets on a TTY when EVM wallet origins are enabled', () => { + const node: ResolveInput = { + kind: 'sdk', + framework: 'node', + name: 'node-app', + packageManager: 'npm', + client: 'pjs', + evm: true, + }; withTty(true, () => { - const nodePartial = { framework: 'node' as const, evm: true }; - + expect(generateNeedsInteractive(node)).toBe(true); expect( - sdkNeedsInteractive( - [ - '--package-manager', - 'npm', - '--client', - 'pjs', - '--evm', - '--name', - 'node-app', - ], - nodePartial, - ), + generateNeedsInteractive({ ...node, substrateMnemonic: '//Alice' }), ).toBe(true); - - expect( - sdkNeedsInteractive( - [ - '--package-manager', - 'npm', - '--client', - 'pjs', - '--evm', - '--name', - 'node-app', - '--substrate-mnemonic', - 'seed', - ], - nodePartial, - ), - ).toBe(true); - expect( - sdkNeedsInteractive( - [ - '--package-manager', - 'npm', - '--client', - 'pjs', - '--evm', - '--name', - 'node-app', - '--substrate-mnemonic', - 'seed', - '--private-key', - '0xabc', - ], - nodePartial, - ), - ).toBe(true); - - expect( - sdkNeedsInteractive( - [ - '--package-manager', - 'npm', - '--client', - 'pjs', - '--evm', - '--name', - 'node-app', - '--substrate-mnemonic', - '//Alice', - '--private-key', - VALID_PRIVATE_KEY, - ], - { - ...nodePartial, - substrateMnemonic: '//Alice', - privateKey: VALID_PRIVATE_KEY, - }, - ), + generateNeedsInteractive({ + ...node, + substrateMnemonic: '//Alice', + privateKey: VALID_PRIVATE_KEY, + }), ).toBe(false); }); }); }); -describe('apiNeedsInteractive', () => { +describe('generateNeedsInteractive (api)', () => { afterEach(() => { vi.unstubAllGlobals(); }); - it('returns false when all api flags are provided on a TTY', () => { + it('is false when every api option is provided on a TTY', () => { withTty(true, () => { expect( - apiNeedsInteractive( - ['--package-manager', 'npm', '--evm', '--name', 'my-app'], - { framework: 'react' }, - ), + generateNeedsInteractive({ + kind: 'api', + framework: 'react', + name: 'my-app', + packageManager: 'npm', + evm: true, + }), ).toBe(false); }); }); - it('returns false when stdin is not a TTY even if flags are missing', () => { + it('is false when stdin is not a TTY even if options are missing', () => { withTty(false, () => { - expect(apiNeedsInteractive([], { framework: 'react' })).toBe(false); + expect( + generateNeedsInteractive({ kind: 'api', framework: 'react' }), + ).toBe(false); }); }); it('requires node secrets on a TTY when wallet origins are enabled', () => { withTty(true, () => { expect( - apiNeedsInteractive( - ['--package-manager', 'npm', '--snowbridge', '--name', 'node-api'], - { framework: 'node', snowbridge: true }, - ), + generateNeedsInteractive({ + kind: 'api', + framework: 'node', + name: 'node-api', + packageManager: 'npm', + snowbridge: true, + }), ).toBe(true); }); }); diff --git a/src/shared/prompt-options.test.ts b/src/shared/prompt-options.test.ts index 7c6816e..e49a8f6 100644 --- a/src/shared/prompt-options.test.ts +++ b/src/shared/prompt-options.test.ts @@ -1,242 +1,169 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { input, select } from '@inquirer/prompts'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { select, text } from '@clack/prompts'; import { - EVM_EXTENSION, - promptFeatureExtensions, - SNOWBRIDGE_EXTENSION, - SWAP_EXTENSION, -} from './feature-extensions-checkbox.js'; -import { promptApiOptions } from './prompt-api.js'; -import { promptSdkOptions } from './prompt-sdk.js'; -import { promptEvmPrivateKey } from './prompt-evm-private-key.js'; -import { promptSubstrateMnemonic } from './prompt-substrate-mnemonic.js'; - -vi.mock('@inquirer/prompts', () => ({ - input: vi.fn(), + applyGenerateDefaults, + hasRejectedSecrets, + promptGenerateOptions, +} from './prompt-options.js'; + +const VALID_PRIVATE_KEY = + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; + +vi.mock('@clack/prompts', () => ({ + text: vi.fn(), select: vi.fn(), - password: vi.fn(), - Separator: class Separator {}, + log: { success: vi.fn() }, + cancel: vi.fn(), + isCancel: vi.fn(() => false), })); vi.mock('./feature-extensions-checkbox.js', () => ({ - promptFeatureExtensions: vi.fn(), - EVM_EXTENSION: 'evm', - SWAP_EXTENSION: 'swap', - SNOWBRIDGE_EXTENSION: 'snowbridge', + EVM_EXTENSION: 'evm-extension', + SWAP_EXTENSION: 'swap-extension', + SNOWBRIDGE_EXTENSION: 'snowbridge-extension', + promptFeatureExtensions: vi.fn(async () => [] as string[]), })); -vi.mock('./prompt-substrate-mnemonic.js', () => ({ - promptSubstrateMnemonic: vi.fn(), +vi.mock('./prompt-secrets.js', () => ({ + promptSubstrateMnemonic: vi.fn(async () => '//Alice'), + promptEvmPrivateKey: vi.fn(async () => VALID_PRIVATE_KEY), })); -vi.mock('./prompt-evm-private-key.js', () => ({ - promptEvmPrivateKey: vi.fn(), -})); - -const mockedInput = vi.mocked(input); +const mockedText = vi.mocked(text); const mockedSelect = vi.mocked(select); -const mockedFeatureExtensions = vi.mocked(promptFeatureExtensions); -const mockedSubstrateMnemonic = vi.mocked(promptSubstrateMnemonic); -const mockedEvmPrivateKey = vi.mocked(promptEvmPrivateKey); -describe('promptSdkOptions', () => { +describe('promptGenerateOptions', () => { beforeEach(() => { vi.clearAllMocks(); }); - afterEach(() => { - vi.restoreAllMocks(); - }); + it('prompts for every missing sdk option', async () => { + mockedText.mockResolvedValue('prompted-app'); + mockedSelect + .mockResolvedValueOnce('npm') + .mockResolvedValueOnce('pjs'); - it('reuses CLI-provided values without calling Inquirer prompts', async () => { - const result = await promptSdkOptions( - { - framework: 'react', - name: 'my-app', - packageManager: 'npm', - client: 'papi', - evm: true, - swap: false, - snowbridge: false, - }, - { - argv: [ - '--name', - 'my-app', - '--package-manager', - 'npm', - '--client', - 'papi', - '--evm', - ], - }, - ); - - expect(result).toEqual({ - name: 'my-app', - client: 'papi', - evm: true, - swap: false, - snowbridge: false, - evmWallet: true, - packageManager: 'npm', - privateKey: undefined, - substrateMnemonic: undefined, + const result = await promptGenerateOptions({ + kind: 'sdk', + framework: 'react', }); - expect(mockedInput).not.toHaveBeenCalled(); - expect(mockedSelect).not.toHaveBeenCalled(); - expect(mockedFeatureExtensions).not.toHaveBeenCalled(); - }); - it('echoes CLI-provided answers before prompting for missing values', async () => { - const log = vi.spyOn(console, 'log').mockImplementation(() => {}); - mockedInput.mockResolvedValue('prompted-app'); - mockedSelect - .mockResolvedValueOnce('yarn') - .mockResolvedValueOnce('dedot'); - mockedFeatureExtensions.mockResolvedValue([EVM_EXTENSION]); - - const result = await promptSdkOptions( - { framework: 'react', name: 'my-app' }, - { argv: ['--name', 'my-app'] }, - ); - - expect(result.name).toBe('my-app'); - expect(result.packageManager).toBe('yarn'); - expect(result.client).toBe('dedot'); - expect(log.mock.calls.some(([line]) => String(line).includes('my-app'))).toBe( - true, - ); - expect(mockedInput).not.toHaveBeenCalled(); + expect(mockedText).toHaveBeenCalledOnce(); expect(mockedSelect).toHaveBeenCalledTimes(2); - expect(mockedFeatureExtensions).toHaveBeenCalledOnce(); - }); - - it('prompts for node secrets when they are not provided via argv', async () => { - mockedSubstrateMnemonic.mockResolvedValue('//Alice'); - mockedEvmPrivateKey.mockResolvedValue('0x1234'); - - const result = await promptSdkOptions( - { framework: 'node', evm: true, name: 'node-app' }, - { - argv: [ - '--name', - 'node-app', - '--package-manager', - 'pnpm', - '--client', - 'pjs', - '--evm', - ], - }, - ); - expect(result).toMatchObject({ - substrateMnemonic: '//Alice', - privateKey: '0x1234', + name: 'prompted-app', + packageManager: 'npm', + client: 'pjs', + evm: false, + swap: false, + snowbridge: false, }); - expect(mockedSubstrateMnemonic).toHaveBeenCalledOnce(); - expect(mockedEvmPrivateKey).toHaveBeenCalledOnce(); }); - it('prompts when value flags are present without values', async () => { - mockedInput.mockResolvedValue('prompted-name'); - mockedSelect.mockResolvedValueOnce('pnpm').mockResolvedValueOnce('pjs'); - mockedFeatureExtensions.mockResolvedValue([]); - - const result = await promptSdkOptions( - { framework: 'react' }, - { argv: ['--name', '--package-manager', '--client'] }, - ); + it('does not prompt for options already provided', async () => { + const result = await promptGenerateOptions({ + kind: 'sdk', + framework: 'react', + name: 'given-app', + packageManager: 'npm', + client: 'papi', + evm: true, + }); + expect(mockedText).not.toHaveBeenCalled(); + expect(mockedSelect).not.toHaveBeenCalled(); expect(result).toMatchObject({ - name: 'prompted-name', - packageManager: 'pnpm', - client: 'pjs', + name: 'given-app', + packageManager: 'npm', + client: 'papi', + evm: true, }); - expect(mockedInput).toHaveBeenCalledOnce(); - expect(mockedSelect).toHaveBeenCalledTimes(2); }); - it('prompts when --name has an invalid value', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - mockedInput.mockResolvedValue('valid-app'); - mockedSelect.mockResolvedValueOnce('pnpm').mockResolvedValueOnce('pjs'); - mockedFeatureExtensions.mockResolvedValue([]); - - const result = await promptSdkOptions( - { framework: 'react', name: 'Invalid Name' }, - { argv: ['--name', 'Invalid Name', '--evm'] }, - ); - - expect(result.name).toBe('valid-app'); - expect(warn).toHaveBeenCalledWith( - expect.stringMatching(/ignoring invalid --name/), - ); - expect(mockedInput).toHaveBeenCalledOnce(); - vi.restoreAllMocks(); - }); -}); + it('never prompts for a client on api projects', async () => { + mockedText.mockResolvedValue('api-app'); + mockedSelect.mockResolvedValueOnce('pnpm'); -describe('promptApiOptions', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); + const result = await promptGenerateOptions({ + kind: 'api', + framework: 'react', + evm: false, + swap: false, + snowbridge: false, + }); - afterEach(() => { - vi.restoreAllMocks(); + expect(mockedSelect).toHaveBeenCalledOnce(); + expect(result.client).toBeUndefined(); }); +}); - it('reuses CLI-provided values without calling Inquirer prompts', async () => { - const result = await promptApiOptions( - { - framework: 'react', - name: 'api-app', - packageManager: 'npm', - evm: false, - swap: true, - snowbridge: false, - }, - { - argv: ['--name', 'api-app', '--package-manager', 'npm', '--swap'], - }, - ); - - expect(result).toEqual({ - name: 'api-app', +describe('applyGenerateDefaults', () => { + it('fills sdk defaults for missing options', () => { + expect(applyGenerateDefaults({ kind: 'sdk', framework: 'react' })).toEqual({ + name: 'my-xcm-app', + client: 'pjs', evm: false, - swap: true, + swap: false, snowbridge: false, - evmWallet: false, - packageManager: 'npm', + packageManager: 'pnpm', privateKey: undefined, substrateMnemonic: undefined, }); - expect(mockedInput).not.toHaveBeenCalled(); - expect(mockedSelect).not.toHaveBeenCalled(); - expect(mockedFeatureExtensions).not.toHaveBeenCalled(); }); - it('prompts for missing values in a mixed CLI + interactive flow', async () => { - mockedInput.mockResolvedValue('api-prompted'); - mockedSelect.mockResolvedValue('bun'); - mockedFeatureExtensions.mockResolvedValue([SNOWBRIDGE_EXTENSION]); + it('uses the api default name and no client', () => { + const result = applyGenerateDefaults({ kind: 'api', framework: 'react' }); + expect(result.name).toBe('my-xcm-api-app'); + expect(result.client).toBeUndefined(); + }); - const result = await promptApiOptions( - { framework: 'vue' }, - { argv: [] }, - ); + it('keeps valid node secrets', () => { + const result = applyGenerateDefaults({ + kind: 'sdk', + framework: 'node', + evm: true, + substrateMnemonic: '//Alice', + privateKey: VALID_PRIVATE_KEY, + }); + expect(result.substrateMnemonic).toBe('//Alice'); + expect(result.privateKey).toBe(VALID_PRIVATE_KEY); + }); - expect(result).toMatchObject({ - name: 'api-prompted', - packageManager: 'bun', - evm: false, - swap: false, - snowbridge: true, - evmWallet: true, + it('drops secrets for non-node frameworks', () => { + const result = applyGenerateDefaults({ + kind: 'sdk', + framework: 'react', + substrateMnemonic: '//Alice', + privateKey: VALID_PRIVATE_KEY, }); - expect(mockedInput).toHaveBeenCalledOnce(); - expect(mockedSelect).toHaveBeenCalledOnce(); - expect(mockedFeatureExtensions).toHaveBeenCalledOnce(); + expect(result.substrateMnemonic).toBeUndefined(); + expect(result.privateKey).toBeUndefined(); + }); +}); + +describe('hasRejectedSecrets', () => { + it('flags invalid secrets', () => { + expect( + hasRejectedSecrets({ kind: 'sdk', framework: 'node', privateKey: 'bad' }), + ).toBe(true); + expect( + hasRejectedSecrets({ + kind: 'sdk', + framework: 'node', + substrateMnemonic: 'definitely not a mnemonic', + }), + ).toBe(true); + }); + + it('accepts valid or absent secrets', () => { + expect(hasRejectedSecrets({ kind: 'sdk', framework: 'node' })).toBe(false); + expect( + hasRejectedSecrets({ + kind: 'sdk', + framework: 'node', + privateKey: VALID_PRIVATE_KEY, + substrateMnemonic: '//Alice', + }), + ).toBe(false); }); }); diff --git a/src/shared/prompt-options.ts b/src/shared/prompt-options.ts new file mode 100644 index 0000000..e309180 --- /dev/null +++ b/src/shared/prompt-options.ts @@ -0,0 +1,211 @@ +import { + applyFeatureFlags, + type ResolvedFeatureFlags, +} from './feature-flags.js'; +import { + EVM_EXTENSION, + promptFeatureExtensions, + SNOWBRIDGE_EXTENSION, + SWAP_EXTENSION, +} from './feature-extensions-checkbox.js'; +import { logResolvedPrompts } from './log-resolved-prompt.js'; +import { + promptEvmPrivateKey, + promptSubstrateMnemonic, +} from './prompt-secrets.js'; +import { promptClient, promptName, promptPackageManager } from './prompts.js'; +import type { + ProjectType, + ResolveInput, + ResolvedOptions, +} from './types.js'; +import { + validateEvmPrivateKey, + validateNameInput, + validateSubstrateMnemonic, +} from './validate.js'; + +export type NameValidator = (name: string) => true | string; + +type PromptGenerateOptions = { + validateName?: NameValidator; +}; + +const DEFAULT_NAME: Record = { + sdk: 'my-xcm-app', + api: 'my-xcm-api-app', +}; + +const featuresProvided = (input: ResolveInput): boolean => { + return ( + input.evm !== undefined || + input.swap !== undefined || + input.snowbridge !== undefined + ); +}; + +const validSecret = ( + value: string | undefined, + validate: (value: string) => true | string, +): string | undefined => { + if (value === undefined) return undefined; + return validate(value) === true ? value : undefined; +}; + +export const hasRejectedSecrets = (input: ResolveInput): boolean => { + return ( + (input.privateKey !== undefined && + validateEvmPrivateKey(input.privateKey) !== true) || + (input.substrateMnemonic !== undefined && + validateSubstrateMnemonic(input.substrateMnemonic) !== true) + ); +}; + +export const generateNeedsInteractive = (input: ResolveInput): boolean => { + if (!process.stdin.isTTY) return false; + if (input.packageManager === undefined) return true; + if (input.kind === 'sdk' && input.client === undefined) return true; + if (!featuresProvided(input)) return true; + if (input.name === undefined) return true; + if (validateNameInput(input.name) !== true) return true; + + const features = applyFeatureFlags({ + evm: input.evm ?? false, + swap: input.swap ?? false, + snowbridge: input.snowbridge ?? false, + }); + if (input.framework === 'node' && input.substrateMnemonic === undefined) { + return true; + } + if ( + input.framework === 'node' && + features.evmWallet && + input.privateKey === undefined + ) { + return true; + } + return false; +}; + +const resolveFeatures = async ( + input: ResolveInput, +): Promise => { + if (featuresProvided(input)) { + return applyFeatureFlags({ + evm: input.evm ?? false, + swap: input.swap ?? false, + snowbridge: input.snowbridge ?? false, + }); + } + const selected = await promptFeatureExtensions({ + evm: input.evm, + swap: input.swap, + snowbridge: input.snowbridge, + }); + return applyFeatureFlags({ + evm: selected.includes(EVM_EXTENSION), + swap: selected.includes(SWAP_EXTENSION), + snowbridge: selected.includes(SNOWBRIDGE_EXTENSION), + }); +}; + +const resolveSecret = async ( + provided: string | undefined, + flagName: string, + validate: (value: string) => true | string, + promptFn: () => Promise, + applies: boolean, +): Promise => { + if (!applies) return undefined; + if (provided !== undefined) { + const result = validate(provided); + if (result === true) return provided; + console.warn(`Warning: ignoring invalid ${flagName}. ${result}`); + } + return promptFn(); +}; + +export const promptGenerateOptions = async ( + input: ResolveInput, + options: PromptGenerateOptions = {}, +): Promise => { + logResolvedPrompts(input); + + const name = + input.name ?? + (await promptName( + DEFAULT_NAME[input.kind], + options.validateName ?? validateNameInput, + )); + + const packageManager = + input.packageManager ?? (await promptPackageManager('pnpm')); + + const client = + input.kind === 'sdk' + ? (input.client ?? (await promptClient('pjs'))) + : undefined; + + const features = await resolveFeatures(input); + const isNode = input.framework === 'node'; + + const substrateMnemonic = await resolveSecret( + input.substrateMnemonic, + '--substrate-mnemonic', + validateSubstrateMnemonic, + promptSubstrateMnemonic, + isNode, + ); + + const privateKey = await resolveSecret( + input.privateKey, + '--private-key', + validateEvmPrivateKey, + promptEvmPrivateKey, + isNode && features.evmWallet, + ); + + return { + name, + client, + evm: features.evm, + swap: features.swap, + snowbridge: features.snowbridge, + packageManager, + privateKey, + substrateMnemonic, + }; +}; + +export const applyGenerateDefaults = (input: ResolveInput): ResolvedOptions => { + const name = input.name ?? DEFAULT_NAME[input.kind]; + const packageManager = input.packageManager ?? 'pnpm'; + const client = + input.kind === 'sdk' ? (input.client ?? 'pjs') : undefined; + + const features = applyFeatureFlags({ + evm: input.evm ?? false, + swap: input.swap ?? false, + snowbridge: input.snowbridge ?? false, + }); + const isNode = input.framework === 'node'; + + const substrateMnemonic = isNode + ? validSecret(input.substrateMnemonic, validateSubstrateMnemonic) + : undefined; + const privateKey = + isNode && features.evmWallet + ? validSecret(input.privateKey, validateEvmPrivateKey) + : undefined; + + return { + name, + client, + evm: features.evm, + swap: features.swap, + snowbridge: features.snowbridge, + packageManager, + privateKey, + substrateMnemonic, + }; +}; diff --git a/src/shared/prompt-sdk.ts b/src/shared/prompt-sdk.ts deleted file mode 100644 index ee03e8e..0000000 --- a/src/shared/prompt-sdk.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { Separator, input, select } from '@inquirer/prompts'; -import { applyFeatureFlags } from './feature-flags.js'; -import { - EVM_EXTENSION, - promptFeatureExtensions, - SNOWBRIDGE_EXTENSION, - SWAP_EXTENSION, -} from './feature-extensions-checkbox.js'; -import { logArgvResolvedPrompts } from './log-resolved-prompt.js'; -import { - argvHasAnyFeatureFlag, - argvHasAcceptedName, - argvHasFlag, - argvNameRejected, -} from './parse-cli-args.js'; -import { promptEvmPrivateKey } from './prompt-evm-private-key.js'; -import { promptSubstrateMnemonic } from './prompt-substrate-mnemonic.js'; -import type { SdkGenerateOptions } from './types.js'; -import { PACKAGE_MANAGERS } from './package-manager.js'; -import { validateNameInput } from './validate.js'; - -type NameValidator = (name: string) => true | string | Promise; - -type PromptProvided = { - framework?: boolean; -}; - -type PromptSdkOptions = { - validateName?: NameValidator; - argv?: string[]; - provided?: PromptProvided; -}; - -export async function promptSdkOptions( - partial: Partial, - options: PromptSdkOptions = {}, -): Promise< - Pick< - SdkGenerateOptions, - | 'name' - | 'client' - | 'evm' - | 'swap' - | 'snowbridge' - | 'packageManager' - | 'privateKey' - | 'substrateMnemonic' - > -> { - const argv = options.argv ?? []; - - logArgvResolvedPrompts({ - argv, - partial, - provided: options.provided, - kind: 'sdk', - defaultName: 'my-xcm-app', - }); - - const defaultName = 'my-xcm-app'; - - if (argvNameRejected(argv, partial.name)) { - const reason = validateNameInput(partial.name ?? ''); - if (reason !== true) { - console.warn(`Warning: ignoring invalid --name. ${reason}`); - } - } - - const name = argvHasAcceptedName(argv, partial.name) - ? (partial.name ?? defaultName) - : await input({ - message: 'Enter the project name', - default: defaultName, - validate: options.validateName ?? validateNameInput, - }); - - const packageManager = argvHasFlag(argv, 'package-manager') - ? (partial.packageManager ?? 'pnpm') - : await select({ - message: 'Select the desired package manager', - choices: [ - new Separator(), - ...PACKAGE_MANAGERS.map((packageManager) => ({ - name: packageManager, - value: packageManager, - })), - ], - default: partial.packageManager ?? 'pnpm', - }); - - const client = argvHasFlag(argv, 'client') - ? (partial.client ?? 'pjs') - : await select({ - message: 'Select the desired JS client type', - choices: [ - new Separator(), - { name: 'Polkadot API', value: 'papi' }, - { name: 'Polkadot JS', value: 'pjs' }, - { name: 'Dedot', value: 'dedot' }, - ], - default: partial.client ?? 'pjs', - }); - - let featureFlags: ReturnType; - if (argvHasAnyFeatureFlag(argv)) { - featureFlags = applyFeatureFlags({ - evm: partial.evm ?? false, - swap: partial.swap ?? false, - snowbridge: partial.snowbridge ?? false, - }); - } else { - const additionalFeatures = await promptFeatureExtensions({ - evm: partial.evm, - swap: partial.swap, - snowbridge: partial.snowbridge, - }); - featureFlags = applyFeatureFlags({ - evm: additionalFeatures.includes(EVM_EXTENSION), - swap: additionalFeatures.includes(SWAP_EXTENSION), - snowbridge: additionalFeatures.includes(SNOWBRIDGE_EXTENSION), - }); - } - - const substrateMnemonic = - partial.framework !== 'node' - ? undefined - : partial.substrateMnemonic !== undefined - ? partial.substrateMnemonic - : await promptSubstrateMnemonic(); - - const privateKey = - partial.framework !== 'node' || !featureFlags.evmWallet - ? undefined - : partial.privateKey !== undefined - ? partial.privateKey - : await promptEvmPrivateKey(); - - return { - name, - client, - ...featureFlags, - packageManager, - privateKey, - substrateMnemonic, - }; -} - -export function sdkNeedsInteractive( - argv: string[], - partial: Partial, -): boolean { - if (!process.stdin.isTTY) return false; - if (!argvHasFlag(argv, 'package-manager')) return true; - if (!argvHasFlag(argv, 'client')) return true; - if (!argvHasAnyFeatureFlag(argv)) return true; - if (!argvHasAcceptedName(argv, partial.name)) return true; - if (partial.framework === 'node' && partial.substrateMnemonic === undefined) { - return true; - } - const featureFlags = applyFeatureFlags({ - evm: partial.evm ?? false, - swap: partial.swap ?? false, - snowbridge: partial.snowbridge ?? false, - }); - if ( - partial.framework === 'node' && - featureFlags.evmWallet && - partial.privateKey === undefined - ) { - return true; - } - return false; -} diff --git a/src/shared/prompt-secrets.ts b/src/shared/prompt-secrets.ts new file mode 100644 index 0000000..00e4a62 --- /dev/null +++ b/src/shared/prompt-secrets.ts @@ -0,0 +1,31 @@ +import { password } from '@clack/prompts'; +import { ask, toClackValidate } from './clack.js'; +import { + validateEvmPrivateKey, + validateSubstrateMnemonic, +} from './validate.js'; + +const promptSecret = async ( + message: string, + validate: (value: string) => true | string, +): Promise => { + const value = await ask( + password({ message, mask: '*', validate: toClackValidate(validate) }), + ); + const trimmed = value.trim(); + return trimmed || undefined; +}; + +export const promptEvmPrivateKey = (): Promise => { + return promptSecret( + 'Your EVM wallet private key for setup (optional, press Enter to skip)', + validateEvmPrivateKey, + ); +}; + +export const promptSubstrateMnemonic = (): Promise => { + return promptSecret( + 'Your Substrate wallet mnemonic for setup (optional, press Enter to skip)', + validateSubstrateMnemonic, + ); +}; diff --git a/src/shared/prompt-substrate-mnemonic.ts b/src/shared/prompt-substrate-mnemonic.ts deleted file mode 100644 index 648eb3e..0000000 --- a/src/shared/prompt-substrate-mnemonic.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { password } from '@inquirer/prompts'; -import { validateSubstrateMnemonic } from './validate.js'; - -export async function promptSubstrateMnemonic(): Promise { - const value = await password({ - message: - 'Your Substrate wallet mnemonic for setup (optional, press Enter to skip)', - mask: '*', - validate: validateSubstrateMnemonic, - }); - - const trimmed = value.trim(); - return trimmed || undefined; -} diff --git a/src/shared/prompts.ts b/src/shared/prompts.ts new file mode 100644 index 0000000..82d8789 --- /dev/null +++ b/src/shared/prompts.ts @@ -0,0 +1,73 @@ +import { select, text } from "@clack/prompts"; +import { ask, toClackValidate } from "./clack.js"; +import { PACKAGE_MANAGERS } from "./package-manager.js"; +import { SDK_CLIENT_LABELS, SDK_CLIENTS } from "./types.js"; +import type { + Framework, + PackageManager, + ProjectType, + SdkClient, +} from "./types.js"; + +export const promptName = ( + initialValue: string, + validate: (value: string) => true | string, +): Promise => { + return ask( + text({ + message: "Enter the project name", + initialValue, + validate: toClackValidate(validate), + }), + ); +}; + +export const promptPackageManager = ( + initialValue: PackageManager, +): Promise => { + return ask( + select({ + message: "Select the desired package manager", + options: PACKAGE_MANAGERS.map((pm) => ({ value: pm, label: pm })), + initialValue, + }), + ); +}; + +export const promptFramework = (): Promise => { + return ask( + select({ + message: "Select the desired framework", + options: [ + { value: "react", label: "Vite - React" }, + { value: "vue", label: "Vite - Vue" }, + { value: "node", label: "NodeJS" }, + ], + }), + ); +}; + +export const promptProjectType = (): Promise => { + return ask( + select({ + message: "Select the desired project type", + options: [ + { value: "sdk", label: "XCM SDK" }, + { value: "api", label: "XCM API" }, + ], + }), + ); +}; + +export const promptClient = (initialValue: SdkClient) => { + return ask( + select({ + message: "Select the desired JS client type", + options: SDK_CLIENTS.map((value) => ({ + value, + label: SDK_CLIENT_LABELS[value], + })), + initialValue, + }), + ); +}; diff --git a/src/shared/run-entry.ts b/src/shared/run-entry.ts deleted file mode 100644 index 6962999..0000000 --- a/src/shared/run-entry.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { isUserError } from './errors.js'; - -export async function runEntry(main: () => Promise): Promise { - try { - await main(); - } catch (error) { - if (isUserError(error)) { - console.error(error.message); - } else { - console.error(error); - } - process.exit(1); - } -} diff --git a/src/shared/types.ts b/src/shared/types.ts index fd1ff8e..f2353f0 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,16 +1,24 @@ -export type Framework = 'react' | 'vue' | 'node'; +export const FRAMEWORKS = ["react", "vue", "node"] as const; +export type Framework = (typeof FRAMEWORKS)[number]; -export type PackageManager = 'npm' | 'yarn' | 'pnpm' | 'bun'; +export const PACKAGE_MANAGERS = ["npm", "yarn", "pnpm", "bun"] as const; +export type PackageManager = (typeof PACKAGE_MANAGERS)[number]; -export type ProjectType = 'sdk' | 'api'; +export const PROJECT_TYPES = ["sdk", "api"] as const; +export type ProjectType = (typeof PROJECT_TYPES)[number]; -export type SdkClient = 'papi' | 'pjs' | 'dedot'; +export const SDK_CLIENTS = ["papi", "pjs", "dedot"] as const; +export type SdkClient = (typeof SDK_CLIENTS)[number]; -export interface FeatureFlags { - evm: boolean; - swap: boolean; - snowbridge: boolean; -} +export const SDK_CLIENT_LABELS: Record = { + papi: "Polkadot API", + pjs: "Polkadot JS", + dedot: "Dedot", +}; + +export const FEATURE_KEYS = ["evm", "swap", "snowbridge"] as const; +export type FeatureKey = (typeof FEATURE_KEYS)[number]; +export type FeatureFlags = Record; export interface SdkGenerateOptions extends FeatureFlags { framework: Framework; @@ -39,3 +47,27 @@ export interface FrameworkMeta { examplesSubdir: string; logoFile?: string; } + +export interface ResolveInput { + kind: ProjectType; + framework: Framework; + name?: string; + client?: SdkClient; + evm?: boolean; + swap?: boolean; + snowbridge?: boolean; + packageManager?: PackageManager; + privateKey?: string; + substrateMnemonic?: string; +} + +export interface ResolvedOptions { + name: string; + client?: SdkClient; + evm: boolean; + swap: boolean; + snowbridge: boolean; + packageManager: PackageManager; + privateKey?: string; + substrateMnemonic?: string; +} diff --git a/src/shared/validate.test.ts b/src/shared/validate.test.ts index 3665c29..ae3f3a7 100644 --- a/src/shared/validate.test.ts +++ b/src/shared/validate.test.ts @@ -1,6 +1,5 @@ -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { - parseSecretFlag, validateEvmPrivateKey, validateSubstrateMnemonic, } from './validate.js'; @@ -42,26 +41,9 @@ describe('validateSubstrateMnemonic', () => { expect(validateSubstrateMnemonic(VALID_MNEMONIC)).toBe(true); }); - it('rejects EVM private keys and other invalid values', () => { - expect(validateSubstrateMnemonic(VALID_PRIVATE_KEY)).toMatch(/EVM private key/); + it('rejects values that are not a BIP39 phrase or //Dev URI', () => { + expect(validateSubstrateMnemonic(VALID_PRIVATE_KEY)).toMatch(/BIP39 phrase/); expect(validateSubstrateMnemonic('seed')).toMatch(/BIP39 phrase/); expect(validateSubstrateMnemonic('not a valid mnemonic')).toMatch(/BIP39 phrase/); }); }); - -describe('parseSecretFlag', () => { - it('returns the value when valid', () => { - expect( - parseSecretFlag('--private-key', VALID_PRIVATE_KEY, validateEvmPrivateKey), - ).toBe(VALID_PRIVATE_KEY); - }); - - it('warns and returns undefined when invalid', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - expect(parseSecretFlag('--private-key', '0xabc', validateEvmPrivateKey)).toBeUndefined(); - expect(warn).toHaveBeenCalledWith( - expect.stringMatching(/ignoring invalid --private-key/), - ); - vi.restoreAllMocks(); - }); -}); diff --git a/src/shared/validate.ts b/src/shared/validate.ts index e9d5144..ff43eeb 100644 --- a/src/shared/validate.ts +++ b/src/shared/validate.ts @@ -1,13 +1,13 @@ import validateNpmPackageName from 'validate-npm-package-name'; -export const EVM_PRIVATE_KEY_PATTERN = /^0x[0-9a-fA-F]{64}$/; +const EVM_PRIVATE_KEY_PATTERN = /^0x[0-9a-fA-F]{64}$/; const SUBSTRATE_DEV_URI_PATTERN = /^\/\/[A-Za-z][A-Za-z0-9]*(?:\/\/[A-Za-z][A-Za-z0-9]*)*$/; const BIP39_MNEMONIC_PATTERN = /^[a-z]+(?: [a-z]+){11,23}$/; -export function validateEvmPrivateKey(value: string): true | string { +export const validateEvmPrivateKey = (value: string): true | string => { const trimmed = value.trim(); if (!trimmed) return true; @@ -18,39 +18,17 @@ export function validateEvmPrivateKey(value: string): true | string { return true; } -export function validateSubstrateMnemonic(value: string): true | string { - const trimmed = value.trim(); - if (!trimmed) return true; - - if (EVM_PRIVATE_KEY_PATTERN.test(trimmed)) { - return 'This looks like an EVM private key. Use a BIP39 mnemonic or //Dev URI (e.g. //Alice).'; - } +const isSubstrateSecret = (value: string): boolean => + SUBSTRATE_DEV_URI_PATTERN.test(value) || BIP39_MNEMONIC_PATTERN.test(value); - if (SUBSTRATE_DEV_URI_PATTERN.test(trimmed) || BIP39_MNEMONIC_PATTERN.test(trimmed)) { - return true; - } +export const validateSubstrateMnemonic = (value: string): true | string => { + const trimmed = value.trim(); + if (!trimmed || isSubstrateSecret(trimmed)) return true; return 'Substrate mnemonic must be a BIP39 phrase (12–24 lowercase words) or a //Dev URI like //Alice.'; } -export function parseSecretFlag( - flag: string, - value: string, - validate: (value: string) => true | string, -): string | undefined { - const result = validate(value); - if (result !== true) { - console.warn(`Warning: ignoring invalid ${flag}. ${result}`); - return undefined; - } - return value; -} - -export function validateNpmName(name: string): boolean { - return validateNpmPackageName(name).validForNewPackages === true; -} - -export function validateNameInput(name: string): true | string { +export const validateNameInput = (name: string): true | string => { const trimmed = name.trim(); if (!trimmed) return 'Project name is required.'; diff --git a/src/shared/write-node-env.ts b/src/shared/write-node-env.ts index 2210f28..66615c5 100644 --- a/src/shared/write-node-env.ts +++ b/src/shared/write-node-env.ts @@ -1,35 +1,35 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import type { Framework } from './types.js'; +import fs from "node:fs"; +import path from "node:path"; +import type { Framework } from "./types.js"; -export function shouldWriteNodeEnv(framework: Framework): boolean { - return framework === 'node'; -} +export const shouldWriteNodeEnv = (framework: Framework): boolean => { + return framework === "node"; +}; -function formatEnvValue(value: string): string { +const formatEnvValue = (value: string): string => { if (/[\s#"'\\]/.test(value)) { - return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; } return value; -} +}; -export async function writeNodeEnv( +export const writeNodeEnv = async ( outDir: string, options: { privateKey?: string; substrateMnemonic?: string; evmWallet?: boolean; } = {}, -): Promise { - const substrateValue = options.substrateMnemonic ?? ''; +): Promise => { + const substrateValue = options.substrateMnemonic ?? ""; const lines = [`SUBSTRATE_MNEMONIC=${formatEnvValue(substrateValue)}`]; if (options.evmWallet) { - const evmValue = options.privateKey ?? ''; + const evmValue = options.privateKey ?? ""; lines.push(`PRIVATE_KEY=${formatEnvValue(evmValue)}`); } - const envPath = path.join(outDir, '.env'); - // Always restrict to owner read/write: the file holds (or will soon hold) - // wallet secrets, so it should never be created group/world-readable. - await fs.promises.writeFile(envPath, `${lines.join('\n')}\n`, { mode: 0o600 }); -} + const envPath = path.join(outDir, ".env"); + await fs.promises.writeFile(envPath, `${lines.join("\n")}\n`, { + mode: 0o600, + }); +}; diff --git a/src/tests/assert-structure.ts b/src/tests/assert-structure.ts index 5ad2f9a..ea9f117 100644 --- a/src/tests/assert-structure.ts +++ b/src/tests/assert-structure.ts @@ -1,6 +1,5 @@ import fs from 'node:fs'; import path from 'node:path'; -import { hasExtraBlankLines } from '../shared/normalize-blank-lines.js'; import type { GeneratedVariant } from './variants.js'; export interface StructureResult { @@ -9,61 +8,14 @@ export interface StructureResult { errors: string[]; } -function fileExists(root: string, rel: string): boolean { +const fileExists = (root: string, rel: string): boolean => { return fs.existsSync(path.join(root, rel)); } -async function walkSourceFiles( - dir: string, - files: string[] = [], -): Promise { - const entries = await fs.promises.readdir(dir, { withFileTypes: true }); - for (const entry of entries) { - const full = path.join(dir, entry.name); - if (entry.name === 'node_modules' || entry.name === 'dist') continue; - if (entry.isDirectory()) { - await walkSourceFiles(full, files); - } else if (/\.(ts|tsx|vue|js|jsx|json|html|css)$/.test(entry.name)) { - files.push(full); - } - } - return files; -} - -async function assertNoExtraBlankLines(root: string): Promise { - const errors: string[] = []; - const files = await walkSourceFiles(root); - for (const file of files) { - const content = await fs.promises.readFile(file, 'utf8'); - if (hasExtraBlankLines(content)) { - errors.push( - `Multiple consecutive blank lines in ${path.relative(root, file)}`, - ); - } - } - return errors; -} - -async function assertNoTemplateArtifacts(root: string): Promise { - const errors: string[] = []; - const files = await walkSourceFiles(root); - for (const file of files) { - if (file.endsWith('.ejs.t')) { - errors.push(`Template artifact: ${path.relative(root, file)}`); - continue; - } - const content = await fs.promises.readFile(file, 'utf8'); - if (content.includes('<%') || content.includes('<%=')) { - errors.push(`Unrendered EJS in ${path.relative(root, file)}`); - } - } - return errors; -} - -function assertPackageDeps( +const assertPackageDeps = ( pkg: Record | undefined>, variant: GeneratedVariant, -): string[] { +): string[] => { const errors: string[] = []; const deps = { ...pkg.dependencies, ...pkg.devDependencies }; @@ -147,7 +99,7 @@ function assertPackageDeps( return errors; } -function assertConditionalFiles(variant: GeneratedVariant, root: string): string[] { +const assertConditionalFiles = (variant: GeneratedVariant, root: string): string[] => { const errors: string[] = []; const isWeb = variant.framework === 'react' || variant.framework === 'vue'; const evmWallet = variant.evm || variant.snowbridge; @@ -275,7 +227,7 @@ function assertConditionalFiles(variant: GeneratedVariant, root: string): string return errors; } -async function assertNodeEnv(variant: GeneratedVariant, root: string): Promise { +const assertNodeEnv = async (variant: GeneratedVariant, root: string): Promise => { const errors: string[] = []; if (variant.framework !== 'node') return errors; @@ -310,9 +262,9 @@ async function assertNodeEnv(variant: GeneratedVariant, root: string): Promise { +): Promise => { const errors: string[] = []; const root = variant.absPath; @@ -341,8 +293,6 @@ export async function assertVariantStructure( } } - errors.push(...(await assertNoTemplateArtifacts(root))); - errors.push(...(await assertNoExtraBlankLines(root))); errors.push(...(await assertNodeEnv(variant, root))); return { variant, ok: errors.length === 0, errors }; diff --git a/src/tests/build.test.ts b/src/tests/build.test.ts index 1533b93..1e0fbf6 100644 --- a/src/tests/build.test.ts +++ b/src/tests/build.test.ts @@ -1,18 +1,18 @@ import { describe, expect, it } from 'vitest'; import { buildVariant } from './run-build.js'; -import { filterVariants, variantTitle } from './test-env.js'; +import { listVariants } from './variants.js'; const BUILD_TIMEOUT_MS = 5 * 60 * 1000; describe('generated project build', () => { - const variants = filterVariants(); + const variants = listVariants(); it.each(variants)('$id', async (variant) => { const result = await buildVariant(variant, BUILD_TIMEOUT_MS); const failed = result.steps.find((step) => !step.ok); expect( result.ok, - failed ? `${failed.name} failed for ${variantTitle(variant)}:\n${failed.output}` : '', + failed ? `${failed.name} failed for ${variant.id}:\n${failed.output}` : '', ).toBe(true); }); }); diff --git a/src/tests/cli-e2e.test.ts b/src/tests/cli-e2e.test.ts index d8d165b..1794eb1 100644 --- a/src/tests/cli-e2e.test.ts +++ b/src/tests/cli-e2e.test.ts @@ -12,10 +12,10 @@ const PACKAGE_ROOT = path.resolve( const CLI_ENTRY = path.join(PACKAGE_ROOT, 'src/index.ts'); const CLI_TIMEOUT_MS = 120_000; -async function runCli( +const runCli = async ( args: string[], cwd = PACKAGE_ROOT, -): Promise<{ ok: boolean; output: string; code: number }> { +): Promise<{ ok: boolean; output: string; code: number }> => { const result = await runCommand( cwd, path.join(PACKAGE_ROOT, 'node_modules/.bin/tsx'), @@ -41,7 +41,7 @@ describe('CLI subprocess', () => { const result = await runCli(['--help']); expect(result.ok).toBe(true); expect(result.output).toContain('create-paraspell sdk'); - expect(result.output).toContain('--type sdk|api'); + expect(result.output).toContain('create-paraspell api'); }); it('prints sdk help without generating a project', async () => { @@ -62,18 +62,16 @@ describe('CLI subprocess', () => { expect(result.output).toContain('--private-key'); }); - it('exits with code 1 for orphan flags without sdk|api', async () => { + it('exits non-zero for orphan flags without sdk|api', async () => { const result = await runCli(['--name', 'orphan']); expect(result.ok).toBe(false); - expect(result.output).toContain( - 'Non-interactive mode requires --type sdk|api', - ); + expect(result.output).toContain('No command registered'); }); it('rejects unknown positional framework values', async () => { const result = await runCli(['sdk', 'angular', '--name', 'bad']); expect(result.ok).toBe(false); - expect(result.output).toContain('Unknown argument "angular"'); + expect(result.output).toContain('Unknown framework "angular"'); }); it('rejects invalid project names in consumer mode', async () => { diff --git a/src/tests/generate-all.ts b/src/tests/generate-all.ts deleted file mode 100644 index 35d08e7..0000000 --- a/src/tests/generate-all.ts +++ /dev/null @@ -1,97 +0,0 @@ -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { API_EXAMPLES } from '../examples/api-examples.js'; -import { SDK_EXAMPLES } from '../examples/sdk-examples.js'; -import { API_FRAMEWORKS, SDK_FRAMEWORKS } from '../shared/frameworks.js'; -import { generateApiApp, generateSdkApp } from '../shared/hygen-runner.js'; -import { normalizePackageManager } from '../shared/package-manager.js'; -import type { Framework } from '../shared/types.js'; -import type { VariantKind } from './variants.js'; - -const cliRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const templatesRoot = path.join(cliRoot, '_templates'); - -export async function generateAllExamples(options?: { - kind?: VariantKind; - framework?: Framework; - packageManager?: string; -}): Promise { - const packageManager = normalizePackageManager(options?.packageManager); - const frameworks: Framework[] = options?.framework - ? [options.framework] - : ['react', 'vue', 'node']; - - if (!options?.kind || options.kind === 'sdk') { - for (const framework of frameworks) { - const meta = SDK_FRAMEWORKS[framework]; - for (const ex of SDK_EXAMPLES[framework]) { - await generateSdkApp({ - meta, - templatesRoot, - opts: { - framework, - name: ex.dir, - client: ex.client, - evm: ex.evm, - swap: ex.swap, - snowbridge: ex.snowbridge, - packageManager, - out: path.join(cliRoot, 'generated', 'xcm-sdk', framework, ex.dir), - }, - }); - } - } - } - - if (!options?.kind || options.kind === 'api') { - for (const framework of frameworks) { - const meta = API_FRAMEWORKS[framework]; - for (const variant of API_EXAMPLES) { - await generateApiApp({ - meta, - templatesRoot, - opts: { - framework, - name: `xcm-api-${variant.name}`, - out: path.join(cliRoot, 'generated', 'xcm-api', framework, variant.name), - evm: variant.evm, - swap: variant.swap, - snowbridge: variant.snowbridge, - packageManager, - }, - }); - } - } - } -} - -async function main(): Promise { - const argv = process.argv.slice(2); - const frameworkPos = argv.find((a) => ['react', 'vue', 'node'].includes(a)); - const framework = frameworkPos as Framework | undefined; - const kindFlag = argv.find((a) => a.startsWith('--kind='))?.split('=')[1]; - const kind = - kindFlag === 'sdk' || kindFlag === 'api' - ? kindFlag - : argv.includes('--kind') - ? (argv[argv.indexOf('--kind') + 1] as VariantKind | undefined) - : undefined; - const pmFlag = argv.find((a) => a.startsWith('--package-manager='))?.split('=')[1]; - const packageManager = pmFlag ?? - (argv.includes('--package-manager') - ? argv[argv.indexOf('--package-manager') + 1] - : undefined); - - await generateAllExamples({ kind, framework, packageManager }); -} - -const isMain = - process.argv[1] !== undefined && - fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); - -if (isMain) { - main().catch((err: unknown) => { - console.error(err); - process.exit(1); - }); -} diff --git a/src/tests/global-setup.ts b/src/tests/global-setup.ts index 4a7a586..2b5dc96 100644 --- a/src/tests/global-setup.ts +++ b/src/tests/global-setup.ts @@ -1,35 +1,38 @@ -import { spawn } from 'node:child_process'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { spawn } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; -const cliRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const cliRoot = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "../..", +); -function runGenerateAll(): Promise { +const globalSetup = (): Promise => { return new Promise((resolve, reject) => { const child = spawn( process.execPath, - [path.join(cliRoot, 'node_modules/tsx/dist/cli.mjs'), 'src/tests/generate-all.ts'], + [ + path.join(cliRoot, "node_modules/tsx/dist/cli.mjs"), + "src/generate-examples.ts", + ], { cwd: cliRoot, - stdio: 'inherit', + stdio: "inherit", env: process.env, }, ); - child.on('close', (code) => { + child.on("close", (code) => { if (code === 0) { resolve(); return; } - reject(new Error(`generate-all.ts exited with code ${code ?? 'unknown'}`)); + reject( + new Error(`generate-examples.ts exited with code ${code ?? "unknown"}`), + ); }); - child.on('error', reject); + child.on("error", reject); }); } -export default async function globalSetup(): Promise { - if (process.env.SKIP_GENERATE === '1') { - return; - } - await runGenerateAll(); -} +export default globalSetup; diff --git a/src/tests/run-build.ts b/src/tests/run-build.ts index f3aa118..12c3502 100644 --- a/src/tests/run-build.ts +++ b/src/tests/run-build.ts @@ -7,10 +7,10 @@ export interface BuildResult { steps: { name: string; ok: boolean; output: string }[]; } -export async function buildVariant( +export const buildVariant = async ( variant: GeneratedVariant, timeoutMs: number, -): Promise { +): Promise => { const steps: BuildResult['steps'] = []; const { pm, step: install } = await installProject(variant.absPath, timeoutMs); diff --git a/src/tests/run-project.ts b/src/tests/run-project.ts index f0dfb3c..75e24fd 100644 --- a/src/tests/run-project.ts +++ b/src/tests/run-project.ts @@ -14,7 +14,7 @@ export interface CommandStep { output: string; } -export function detectPackageManager(projectDir: string): PackageManager { +const detectPackageManager = (projectDir: string): PackageManager => { try { const pkg = JSON.parse( fs.readFileSync(path.join(projectDir, 'package.json'), 'utf8'), @@ -29,7 +29,7 @@ export function detectPackageManager(projectDir: string): PackageManager { return 'pnpm'; } -export function installArgs(pm: PackageManager): [string, string[]] { +const installArgs = (pm: PackageManager): [string, string[]] => { switch (pm) { case 'npm': return ['npm', ['install', '--no-audit', '--no-fund']]; @@ -42,7 +42,7 @@ export function installArgs(pm: PackageManager): [string, string[]] { } } -export function runArgs(pm: PackageManager, script: string): [string, string[]] { +const runArgs = (pm: PackageManager, script: string): [string, string[]] => { switch (pm) { case 'npm': return ['npm', ['run', script]]; @@ -55,12 +55,12 @@ export function runArgs(pm: PackageManager, script: string): [string, string[]] } } -export function runCommand( +export const runCommand = ( cwd: string, command: string, args: string[], timeoutMs: number, -): Promise<{ ok: boolean; output: string }> { +): Promise<{ ok: boolean; output: string }> => { return new Promise((resolve) => { const child = spawn(command, args, { cwd, @@ -97,10 +97,10 @@ export function runCommand( }); } -export async function installProject( +export const installProject = async ( projectDir: string, timeoutMs: number, -): Promise<{ pm: PackageManager; step: CommandStep }> { +): Promise<{ pm: PackageManager; step: CommandStep }> => { const pm = detectPackageManager(projectDir); if (isInGeneratedWorkspace(projectDir) && hasProjectDependencies(projectDir)) { @@ -122,12 +122,12 @@ export async function installProject( }; } -export async function runProjectScript( +export const runProjectScript = async ( projectDir: string, pm: PackageManager, script: string, timeoutMs: number, -): Promise { +): Promise => { const [cmd, argv] = runArgs(pm, script); const result = await runCommand(projectDir, cmd, argv, timeoutMs); return { name: script, ok: result.ok, output: result.output }; diff --git a/src/tests/run-typecheck.ts b/src/tests/run-typecheck.ts index 2f183e8..d23ea24 100644 --- a/src/tests/run-typecheck.ts +++ b/src/tests/run-typecheck.ts @@ -7,10 +7,10 @@ export interface TypecheckResult { steps: { name: string; ok: boolean; output: string }[]; } -export async function typecheckVariant( +export const typecheckVariant = async ( variant: GeneratedVariant, timeoutMs: number, -): Promise { +): Promise => { const steps: TypecheckResult['steps'] = []; const { pm, step: install } = await installProject(variant.absPath, timeoutMs); diff --git a/src/tests/structure.test.ts b/src/tests/structure.test.ts index b242f92..8b67c50 100644 --- a/src/tests/structure.test.ts +++ b/src/tests/structure.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from 'vitest'; import { assertVariantStructure } from './assert-structure.js'; -import { filterVariants, variantTitle } from './test-env.js'; +import { listVariants } from './variants.js'; describe('generated project structure', () => { - const variants = filterVariants(); + const variants = listVariants(); it.each(variants)('$id', async (variant) => { const result = await assertVariantStructure(variant); - expect(result.errors, variantTitle(variant)).toEqual([]); + expect(result.errors, variant.id).toEqual([]); }); }); diff --git a/src/tests/test-env.ts b/src/tests/test-env.ts deleted file mode 100644 index 5d77839..0000000 --- a/src/tests/test-env.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { parseFramework } from '../shared/frameworks.js'; -import type { Framework } from '../shared/types.js'; -import { listVariants, type VariantKind } from './variants.js'; - -export function filterVariants(): ReturnType { - const kind = process.env.TEST_KIND as VariantKind | undefined; - const frameworkRaw = process.env.TEST_FRAMEWORK; - const framework = frameworkRaw ? parseFramework(frameworkRaw) : null; - - return listVariants({ - kind: kind === 'sdk' || kind === 'api' ? kind : undefined, - framework: framework ?? undefined, - }); -} - -export function variantTitle(variant: { id: string; framework: Framework }): string { - return variant.id; -} diff --git a/src/tests/typecheck.test.ts b/src/tests/typecheck.test.ts index 7b2e3bb..3f3e574 100644 --- a/src/tests/typecheck.test.ts +++ b/src/tests/typecheck.test.ts @@ -1,18 +1,18 @@ import { describe, expect, it } from 'vitest'; import { typecheckVariant } from './run-typecheck.js'; -import { filterVariants, variantTitle } from './test-env.js'; +import { listVariants } from './variants.js'; const TYPECHECK_TIMEOUT_MS = 5 * 60 * 1000; describe('generated project typecheck', () => { - const variants = filterVariants(); + const variants = listVariants(); it.each(variants)('$id', async (variant) => { const result = await typecheckVariant(variant, TYPECHECK_TIMEOUT_MS); const failed = result.steps.find((step) => !step.ok); expect( result.ok, - failed ? `${failed.name} failed for ${variantTitle(variant)}:\n${failed.output}` : '', + failed ? `${failed.name} failed for ${variant.id}:\n${failed.output}` : '', ).toBe(true); }); }); diff --git a/src/tests/variants.test.ts b/src/tests/variants.test.ts index 12b7f43..8bdbc71 100644 --- a/src/tests/variants.test.ts +++ b/src/tests/variants.test.ts @@ -1,50 +1,27 @@ import { describe, expect, it } from 'vitest'; -import { API_EXAMPLES, API_VARIANTS_PER_FRAMEWORK } from '../examples/api-examples.js'; -import { SDK_EXAMPLES, SDK_VARIANTS_PER_FRAMEWORK } from '../examples/sdk-examples.js'; -import { FEATURE_COMBOS, SDK_CLIENTS } from '../examples/feature-combos.js'; -import type { Framework } from '../shared/types.js'; +import { FEATURE_COMBOS } from '../generate-examples.js'; +import { FRAMEWORKS, SDK_CLIENTS } from '../shared/types.js'; import { listVariants } from './variants.js'; -const FRAMEWORKS: Framework[] = ['react', 'vue', 'node']; +const SDK_PER_FRAMEWORK = SDK_CLIENTS.length * FEATURE_COMBOS.length; +const API_PER_FRAMEWORK = FEATURE_COMBOS.length; describe('example matrix', () => { it('lists all SDK variants (framework × client × feature combo)', () => { - expect(listVariants({ kind: 'sdk' })).toHaveLength( - FRAMEWORKS.length * SDK_VARIANTS_PER_FRAMEWORK, - ); - expect(SDK_VARIANTS_PER_FRAMEWORK).toBe(SDK_CLIENTS.length * FEATURE_COMBOS.length); + const sdk = listVariants().filter((v) => v.kind === 'sdk'); + expect(sdk).toHaveLength(FRAMEWORKS.length * SDK_PER_FRAMEWORK); }); it('lists all API variants (framework × feature combo)', () => { - expect(listVariants({ kind: 'api' })).toHaveLength( - FRAMEWORKS.length * API_VARIANTS_PER_FRAMEWORK, - ); - expect(API_VARIANTS_PER_FRAMEWORK).toBe(FEATURE_COMBOS.length); - }); - - it('matches sdk-examples.ts per framework', () => { - for (const framework of Object.keys(SDK_EXAMPLES) as Framework[]) { - expect(listVariants({ kind: 'sdk', framework })).toHaveLength( - SDK_EXAMPLES[framework].length, - ); - expect(SDK_EXAMPLES[framework]).toHaveLength(SDK_VARIANTS_PER_FRAMEWORK); - } - }); - - it('matches api-examples.ts per framework', () => { - for (const framework of FRAMEWORKS) { - expect(listVariants({ kind: 'api', framework })).toHaveLength( - API_EXAMPLES.length, - ); - expect(API_EXAMPLES).toHaveLength(API_VARIANTS_PER_FRAMEWORK); - } + const api = listVariants().filter((v) => v.kind === 'api'); + expect(api).toHaveLength(FRAMEWORKS.length * API_PER_FRAMEWORK); }); it('uses unique variant ids', () => { const ids = listVariants().map((v) => v.id); expect(new Set(ids).size).toBe(ids.length); expect(ids).toHaveLength( - FRAMEWORKS.length * (SDK_VARIANTS_PER_FRAMEWORK + API_VARIANTS_PER_FRAMEWORK), + FRAMEWORKS.length * (SDK_PER_FRAMEWORK + API_PER_FRAMEWORK), ); }); }); diff --git a/src/tests/variants.ts b/src/tests/variants.ts index dd4c2ca..9ad62db 100644 --- a/src/tests/variants.ts +++ b/src/tests/variants.ts @@ -1,14 +1,21 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { API_EXAMPLES } from '../examples/api-examples.js'; -import { SDK_EXAMPLES } from '../examples/sdk-examples.js'; -import type { FeatureFlags, Framework, SdkClient } from '../shared/types.js'; - -export type VariantKind = 'sdk' | 'api'; +import { + FEATURE_COMBOS, + apiExampleName, + sdkExampleDir, +} from '../generate-examples.js'; +import { FRAMEWORKS, SDK_CLIENTS } from '../shared/types.js'; +import type { + FeatureFlags, + Framework, + ProjectType, + SdkClient, +} from '../shared/types.js'; export interface GeneratedVariant extends FeatureFlags { id: string; - kind: VariantKind; + kind: ProjectType; framework: Framework; dir: string; absPath: string; @@ -17,46 +24,41 @@ export interface GeneratedVariant extends FeatureFlags { const cliRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '../..'); -export function listVariants(options?: { - kind?: VariantKind; - framework?: Framework; -}): GeneratedVariant[] { +export const listVariants = (): GeneratedVariant[] => { const variants: GeneratedVariant[] = []; - if (!options?.kind || options.kind === 'sdk') { - for (const framework of Object.keys(SDK_EXAMPLES) as Framework[]) { - if (options?.framework && options.framework !== framework) continue; - for (const ex of SDK_EXAMPLES[framework]) { + for (const framework of FRAMEWORKS) { + for (const client of SDK_CLIENTS) { + for (const combo of FEATURE_COMBOS) { + const dir = sdkExampleDir(client, combo); variants.push({ - id: `sdk/${framework}/${ex.dir}`, + id: `sdk/${framework}/${dir}`, kind: 'sdk', framework, - dir: ex.dir, - absPath: path.join(cliRoot, 'generated', 'xcm-sdk', framework, ex.dir), - client: ex.client, - evm: ex.evm, - swap: ex.swap, - snowbridge: ex.snowbridge, + dir, + absPath: path.join(cliRoot, 'generated', 'xcm-sdk', framework, dir), + client, + evm: combo.evm, + swap: combo.swap, + snowbridge: combo.snowbridge, }); } } } - if (!options?.kind || options.kind === 'api') { - for (const framework of ['react', 'vue', 'node'] as Framework[]) { - if (options?.framework && options.framework !== framework) continue; - for (const ex of API_EXAMPLES) { - variants.push({ - id: `api/${framework}/${ex.name}`, - kind: 'api', - framework, - dir: ex.name, - absPath: path.join(cliRoot, 'generated', 'xcm-api', framework, ex.name), - evm: ex.evm, - swap: ex.swap, - snowbridge: ex.snowbridge, - }); - } + for (const framework of FRAMEWORKS) { + for (const combo of FEATURE_COMBOS) { + const dir = apiExampleName(combo); + variants.push({ + id: `api/${framework}/${dir}`, + kind: 'api', + framework, + dir, + absPath: path.join(cliRoot, 'generated', 'xcm-api', framework, dir), + evm: combo.evm, + swap: combo.swap, + snowbridge: combo.snowbridge, + }); } } diff --git a/src/tests/workspace-install.ts b/src/tests/workspace-install.ts index 27f9d07..e34ac5e 100644 --- a/src/tests/workspace-install.ts +++ b/src/tests/workspace-install.ts @@ -1,72 +1,71 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { runCommand } from './run-project.js'; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { runCommand } from "./run-project.js"; export const WORKSPACE_INSTALL_TIMEOUT_MS = 20 * 60 * 1000; -const cliRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const cliRoot = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "../..", +); -export function getGeneratedRoot(): string { - return path.join(cliRoot, 'generated'); -} +const getGeneratedRoot = (): string => { + return path.join(cliRoot, "generated"); +}; const GENERATED_WORKSPACE_PACKAGE = { - name: 'paraspell-generated-workspace', + name: "paraspell-generated-workspace", private: true, pnpm: { // polkadot-api (papi) and @polkadot/api (snowbridge) pull different versions of // @polkadot-api/json-rpc-provider-proxy; without this override Vite resolves // proxy@0.4.0 against provider@0.0.1 and fails during dependency optimization. overrides: { - '@polkadot-api/json-rpc-provider': '0.2.0', + "@polkadot-api/json-rpc-provider": "0.2.0", }, }, } as const; -export function writeGeneratedWorkspace(): void { +const writeGeneratedWorkspace = (): void => { const root = getGeneratedRoot(); fs.mkdirSync(root, { recursive: true }); fs.writeFileSync( - path.join(root, 'pnpm-workspace.yaml'), + path.join(root, "pnpm-workspace.yaml"), "packages:\n - 'xcm-sdk/*/*'\n - 'xcm-api/*/*'\n", ); fs.writeFileSync( - path.join(root, 'package.json'), + path.join(root, "package.json"), `${JSON.stringify(GENERATED_WORKSPACE_PACKAGE, null, 2)}\n`, ); -} +}; -export function isInGeneratedWorkspace(projectDir: string): boolean { +export const isInGeneratedWorkspace = (projectDir: string): boolean => { const generatedRoot = getGeneratedRoot(); return ( - projectDir.startsWith(`${generatedRoot}${path.sep}`) - && fs.existsSync(path.join(generatedRoot, 'pnpm-workspace.yaml')) + projectDir.startsWith(`${generatedRoot}${path.sep}`) && + fs.existsSync(path.join(generatedRoot, "pnpm-workspace.yaml")) ); -} +}; -export function hasProjectDependencies(projectDir: string): boolean { - return fs.existsSync(path.join(projectDir, 'node_modules')); -} +export const hasProjectDependencies = (projectDir: string): boolean => { + return fs.existsSync(path.join(projectDir, "node_modules")); +}; let installPromise: Promise | undefined; -export function ensureGeneratedWorkspaceInstall( +export const ensureGeneratedWorkspaceInstall = ( timeoutMs = WORKSPACE_INSTALL_TIMEOUT_MS, -): Promise { - if (process.env.SKIP_INSTALL === '1') { - return Promise.resolve(); - } - +) => { if (!installPromise) { installPromise = (async () => { writeGeneratedWorkspace(); const result = await runCommand( getGeneratedRoot(), - 'pnpm', - ['install'], + "pnpm", + ["install", "--no-frozen-lockfile"], timeoutMs, ); if (!result.ok) { @@ -77,4 +76,4 @@ export function ensureGeneratedWorkspaceInstall( } return installPromise; -} +}; diff --git a/vitest.config.ts b/vitest.config.ts index 2e4415b..8182e9c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -17,13 +17,12 @@ export default defineConfig({ 'src/shared/feature-flags.test.ts', 'src/shared/normalize-blank-lines.test.ts', 'src/shared/log-resolved-prompt.test.ts', - 'src/shared/parse-cli-args.test.ts', 'src/shared/validate.test.ts', 'src/shared/prompt-needs-interactive.test.ts', 'src/shared/prompt-options.test.ts', 'src/run-cli.test.ts', 'src/interactive.test.ts', - 'src/examples/feature-combos.test.ts', + 'src/generate-examples.test.ts', 'src/tests/cli-e2e.test.ts', 'src/tests/variants.test.ts', 'src/tests/structure.test.ts',