Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/publish-on-tag.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: publish-on-tag

on:
push:
tags:
- 'v*.*.*'

jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 18
registry-url: 'https://registry.npmjs.org'

- name: Install dependencies
run: yarn install --frozen-lockfile

- name: Build CLI
run: yarn workspace @castui/cli run build

- name: Publish @castui/cli
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
cd packages/cli
npm publish --access public

3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ yarn-error.log
*.log
*.tgz
.DS_Store
instructions.md
instructions.md
packages/cli/dist
15 changes: 12 additions & 3 deletions packages/cli/bin/castui.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,18 @@
const { Command } = require('commander');
const fs = require('fs');
const path = require('path');
const pathToGenerate = path.resolve(__dirname, '../src/generate');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { generate } = require(pathToGenerate);
const distGeneratePath = path.resolve(__dirname, '../dist/generate.js');

let generate;
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
({ generate } = require(distGeneratePath));
} catch (error) {
console.error(
'Unable to load compiled CLI sources. Please run "yarn workspace @castui/cli run build" first.'
);
process.exit(1);
}

const program = new Command();

Expand Down
10 changes: 10 additions & 0 deletions packages/cli/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/** @type {import('jest').Config} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
moduleFileExtensions: ['ts', 'js', 'json'],
testMatch: ['**/__tests__/**/*.test.ts'],
collectCoverageFrom: ['src/**/*.{ts,js}', '!src/**/*.d.ts']
};

11 changes: 9 additions & 2 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,26 @@
"access": "public"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"start": "node ./bin/castui.js",
"test:smoke": "node ./bin/castui.js | grep 'Welcome to CastUI' || exit 1"
"test:smoke": "node ./bin/castui.js | grep 'Welcome to CastUI' || exit 1",
"test": "jest"
},
"license": "MIT",
"dependencies": {
"@codama/nodes-from-anchor": "^1.2.9",
"commander": "^14.0.2",
"ejs": "^3.1.10",
"execa": "^8.0.1",
"fs-extra": "^11.2.0"
},
"devDependencies": {
"@types/ejs": "^3.1.5",
"@types/node": "^24.10.0"
"@types/jest": "^29.5.12",
"@types/node": "^24.10.0",
"jest": "^29.7.0",
"ts-jest": "^29.2.5",
"typescript": "^5.4.5"
}
}

56 changes: 56 additions & 0 deletions packages/cli/src/__tests__/integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import fs from 'fs-extra';
import os from 'node:os';
import path from 'node:path';
import { spawn } from 'node:child_process';

const CLI_PATH = path.resolve(__dirname, '../../bin/castui.js');
const IDL_PATH = path.resolve(__dirname, '../../../../tests/fixtures/simple_idl.json');
const REPO_ROOT = path.resolve(__dirname, '../../..');

function run(command: string, args: string[], options: { cwd?: string } = {}) {
return new Promise<void>((resolve, reject) => {
const child = spawn(command, args, {
cwd: options.cwd,
stdio: 'inherit',
shell: false
});
child.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`${command} exited with code ${code}`));
}
});
child.on('error', reject);
});
}

describe('castui CLI integration', () => {
jest.setTimeout(60000);

it('generates files from IDL without running install', async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'castui-integration-'));

try {
const outDir = path.join(tmpDir, 'generated');

await run('yarn', ['workspace', '@castui/cli', 'run', 'build'], { cwd: REPO_ROOT });

await run('node', [CLI_PATH, '--idl', IDL_PATH, '--out', outDir, '--no-install'], {
cwd: REPO_ROOT
});

const instructionFile = path.join(outDir, 'pages', 'instruction', 'initialize.tsx');
const metadataFile = path.join(outDir, '.castui', 'metadata.json');

expect(await fs.pathExists(instructionFile)).toBe(true);
expect(await fs.pathExists(metadataFile)).toBe(true);

const metadata = await fs.readJSON(metadataFile);
expect(metadata.idlPath).toContain('simple_idl.json');
} finally {
await fs.remove(tmpDir);
}
});
});

2 changes: 1 addition & 1 deletion packages/cli/src/__tests__/mapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { mapRootNodeToIR } from '../mapper';

describe('mapRootNodeToIR', () => {
it('maps instructions, args, and accounts to IR', async () => {
const idlPath = path.resolve(__dirname, '../../../tests/fixtures/simple_idl.json');
const idlPath = path.resolve(__dirname, '../../../../tests/fixtures/simple_idl.json');
const root = await parseIdlToRootNode(idlPath);
const ir = mapRootNodeToIR(root as any);

Expand Down
18 changes: 10 additions & 8 deletions packages/cli/src/__tests__/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,19 @@ import { parseIdlToRootNode } from '../parser';

describe('parseIdlToRootNode', () => {
it('parses an Anchor IDL into a Codama root node', async () => {
const idlPath = path.resolve(__dirname, '../../../tests/fixtures/simple_idl.json');
const root = await parseIdlToRootNode(idlPath);
const idlPath = path.resolve(__dirname, '../../../../tests/fixtures/simple_idl.json');
const root = (await parseIdlToRootNode(idlPath)) as any;
const program = root.program ?? root.programs?.[0] ?? root;

expect(root.name).toBe('simple_program');
expect(root.instructions).toBeDefined();
expect(Array.isArray(root.instructions)).toBe(true);
expect(root.instructions.length).toBeGreaterThan(0);
expect(program.name).toBe('simpleProgram');
expect(program.instructions).toBeDefined();
expect(Array.isArray(program.instructions)).toBe(true);
expect(program.instructions.length).toBeGreaterThan(0);

const initialize = root.instructions[0];
const initialize = program.instructions[0];
expect(initialize.name).toBe('initialize');
expect(initialize.args.length).toBe(2);
expect(Array.isArray(initialize.arguments)).toBe(true);
expect(initialize.arguments.length).toBeGreaterThan(0);
expect(initialize.accounts.length).toBe(2);
});
});
Expand Down
99 changes: 89 additions & 10 deletions packages/cli/src/mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,24 +31,56 @@ export interface InstructionIR {
docs?: string[];
}

interface CodamaInstruction {
type CodamaInstruction = {
name: string;
args?: any[];
accounts?: any[];
docs?: string[];
}
};

type CodamaProgram = {
name: string;
instructions?: CodamaInstruction[];
};

type CodamaRoot = {
instructions?: CodamaInstruction[];
program?: CodamaProgram;
programs?: CodamaProgram[];
};

function extractInstructions(root: CodamaRoot): CodamaInstruction[] {
if (!root) {
return [];
}

interface CodamaRoot {
instructions: CodamaInstruction[];
if (Array.isArray(root.instructions) && root.instructions.length > 0) {
return root.instructions;
}

if (Array.isArray(root.program?.instructions) && root.program?.instructions.length) {
return root.program.instructions;
}

if (Array.isArray(root.programs?.[0]?.instructions) && root.programs?.[0]?.instructions?.length) {
return root.programs[0].instructions ?? [];
}

return [];
}

export function mapRootNodeToIR(root: CodamaRoot): InstructionIR[] {
if (!root || !Array.isArray(root.instructions)) {
const instructions = extractInstructions(root);

if (!instructions.length) {
return [];
}

return root.instructions.map((instruction) => {
const args = (instruction.args ?? []).map(mapArgument);
return instructions.map((instruction: any) => {
const rawArgs = instruction.arguments ?? instruction.args ?? [];
const args = rawArgs
.filter((arg: any) => arg.name !== 'discriminator')
.map(mapArgument);
const accounts = (instruction.accounts ?? []).map(mapAccount);

return {
Expand All @@ -60,8 +92,15 @@ export function mapRootNodeToIR(root: CodamaRoot): InstructionIR[] {
});
}

function isOptionType(type: any): boolean {
if (!type) return false;
if (type.option || type.optional) return true;
if (typeof type === 'object' && type.kind === 'optionTypeNode') return true;
return false;
}

function mapArgument(arg: any): ArgIR {
const optional = Boolean(arg?.isOptional ?? arg?.optional ?? arg?.type?.option);
const optional = Boolean(arg?.isOptional ?? arg?.optional ?? isOptionType(arg?.type));
const uiType = mapTypeToUi(arg?.type);
const children = mapNestedArgs(arg?.type);

Expand All @@ -77,9 +116,9 @@ function mapArgument(arg: any): ArgIR {
function mapAccount(account: any): AccountIR {
return {
name: account?.name ?? 'unknown',
isSigner: Boolean(account?.isSigner ?? account?.signer ?? account?.signer === true),
isSigner: Boolean(account?.isSigner ?? account?.signer === true),
isWritable: Boolean(account?.isMut ?? account?.isWritable),
optional: Boolean(account?.optional),
optional: Boolean(account?.optional ?? account?.isOptional),
role: account?.role,
seeds: account?.seeds
};
Expand Down Expand Up @@ -119,6 +158,36 @@ function mapTypeToUi(type: any): UiType {
}

if (typeof type === 'object') {
switch (type.kind) {
case 'publicKeyTypeNode':
return 'Address';
case 'booleanTypeNode':
return 'Toggle';
case 'stringTypeNode':
case 'utf8StringTypeNode':
return 'Text';
case 'numberTypeNode': {
const format = type.format;
if (['i64', 'u64', 'i128', 'u128', 'i256', 'u256'].includes(format)) {
return 'BigInt';
}
return 'Number';
}
case 'bytesTypeNode':
case 'setTypeNode':
case 'vectorTypeNode':
case 'arrayTypeNode':
return 'List';
case 'optionTypeNode':
return mapTypeToUi(type.item ?? type.type);
case 'enumTypeNode':
return 'Select';
case 'structTypeNode':
return 'Text';
default:
break;
}

if (type.option) {
return mapTypeToUi(type.option);
}
Expand All @@ -144,6 +213,16 @@ function mapNestedArgs(type: any): ArgIR[] {
return [];
}

if (type.kind === 'structTypeNode' && Array.isArray(type.fields)) {
return type.fields.map((field: any) =>
mapArgument({
name: field.name,
type: field.type,
docs: field.docs
})
);
}

if (type.struct && Array.isArray(type.struct.fields)) {
return type.struct.fields.map(mapArgument);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export async function renderToDir(
outDir: string,
opts: RenderOptions
): Promise<void> {
const templateDir = path.resolve(__dirname, '../../templates', opts.template);
const templateDir = path.resolve(__dirname, '../../../templates', opts.template);

await fs.ensureDir(outDir);
await fs.copy(templateDir, outDir, {
Expand Down
15 changes: 15 additions & 0 deletions packages/cli/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "es2019",
"module": "commonjs",
"esModuleInterop": true,
"strict": false,
"skipLibCheck": true,
"resolveJsonModule": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

Loading