diff --git a/cli/src/__tests__/generate-bindings.test.ts b/cli/src/__tests__/generate-bindings.test.ts new file mode 100644 index 00000000..f9e5b272 --- /dev/null +++ b/cli/src/__tests__/generate-bindings.test.ts @@ -0,0 +1,231 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + buildBindingsArgs, + generateBindings, + resolveBinary, + BindingsOptionError, + SUPPORTED_LANGUAGES, + type CommandRunner +} from '../commands/generate-bindings.js'; + +const CONTRACT_ID = 'CADQOBYHA4DQOBYHA4DQOBYHA4DQOBYHA4DQOBYHA4DQOBYHA4DQP5KR'; + +/** Runner stub that records the invocation and returns a scripted result. */ +function stubRunner( + result: Partial<{ exitCode: number | null; stdout: string; stderr: string }> = {} +) { + return vi.fn(async () => ({ + exitCode: result.exitCode ?? 0, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '' + })) as unknown as CommandRunner & ReturnType; +} + +describe('CLI generate-bindings command (#701)', () => { + describe('buildBindingsArgs', () => { + it('builds a typescript invocation from a local wasm artifact', () => { + const args = buildBindingsArgs({ + language: 'typescript', + wasm: './token.wasm', + outputDir: './packages/token' + }); + + expect(args).toEqual([ + 'contract', + 'bindings', + 'typescript', + '--wasm', + './token.wasm', + '--output-dir', + './packages/token' + ]); + }); + + it('passes network options when generating from a deployed contract', () => { + const args = buildBindingsArgs({ + language: 'typescript', + contractId: CONTRACT_ID, + outputDir: './out', + rpcUrl: 'https://rpc.example', + networkPassphrase: 'Test SDF Network ; September 2015' + }); + + expect(args).toContain('--contract-id'); + expect(args).toContain(CONTRACT_ID); + expect(args).toContain('--rpc-url'); + expect(args).toContain('https://rpc.example'); + expect(args).toContain('--network-passphrase'); + }); + + it('omits network options when generating from a local wasm', () => { + const args = buildBindingsArgs({ + language: 'typescript', + wasm: './token.wasm', + outputDir: './out', + rpcUrl: 'https://rpc.example', + networkPassphrase: 'Test SDF Network ; September 2015' + }); + + expect(args).not.toContain('--rpc-url'); + expect(args).not.toContain('--network-passphrase'); + }); + + it('appends --overwrite when requested', () => { + const args = buildBindingsArgs({ + language: 'typescript', + wasm: './token.wasm', + outputDir: './out', + overwrite: true + }); + + expect(args).toContain('--overwrite'); + }); + + it('builds a rust invocation with only the wasm flag', () => { + const args = buildBindingsArgs({ language: 'rust', wasm: './token.wasm' }); + + expect(args).toEqual(['contract', 'bindings', 'rust', '--wasm', './token.wasm']); + expect(args).not.toContain('--output-dir'); + }); + + it('supports every language the Stellar CLI exposes', () => { + for (const language of SUPPORTED_LANGUAGES) { + const args = buildBindingsArgs({ + language, + wasm: './token.wasm', + outputDir: './out' + }); + expect(args.slice(0, 3)).toEqual(['contract', 'bindings', language]); + } + }); + + it('rejects an unsupported language', () => { + expect(() => + buildBindingsArgs({ language: 'cobol', wasm: './t.wasm', outputDir: './out' }) + ).toThrow(BindingsOptionError); + }); + + it('rejects a missing contract source', () => { + expect(() => buildBindingsArgs({ language: 'typescript', outputDir: './out' })).toThrow( + /exactly one of --wasm/ + ); + }); + + it('rejects more than one contract source', () => { + expect(() => + buildBindingsArgs({ + language: 'typescript', + wasm: './t.wasm', + contractId: CONTRACT_ID, + outputDir: './out' + }) + ).toThrow(/mutually exclusive/); + }); + + it('rejects a missing output directory for languages that require one', () => { + expect(() => buildBindingsArgs({ language: 'typescript', wasm: './t.wasm' })).toThrow( + /--output-dir is required/ + ); + }); + + it('rejects a network source for the rust generator, which reads local wasm only', () => { + expect(() => + buildBindingsArgs({ language: 'rust', contractId: CONTRACT_ID }) + ).toThrow(/reads a local build only/); + }); + }); + + describe('resolveBinary', () => { + it('defaults to the stellar binary', () => { + const previous = { ...process.env }; + delete process.env.STELLAR_CLI_BIN; + delete process.env.SOROBAN_CLI_BIN; + + expect(resolveBinary()).toBe('stellar'); + + process.env = previous; + }); + + it('honours an overridden binary path', () => { + const previous = process.env.STELLAR_CLI_BIN; + process.env.STELLAR_CLI_BIN = '/opt/soroban'; + + expect(resolveBinary()).toBe('/opt/soroban'); + + if (previous === undefined) delete process.env.STELLAR_CLI_BIN; + else process.env.STELLAR_CLI_BIN = previous; + }); + }); + + describe('generateBindings', () => { + it('reports success when the generator exits cleanly', async () => { + const runner = stubRunner({ exitCode: 0, stdout: 'Generated!' }); + + const result = await generateBindings( + { language: 'typescript', wasm: './token.wasm', outputDir: './out' }, + runner, + 'stellar' + ); + + expect(result.success).toBe(true); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('Generated!'); + expect(runner).toHaveBeenCalledWith('stellar', [ + 'contract', + 'bindings', + 'typescript', + '--wasm', + './token.wasm', + '--output-dir', + './out' + ]); + }); + + it('reports failure and surfaces stderr when the generator exits non-zero', async () => { + const runner = stubRunner({ exitCode: 1, stderr: 'error: invalid wasm' }); + + const result = await generateBindings( + { language: 'typescript', wasm: './bad.wasm', outputDir: './out' }, + runner, + 'stellar' + ); + + expect(result.success).toBe(false); + expect(result.exitCode).toBe(1); + expect(result.error).toMatch(/exited with code 1/); + expect(result.error).toMatch(/invalid wasm/); + }); + + it('reports an install hint when the Stellar CLI is not on PATH', async () => { + const runner = vi.fn(async () => { + const err: any = new Error('spawn stellar ENOENT'); + err.code = 'ENOENT'; + throw err; + }) as unknown as CommandRunner; + + const result = await generateBindings( + { language: 'typescript', wasm: './token.wasm', outputDir: './out' }, + runner, + 'stellar' + ); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/Install the Stellar CLI/); + expect(result.error).toMatch(/STELLAR_CLI_BIN/); + }); + + it('does not invoke the generator when the options are invalid', async () => { + const runner = stubRunner(); + + const result = await generateBindings( + { language: 'typescript', outputDir: './out' }, + runner, + 'stellar' + ); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/exactly one of --wasm/); + expect(runner).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/cli/src/commands/generate-bindings.ts b/cli/src/commands/generate-bindings.ts new file mode 100644 index 00000000..5745ffff --- /dev/null +++ b/cli/src/commands/generate-bindings.ts @@ -0,0 +1,254 @@ +import { spawn } from 'node:child_process'; +import { Command } from 'commander'; +import { getClientConfig } from '../utils/config.js'; +import logger from '../utils/logger.js'; + +export type BindingsLanguage = + | 'typescript' + | 'rust' + | 'python' + | 'java' + | 'flutter' + | 'swift' + | 'php'; + +export const SUPPORTED_LANGUAGES: BindingsLanguage[] = [ + 'typescript', + 'rust', + 'python', + 'java', + 'flutter', + 'swift', + 'php' +]; + +/** + * `stellar contract bindings rust` reads a local wasm only and writes to + * stdout, so it accepts neither an output directory nor a network source. + */ +const WASM_ONLY_LANGUAGES: BindingsLanguage[] = ['rust']; + +export interface GenerateBindingsOptions { + language: string; + wasm?: string; + wasmHash?: string; + contractId?: string; + outputDir?: string; + overwrite?: boolean; + rpcUrl?: string; + networkPassphrase?: string; + network?: string; +} + +export interface BindingsResult { + success: boolean; + command: string; + args: string[]; + exitCode: number | null; + stdout: string; + stderr: string; + error?: string; +} + +export interface CommandRunner { + (command: string, args: string[]): Promise<{ + exitCode: number | null; + stdout: string; + stderr: string; + }>; +} + +export class BindingsOptionError extends Error {} + +/** + * Resolves the soroban CLI binary. The tool was renamed `soroban` -> `stellar`, + * so the binary is overridable for environments still on the older name. + */ +export function resolveBinary(): string { + return process.env.STELLAR_CLI_BIN || process.env.SOROBAN_CLI_BIN || 'stellar'; +} + +/** + * Builds the argument vector for `stellar contract bindings `. + * + * Validates the option combination up front so a misuse is reported by this + * CLI directly instead of surfacing as an opaque subprocess failure. + */ +export function buildBindingsArgs(options: GenerateBindingsOptions): string[] { + const language = options.language as BindingsLanguage; + + if (!SUPPORTED_LANGUAGES.includes(language)) { + throw new BindingsOptionError( + `Unsupported bindings language: ${options.language}. Supported: ${SUPPORTED_LANGUAGES.join(', ')}` + ); + } + + const sources = [options.wasm, options.wasmHash, options.contractId].filter(Boolean); + if (sources.length === 0) { + throw new BindingsOptionError( + 'A contract source is required: provide exactly one of --wasm, --wasm-hash or --contract-id' + ); + } + if (sources.length > 1) { + throw new BindingsOptionError( + 'Provide exactly one contract source: --wasm, --wasm-hash and --contract-id are mutually exclusive' + ); + } + + const args = ['contract', 'bindings', language]; + + if (WASM_ONLY_LANGUAGES.includes(language)) { + if (!options.wasm) { + throw new BindingsOptionError( + `The ${language} generator reads a local build only: use --wasm` + ); + } + args.push('--wasm', options.wasm); + return args; + } + + if (!options.outputDir) { + throw new BindingsOptionError(`--output-dir is required for ${language} bindings`); + } + + if (options.wasm) args.push('--wasm', options.wasm); + if (options.wasmHash) args.push('--wasm-hash', options.wasmHash); + if (options.contractId) args.push('--contract-id', options.contractId); + + args.push('--output-dir', options.outputDir); + if (options.overwrite) args.push('--overwrite'); + + // Only a network-sourced generation needs to reach an RPC node. + if (!options.wasm) { + if (options.network) args.push('--network', options.network); + if (options.rpcUrl) args.push('--rpc-url', options.rpcUrl); + if (options.networkPassphrase) { + args.push('--network-passphrase', options.networkPassphrase); + } + } + + return args; +} + +/** Default runner: spawns the soroban CLI and collects its output. */ +export const spawnRunner: CommandRunner = (command, args) => + new Promise((resolve, reject) => { + const child = spawn(command, args, { shell: false }); + + let stdout = ''; + let stderr = ''; + + child.stdout?.on('data', chunk => { + stdout += chunk.toString(); + }); + child.stderr?.on('data', chunk => { + stderr += chunk.toString(); + }); + + child.on('error', reject); + child.on('close', exitCode => resolve({ exitCode, stdout, stderr })); + }); + +/** + * Runs `stellar contract bindings ` and reports the outcome. + */ +export async function generateBindings( + options: GenerateBindingsOptions, + runner: CommandRunner = spawnRunner, + binary: string = resolveBinary() +): Promise { + let args: string[]; + try { + args = buildBindingsArgs(options); + } catch (err: any) { + return { + success: false, + command: binary, + args: [], + exitCode: null, + stdout: '', + stderr: '', + error: err.message + }; + } + + try { + const { exitCode, stdout, stderr } = await runner(binary, args); + return { + success: exitCode === 0, + command: binary, + args, + exitCode, + stdout, + stderr, + error: + exitCode === 0 + ? undefined + : `${binary} exited with code ${exitCode}${stderr ? `: ${stderr.trim()}` : ''}` + }; + } catch (err: any) { + const notFound = err?.code === 'ENOENT'; + return { + success: false, + command: binary, + args, + exitCode: null, + stdout: '', + stderr: '', + error: notFound + ? `Could not run "${binary}". Install the Stellar CLI (https://developers.stellar.org/docs/tools/cli) or set STELLAR_CLI_BIN to its path.` + : err.message + }; + } +} + +/** + * Builds the `generate-bindings` command. + */ +export function createGenerateBindingsCommand(): Command { + return new Command('generate-bindings') + .description('Generate contract client bindings via the Stellar CLI code generator') + .option('-l, --language ', `Target language (${SUPPORTED_LANGUAGES.join(', ')})`, 'typescript') + .option('--wasm ', 'Local .wasm artifact to generate from') + .option('--wasm-hash ', 'Hash of a WASM blob already uploaded to the network') + .option('--contract-id ', 'Deployed contract to generate from') + .option('-o, --output-dir ', 'Directory to write the generated package into') + .option('--overwrite', 'Overwrite the output directory if it already exists') + .action(async (options) => { + try { + const clientConfig = getClientConfig(); + const contractId = options.contractId + || (!options.wasm && !options.wasmHash ? clientConfig.contractId : undefined); + + logger.debug(`Generating ${options.language} bindings`); + + const result = await generateBindings({ + language: options.language, + wasm: options.wasm, + wasmHash: options.wasmHash, + contractId, + outputDir: options.outputDir, + overwrite: options.overwrite, + rpcUrl: clientConfig.rpcUrl, + networkPassphrase: clientConfig.networkPassphrase + }); + + logger.debug(`Running: ${result.command} ${result.args.join(' ')}`); + if (result.stdout.trim()) logger.info(result.stdout.trim()); + + if (result.success) { + logger.success( + options.outputDir + ? `Generated ${options.language} bindings in ${options.outputDir}` + : `Generated ${options.language} bindings` + ); + } else { + logger.error(result.error ?? 'Bindings generation failed'); + process.exitCode = 1; + } + } catch (err: any) { + logger.error(`Error: ${err.message}`); + process.exitCode = 1; + } + }); +} diff --git a/cli/src/parseArgs.ts b/cli/src/parseArgs.ts index 74f6903f..59793e5f 100644 --- a/cli/src/parseArgs.ts +++ b/cli/src/parseArgs.ts @@ -1,6 +1,7 @@ import { Command } from "commander"; import { createUpgradeCommand } from "./commands/upgrade.js"; import { createSmokeTestCommand } from "./commands/smoke-test.js"; +import { createGenerateBindingsCommand } from "./commands/generate-bindings.js"; const VERSION = "0.1.0"; @@ -20,7 +21,8 @@ export function buildProgram(): Command { program .addCommand(createUpgradeCommand()) - .addCommand(createSmokeTestCommand()); + .addCommand(createSmokeTestCommand()) + .addCommand(createGenerateBindingsCommand()); return program; }