@wagmi/cli
Version:
Manage and generate code from Ethereum ABIs
409 lines (371 loc) • 12.7 kB
text/typescript
import type { Abi } from 'abitype'
import { Abi as AbiSchema } from 'abitype/zod'
import { camelCase } from 'change-case'
import type { FSWatcher, WatchOptions } from 'chokidar'
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 { type Address, getAddress } from 'viem'
import { z } from 'zod'
import type { Contract, ContractConfig, Plugin, Watch } from '../config.js'
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 type Generate = z.infer<typeof Generate>
export async function generate(options: Generate = {}) {
// 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()
type Watcher = FSWatcher & { config?: Watch }
const watchers: Watcher[] = []
const watchWriteDelay = 100
const watchOptions: WatchOptions = {
atomic: true,
// awaitWriteFinish: true,
ignoreInitial: true,
persistent: true,
}
const outNames = new Set<string>()
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: Watch[] = []
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<string>()
const contractMap = new Map<string, Contract>()
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 = []
type Output = {
plugin: Pick<Plugin, 'name'>
} & Awaited<ReturnType<NonNullable<Plugin['run']>>>
const outputs: Output[] = []
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: NodeJS.Timeout | null
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: Output[] = []
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 as 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,
}: ContractConfig & { isTypeScript: boolean }): Promise<Contract> {
const constAssertion = isTypeScript ? ' as const' : ''
const abiName = `${camelCase(name)}Abi`
try {
abi = (await AbiSchema.parseAsync(abi)) as 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: Contract['meta'] = { abiName }
if (address) {
let resolvedAddress: Address | Record<number, Address>
try {
const Address = z
.string()
.regex(/^0x[a-fA-F0-9]{40}$/, { message: 'Invalid address' })
.transform((val) => getAddress(val)) as z.ZodType<Address>
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,
}: {
content: string[]
contracts: Contract[]
imports: string[]
prepend: string[]
filename: string
}) {
// 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 }: { name: string }) {
return dedent`
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// ${name}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
`
}