diff --git a/README.md b/README.md index 4bad989..93d2ac2 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -
@@ -23,7 +23,7 @@
## Get started
```bash
-pnpm create-paraspell
+pnpm dlx paraspell-cli@latest
```
The wizard helps you choose what to build, shows you the final setup, creates
@@ -37,9 +37,9 @@ pnpm dev
Use npm, Yarn, or Bun instead if that is what your project uses:
```bash
-npm create paraspell@latest
-yarn create paraspell
-bun create paraspell
+npx paraspell-cli@latest
+yarn dlx paraspell-cli@latest
+bunx paraspell-cli@latest
```
Requires Node.js 24 or newer.
@@ -85,19 +85,19 @@ Prefer explicit commands in CI or when you already know the setup you want:
```bash
# React app using the XCM SDK and PAPI
-npx create-paraspell@latest sdk react \
+npx paraspell-cli@latest sdk react \
--name my-xcm-app \
--client papi \
--package-manager pnpm
# Vue app using the XCM API with EVM origins and swaps
-npx create-paraspell@latest api vue \
+npx paraspell-cli@latest api vue \
--name my-xcm-api \
--extensions evm,swap \
--package-manager npm
# Headless SDK server with swaps
-npx create-paraspell@latest sdk node \
+npx paraspell-cli@latest sdk node \
--name my-xcm-server \
--client dedot \
--extensions swap
@@ -107,9 +107,9 @@ Non-interactive environments use sensible defaults and leave dependency
installation as an explicit CI step.
```bash
-create-paraspell --help
-create-paraspell sdk --help
-create-paraspell api --help
+paraspell-cli --help
+paraspell-cli sdk --help
+paraspell-cli api --help
```
## A note about wallet secrets
diff --git a/e2e/generated-projects.test.ts b/e2e/generated-projects.test.ts
index 93fb215..f75ebe6 100644
--- a/e2e/generated-projects.test.ts
+++ b/e2e/generated-projects.test.ts
@@ -33,6 +33,7 @@ const PACKAGE_MANAGER_COMMANDS: Record<
const GENERATION_CASES = BUILD_CASES.map(
(testCase) => [caseId(testCase), testCase] as const,
);
+const MATRIX_BUILD_TIMEOUT_MS = 10 * 60 * 1000;
let root: string;
let projectsRoot: string;
@@ -59,35 +60,40 @@ describe('generated projects', () => {
await generateProject(testCase, projectPath(testCase));
});
- it('builds every supported project combination', async () => {
- const firstWorkspace = projectPath(BUILD_CASES[0]);
- const allowBuilds = fs.readFileSync(
- path.join(firstWorkspace, 'pnpm-workspace.yaml'),
- 'utf8',
- );
+ it(
+ 'builds and lints every supported project combination',
+ async () => {
+ const firstWorkspace = projectPath(BUILD_CASES[0]);
+ const allowBuilds = fs.readFileSync(
+ path.join(firstWorkspace, 'pnpm-workspace.yaml'),
+ 'utf8',
+ );
- fs.writeFileSync(
- path.join(root, 'package.json'),
- JSON.stringify({ name: 'generated-projects', private: true }),
- );
- fs.writeFileSync(
- path.join(root, 'pnpm-workspace.yaml'),
- `packages:
+ fs.writeFileSync(
+ path.join(root, 'package.json'),
+ JSON.stringify({ name: 'generated-projects', private: true }),
+ );
+ fs.writeFileSync(
+ path.join(root, 'pnpm-workspace.yaml'),
+ `packages:
- projects/*-pnpm
overrides:
"@polkadot-api/json-rpc-provider": ^0.2.0
${allowBuilds}`,
- );
+ );
- await runCommand('pnpm', ['install'], {
- cwd: root,
- });
- await runCommand(
- 'pnpm',
- ['--recursive', '--workspace-concurrency=4', 'run', 'build'],
- { cwd: root },
- );
- });
+ await runCommand('pnpm', ['install'], {
+ cwd: root,
+ });
+ await runCommand('pnpm', ['-r', '--no-bail', 'run', 'build'], {
+ cwd: root,
+ });
+ await runCommand('pnpm', ['-r', '--no-bail', 'run', 'lint'], {
+ cwd: root,
+ });
+ },
+ MATRIX_BUILD_TIMEOUT_MS,
+ );
const installAndBuild = async (
testCase: TGenerationCase,
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 59a8069..e8baac0 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -6,7 +6,7 @@ import eslintConfigPrettier from 'eslint-config-prettier/flat';
import tseslint from 'typescript-eslint';
export default defineConfig({
- name: 'create-paraspell/typescript',
+ name: 'paraspell-cli/typescript',
files: ['src/**/*.ts', 'e2e/**/*.ts'],
extends: [
js.configs.recommended,
diff --git a/package.json b/package.json
index 8cba2e7..b675e5d 100644
--- a/package.json
+++ b/package.json
@@ -1,12 +1,12 @@
{
- "name": "create-paraspell",
+ "name": "paraspell-cli",
"version": "1.0.0",
"description": "Scaffold ParaSpell XCM SDK and XCM API starter apps",
"type": "module",
"license": "MIT",
"author": "ParaSpell",
"bin": {
- "create-paraspell": "dist/index.js"
+ "paraspell-cli": "dist/index.js"
},
"files": [
"dist",
diff --git a/src/generator/context.test.ts b/src/generator/context.test.ts
index ee4dcc5..8ed9fa7 100644
--- a/src/generator/context.test.ts
+++ b/src/generator/context.test.ts
@@ -47,11 +47,7 @@ describe('createTemplateContext', () => {
},
);
- it.each([
- [{ evm: false, swap: false, snowbridge: false }, 'Astar'],
- [{ evm: true, swap: false, snowbridge: false }, 'Moonbeam'],
- [{ evm: true, swap: false, snowbridge: true }, 'Ethereum'],
- ])('selects the default origin for %o', (extensions, expected) => {
+ it('selects the Node start command', () => {
const context = createTemplateContext({
kind: 'sdk',
opts: {
@@ -60,11 +56,10 @@ describe('createTemplateContext', () => {
client: 'papi',
packageManager: 'pnpm',
out: '/tmp/example',
- extensions,
+ extensions: { evm: false, swap: false, snowbridge: false },
},
});
- expect(context.defaultOriginChain).toBe(expected);
expect(context.startCmd).toBe('pnpm start');
});
});
diff --git a/src/generator/context.ts b/src/generator/context.ts
index f5e6b92..162c476 100644
--- a/src/generator/context.ts
+++ b/src/generator/context.ts
@@ -31,10 +31,5 @@ export const createTemplateContext = ({
projectKind: kind,
extensions: opts.extensions,
evmWallet: requiresEvmWallet(opts.extensions),
- defaultOriginChain: opts.extensions.snowbridge
- ? 'Ethereum'
- : opts.extensions.evm
- ? 'Moonbeam'
- : 'Astar',
};
};
diff --git a/src/generator/template-dependencies/package.json b/src/generator/template-dependencies/package.json
index 96a8b7c..41fcd1d 100644
--- a/src/generator/template-dependencies/package.json
+++ b/src/generator/template-dependencies/package.json
@@ -2,7 +2,7 @@
"name": "@paraspell/cli-template-dependencies",
"version": "0.0.0",
"private": true,
- "description": "Canonical dependency versions emitted by create-paraspell",
+ "description": "Canonical dependency versions emitted by paraspell-cli",
"dependencies": {
"@eslint/js": "^10.0.1",
"@paraspell/descriptors": "14.1.0",
@@ -23,6 +23,7 @@
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.4",
"@vitejs/plugin-vue": "^6.0.8",
+ "@vue/tsconfig": "^0.9.1",
"axios": "^1.18.1",
"dedot": "^1.3.0",
"dotenv": "^17.4.2",
@@ -41,7 +42,7 @@
"tsx": "^4.23.1",
"typescript": "^6.0.3",
"typescript-eslint": "^8.65.0",
- "viem": "2.55.5",
+ "viem": "2.55.11",
"vite": "^8.1.5",
"vite-plugin-wasm": "^3.6.0",
"vue": "^3.5.40",
diff --git a/src/generator/templates/index.test.ts b/src/generator/templates/index.test.ts
index 40919ac..c00ba9e 100644
--- a/src/generator/templates/index.test.ts
+++ b/src/generator/templates/index.test.ts
@@ -1,45 +1,22 @@
import { describe, expect, it } from 'vitest';
import {
+ FRAMEWORKS,
+ PROJECT_TYPES,
+ SDK_CLIENTS,
type TExtensions,
type TFramework,
- type TPackageManager,
type TProjectType,
type TSdkClient,
} from '../../shared/project-options.js';
import { createTemplateContext } from '../context.js';
import { createTemplateFiles } from './index.js';
-type TTemplateCase = [
- kind: TProjectType,
- framework: TFramework,
- client: TSdkClient,
- packageManager: TPackageManager,
- extensions?: TExtensions[],
-];
-
-const templateCases: TTemplateCase[] = [
- ['sdk', 'react', 'papi', 'pnpm'],
- ['sdk', 'vue', 'pjs', 'npm'],
- [
- 'sdk',
- 'vue',
- 'papi',
- 'pnpm',
- [{ evm: false, swap: false, snowbridge: false }],
- ],
- ['sdk', 'node', 'dedot', 'yarn'],
- ['api', 'react', 'pjs', 'yarn'],
- ['api', 'vue', 'dedot', 'pnpm'],
- ['api', 'node', 'papi', 'npm'],
-];
-
-const extensionSets: TExtensions[] = [
- { evm: false, swap: false, snowbridge: false },
- { evm: true, swap: false, snowbridge: false },
- { evm: false, swap: true, snowbridge: false },
- { evm: false, swap: false, snowbridge: true },
- { evm: true, swap: true, snowbridge: true },
-];
+const EXTENSION_COMBINATIONS: readonly TExtensions[] = [false, true].flatMap(
+ (evm) =>
+ [false, true].flatMap((swap) =>
+ [false, true].map((snowbridge) => ({ evm, swap, snowbridge })),
+ ),
+);
const renderTemplates = (
kind: TProjectType,
@@ -64,33 +41,49 @@ const renderTemplates = (
};
describe('createTemplateFiles', () => {
- it.each(templateCases)(
- 'renders the %s %s templates',
- (kind, framework, client, packageManager, caseExtensions) => {
- for (const extensions of caseExtensions ?? extensionSets) {
- const context = createTemplateContext({
- kind,
- opts: {
- framework,
- name: `${kind}-${framework}`,
- client,
- packageManager,
- out: '/tmp/not-written',
- extensions,
- },
- });
- const files = createTemplateFiles(context);
- const paths = files.map((file) => file.path);
+ it('renders every supported project combination without invalid files', () => {
+ let combinations = 0;
+
+ for (const kind of PROJECT_TYPES) {
+ for (const framework of FRAMEWORKS) {
+ const clients = kind === 'sdk' ? SDK_CLIENTS : (['papi'] as const);
+
+ for (const client of clients) {
+ for (const extensions of EXTENSION_COMBINATIONS) {
+ const caseName = [
+ kind,
+ framework,
+ client,
+ ...Object.entries(extensions)
+ .filter(([, enabled]) => enabled)
+ .map(([extension]) => extension),
+ ].join('-');
+ const context = createTemplateContext({
+ kind,
+ opts: {
+ framework,
+ name: caseName,
+ client,
+ packageManager: 'pnpm',
+ out: '/tmp/not-written',
+ extensions,
+ },
+ });
+ const files = createTemplateFiles(context);
+ const paths = files.map((file) => file.path);
- expect(new Set(paths).size).toBe(paths.length);
- expect(paths).toContain('package.json');
- for (const file of files) {
- const output = file.render();
- expect(output.length).toBeGreaterThan(0);
+ expect(new Set(paths).size, caseName).toBe(paths.length);
+ for (const file of files) {
+ expect(file.render(), `${caseName}:${file.path}`).not.toBe('');
+ }
+ combinations += 1;
+ }
}
}
- },
- );
+ }
+
+ expect(combinations).toBe(96);
+ });
it('keeps minimal Node SDK manifests free of browser and extension dependencies', () => {
const context = createTemplateContext({
@@ -123,24 +116,6 @@ describe('createTemplateFiles', () => {
expect(manifest.dependencies).not.toHaveProperty('@paraspell/swap');
});
- it.each([
- ['react', 'src/main.tsx'],
- ['vue', 'src/main.ts'],
- ] as const)(
- 'shares the %s entrypoint across SDK and API templates',
- (framework, mainPath) => {
- const extensions = {
- evm: false,
- swap: false,
- snowbridge: false,
- };
- const sdk = renderTemplates('sdk', framework, 'papi', extensions);
- const api = renderTemplates('api', framework, 'papi', extensions);
-
- expect(sdk.get(mainPath)).toBe(api.get(mainPath));
- },
- );
-
it('omits configuration-specific dead output', () => {
const minimalExtensions = {
evm: false,
@@ -153,17 +128,21 @@ describe('createTemplateFiles', () => {
expect(apiReact.has('src/evm/index.ts')).toBe(false);
expect(apiReact.has('src/wallet/evm/index.ts')).toBe(false);
expect(apiReact.has('src/utils.ts')).toBe(false);
- const apiPapiSubmit = apiReact.get('src/submit/submitPapiTransaction.ts');
+ const apiPapiSubmit = apiReact.get('src/utils/submitPapiTransaction.ts');
expect(apiPapiSubmit).toContain('tx.signAndSubmit(signer)');
expect(apiPapiSubmit).toContain('throw new Error(message)');
+ expect(apiPapiSubmit).toContain('{ cause: error }');
expect(apiPapiSubmit).not.toContain('UnsupportedOperationError');
expect(apiPapiSubmit).not.toContain('signSubmitAndWatch');
- expect(apiReact.get('src/submit/submitEvmTx.ts')).toContain(
+ expect(apiReact.get('src/utils/submitEvmTx.ts')).toContain(
'walletClient.sendTransaction',
);
- expect(apiReact.get('src/submit/submitEvmTx.ts')).not.toContain(
+ expect(apiReact.get('src/utils/submitEvmTx.ts')).not.toContain(
'createPublicClient',
);
+ const apiDataHook = apiReact.get('src/hooks/useApiData.ts');
+ expect(apiDataHook).toContain('state?.url === url');
+ expect(apiDataHook).not.toContain('setLoading');
const minimalPapiReact = renderTemplates(
'sdk',
@@ -178,11 +157,11 @@ describe('createTemplateFiles', () => {
expect(minimalPapiReact.get('src/hooks/usePapiWallet.ts')).not.toMatch(
/return\s*\{[\s\S]*?\bselectedAccount,/,
);
- expect(minimalPapiReact.get('src/xcm/papi.ts')).toContain(
+ expect(minimalPapiReact.get('src/utils/submitUsingSdk.ts')).toContain(
'submitPapiTransaction(tx, signer)',
);
const sdkPapiSubmit = minimalPapiReact.get(
- 'src/xcm/submitPapiTransaction.ts',
+ 'src/utils/submitPapiTransaction.ts',
);
expect(sdkPapiSubmit).toBe(apiPapiSubmit);
@@ -244,12 +223,38 @@ describe('createTemplateFiles', () => {
expect(minimalPapiVue.get('src/types.ts')).not.toContain(
'TWalletControlsSubstrateProps',
);
+ expect(minimalPapiVue.get('src/components/TransferForm.vue')).toContain(
+ 'import { ref, watch } from "vue";',
+ );
const fullPapiVue = renderTemplates('sdk', 'vue', 'papi', fullExtensions);
expect(fullPapiVue.get('src/types.ts')).not.toContain(
'TWalletControlsEvmProps',
);
});
+ it.each([
+ ['react', 'src/hooks/useWalletWithEvm.ts'],
+ ['vue', 'src/composables/useWalletWithEvm.ts'],
+ ] as const)(
+ 'keeps generated %s SDK submission routing in one place',
+ (framework, walletPath) => {
+ const output = renderTemplates('sdk', framework, 'papi', {
+ evm: true,
+ swap: true,
+ snowbridge: true,
+ });
+ const wallet = output.get(walletPath)!;
+ const submitUsingSdk = output.get('src/utils/submitUsingSdk.ts')!;
+
+ expect(output.has('src/utils/connectWalletAlert.ts')).toBe(true);
+ expect(output.has('src/utils/submitTransfer.ts')).toBe(false);
+ expect(wallet).toContain('await submitUsingSdk(formValues, options);');
+ expect(wallet).not.toContain('submitEvmIfNeeded');
+ expect(submitUsingSdk).toContain('isChainEvm');
+ expect(submitUsingSdk).toContain('submitEvmTransferFromForm');
+ },
+ );
+
it('renders the ParaSpell wordmark and dedicated favicon in browser apps', () => {
const context = createTemplateContext({
kind: 'api',
@@ -272,7 +277,7 @@ describe('createTemplateFiles', () => {
});
it.each(['sdk', 'api'] as const)(
- 'organizes generated React %s hooks and components behind folder barrels',
+ 'organizes generated React %s source into components, hooks, and utils',
(kind) => {
const output = renderTemplates(kind, 'react', 'dedot', {
evm: true,
@@ -289,6 +294,9 @@ describe('createTemplateFiles', () => {
filePath !== 'src/main.tsx' &&
filePath !== 'src/App.tsx',
);
+ const utilityPaths = paths.filter((filePath) =>
+ filePath.startsWith('src/utils/'),
+ );
expect(hookPaths.length).toBeGreaterThan(0);
expect(
@@ -300,21 +308,20 @@ describe('createTemplateFiles', () => {
/^src\/components(?:\/common)?\/[^/]+\.tsx$/.test(filePath),
),
).toBe(true);
+ expect(utilityPaths.length).toBeGreaterThan(0);
expect(
- paths.some((filePath) => filePath.startsWith('src/components/common/')),
- ).toBe(false);
- expect(output.get('src/components/index.ts')).toContain(
- 'export { XcmTransfer }',
- );
+ utilityPaths.every((filePath) => /^src\/utils\/[^/]+$/.test(filePath)),
+ ).toBe(true);
+ expect(output.has('src/components/index.ts')).toBe(false);
+ expect(output.has('src/hooks/index.ts')).toBe(false);
expect(output.has('src/components/TransferForm.tsx')).toBe(true);
expect(output.has('src/components/XcmTransferForm.tsx')).toBe(false);
- expect(output.get('src/hooks/index.ts')).toContain(
- 'export { useWalletWithEvm }',
- );
expect(output.get('src/main.tsx')).toContain('from "./App.tsx"');
- expect(output.get('src/App.tsx')).toContain('from "./components"');
+ expect(output.get('src/App.tsx')).toContain(
+ 'from "./components/XcmTransfer"',
+ );
expect(output.get('src/components/XcmTransfer.tsx')).toContain(
- 'from "../hooks"',
+ 'from "../hooks/useWalletWithEvm"',
);
if (kind === 'sdk') {
expect(output.get('src/components/XcmTransfer.tsx')).toContain(
@@ -325,7 +332,7 @@ describe('createTemplateFiles', () => {
);
it.each(['sdk', 'api'] as const)(
- 'organizes generated Vue %s composables and components conventionally',
+ 'organizes generated Vue %s source into components, composables, and utils',
(kind) => {
const output = renderTemplates(kind, 'vue', 'dedot', {
evm: true,
@@ -339,6 +346,9 @@ describe('createTemplateFiles', () => {
const componentPaths = paths.filter(
(filePath) => filePath.endsWith('.vue') && filePath !== 'src/App.vue',
);
+ const utilityPaths = paths.filter((filePath) =>
+ filePath.startsWith('src/utils/'),
+ );
expect(composablePaths.length).toBeGreaterThan(0);
expect(
@@ -352,21 +362,53 @@ describe('createTemplateFiles', () => {
/^src\/components(?:\/common)?\/[^/]+\.vue$/.test(filePath),
),
).toBe(true);
+ expect(utilityPaths.length).toBeGreaterThan(0);
expect(
- paths.some((filePath) => filePath.startsWith('src/components/common/')),
- ).toBe(false);
- expect(output.get('src/composables/index.ts')).toContain(
- 'export { useWalletWithEvm }',
- );
- expect(output.get('src/components/index.ts')).toContain(
- 'export { default as XcmTransfer }',
- );
+ utilityPaths.every((filePath) => /^src\/utils\/[^/]+$/.test(filePath)),
+ ).toBe(true);
+ expect(output.has('src/components/index.ts')).toBe(false);
+ expect(output.has('src/composables/index.ts')).toBe(false);
expect(output.has('src/components/TransferForm.vue')).toBe(true);
expect(output.has('src/components/XcmTransferForm.vue')).toBe(false);
- expect(output.get('src/App.vue')).toContain('from "./components"');
+ expect(output.get('src/App.vue')).toContain(
+ 'from "./components/XcmTransfer.vue"',
+ );
expect(output.get('src/components/XcmTransfer.vue')).toContain(
- 'from "../composables"',
+ 'from "../composables/useWalletWithEvm"',
+ );
+ const transferForm = output.get('src/components/TransferForm.vue')!;
+ expect(transferForm).toContain('const handleSubmit = () => {');
+ expect(transferForm).toContain('