UNPKG

@wagmi/cli

Version:

Manage and generate code from Ethereum ABIs

354 lines (348 loc) 14 kB
import { Abi as AbiSchema } from 'abitype/zod'; import { camelCase } from 'change-case'; import { watch } from 'chokidar'; import { default as dedent } from 'dedent'; import { default as fs } from 'fs-extra'; import { basename, dirname, resolve } from 'pathe'; import pc from 'picocolors'; import { getAddress } from 'viem'; import { z } from 'zod'; import { fromZodError } from '../errors.js'; import * as logger from '../logger.js'; import { findConfig } from '../utils/findConfig.js'; import { format } from '../utils/format.js'; import { getAddressDocString } from '../utils/getAddressDocString.js'; import { getIsUsingTypeScript } from '../utils/getIsUsingTypeScript.js'; import { resolveConfig } from '../utils/resolveConfig.js'; const Generate = z.object({ /** Path to config file */ config: z.string().optional(), /** Directory to search for config file */ root: z.string().optional(), /** Watch for file system changes to config and plugins */ watch: z.boolean().optional(), }); export async function generate(options = {}) { // Validate command line options try { await Generate.parseAsync(options); } catch (error) { if (error instanceof z.ZodError) throw fromZodError(error, { prefix: 'Invalid option' }); throw error; } // Get cli config file const configPath = await findConfig(options); if (!configPath) { if (options.config) throw new Error(`Config not found at ${pc.gray(options.config)}`); throw new Error('Config not found'); } const resolvedConfigs = await resolveConfig({ configPath }); const isTypeScript = await getIsUsingTypeScript(); const watchers = []; const watchWriteDelay = 100; const watchOptions = { atomic: true, // awaitWriteFinish: true, ignoreInitial: true, persistent: true, }; const outNames = new Set(); const isArrayConfig = Array.isArray(resolvedConfigs); const configs = isArrayConfig ? resolvedConfigs : [resolvedConfigs]; for (const config of configs) { if (isArrayConfig) logger.log(`Using config ${pc.gray(basename(configPath))}`); if (!config.out) throw new Error('out is required.'); if (outNames.has(config.out)) throw new Error(`out "${config.out}" must be unique.`); outNames.add(config.out); // Collect contracts and watch configs from plugins const plugins = (config.plugins ?? []).map((x, i) => ({ ...x, id: `${x.name}-${i}`, })); const spinner = logger.spinner(); spinner.start('Validating plugins'); for (const plugin of plugins) { await plugin.validate?.(); } spinner.succeed(); // Add plugin contracts to config contracts const contractConfigs = config.contracts ?? []; const watchConfigs = []; spinner.start('Resolving contracts'); for (const plugin of plugins) { if (plugin.watch) watchConfigs.push(plugin.watch); if (plugin.contracts) { const contracts = await plugin.contracts(); contractConfigs.push(...contracts); } } // Get contracts from config const contractNames = new Set(); const contractMap = new Map(); for (const contractConfig of contractConfigs) { if (contractNames.has(contractConfig.name)) throw new Error(`Contract name "${contractConfig.name}" must be unique.`); const contract = await getContract({ ...contractConfig, isTypeScript }); contractMap.set(contract.name, contract); contractNames.add(contractConfig.name); } // Sort contracts by name Ascending (low to high) as the key is `String` const sortedAscContractMap = new Map([...contractMap].sort()); const contracts = [...sortedAscContractMap.values()]; if (!contracts.length && !options.watch) { spinner.fail(); logger.warn('No contracts found.'); return; } spinner.succeed(); // Run plugins const imports = []; const prepend = []; const content = []; const outputs = []; spinner.start('Running plugins'); for (const plugin of plugins) { if (!plugin.run) continue; const result = await plugin.run({ contracts, isTypeScript, outputs, }); outputs.push({ plugin: { name: plugin.name }, ...result, }); if (!result.imports && !result.prepend && !result.content) continue; content.push(getBannerContent({ name: plugin.name }), result.content); result.imports && imports.push(result.imports); result.prepend && prepend.push(result.prepend); } spinner.succeed(); // Write output to file spinner.start(`Writing to ${pc.gray(config.out)}`); await writeContracts({ content, contracts, imports, prepend, filename: config.out, }); spinner.succeed(); if (options.watch) { if (!watchConfigs.length) { logger.log(pc.gray('Used --watch flag, but no plugins are watching.')); continue; } logger.log(); logger.log('Setting up watch process'); // Watch for changes let timeout; for (const watchConfig of watchConfigs) { const paths = typeof watchConfig.paths === 'function' ? await watchConfig.paths() : watchConfig.paths; const watcher = watch(paths, watchOptions); // Watch for changes to files, new files, and deleted files watcher.on('all', async (event, path) => { if (event !== 'change' && event !== 'add' && event !== 'unlink') return; let needsWrite = false; if (event === 'change' || event === 'add') { const eventFn = event === 'change' ? watchConfig.onChange : watchConfig.onAdd; const config = await eventFn?.(path); if (!config) return; const contract = await getContract({ ...config, isTypeScript }); contractMap.set(contract.name, contract); needsWrite = true; } else if (event === 'unlink') { const name = await watchConfig.onRemove?.(path); if (!name) return; contractMap.delete(name); needsWrite = true; } // Debounce writes if (needsWrite) { if (timeout) clearTimeout(timeout); timeout = setTimeout(async () => { timeout = null; // Sort contracts by name Ascending (low to high) as the key is `String` const sortedAscContractMap = new Map([...contractMap].sort()); const contracts = [...sortedAscContractMap.values()]; const imports = []; const prepend = []; const content = []; const outputs = []; for (const plugin of plugins) { if (!plugin.run) continue; const result = await plugin.run({ contracts, isTypeScript, outputs, }); outputs.push({ plugin: { name: plugin.name }, ...result, }); if (!result.imports && !result.prepend && !result.content) continue; content.push(getBannerContent({ name: plugin.name }), result.content); result.imports && imports.push(result.imports); result.prepend && prepend.push(result.prepend); } const spinner = logger.spinner(); spinner.start(`Writing to ${pc.gray(config.out)}`); await writeContracts({ content, contracts, imports, prepend, filename: config.out, }); spinner.succeed(); }, watchWriteDelay); needsWrite = false; } }); // Run parallel command on ready if (watchConfig.command) watcher.on('ready', async () => { await watchConfig.command?.(); }); watcher.config = watchConfig; watchers.push(watcher); } } } if (!watchers.length) return; // Watch `@wagmi/cli` config file for changes const watcher = watch(configPath).on('change', async (path) => { logger.log(`> Found a change to config ${pc.gray(basename(path))}. Restart process for changes to take effect.`); }); watchers.push(watcher); // Display message and close watchers on exit process.once('SIGINT', shutdown); process.once('SIGTERM', shutdown); async function shutdown() { logger.log(); logger.log('Shutting down watch process'); const promises = []; for (const watcher of watchers) { if (watcher.config?.onClose) promises.push(watcher.config?.onClose?.()); promises.push(watcher.close()); } await Promise.allSettled(promises); process.exit(0); } } async function getContract({ abi, address, name, isTypeScript, }) { const constAssertion = isTypeScript ? ' as const' : ''; const abiName = `${camelCase(name)}Abi`; try { abi = (await AbiSchema.parseAsync(abi)); } catch (error) { if (error instanceof z.ZodError) throw fromZodError(error, { prefix: `Invalid ABI for contract "${name}"`, }); throw error; } const docString = typeof address === 'object' ? dedent `\n /** ${getAddressDocString({ address })} */ ` : ''; let content = dedent ` ${getBannerContent({ name })} ${docString} export const ${abiName} = ${JSON.stringify(abi)}${constAssertion} `; let meta = { abiName }; if (address) { let resolvedAddress; try { const Address = z .string() .regex(/^0x[a-fA-F0-9]{40}$/, { message: 'Invalid address' }) .transform((val) => getAddress(val)); const MultiChainAddress = z.record(z.string(), Address); const AddressSchema = z.union([Address, MultiChainAddress]); resolvedAddress = await AddressSchema.parseAsync(address); } catch (error) { if (error instanceof z.ZodError) throw fromZodError(error, { prefix: `Invalid address for contract "${name}"`, }); throw error; } const addressName = `${camelCase(name)}Address`; const configName = `${camelCase(name)}Config`; meta = { ...meta, addressName, configName, }; const addressContent = typeof resolvedAddress === 'string' ? JSON.stringify(resolvedAddress) : // Remove quotes from chain id key JSON.stringify(resolvedAddress, null, 2).replace(/"(\d*)":/gm, '$1:'); content = dedent ` ${content} ${docString} export const ${addressName} = ${addressContent}${constAssertion} ${docString} export const ${configName} = { address: ${addressName}, abi: ${abiName} }${constAssertion} `; } return { abi, address, content, meta, name }; } async function writeContracts({ content, contracts, imports, prepend, filename, }) { // Assemble code let code = dedent ` ${imports.join('\n\n') ?? ''} ${prepend.join('\n\n') ?? ''} `; for (const contract of contracts) { code = dedent ` ${code} ${contract.content} `; } code = dedent ` ${code} ${content.join('\n\n') ?? ''} `; // Format and write output const cwd = process.cwd(); const outPath = resolve(cwd, filename); await fs.ensureDir(dirname(outPath)); const formatted = await format(code); await fs.writeFile(outPath, formatted); } function getBannerContent({ name }) { return dedent ` ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // ${name} ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// `; } //# sourceMappingURL=generate.js.map