From 3dc0cc5b0470e6704297c8ef6e3ee50a2948f8b3 Mon Sep 17 00:00:00 2001 From: Pawan Paudel Date: Fri, 22 Mar 2024 00:18:49 +0545 Subject: [PATCH] feat: Add more options to deploying contract & CLI --- README.md | 34 +++++++++---- src/cli.ts | 53 ++++++++++++++++---- src/lib/deploy.ts | 120 ++++++++++++++++++++++++++++++++++++---------- src/lib/wallet.ts | 23 +++++++-- 4 files changed, 185 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index e906e30..1df58a7 100644 --- a/README.md +++ b/README.md @@ -44,13 +44,19 @@ Usage: ao-deploy [options] A CLI tool to deploy AO contracts Arguments: - contractPath Contract main file path to deploy + contractPath Contract main file path to deploy Options: - -V, --version output the version number - -n, --name [name] Name of contract to deploy (default: "default") - -w, --wallet-path [walletPath] Wallet JWK file path - -h, --help display help for command + -V, --version output the version number + -n, --name [name] Name of the process to spawn (default: "default") + -w, --wallet [wallet] Wallet JWK file path + -s, --scheduler [scheduler] Scheduler to use for Process + -m, --module [module] The module source to use to spin up Process + -c, --cron [interval] Cron interval to use for Process i.e (1-minute, 5-minutes) + -t, --tags [tags...] Additional tags to use when spawning Process + --retry-count [count] Retry count to spawn Process (default: "10") + --retry-delay [delay] Retry delay in seconds (default: "3000") + -h, --help display help for command ``` #### Example @@ -81,12 +87,24 @@ import { deployContract } from 'ao-deploy' async function main() { try { - const { messageId, processId } = await deployContract({ name: 'demo', walletPath: 'wallet.json', contractPath: 'process.lua' }) + const { messageId, processId } = await deployContract( + { + name: 'demo', + wallet: 'wallet.json', + contractPath: 'process.lua', + tags: [{ name: 'Custom', value: 'Tag' }], + retry: { + count: 10, + delay: 3000, + }, + }, + ) const processUrl = `https://ao_marton.g8way.io/#/process/${processId}` - console.log(`\nDeployed Process: ${processUrl} \nDeployment Message: ${processUrl}/${messageId}`) + const messageUrl = `${processUrl}/${messageId}` + console.log(`\nDeployed Process: ${processUrl} \nDeployment Message: ${messageUrl}`) } catch (error: any) { - console.log('\nDeploy failed!\n') + console.log('\nDeployment failed!\n') console.log(error?.message ?? 'Failed to deploy contract!') } } diff --git a/src/cli.ts b/src/cli.ts index 064818e..978a674 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,24 +6,37 @@ import { fileURLToPath } from 'node:url' import fs from 'node:fs' import chalk from 'chalk' import { Command } from 'commander' -import { deployContract } from './lib/deploy' +import { type Tag, deployContract } from './lib/deploy' const PKG_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '../') -export function getVersion() { +function getVersion() { const packageJsonPath = path.join(PKG_ROOT, 'package.json') const packageJson = JSON.parse(fs.readFileSync(packageJsonPath).toString()) return packageJson.version || '1.0.0' } +function parseToInt(value: string, defaultValue: number) { + const parsedValue = Number.parseInt(value) + if (Number.isNaN(parsedValue)) + return defaultValue + return parsedValue +} + const program = new Command() program .name('ao-deploy') .description('A CLI tool to deploy AO contracts') .version(getVersion()) .argument('', 'Contract main file path to deploy') - .option('-n, --name [name]', 'Name of contract to deploy', 'default') - .option('-w, --wallet-path [walletPath]', 'Wallet JWK file path') + .option('-n, --name [name]', 'Name of the process to spawn', 'default') + .option('-w, --wallet [wallet]', 'Wallet JWK file path') + .option('-s, --scheduler [scheduler]', 'Scheduler to use for Process') + .option('-m, --module [module]', 'The module source to use to spin up Process') + .option('-c, --cron [interval]', 'Cron interval to use for Process i.e (1-minute, 5-minutes)') + .option('-t, --tags [tags...]', 'Additional tags to use when spawning Process') + .option('--retry-count [count]', 'Retry count to spawn Process', '10') + .option('--retry-delay [delay]', 'Retry delay in seconds', '3000') program.parse(process.argv) @@ -33,12 +46,36 @@ const contractPath = program.args[0] async function main() { try { console.log(chalk.gray('Deploying...\n')) - const { messageId, processId } = await deployContract({ name: options.name, walletPath: options.walletPath, contractPath }) - const processUrl = `https://ao_marton.g8way.io/#/process/${processId}` - console.log(chalk.green(`\nDeployed Process: ${processUrl} \nDeployment Message: ${processUrl}/${messageId}`)) + const tags: Tag[] = Array.isArray(options.tags) + ? options.tags.reduce((accumulator, tag) => { + if (tag && tag.includes(':')) { + const [name, value] = tag.split(':') + accumulator.push({ name, value }) + } + return accumulator + }, []) + : [] + const { messageId, processId } = await deployContract( + { + name: options.name, + wallet: options.wallet, + contractPath, + scheduler: options.scheduler, + module: options.module, + cron: options.cron, + tags, + retry: { + count: parseToInt(options.retryCount, 10), + delay: parseToInt(options.retryDelay, 3000), + }, + }, + ) + const processUrl = chalk.green(`https://ao_marton.g8way.io/#/process/${processId}`) + const messageUrl = chalk.green(`${processUrl}/${messageId}`) + console.log(`\nDeployed Process: ${processUrl} \nDeployment Message: ${messageUrl}`) } catch (error: any) { - console.log(chalk.red('\nDeploy failed!\n')) + console.log(chalk.red('\nDeployment failed!\n')) console.log(chalk.red(error?.message ?? 'Failed to deploy contract!')) } } diff --git a/src/lib/deploy.ts b/src/lib/deploy.ts index bcc5f84..62233f8 100644 --- a/src/lib/deploy.ts +++ b/src/lib/deploy.ts @@ -5,17 +5,67 @@ import { spawn, } from '@permaweb/aoconnect' import Ardb from 'ardb' -import { arweave, getWallet, getWalletAddress } from './wallet' +import type { JWKInterface } from 'arweave/node/lib/wallet' +import { arweave, getWallet, getWalletAddress, isArweaveAddress } from './wallet' import { loadContract } from './load' -const ardb = new ((Ardb as any)?.default ?? Ardb)(arweave) - +/** + * Args for deployContract + */ export interface DeployArgs { + /** + * Process name to spawn + * @default "default" + */ name?: string - walletPath?: string + /** + * Path to contract main file + */ contractPath: string + /** + * The module source to use to spin up Process + * @default "Fetches from `https://raw.githubusercontent.com/permaweb/aos/main/package.json`" + */ + module?: string + /** + * Scheduler to use for Process + * @default "_GQ33BkPtZrqxA84vM8Zk-N2aO0toNNu_C-l-rawrBA" + */ + scheduler?: string + /** + * Additional tags to use for spawning Process + */ + tags?: Tag[] + /** + * Cron interval to use for Process i.e (1-minute, 5-minutes) + */ + cron?: string + /** + * Wallet path or JWK itself + */ + wallet?: JWKInterface | string + + /** + * Retry options + */ + retry?: { + /** + * Retry count + * @default 10 + */ + count?: number + /** + * Retry delay in milliseconds + * @default 3000 + */ + delay?: number + } } +export interface Tag { name: string, value: string } + +const ardb = new ((Ardb as any)?.default ?? Ardb)(arweave) + /** * Retries a given function up to a maximum number of attempts. * @param fn - The asynchronous function to retry, which should return a Promise. @@ -23,26 +73,27 @@ export interface DeployArgs { * @param delay - The delay between attempts in milliseconds. * @return A Promise that resolves with the result of the function or rejects after all attempts fail. */ -async function retry( +async function retryWithDelay( fn: () => Promise, maxAttempts: number, delay: number = 1000, ): Promise { let attempts = 0 - const attempt = (): Promise => { - return fn().catch((error) => { + const attempt = async (): Promise => { + try { + return await fn() + } + catch (error) { attempts += 1 if (attempts < maxAttempts) { console.log(`Attempt ${attempts} failed, retrying...`) - return new Promise(resolve => - setTimeout(() => resolve(attempt()), delay), - ) + return new Promise(resolve => setTimeout(() => resolve(attempt()), delay)) } else { throw error } - }) + } } return attempt() @@ -55,6 +106,7 @@ async function sleep(delay: number = 3000) { async function getAos() { const defaultVersion = '1.10.22' const defaultModule = 'SBNb1qPQ1TDwpD_mboxm2YllmMLXpWw4U8P9Ff8W9vk' + const defaultScheduler = '_GQ33BkPtZrqxA84vM8Zk-N2aO0toNNu_C-l-rawrBA' try { const pkg = await ( await fetch( @@ -62,12 +114,13 @@ async function getAos() { ) ).json() as { version: string, aos: { module: string } } return { - version: pkg?.version ?? defaultVersion, - module: pkg?.aos?.module ?? defaultModule, + aosVersion: pkg?.version ?? defaultVersion, + aosModule: pkg?.aos?.module ?? defaultModule, + aosScheduler: defaultScheduler, } } catch { - return { version: defaultVersion, module: defaultModule } + return { aosVersion: defaultVersion, aosModule: defaultModule, aosScheduler: defaultScheduler } } } @@ -95,20 +148,37 @@ async function findProcess(name: string, aosModule: string, owner: string) { return tx?.id } -export async function deployContract({ name, walletPath, contractPath }: DeployArgs) { +export async function deployContract({ name, wallet, contractPath, tags, cron, module, scheduler, retry }: DeployArgs) { // Create a new process name = name || 'default' - const { version, module } = await getAos() - const wallet = await getWallet(walletPath) - const owner = await getWalletAddress(wallet) - let processId = await findProcess(name, module, owner) - const scheduler = '_GQ33BkPtZrqxA84vM8Zk-N2aO0toNNu_C-l-rawrBA' + tags = Array.isArray(tags) ? tags : [] + retry = retry ?? { count: 10, delay: 3000 } + + const { aosVersion, aosModule, aosScheduler } = await getAos() + module = isArweaveAddress(module) ? module! : aosModule + scheduler = isArweaveAddress(scheduler) ? scheduler! : aosScheduler + + const walletJWK = await getWallet(wallet) + const owner = await getWalletAddress(walletJWK) const signer = createDataItemSigner(wallet) - const tags = [ + + let processId = await findProcess(name, module, owner) + + tags = [ { name: 'App-Name', value: 'aos' }, { name: 'Name', value: name }, - { name: 'aos-Version', value: version }, + { name: 'aos-Version', value: aosVersion }, + ...tags, ] + if (cron) { + if (/^\d+\-(second|seconds|minute|minutes|hour|hours|day|days|month|months|year|years|block|blocks|Second|Seconds|Minute|Minutes|Hour|Hours|Day|Days|Month|Months|Year|Years|Block|Blocks)$/.test(cron)) { + tags = [...tags, { name: 'Cron-Interval', value: cron }, { name: 'Cron-Tag-Action', value: 'Cron' }, + ] + } + else { + throw new Error('Invalid cron flag!') + } + } const data = '1984' if (!processId) { @@ -119,7 +189,7 @@ export async function deployContract({ name, walletPath, contractPath }: DeployA const contractSrc = loadContract(contractPath) // Load contract to process - const messageId = await retry( + const messageId = await retryWithDelay( async () => message({ process: processId, @@ -127,8 +197,8 @@ export async function deployContract({ name, walletPath, contractPath }: DeployA data: contractSrc, signer, }), - 10, - 3000, + retry.count ?? 10, + retry.delay ?? 3000, ) const { Output } = await result({ process: processId, message: messageId }) diff --git a/src/lib/wallet.ts b/src/lib/wallet.ts index 16d7305..5d98ec0 100644 --- a/src/lib/wallet.ts +++ b/src/lib/wallet.ts @@ -10,12 +10,27 @@ export const arweave = Arweave.init({ protocol: 'https', }) -export async function getWallet(walletPath?: fs.PathOrFileDescriptor) { +/** + * Check if the passed argument is a valid JSON Web Key (JWK) for Arweave. + * @param obj - The object to check for JWK validity. + * @returns {boolean} True if it's a valid Arweave JWK, otherwise false. + */ +function isJwk(obj: any): boolean { + if (typeof obj !== 'object') + return false + const requiredKeys = ['n', 'e', 'd', 'p', 'q', 'dp', 'dq', 'qi'] + return requiredKeys.every(key => key in obj) +} + +export async function getWallet(walletOrPath?: fs.PathOrFileDescriptor | JWKInterface): Promise { try { - if (!walletPath) - throw new Error('Wallet path not specified') + if (!walletOrPath) + throw new Error('Wallet not specified') + + if (isJwk(walletOrPath)) + return walletOrPath as JWKInterface - const jwk = fs.readFileSync(walletPath, 'utf8') + const jwk = fs.readFileSync(walletOrPath as string, 'utf8') return JSON.parse(jwk) } catch (e) {