From 1ec3e180e2e0ce5b413683d9485d3010df9fc98e Mon Sep 17 00:00:00 2001 From: Jesse van Muijden Date: Fri, 3 Nov 2023 05:20:39 -0400 Subject: [PATCH 01/13] feat(kadena-cli): Dynamic config --- .../kadena-cli/src/account/fundQuestions.ts | 2 +- .../src/account/getBalanceCommand.ts | 87 ++++++++++ .../tools/kadena-cli/src/account/index.ts | 2 + .../kadena-cli/src/config/configQuestions.ts | 2 +- packages/tools/kadena-cli/src/config/index.ts | 16 +- .../kadena-cli/src/config/infoCommand.ts | 34 ---- .../kadena-cli/src/config/infoQuestions.ts | 66 -------- .../src/config/initConfigCommand.ts | 148 +----------------- .../kadena-cli/src/constants/networks.ts | 10 +- .../tools/kadena-cli/src/constants/options.ts | 14 ++ .../tools/kadena-cli/src/constants/prompts.ts | 34 ++++ .../kadena-cli/src/constants/questions.ts | 24 +++ .../src/networks/createNetworksCommand.ts | 37 ++--- .../{utils/bootstrap.ts => networks/init.ts} | 9 +- .../src/networks/manageNetworksCommand.ts | 4 +- .../src/networks/networksCreateQuestions.ts | 14 +- .../src/networks/networksHelpers.ts | 16 +- .../tools/kadena-cli/src/utils/helpers.ts | 13 +- 18 files changed, 228 insertions(+), 304 deletions(-) create mode 100644 packages/tools/kadena-cli/src/account/getBalanceCommand.ts delete mode 100644 packages/tools/kadena-cli/src/config/infoCommand.ts delete mode 100644 packages/tools/kadena-cli/src/config/infoQuestions.ts create mode 100644 packages/tools/kadena-cli/src/constants/options.ts create mode 100644 packages/tools/kadena-cli/src/constants/prompts.ts create mode 100644 packages/tools/kadena-cli/src/constants/questions.ts rename packages/tools/kadena-cli/src/{utils/bootstrap.ts => networks/init.ts} (58%) diff --git a/packages/tools/kadena-cli/src/account/fundQuestions.ts b/packages/tools/kadena-cli/src/account/fundQuestions.ts index 986260cf64..36b0dac740 100644 --- a/packages/tools/kadena-cli/src/account/fundQuestions.ts +++ b/packages/tools/kadena-cli/src/account/fundQuestions.ts @@ -48,7 +48,7 @@ export const fundQuestions: IQuestion[] = [ prompt: async () => await select({ message: 'Choose your network', - choices: getExistingNetworks(), + choices: await getExistingNetworks(), }), }, { diff --git a/packages/tools/kadena-cli/src/account/getBalanceCommand.ts b/packages/tools/kadena-cli/src/account/getBalanceCommand.ts new file mode 100644 index 0000000000..3b84ebf566 --- /dev/null +++ b/packages/tools/kadena-cli/src/account/getBalanceCommand.ts @@ -0,0 +1,87 @@ +import { + collectResponses, +} from '../utils/helpers.js'; +import { processZodErrors } from '../utils/processZodErrors.js'; + +import type { Command } from 'commander'; +import { ZodError, unknown, z } from 'zod'; + +import { account, chainId, network, networkId } from '../constants/options.js'; +import { accountPrompt, chainIdPrompt, networkPrompt } from '../constants/prompts.js'; +import { Questions } from '../constants/questions.js'; +import { displayNetworkConfig, loadNetworkConfig } from '../networks/networksHelpers.js'; + +import { Pact, createClient } from '@kadena/client'; + +import chalk from 'chalk'; +import { ChainId } from '@kadena/types'; + +const GetBalanceQuestions = Questions.pick({ + account: true, + network: true, + chainId: true, +}); + +type TGetBalanceQuestions = z.infer; + +export function getBalanceCommand(program: Command, version: string): void { + program + .command('get-balance') + .description('get the balance of an account') + .addOption(account) + .addOption(network) + .addOption(chainId) + .action(async (args: TGetBalanceQuestions) => { + try { + const responses = await collectResponses(args, [ + { key: 'account', prompt: accountPrompt }, + { key: 'network', prompt: networkPrompt }, + { key: 'chainId', prompt: chainIdPrompt }, + ]); + + const configuration: TGetBalanceQuestions = { + ...args, + ...responses, + }; + + GetBalanceQuestions.parse(configuration); + + // Display full configuration + // console.log(chalk.green(configuration)); + + // Display full command for reuse + + await makeGetBalanceRequest(configuration.account, configuration.network, configuration.chainId); + } catch (e) { + if (e instanceof ZodError) { + processZodErrors(program, e, args); + return; + } + throw e; + } + }); +} + +const makeGetBalanceRequest = async (account: string, network: string, chainId: number) => { + const networkConfiguration = loadNetworkConfig(network); + displayNetworkConfig(networkConfiguration); + const client = createClient(`${networkConfiguration.networkHost}chainweb/0.0/${networkConfiguration.networkId}/chain/${chainId}/pact`); + const transaction = Pact.builder + .execution(Pact.modules.coin['get-balance'](account)) + .setMeta({ chainId: (chainId || 0).toString() as unknown as ChainId }) + .setNetworkId(networkConfiguration.networkId || '') + .createTransaction(); + + try { + const response = await client.dirtyRead(transaction); + const { result } = response; + + if (result.status === 'success') { + console.log(chalk.green(`The balance of ${account} on chain ${chainId} of ${network} is ${result.data}`)); + } else { + console.error(result.error); + } + } catch (e: unknown) { + console.error((e as Error).message); + } +}; diff --git a/packages/tools/kadena-cli/src/account/index.ts b/packages/tools/kadena-cli/src/account/index.ts index ef90251239..1fcaac9401 100644 --- a/packages/tools/kadena-cli/src/account/index.ts +++ b/packages/tools/kadena-cli/src/account/index.ts @@ -1,6 +1,7 @@ import { fundCommand } from './fundCommand.js'; import type { Command } from 'commander'; +import { getBalanceCommand } from './getBalanceCommand.js'; const SUBCOMMAND_ROOT: 'account' = 'account'; @@ -10,4 +11,5 @@ export function accountCommandFactory(program: Command, version: string): void { .description(`Tool to manage accounts of fungibles (e.g. 'coin')`); fundCommand(accountProgram, version); + getBalanceCommand(accountProgram, version); } diff --git a/packages/tools/kadena-cli/src/config/configQuestions.ts b/packages/tools/kadena-cli/src/config/configQuestions.ts index e5a90b66c5..9a0a292820 100644 --- a/packages/tools/kadena-cli/src/config/configQuestions.ts +++ b/packages/tools/kadena-cli/src/config/configQuestions.ts @@ -32,7 +32,7 @@ interface ICustomChoice { } export async function askForNetwork(): Promise { - const existingNetworks: ICustomChoice[] = getExistingNetworks(); + const existingNetworks: ICustomChoice[] = await getExistingNetworks(); existingNetworks .filter((v, i, a) => a.findIndex((v2) => v2.name === v.name) === i) .map((network) => { diff --git a/packages/tools/kadena-cli/src/config/index.ts b/packages/tools/kadena-cli/src/config/index.ts index eca1560d93..ebda94f5d8 100644 --- a/packages/tools/kadena-cli/src/config/index.ts +++ b/packages/tools/kadena-cli/src/config/index.ts @@ -1,11 +1,5 @@ -import { createSimpleSubCommand } from '../utils/helpers.js'; - -import type { IShowConfigurationArgs } from './infoCommand.js'; -import { showConfigurationAction } from './infoCommand.js'; import { initCommand } from './initConfigCommand.js'; - import type { Command } from 'commander'; -import { Option } from 'commander'; /** * Represents the root command for the configuration CLI. @@ -23,17 +17,9 @@ export function configCommandFactory(program: Command, version: string): void { const configProgram = program .command(SUBCOMMAND_ROOT) .description( - `Tool for setting up and managing the CLI configuration and contexts`, + `Tool for setting up the Kadena CLI configuration`, ); // create project configuration initCommand(configProgram, version); - - // show configuration - createSimpleSubCommand( - 'show', - 'displays configuration ', - showConfigurationAction, - [new Option('-p, --projectName ', 'Name of project')], - )(configProgram); } diff --git a/packages/tools/kadena-cli/src/config/infoCommand.ts b/packages/tools/kadena-cli/src/config/infoCommand.ts deleted file mode 100644 index a37894f452..0000000000 --- a/packages/tools/kadena-cli/src/config/infoCommand.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { collectResponses } from '../utils/helpers.js'; - -import { displayGeneralConfig, getProjectConfig } from './configHelpers.js'; -import type { TConfigOptions } from './configQuestions.js'; -import { ConfigOptions } from './configQuestions.js'; -import { projectNameQuestions } from './infoQuestions.js'; - -import chalk from 'chalk'; -import debug from 'debug'; - -export type IShowConfigurationArgs = Pick; - -export const showConfigurationAction = async ( - args: IShowConfigurationArgs, -): Promise => { - debug('init:action')({ args }); - - const responses = await collectResponses(args, projectNameQuestions); - - const config = { ...args, ...responses }; - - ConfigOptions.pick({ projectName: true }).parse(config); - - try { - // existing projects have a prefix - displayGeneralConfig(getProjectConfig(config.projectName.toLowerCase())); - } catch (e) { - console.error( - chalk.red( - `Project config file '${config.projectName.toLowerCase()}' not found`, - ), - ); - } -}; diff --git a/packages/tools/kadena-cli/src/config/infoQuestions.ts b/packages/tools/kadena-cli/src/config/infoQuestions.ts deleted file mode 100644 index cff59ffac4..0000000000 --- a/packages/tools/kadena-cli/src/config/infoQuestions.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { projectPrefix } from '../constants/config.js'; -import type { ICustomChoice, IQuestion } from '../utils/helpers.js'; -import { - capitalizeFirstLetter, - getExistingProjects, - isAlphanumeric, -} from '../utils/helpers.js'; - -import type { TConfigOptions } from './configQuestions.js'; - -import { input, select } from '@inquirer/prompts'; - -// import { configQuestions } from './configQuestions.js'; - -// interface IProjectNameQuestion -// extends Pick, 'key' | 'prompt'> {} - -// Filter the question objects where key is 'projectName' -// export const projectNameQuestion: IProjectNameQuestion[] = -// configQuestions.filter((question) => question.key === 'projectName'); - -export async function askForProject(): Promise { - const existingProjects: ICustomChoice[] = getExistingProjects(); - - existingProjects - .filter((v, i, a) => a.findIndex((v2) => v2.name === v.name) === i) - .map((project) => { - return { - value: project.value, - name: capitalizeFirstLetter(project.value), - }; - }); - - const projectChoice = await select({ - message: 'Select an existing network or create a new one:', - choices: [ - ...existingProjects, - { value: 'CREATE_NEW', name: `Don't pick from list` } as ICustomChoice, - ], - }); - - if (projectChoice === 'CREATE_NEW') { - const projectName = await input({ - validate: function (input) { - if (input === '') { - return 'Projectname cannot be empty! Please enter a projectname.'; - } - if (!isAlphanumeric(input)) { - return 'Project name must be alphanumeric! Please enter a valid projectname.'; - } - return true; - }, - message: `Enter your project name to display (without ${projectPrefix} prefix):`, - }); - return projectName.toLowerCase(); - } - - return projectChoice.toLowerCase(); -} - -export const projectNameQuestions: IQuestion[] = [ - { - key: 'projectName', - prompt: async () => await askForProject(), - }, -]; diff --git a/packages/tools/kadena-cli/src/config/initConfigCommand.ts b/packages/tools/kadena-cli/src/config/initConfigCommand.ts index a89efa67c2..e266b7c00c 100644 --- a/packages/tools/kadena-cli/src/config/initConfigCommand.ts +++ b/packages/tools/kadena-cli/src/config/initConfigCommand.ts @@ -1,153 +1,17 @@ -import { projectPrefix, projectRootPath } from '../constants/config.js'; -import { ensureFileExists } from '../utils/filesystem.js'; -import { clearCLI, collectResponses } from '../utils/helpers.js'; -import { processZodErrors } from '../utils/processZodErrors.js'; - -import { - displayGeneralConfig, - getProjectConfig, - writeProjectConfig, -} from './configHelpers.js'; -import type { TConfigOptions } from './configQuestions.js'; -import { ConfigOptions, configQuestions } from './configQuestions.js'; - -import { select } from '@inquirer/prompts'; import chalk from 'chalk'; import type { Command } from 'commander'; -import { Option } from 'commander'; import debug from 'debug'; -import path from 'path'; - -async function shouldProceedWithConfigInit( - projectName: string, -): Promise { - const filePath = path.join(projectRootPath, `${projectName}.yaml`); - if (ensureFileExists(filePath)) { - const overwrite = await select({ - message: `Your config already exists. Do you want to update it?`, - choices: [ - { value: 'yes', name: 'Yes' }, - { value: 'no', name: 'No' }, - ], - }); - return overwrite === 'yes'; - } - return true; -} - -async function runConfigInitialization( - program: Command, - version: string, - args: TConfigOptions, -): Promise { - try { - // ./bin/kadena-cli.js contract deploy --network mainnet - // args - // missign args via collectResponses - // - chainId - // - keys - const responses = await collectResponses(args, configQuestions); - - // - chainId 1-5 - // - keys hd blaat - // - keys-hd-index 0-50 - const config = { ...args, ...responses }; - // final arguments - // show command with all arguments - - // build/extend config based on arguments - // nework: mainnet - // - networkId: mainnet01 - // - networkHost: http://risetnirsetn - // keys: - // - public: irestn secret: riest - // - public: irestn secret: riest - // - public: irestn secret: riest - // - public: irestn secret: riest - // - public: irestn secret: riest - // - public: irestn secret: riest - // - public: irestn secret: riest - // - public: irestn secret: riest - // const commandConfig = postProcessing(finalResponses) - - // execute command - ConfigOptions.parse(config); - - writeProjectConfig(config); - - displayGeneralConfig( - // new project don't have a prefix yet - getProjectConfig(`${projectPrefix}${config.projectName.toLowerCase()}`), - ); - - const proceed = await select({ - message: 'Is the above configuration correct?', - choices: [ - { value: 'yes', name: 'Yes' }, - { value: 'no', name: 'No' }, - ], - }); - - if (proceed === 'no') { - clearCLI(true); - console.log(chalk.yellow("Let's restart the configuration process.")); - await runConfigInitialization(program, version, args); - } else { - console.log(chalk.green('Configuration complete. Goodbye!')); - } - } catch (e) { - console.error(e); - processZodErrors(program, e, args); - } -} export function initCommand(program: Command, version: string): void { program .command('init') - .description( - 'Configuration of Project. E.g. context, network, config directory.', - ) - .option('-p, --projectName ', 'Name of project') - .option( - '-n, --defaultNetwork ', - 'Kadena network (e.g. "mainnet")', - ) - .addOption( - new Option('-c, --chainId ', 'Chain to retrieve from)').argParser( - (value) => parseInt(value, 10), - ), - ) - .action(async (args: TConfigOptions) => { - debug('init:action')({ args }); - /** - * 1. config init voor bootstrap `.kadena` - * 2. bootstrap doen we alleen bij `config init` OF - * - elke call van een ander commando moet checken of de folder bestaat, - * zo niet automatisch aanmaken, - * - directories: devnet, keys, networks - * - in networks: mainnet.yml, testnet.yml, devnet.yml - * 3. project.yml kan weg - * 4. elke action bestaat uit de volgende stappen - * - args komen binnen - * - `collectResponses` obv missing args - * - arg name is globally unique, overal betekent een arg hetzelfde - * - per command worden dus een subset van questions gesteld - * - convert "args + responses + config" => "commandConfig" - * - show final command obv args + responses - * - show overzicht van alle configuratie opties - */ - - if ( - args.projectName && - !(await shouldProceedWithConfigInit(args.projectName)) - ) { - console.log(chalk.yellow('Config initialization cancelled.')); - return; - } + .description('Initialize default configuration of the Kadena CLI') + .action(async () => { + debug('init:action')({}); - // TODO: make this fix nicer - await import('./../utils/bootstrap.js'); + await import('./../networks/init.js'); + console.log(chalk.green('Configured default networks.')); - await runConfigInitialization(program, version, args); + console.log(chalk.green('Configuration complete!')); }); } diff --git a/packages/tools/kadena-cli/src/constants/networks.ts b/packages/tools/kadena-cli/src/constants/networks.ts index 78d4ef8a34..8d35817ab3 100644 --- a/packages/tools/kadena-cli/src/constants/networks.ts +++ b/packages/tools/kadena-cli/src/constants/networks.ts @@ -21,6 +21,12 @@ export const networkDefaults: IDefaultNetworkOptions = { networkHost: 'https://api.testnet.chainweb.com', networkExplorerUrl: 'https://explorer.chainweb.com/testnet/tx/', }, + devnet: { + network: 'devnet', + networkId: 'fast-development', + networkHost: 'http://localhost:8080/', + networkExplorerUrl: 'http://localhost:8080/explorer', + }, other: { network: '', networkId: '', @@ -30,5 +36,5 @@ export const networkDefaults: IDefaultNetworkOptions = { }; export const defaultNetworksPath: string = `${process.cwd()}/.kadena/networks`; -export const standardNetworks: string[] = ['mainnet', 'testnet']; -export const defaultNetwork: string = 'testnet'; +export const standardNetworks: string[] = ['mainnet', 'testnet', 'devnet']; +export const defaultNetwork: string = 'devnet'; diff --git a/packages/tools/kadena-cli/src/constants/options.ts b/packages/tools/kadena-cli/src/constants/options.ts new file mode 100644 index 0000000000..85303435a7 --- /dev/null +++ b/packages/tools/kadena-cli/src/constants/options.ts @@ -0,0 +1,14 @@ +import { Option } from "commander"; + +export const account = new Option('-a, --account ', 'Receiver (k:) wallet address'); + +export const chainId = new Option('-c, --chainId ', 'Chain to retrieve from (default 1)') + .argParser((value) => parseInt(value, 10)); + +export const network = new Option('-n, --network ', 'Kadena network (e.g. "mainnet")'); + +export const networkId = new Option('-nid, --networkId ', 'Kadena network Id (e.g. "mainnet01")'); + +export const networkHost = new Option('-h, --networkHost ', 'Kadena network host (e.g. "https://api.chainweb.com")'); + +export const networkExplorerUrl = new Option('-e, --networkExplorerUrl ', 'Kadena network explorer URL (e.g. "https://explorer.chainweb.com/mainnet/tx/")'); diff --git a/packages/tools/kadena-cli/src/constants/prompts.ts b/packages/tools/kadena-cli/src/constants/prompts.ts new file mode 100644 index 0000000000..c9358d5c5e --- /dev/null +++ b/packages/tools/kadena-cli/src/constants/prompts.ts @@ -0,0 +1,34 @@ +import { input, select } from "@inquirer/prompts"; +import { ICustomNetworksChoice } from "../networks/networksHelpers.js"; +import { getExistingNetworks } from "../utils/helpers.js"; +import { runNetworksCreate } from "../networks/createNetworksCommand.js"; + +export const accountPrompt = async () => await input({ message: 'Enter the Kadena k:account' }); + +export const chainIdPrompt = async () => + parseInt(await input({ message: 'Enter chainId (0-19)' }), 10); + +export const networkPrompt = async (): Promise => { + const existingNetworks: ICustomNetworksChoice[] = await getExistingNetworks(); + + if (existingNetworks.length > 0) { + const selectedNetwork = await select({ + message: 'Select a network', + choices: [ + ...existingNetworks, + { value: undefined, name: 'Define a new network' }, + ], + }); + + if (selectedNetwork !== undefined) { + return selectedNetwork; + } + } + + // At this point there is either no network defined yet, + // or the user chose to define a new network. + // Create and select new network. + const network = await runNetworksCreate(); + + return network || ''; +}; diff --git a/packages/tools/kadena-cli/src/constants/questions.ts b/packages/tools/kadena-cli/src/constants/questions.ts new file mode 100644 index 0000000000..cacff73e46 --- /dev/null +++ b/packages/tools/kadena-cli/src/constants/questions.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +// eslint-disable-next-line @rushstack/typedef-var +export const Questions = z.object({ + account: z + .string(), + // .min(60, { message: 'Account must be 60 or more characters long' }) + // .startsWith('k:', { message: 'Account should start with k:' }), + chainId: z + .number({ + /* eslint-disable-next-line @typescript-eslint/naming-convention */ + invalid_type_error: 'Error: -c, --chain must be a number', + }) + .min(0) + .max(19), + network: z.string({}), + networkId: z.string().optional(), + networkHost: z.string().optional(), + networkExplorerUrl: z.string().optional(), +}); + +export type TQuestions = z.infer; + + diff --git a/packages/tools/kadena-cli/src/networks/createNetworksCommand.ts b/packages/tools/kadena-cli/src/networks/createNetworksCommand.ts index 32a4e587af..d2773fcea4 100644 --- a/packages/tools/kadena-cli/src/networks/createNetworksCommand.ts +++ b/packages/tools/kadena-cli/src/networks/createNetworksCommand.ts @@ -1,4 +1,5 @@ import { defaultNetworksPath } from '../constants/networks.js'; +import { network, networkExplorerUrl, networkHost, networkId } from '../constants/options.js'; import { ensureFileExists } from '../utils/filesystem.js'; import { clearCLI, collectResponses } from '../utils/helpers.js'; import { processZodErrors } from '../utils/processZodErrors.js'; @@ -33,13 +34,13 @@ async function shouldProceedWithNetworkCreate( return true; } -async function runNetworksCreate( - program: Command, - version: string, - args: TNetworksCreateOptions, -): Promise { +export async function runNetworksCreate( + program?: Command, + version?: string, + args?: TNetworksCreateOptions, +): Promise { try { - const responses = await collectResponses(args, networksCreateQuestions); + const responses = await collectResponses(args || {}, networksCreateQuestions); const networkConfig = { ...args, ...responses }; @@ -62,11 +63,14 @@ async function runNetworksCreate( console.log(chalk.yellow("Let's restart the configuration process.")); await runNetworksCreate(program, version, args); } else { - console.log(chalk.green('Configuration complete. Goodbye!')); + console.log(chalk.green('Network configuration complete!')); + return networkConfig.network } } catch (e) { console.error(e); - processZodErrors(program, e, args); + if (program) { + processZodErrors(program, e, args); + } } } @@ -74,19 +78,10 @@ export function createNetworksCommand(program: Command, version: string): void { program .command('create') .description('Create new network') - .option('-n, --network ', 'Kadena network (e.g. "mainnet")') - .option( - '-nid, --networkId ', - 'Kadena network Id (e.g. "mainnet01")', - ) - .option( - '-h, --networkHost ', - 'Kadena network host (e.g. "https://api.chainweb.com")', - ) - .option( - '-e, --networkExplorerUrl ', - 'Kadena network explorer (e.g. "https://explorer.chainweb.com/mainnet/tx/")', - ) + .addOption(network) + .addOption(networkId) + .addOption(networkHost) + .addOption(networkExplorerUrl) .action(async (args: TNetworksCreateOptions) => { debug('network-create:action')({ args }); diff --git a/packages/tools/kadena-cli/src/utils/bootstrap.ts b/packages/tools/kadena-cli/src/networks/init.ts similarity index 58% rename from packages/tools/kadena-cli/src/utils/bootstrap.ts rename to packages/tools/kadena-cli/src/networks/init.ts index 394672e73e..71dc999136 100644 --- a/packages/tools/kadena-cli/src/utils/bootstrap.ts +++ b/packages/tools/kadena-cli/src/networks/init.ts @@ -1,13 +1,6 @@ -// Bootstrapper for CLI - -// Path: src/utils/bootstrap.ts -// write default networks to file - import { networkDefaults } from '../constants/networks.js'; import { writeNetworks } from '../networks/networksHelpers.js'; -// create default mainnet writeNetworks(networkDefaults.mainnet); - -// create default testnet writeNetworks(networkDefaults.testnet); +writeNetworks(networkDefaults.devnet); diff --git a/packages/tools/kadena-cli/src/networks/manageNetworksCommand.ts b/packages/tools/kadena-cli/src/networks/manageNetworksCommand.ts index 828e4e7cf7..641f1b7f5d 100644 --- a/packages/tools/kadena-cli/src/networks/manageNetworksCommand.ts +++ b/packages/tools/kadena-cli/src/networks/manageNetworksCommand.ts @@ -23,13 +23,13 @@ import path from 'path'; export interface IManageNetworksOptions {} -export function manageNetworks(program: Command, version: string): void { +export async function manageNetworks(program: Command, version: string): Promise { program .command('manage') .description('Manage network(s)') .action(async (args: IManageNetworksOptions) => { try { - const existingNetworks: ICustomNetworksChoice[] = getExistingNetworks(); + const existingNetworks: ICustomNetworksChoice[] = await getExistingNetworks(); if (existingNetworks.length === 0) { console.log(chalk.red('No existing networks found.')); diff --git a/packages/tools/kadena-cli/src/networks/networksCreateQuestions.ts b/packages/tools/kadena-cli/src/networks/networksCreateQuestions.ts index f697028666..55f02359c7 100644 --- a/packages/tools/kadena-cli/src/networks/networksCreateQuestions.ts +++ b/packages/tools/kadena-cli/src/networks/networksCreateQuestions.ts @@ -31,7 +31,7 @@ interface ICustomChoice { } export async function askForNetwork(): Promise { - const existingNetworks: ICustomChoice[] = getExistingNetworks(); + const existingNetworks: ICustomChoice[] = await getExistingNetworks(); // const prefixedStandardNetworks: ICustomChoice[] = standardNetworks.map( // (network) => { @@ -69,9 +69,9 @@ export async function askForNetwork(): Promise { if (input === '') { return 'Network name cannot be empty! Please enter something.'; } - if (!isAlphabetic(input)) { - return 'Network name must be alphabetic! Please enter a valid name.'; - } + // if (!isAlphabetic(input)) { + // return 'Network name must be alphabetic! Please enter a valid name.'; + // } return true; }, message: 'Enter the name for your new network:', @@ -98,9 +98,9 @@ export const networksCreateQuestions: IQuestion[] = [ default: `${network}01`, message: `Enter ${network} network Id (e.g. "${network}01")`, validate: function (input) { - if (!isAlphanumeric(input)) { - return 'NetworkId must be alphanumeric! Please enter a valid name.'; - } + // if (!isAlphanumeric(input)) { + // return 'NetworkId must be alphanumeric! Please enter a valid name.'; + // } return true; }, }); diff --git a/packages/tools/kadena-cli/src/networks/networksHelpers.ts b/packages/tools/kadena-cli/src/networks/networksHelpers.ts index afc9827808..3056cd64f4 100644 --- a/packages/tools/kadena-cli/src/networks/networksHelpers.ts +++ b/packages/tools/kadena-cli/src/networks/networksHelpers.ts @@ -96,7 +96,19 @@ export function displayNetworkConfig( displaySeparator(); } -export function displayNetworksConfig(): void { +export function loadNetworkConfig(network: string): TNetworksCreateOptions | never { + const networkFilePath = path.join(defaultNetworksPath, `${network}.yaml`); + + if (! existsSync(networkFilePath)) { + throw new Error('Network configuration file not found.') + } + + return (yaml.load( + readFileSync(networkFilePath, 'utf8'), + ) as TNetworksCreateOptions); +} + +export async function displayNetworksConfig(): Promise { const log = console.log; const formatLength = 80; // Maximum width for the display @@ -120,7 +132,7 @@ export function displayNetworksConfig(): void { return ` ${keyValue}${' '.repeat(remainingWidth)} `; }; - const existingNetworks: ICustomNetworksChoice[] = getExistingNetworks(); + const existingNetworks: ICustomNetworksChoice[] = await getExistingNetworks(); const standardNetworks: string[] = ['mainnet', 'testnet']; existingNetworks.forEach(({ value }) => { diff --git a/packages/tools/kadena-cli/src/utils/helpers.ts b/packages/tools/kadena-cli/src/utils/helpers.ts index f8304172a4..b6f2fee9d8 100644 --- a/packages/tools/kadena-cli/src/utils/helpers.ts +++ b/packages/tools/kadena-cli/src/utils/helpers.ts @@ -237,11 +237,18 @@ export function capitalizeFirstLetter(str: string): string { return str.charAt(0).toUpperCase() + str.slice(1); } -export function getExistingNetworks(): ICustomChoice[] { - if (!existsSync(defaultNetworksPath)) { - mkdirSync(defaultNetworksPath, { recursive: true }); +export async function ensureNetworksConfiguration(): Promise { + if (existsSync(defaultNetworksPath)) { + return; } + mkdirSync(defaultNetworksPath, { recursive: true }); + await import('./../networks/init.js'); +} + +export async function getExistingNetworks(): Promise { + await ensureNetworksConfiguration(); + try { return readdirSync(defaultNetworksPath).map((filename) => ({ value: path.basename(filename.toLowerCase(), '.yaml'), From e9eacd30c4d92af41f30d0378fcf3aa6ca195f96 Mon Sep 17 00:00:00 2001 From: Jesse van Muijden Date: Fri, 3 Nov 2023 14:03:33 -0400 Subject: [PATCH 02/13] feat(kadena-cli): use @kadena/client-utils --- .../kadena-cli/.kadena/keys/andy.plain.key | 1 - .../tools/kadena-cli/.kadena/keys/b.hd.phrase | 1 - .../tools/kadena-cli/.kadena/keys/n.hd.phrase | 1 - .../kadena-cli/.kadena/networks/testnet.yaml | 2 +- packages/tools/kadena-cli/package.json | 1 + .../src/account/getBalanceCommand.ts | 27 ++++++------------- pnpm-lock.yaml | 3 +++ 7 files changed, 13 insertions(+), 23 deletions(-) delete mode 100644 packages/tools/kadena-cli/.kadena/keys/andy.plain.key delete mode 100644 packages/tools/kadena-cli/.kadena/keys/b.hd.phrase delete mode 100644 packages/tools/kadena-cli/.kadena/keys/n.hd.phrase diff --git a/packages/tools/kadena-cli/.kadena/keys/andy.plain.key b/packages/tools/kadena-cli/.kadena/keys/andy.plain.key deleted file mode 100644 index c151137d33..0000000000 --- a/packages/tools/kadena-cli/.kadena/keys/andy.plain.key +++ /dev/null @@ -1 +0,0 @@ -{"publicKey":"451abcee2d59decc36f67aaf4ab852602c61e9e1bc3e1c495c2ac8fc63a0a581","privateKey":"1fbc57ef245cab304ef21b168cd0c3b3fd50e6add794808c4582954feeb9c5f5"} \ No newline at end of file diff --git a/packages/tools/kadena-cli/.kadena/keys/b.hd.phrase b/packages/tools/kadena-cli/.kadena/keys/b.hd.phrase deleted file mode 100644 index 767a79f51d..0000000000 --- a/packages/tools/kadena-cli/.kadena/keys/b.hd.phrase +++ /dev/null @@ -1 +0,0 @@ -marine shoe thunder census minor mirror bargain evoke limb biology episode silk \ No newline at end of file diff --git a/packages/tools/kadena-cli/.kadena/keys/n.hd.phrase b/packages/tools/kadena-cli/.kadena/keys/n.hd.phrase deleted file mode 100644 index e10525ac04..0000000000 --- a/packages/tools/kadena-cli/.kadena/keys/n.hd.phrase +++ /dev/null @@ -1 +0,0 @@ -left rare pave unhappy raccoon club shield glad license matter catalog bonus \ No newline at end of file diff --git a/packages/tools/kadena-cli/.kadena/networks/testnet.yaml b/packages/tools/kadena-cli/.kadena/networks/testnet.yaml index 70afdfa6cf..9e5d2615a9 100644 --- a/packages/tools/kadena-cli/.kadena/networks/testnet.yaml +++ b/packages/tools/kadena-cli/.kadena/networks/testnet.yaml @@ -1,4 +1,4 @@ network: testnet -networkId: testnet01 +networkId: testnet04 networkHost: https://api.testnet.chainweb.com networkExplorerUrl: https://explorer.chainweb.com/testnet/tx/ diff --git a/packages/tools/kadena-cli/package.json b/packages/tools/kadena-cli/package.json index 5c4686209e..0dab86c8e7 100644 --- a/packages/tools/kadena-cli/package.json +++ b/packages/tools/kadena-cli/package.json @@ -42,6 +42,7 @@ "dependencies": { "@inquirer/prompts": "^3.0.4", "@kadena/client": "workspace:^", + "@kadena/client-utils": "workspace:^", "@kadena/cryptography-utils": "workspace:*", "@kadena/pactjs": "workspace:*", "@kadena/pactjs-cli": "workspace:^", diff --git a/packages/tools/kadena-cli/src/account/getBalanceCommand.ts b/packages/tools/kadena-cli/src/account/getBalanceCommand.ts index 3b84ebf566..a37fd87664 100644 --- a/packages/tools/kadena-cli/src/account/getBalanceCommand.ts +++ b/packages/tools/kadena-cli/src/account/getBalanceCommand.ts @@ -12,6 +12,7 @@ import { Questions } from '../constants/questions.js'; import { displayNetworkConfig, loadNetworkConfig } from '../networks/networksHelpers.js'; import { Pact, createClient } from '@kadena/client'; +import { getBalance } from '@kadena/client-utils/coin'; import chalk from 'chalk'; import { ChainId } from '@kadena/types'; @@ -65,23 +66,11 @@ export function getBalanceCommand(program: Command, version: string): void { const makeGetBalanceRequest = async (account: string, network: string, chainId: number) => { const networkConfiguration = loadNetworkConfig(network); displayNetworkConfig(networkConfiguration); - const client = createClient(`${networkConfiguration.networkHost}chainweb/0.0/${networkConfiguration.networkId}/chain/${chainId}/pact`); - const transaction = Pact.builder - .execution(Pact.modules.coin['get-balance'](account)) - .setMeta({ chainId: (chainId || 0).toString() as unknown as ChainId }) - .setNetworkId(networkConfiguration.networkId || '') - .createTransaction(); - - try { - const response = await client.dirtyRead(transaction); - const { result } = response; - - if (result.status === 'success') { - console.log(chalk.green(`The balance of ${account} on chain ${chainId} of ${network} is ${result.data}`)); - } else { - console.error(result.error); - } - } catch (e: unknown) { - console.error((e as Error).message); - } + const balance = await getBalance( + account, + networkConfiguration.networkId || '', + (chainId.toString() || '0') as ChainId, + networkConfiguration.networkHost, + ) + console.log(chalk.green(`The balance of ${account} on chain ${chainId} of ${network} is ${balance}`)); }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1fde84761..64696e770c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1820,6 +1820,9 @@ importers: '@kadena/client': specifier: workspace:^ version: link:../../libs/client + '@kadena/client-utils': + specifier: workspace:^ + version: link:../../libs/client-utils '@kadena/cryptography-utils': specifier: workspace:* version: link:../../libs/cryptography-utils From 3a64e410862b875d24d225c26ddff7b5e85fb182 Mon Sep 17 00:00:00 2001 From: Jesse van Muijden Date: Mon, 6 Nov 2023 10:28:38 -0400 Subject: [PATCH 03/13] wip(kadena-cli): refactor to standardized config --- .../src/account/getBalanceCommand.ts | 102 ++++++--------- .../tools/kadena-cli/src/account/index.ts | 5 +- .../kadena-cli/src/constants/questions.ts | 16 +++ .../tools/kadena-cli/src/networks/index.ts | 10 +- .../tools/kadena-cli/src/utils/helpers.ts | 120 +++++++++++++++++- 5 files changed, 185 insertions(+), 68 deletions(-) diff --git a/packages/tools/kadena-cli/src/account/getBalanceCommand.ts b/packages/tools/kadena-cli/src/account/getBalanceCommand.ts index a37fd87664..a8a31dce38 100644 --- a/packages/tools/kadena-cli/src/account/getBalanceCommand.ts +++ b/packages/tools/kadena-cli/src/account/getBalanceCommand.ts @@ -1,76 +1,58 @@ import { - collectResponses, + collectResponses, createCommand, createSimpleSubCommand, globalOptions, } from '../utils/helpers.js'; import { processZodErrors } from '../utils/processZodErrors.js'; import type { Command } from 'commander'; import { ZodError, unknown, z } from 'zod'; -import { account, chainId, network, networkId } from '../constants/options.js'; +import { account, chainId, network } from '../constants/options.js'; import { accountPrompt, chainIdPrompt, networkPrompt } from '../constants/prompts.js'; import { Questions } from '../constants/questions.js'; import { displayNetworkConfig, loadNetworkConfig } from '../networks/networksHelpers.js'; -import { Pact, createClient } from '@kadena/client'; import { getBalance } from '@kadena/client-utils/coin'; import chalk from 'chalk'; import { ChainId } from '@kadena/types'; -const GetBalanceQuestions = Questions.pick({ - account: true, - network: true, - chainId: true, -}); - -type TGetBalanceQuestions = z.infer; - -export function getBalanceCommand(program: Command, version: string): void { - program - .command('get-balance') - .description('get the balance of an account') - .addOption(account) - .addOption(network) - .addOption(chainId) - .action(async (args: TGetBalanceQuestions) => { - try { - const responses = await collectResponses(args, [ - { key: 'account', prompt: accountPrompt }, - { key: 'network', prompt: networkPrompt }, - { key: 'chainId', prompt: chainIdPrompt }, - ]); - - const configuration: TGetBalanceQuestions = { - ...args, - ...responses, - }; - - GetBalanceQuestions.parse(configuration); - - // Display full configuration - // console.log(chalk.green(configuration)); - - // Display full command for reuse - - await makeGetBalanceRequest(configuration.account, configuration.network, configuration.chainId); - } catch (e) { - if (e instanceof ZodError) { - processZodErrors(program, e, args); - return; - } - throw e; - } - }); -} - -const makeGetBalanceRequest = async (account: string, network: string, chainId: number) => { - const networkConfiguration = loadNetworkConfig(network); - displayNetworkConfig(networkConfiguration); - const balance = await getBalance( - account, - networkConfiguration.networkId || '', - (chainId.toString() || '0') as ChainId, - networkConfiguration.networkHost, - ) - console.log(chalk.green(`The balance of ${account} on chain ${chainId} of ${network} is ${balance}`)); -}; +export const createGetBalanceCommand = createCommand( + 'get-balance', + 'get the balance of an account', + [ + globalOptions.account(), + globalOptions.network(), + globalOptions.chainId() + ], + async (config) => { + console.log('config', JSON.stringify(config, null, 2)) + + // return await getBalance(config.account, config.networkConfig.networkId, config.chainId, config.networkConfig.networkHost) + } +) + +// export const createGetBalanceCommand = createSimpleSubCommand( +// 'account', +// 'get-balance', +// 'get the balance of an account', +// [ +// { key: 'account' }, +// { key: 'network', optional: true }, +// { key: 'chainId' }, +// ] +// )(program, version)(async (config) => { +// await makeGetBalanceRequest(config.account, config.network, config.chainId); +// }) + + +// const makeGetBalanceRequest = async (account: string, network: string, chainId: number) => { +// const networkConfiguration = loadNetworkConfig(network); +// displayNetworkConfig(networkConfiguration); +// const balance = await getBalance( +// account, +// networkConfiguration.networkId || '', +// (chainId.toString() || '0') as ChainId, +// networkConfiguration.networkHost, +// ) +// console.log(chalk.green(`The balance of ${account} on chain ${chainId} of ${network} is ${balance}`)); +// }; diff --git a/packages/tools/kadena-cli/src/account/index.ts b/packages/tools/kadena-cli/src/account/index.ts index 1fcaac9401..28b997467e 100644 --- a/packages/tools/kadena-cli/src/account/index.ts +++ b/packages/tools/kadena-cli/src/account/index.ts @@ -1,7 +1,7 @@ import { fundCommand } from './fundCommand.js'; import type { Command } from 'commander'; -import { getBalanceCommand } from './getBalanceCommand.js'; +import { createGetBalanceCommand } from './getBalanceCommand.js'; const SUBCOMMAND_ROOT: 'account' = 'account'; @@ -11,5 +11,6 @@ export function accountCommandFactory(program: Command, version: string): void { .description(`Tool to manage accounts of fungibles (e.g. 'coin')`); fundCommand(accountProgram, version); - getBalanceCommand(accountProgram, version); + // getBalanceCommand(accountProgram, version); + createGetBalanceCommand(accountProgram, version); } diff --git a/packages/tools/kadena-cli/src/constants/questions.ts b/packages/tools/kadena-cli/src/constants/questions.ts index cacff73e46..552eabe3f4 100644 --- a/packages/tools/kadena-cli/src/constants/questions.ts +++ b/packages/tools/kadena-cli/src/constants/questions.ts @@ -1,4 +1,6 @@ import { z } from "zod"; +import { accountPrompt } from "./prompts.js"; +import { account } from "./options.js"; // eslint-disable-next-line @rushstack/typedef-var export const Questions = z.object({ @@ -19,6 +21,20 @@ export const Questions = z.object({ networkExplorerUrl: z.string().optional(), }); +export const options = { + account: { + prompt: accountPrompt, + validation: z.string(), + option: new Option('-a, --account ', 'Receiver (k:) wallet address'), + }, + chainId: { + + }, + network: z.string({}), +}; + +export const chainId = z.number(); + export type TQuestions = z.infer; diff --git a/packages/tools/kadena-cli/src/networks/index.ts b/packages/tools/kadena-cli/src/networks/index.ts index 7d79b692b4..59f084750b 100644 --- a/packages/tools/kadena-cli/src/networks/index.ts +++ b/packages/tools/kadena-cli/src/networks/index.ts @@ -18,11 +18,11 @@ export function networksCommandFactory( .description(`Tool to create and manage networks`); // Attach list subcommands to the networksProgram - createSimpleSubCommand( - 'list', - 'List all available networks', - listNetworksAction, - )(networksProgram); + // createSimpleSubCommand( + // 'list', + // 'List all available networks', + // listNetworksAction, + // )(networksProgram); manageNetworks(networksProgram, version); createNetworksCommand(networksProgram, version); diff --git a/packages/tools/kadena-cli/src/utils/helpers.ts b/packages/tools/kadena-cli/src/utils/helpers.ts index b6f2fee9d8..7f6b43cfdf 100644 --- a/packages/tools/kadena-cli/src/utils/helpers.ts +++ b/packages/tools/kadena-cli/src/utils/helpers.ts @@ -5,9 +5,14 @@ import { defaultNetworksPath } from '../constants/networks.js'; import chalk from 'chalk'; import clear from 'clear'; -import type { Command, Option } from 'commander'; +import { Command, Option } from 'commander'; import { existsSync, mkdirSync, readdirSync } from 'fs'; import path from 'path'; +import { accountPrompt, chainIdPrompt, networkPrompt } from '../constants/prompts.js'; +import { z } from 'zod'; +import { ICustomNetworksChoice, loadNetworkConfig } from '../networks/networksHelpers.js'; +import { select } from '@inquirer/prompts'; +import { runNetworksCreate } from '../networks/createNetworksCommand.js'; export interface ICustomChoice { value: string; @@ -198,6 +203,7 @@ export function getPubKeyFromAccount(account: string): string { */ export function createSimpleSubCommand( + subject: string, name: string, description: string, actionFn: (args: T) => Promise | void, @@ -219,6 +225,118 @@ export function createSimpleSubCommand( }; } + +const createOption = any +}>(option: T) => { + return (optional: boolean = false) => ({ + ...option, + validation: optional + ? option.validation.optional() + : option.validation + }) +} + +export const globalOptions = { + account: createOption({ + key: 'account', + prompt: accountPrompt, + validation: z.string(), + option: new Option('-a, --account ', 'Receiver (k:) wallet address'), + }), + chainId: createOption({ + key: 'chainId', + prompt: chainIdPrompt, + validation: z.number({ + /* eslint-disable-next-line @typescript-eslint/naming-convention */ + invalid_type_error: 'Error: -c, --chain must be a number', + }).min(0).max(19), + option: new Option('-c, --chain ',) + }), + network: createOption({ + key: 'network', + prompt: networkPrompt, // mainnet, testnet, devnet + validation: z.string(), + option: new Option('-n, --network ', 'Kadena network (e.g. "mainnet")'), + expand(networkName: string) { + return loadNetworkConfig(networkName) + }, + }) +}; + +type GlobalOptions = typeof globalOptions; + +export function createCommand)[]>( + name: string, + description: string, + options: T, + action: (finalConfig: any) => any +) { + return (program: Command, version: string) => { + + const command = program.command(name).description(description); + + options.forEach((option) => { + command.addOption(option.option); + }); + + command.action(async (args, ...rest) => { + try { + // collectResponses + const questionsMap = options.map(({prompt, key}) => ({key, prompt})) + const responses = collectResponses(args, questionsMap) + const newArgs = {...args, ...responses } + // zod validatie + const zodValidationObject= options.reduce((zObject, {key, validation}) => { + zObject[key] = validation; + return zObject; + }, {} as Record) + + z.object(zodValidationObject).parse(newArgs) + + // const config = getFullConfigFromArgs(newArgs) + const config = {...newArgs}; + options.forEach((option) => { + if('expand' in option) { + // key: network + // config[networkConfig] + config[option.key] = option.expand(args[option.key]) + } + }) + + // execute action(finalConfig) + await action(config); + /** get-balance --chainId 0 + * + * args: { chainId: 0} + * > colletResponses + * which network to use + * () testnet + * (x) mainnet + * newArgs: { chainId: 0, network: 'mainnet' } + * + * > expandConfig + * network.expand() < ... + * + * config: { chainId: 0, network: 'mainnet', networkConfig: { + * networkHost: 'http://api.chainweb.com', + * explorer: 'explorer.chainweb.com', + * networkId: 'mainnet01' + * }} + */ + } catch (error) { + console.log(chalk.red(`Error executing command ${name}: ${error})`)); + process.exit(1); + } + }); + } +} + + + /** * Capitalizes the first letter of a string. * From f1c01efd339d9a48ed6bab42ad1247d6065219fb Mon Sep 17 00:00:00 2001 From: Albert G <516972+alber70g@users.noreply.github.com> Date: Mon, 6 Nov 2023 15:53:39 +0100 Subject: [PATCH 04/13] fix(kadena-cli): account get-balance now works. But without types --- .../src/account/getBalanceCommand.ts | 41 ++++--- .../tools/kadena-cli/src/constants/prompts.ts | 13 +- .../tools/kadena-cli/src/utils/helpers.ts | 112 +++++++++++------- 3 files changed, 102 insertions(+), 64 deletions(-) diff --git a/packages/tools/kadena-cli/src/account/getBalanceCommand.ts b/packages/tools/kadena-cli/src/account/getBalanceCommand.ts index a8a31dce38..909ce422cc 100644 --- a/packages/tools/kadena-cli/src/account/getBalanceCommand.ts +++ b/packages/tools/kadena-cli/src/account/getBalanceCommand.ts @@ -1,5 +1,8 @@ import { - collectResponses, createCommand, createSimpleSubCommand, globalOptions, + collectResponses, + createCommand, + createSimpleSubCommand, + globalOptions, } from '../utils/helpers.js'; import { processZodErrors } from '../utils/processZodErrors.js'; @@ -7,29 +10,40 @@ import type { Command } from 'commander'; import { ZodError, unknown, z } from 'zod'; import { account, chainId, network } from '../constants/options.js'; -import { accountPrompt, chainIdPrompt, networkPrompt } from '../constants/prompts.js'; +import { + accountPrompt, + chainIdPrompt, + networkPrompt, +} from '../constants/prompts.js'; import { Questions } from '../constants/questions.js'; -import { displayNetworkConfig, loadNetworkConfig } from '../networks/networksHelpers.js'; +import { + displayNetworkConfig, + loadNetworkConfig, +} from '../networks/networksHelpers.js'; import { getBalance } from '@kadena/client-utils/coin'; -import chalk from 'chalk'; import { ChainId } from '@kadena/types'; +import chalk from 'chalk'; +// eslint-disable-next-line @rushstack/typedef-var export const createGetBalanceCommand = createCommand( 'get-balance', 'get the balance of an account', - [ - globalOptions.account(), - globalOptions.network(), - globalOptions.chainId() - ], + [globalOptions.account(), globalOptions.network(), globalOptions.chainId()], async (config) => { - console.log('config', JSON.stringify(config, null, 2)) + // console.log('config', JSON.stringify(config, null, 2)) - // return await getBalance(config.account, config.networkConfig.networkId, config.chainId, config.networkConfig.networkHost) - } -) + const balance = await getBalance( + config.account, + config.networkConfig.networkId, + config.chainId, + config.networkConfig.networkHost, + ); + console.log({ balance }); + return balance; + }, +); // export const createGetBalanceCommand = createSimpleSubCommand( // 'account', @@ -44,7 +58,6 @@ export const createGetBalanceCommand = createCommand( // await makeGetBalanceRequest(config.account, config.network, config.chainId); // }) - // const makeGetBalanceRequest = async (account: string, network: string, chainId: number) => { // const networkConfiguration = loadNetworkConfig(network); // displayNetworkConfig(networkConfiguration); diff --git a/packages/tools/kadena-cli/src/constants/prompts.ts b/packages/tools/kadena-cli/src/constants/prompts.ts index c9358d5c5e..7f7f7f1784 100644 --- a/packages/tools/kadena-cli/src/constants/prompts.ts +++ b/packages/tools/kadena-cli/src/constants/prompts.ts @@ -1,12 +1,13 @@ -import { input, select } from "@inquirer/prompts"; -import { ICustomNetworksChoice } from "../networks/networksHelpers.js"; -import { getExistingNetworks } from "../utils/helpers.js"; -import { runNetworksCreate } from "../networks/createNetworksCommand.js"; +import { input, select } from '@inquirer/prompts'; +import { runNetworksCreate } from '../networks/createNetworksCommand.js'; +import { ICustomNetworksChoice } from '../networks/networksHelpers.js'; +import { getExistingNetworks } from '../utils/helpers.js'; -export const accountPrompt = async () => await input({ message: 'Enter the Kadena k:account' }); +export const accountPrompt = async () => + await input({ message: 'Enter the Kadena k:account' }); export const chainIdPrompt = async () => - parseInt(await input({ message: 'Enter chainId (0-19)' }), 10); + await input({ message: 'Enter chainId (0-19)' }); export const networkPrompt = async (): Promise => { const existingNetworks: ICustomNetworksChoice[] = await getExistingNetworks(); diff --git a/packages/tools/kadena-cli/src/utils/helpers.ts b/packages/tools/kadena-cli/src/utils/helpers.ts index 7f6b43cfdf..3eefc544f8 100644 --- a/packages/tools/kadena-cli/src/utils/helpers.ts +++ b/packages/tools/kadena-cli/src/utils/helpers.ts @@ -3,16 +3,23 @@ import type { TConfigOptions } from '../config/configQuestions.js'; import { projectPrefix, projectRootPath } from '../constants/config.js'; import { defaultNetworksPath } from '../constants/networks.js'; +import { select } from '@inquirer/prompts'; import chalk from 'chalk'; import clear from 'clear'; import { Command, Option } from 'commander'; import { existsSync, mkdirSync, readdirSync } from 'fs'; import path from 'path'; -import { accountPrompt, chainIdPrompt, networkPrompt } from '../constants/prompts.js'; import { z } from 'zod'; -import { ICustomNetworksChoice, loadNetworkConfig } from '../networks/networksHelpers.js'; -import { select } from '@inquirer/prompts'; +import { + accountPrompt, + chainIdPrompt, + networkPrompt, +} from '../constants/prompts.js'; import { runNetworksCreate } from '../networks/createNetworksCommand.js'; +import { + ICustomNetworksChoice, + loadNetworkConfig, +} from '../networks/networksHelpers.js'; export interface ICustomChoice { value: string; @@ -225,58 +232,70 @@ export function createSimpleSubCommand( }; } - -const createOption = any -}>(option: T) => { - return (optional: boolean = false) => ({ +const createOption = < + T extends { + prompt: any; + validation: any; + option: Option; + expand?: (label: string) => any; + }, +>( + option: T, +) => { + return (optional: boolean = true) => ({ ...option, - validation: optional - ? option.validation.optional() - : option.validation - }) -} + validation: optional ? option.validation.optional() : option.validation, + }); +}; +// eslint-disable-next-line @rushstack/typedef-var export const globalOptions = { account: createOption({ key: 'account', prompt: accountPrompt, validation: z.string(), - option: new Option('-a, --account ', 'Receiver (k:) wallet address'), + option: new Option( + '-a, --account ', + 'Receiver (k:) wallet address', + ), }), chainId: createOption({ key: 'chainId', prompt: chainIdPrompt, - validation: z.number({ - /* eslint-disable-next-line @typescript-eslint/naming-convention */ - invalid_type_error: 'Error: -c, --chain must be a number', - }).min(0).max(19), - option: new Option('-c, --chain ',) + validation: z + .string({ + /* eslint-disable-next-line @typescript-eslint/naming-convention */ + invalid_type_error: 'Error: -c, --chain-id must be a number', + }) + .min(0) + .max(19), + option: new Option('-c, --chain-id '), }), network: createOption({ key: 'network', prompt: networkPrompt, // mainnet, testnet, devnet validation: z.string(), - option: new Option('-n, --network ', 'Kadena network (e.g. "mainnet")'), + option: new Option( + '-n, --network ', + 'Kadena network (e.g. "mainnet")', + ), expand(networkName: string) { - return loadNetworkConfig(networkName) + return loadNetworkConfig(networkName); }, - }) + }), }; type GlobalOptions = typeof globalOptions; -export function createCommand)[]>( +export function createCommand< + T extends ReturnType[], +>( name: string, description: string, options: T, - action: (finalConfig: any) => any + action: (finalConfig: Record) => any, ) { - return (program: Command, version: string) => { - + return (program: Command, version: string) => { const command = program.command(name).description(description); options.forEach((option) => { @@ -286,26 +305,32 @@ export function createCommand { try { // collectResponses - const questionsMap = options.map(({prompt, key}) => ({key, prompt})) - const responses = collectResponses(args, questionsMap) - const newArgs = {...args, ...responses } + const questionsMap = options.map(({ prompt, key }) => ({ + key, + prompt, + })); + const responses = await collectResponses(args, questionsMap); + const newArgs = { ...args, ...responses }; // zod validatie - const zodValidationObject= options.reduce((zObject, {key, validation}) => { - zObject[key] = validation; - return zObject; - }, {} as Record) + const zodValidationObject = options.reduce( + (zObject, { key, validation }) => { + zObject[key] = validation; + return zObject; + }, + {} as Record, + ); - z.object(zodValidationObject).parse(newArgs) + z.object(zodValidationObject).parse(newArgs); // const config = getFullConfigFromArgs(newArgs) - const config = {...newArgs}; + const config = { ...newArgs }; options.forEach((option) => { - if('expand' in option) { + if ('expand' in option) { // key: network // config[networkConfig] - config[option.key] = option.expand(args[option.key]) + config[`${option.key}Config`] = option.expand(args[option.key]); } - }) + }); // execute action(finalConfig) await action(config); @@ -328,15 +353,14 @@ export function createCommand Date: Mon, 6 Nov 2023 16:12:25 +0100 Subject: [PATCH 05/13] fix(kadena-cli): add logger for command and config --- .../src/account/getBalanceCommand.ts | 55 +------ .../kadena-cli/src/utils/createCommand.ts | 89 ++++++++++++ .../kadena-cli/src/utils/globalOptions.ts | 62 ++++++++ .../tools/kadena-cli/src/utils/helpers.ts | 137 +----------------- 4 files changed, 155 insertions(+), 188 deletions(-) create mode 100644 packages/tools/kadena-cli/src/utils/createCommand.ts create mode 100644 packages/tools/kadena-cli/src/utils/globalOptions.ts diff --git a/packages/tools/kadena-cli/src/account/getBalanceCommand.ts b/packages/tools/kadena-cli/src/account/getBalanceCommand.ts index 909ce422cc..fef7e5ee0b 100644 --- a/packages/tools/kadena-cli/src/account/getBalanceCommand.ts +++ b/packages/tools/kadena-cli/src/account/getBalanceCommand.ts @@ -1,30 +1,6 @@ -import { - collectResponses, - createCommand, - createSimpleSubCommand, - globalOptions, -} from '../utils/helpers.js'; -import { processZodErrors } from '../utils/processZodErrors.js'; - -import type { Command } from 'commander'; -import { ZodError, unknown, z } from 'zod'; - -import { account, chainId, network } from '../constants/options.js'; -import { - accountPrompt, - chainIdPrompt, - networkPrompt, -} from '../constants/prompts.js'; -import { Questions } from '../constants/questions.js'; -import { - displayNetworkConfig, - loadNetworkConfig, -} from '../networks/networksHelpers.js'; - import { getBalance } from '@kadena/client-utils/coin'; - -import { ChainId } from '@kadena/types'; -import chalk from 'chalk'; +import { createCommand } from '../utils/createCommand.js'; +import { globalOptions } from '../utils/globalOptions.js'; // eslint-disable-next-line @rushstack/typedef-var export const createGetBalanceCommand = createCommand( @@ -32,8 +8,6 @@ export const createGetBalanceCommand = createCommand( 'get the balance of an account', [globalOptions.account(), globalOptions.network(), globalOptions.chainId()], async (config) => { - // console.log('config', JSON.stringify(config, null, 2)) - const balance = await getBalance( config.account, config.networkConfig.networkId, @@ -44,28 +18,3 @@ export const createGetBalanceCommand = createCommand( return balance; }, ); - -// export const createGetBalanceCommand = createSimpleSubCommand( -// 'account', -// 'get-balance', -// 'get the balance of an account', -// [ -// { key: 'account' }, -// { key: 'network', optional: true }, -// { key: 'chainId' }, -// ] -// )(program, version)(async (config) => { -// await makeGetBalanceRequest(config.account, config.network, config.chainId); -// }) - -// const makeGetBalanceRequest = async (account: string, network: string, chainId: number) => { -// const networkConfiguration = loadNetworkConfig(network); -// displayNetworkConfig(networkConfiguration); -// const balance = await getBalance( -// account, -// networkConfiguration.networkId || '', -// (chainId.toString() || '0') as ChainId, -// networkConfiguration.networkHost, -// ) -// console.log(chalk.green(`The balance of ${account} on chain ${chainId} of ${network} is ${balance}`)); -// }; diff --git a/packages/tools/kadena-cli/src/utils/createCommand.ts b/packages/tools/kadena-cli/src/utils/createCommand.ts new file mode 100644 index 0000000000..2f05fbe0b2 --- /dev/null +++ b/packages/tools/kadena-cli/src/utils/createCommand.ts @@ -0,0 +1,89 @@ +import chalk from 'chalk'; +import type { Command } from 'commander'; +import { z } from 'zod'; +import type { GlobalOptions } from './helpers.js'; +import { collectResponses } from './helpers.js'; + +const formatLength: 80 = 80; +const formatConfig = (key: string, value?: string | number): string => { + const valueDisplay = + value !== undefined && value.toString().trim() !== '' + ? chalk.green(value.toString()) + : chalk.red('Not Set'); + const keyValue = `${key}: ${valueDisplay}`; + const remainingWidth = + formatLength - keyValue.length > 0 ? formatLength - keyValue.length : 0; + return ` ${keyValue}${' '.repeat(remainingWidth)} `; +}; + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function createCommand< + T extends ReturnType[], +>( + name: string, + description: string, + options: T, + action: (finalConfig: Record) => any, +) { + return (program: Command, version: string) => { + const command = program.command(name).description(description); + + options.forEach((option) => { + command.addOption(option.option); + }); + + command.action(async (args, ...rest) => { + try { + // collectResponses + const questionsMap = options.map(({ prompt, key }) => ({ + key, + prompt, + })); + const responses = await collectResponses(args, questionsMap); + const newArgs = { ...args, ...responses }; + + console.log( + `executing: kadena ${program.name()} ${name} ${Object.getOwnPropertyNames( + newArgs, + ) + .map((arg) => `--${arg} ${newArgs[arg]}`) + .join(' ')}`, + ); + + // zod validation + const zodValidationObject = options.reduce( + (zObject, { key, validation }) => { + zObject[key] = validation; + return zObject; + }, + {} as Record, + ); + + z.object(zodValidationObject).parse(newArgs); + + // const config = getFullConfigFromArgs(newArgs) + const config = { ...newArgs }; + options.forEach((option) => { + if ('expand' in option) { + // write expanded config to Config + config[`${option.key}Config`] = option.expand(args[option.key]); + } + }); + + Object.getOwnPropertyNames(config).forEach((key) => { + const value = config[key]; + const isObject = typeof value === 'object'; + console.log( + formatConfig(key, isObject ? JSON.stringify(value) : value), + ); + }); + // execute action with config + await action(config); + } catch (error) { + console.error(error); + console.error(chalk.red(`Error executing command ${name}: ${error})`)); + process.exit(1); + } + }); + }; +} diff --git a/packages/tools/kadena-cli/src/utils/globalOptions.ts b/packages/tools/kadena-cli/src/utils/globalOptions.ts new file mode 100644 index 0000000000..458ea01f0f --- /dev/null +++ b/packages/tools/kadena-cli/src/utils/globalOptions.ts @@ -0,0 +1,62 @@ +import { Option } from 'commander'; +import { z } from 'zod'; +import { + accountPrompt, + chainIdPrompt, + networkPrompt, +} from '../constants/prompts.js'; +import { loadNetworkConfig } from '../networks/networksHelpers.js'; + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export const createOption = < + T extends { + prompt: any; + validation: any; + option: Option; + expand?: (label: string) => any; + }, +>( + option: T, +) => { + return (optional: boolean = true) => ({ + ...option, + validation: optional ? option.validation.optional() : option.validation, + }); +}; + +// eslint-disable-next-line @rushstack/typedef-var +export const globalOptions = { + account: createOption({ + key: 'account', + prompt: accountPrompt, + validation: z.string(), + option: new Option( + '-a, --account ', + 'Receiver (k:) wallet address', + ), + }), + chainId: createOption({ + key: 'chainId', + prompt: chainIdPrompt, + validation: z + .string({ + /* eslint-disable-next-line @typescript-eslint/naming-convention */ + invalid_type_error: 'Error: -c, --chain-id must be a number', + }) + .min(0) + .max(19), + option: new Option('-c, --chain-id '), + }), + network: createOption({ + key: 'network', + prompt: networkPrompt, + validation: z.string(), + option: new Option( + '-n, --network ', + 'Kadena network (e.g. "mainnet")', + ), + expand(networkName: string) { + return loadNetworkConfig(networkName); + }, + }), +} as const; diff --git a/packages/tools/kadena-cli/src/utils/helpers.ts b/packages/tools/kadena-cli/src/utils/helpers.ts index 3eefc544f8..7390fd1b54 100644 --- a/packages/tools/kadena-cli/src/utils/helpers.ts +++ b/packages/tools/kadena-cli/src/utils/helpers.ts @@ -9,17 +9,11 @@ import clear from 'clear'; import { Command, Option } from 'commander'; import { existsSync, mkdirSync, readdirSync } from 'fs'; import path from 'path'; -import { z } from 'zod'; -import { - accountPrompt, - chainIdPrompt, - networkPrompt, -} from '../constants/prompts.js'; import { runNetworksCreate } from '../networks/createNetworksCommand.js'; import { ICustomNetworksChoice, - loadNetworkConfig, } from '../networks/networksHelpers.js'; +import { globalOptions } from './globalOptions.js'; export interface ICustomChoice { value: string; @@ -232,134 +226,7 @@ export function createSimpleSubCommand( }; } -const createOption = < - T extends { - prompt: any; - validation: any; - option: Option; - expand?: (label: string) => any; - }, ->( - option: T, -) => { - return (optional: boolean = true) => ({ - ...option, - validation: optional ? option.validation.optional() : option.validation, - }); -}; - -// eslint-disable-next-line @rushstack/typedef-var -export const globalOptions = { - account: createOption({ - key: 'account', - prompt: accountPrompt, - validation: z.string(), - option: new Option( - '-a, --account ', - 'Receiver (k:) wallet address', - ), - }), - chainId: createOption({ - key: 'chainId', - prompt: chainIdPrompt, - validation: z - .string({ - /* eslint-disable-next-line @typescript-eslint/naming-convention */ - invalid_type_error: 'Error: -c, --chain-id must be a number', - }) - .min(0) - .max(19), - option: new Option('-c, --chain-id '), - }), - network: createOption({ - key: 'network', - prompt: networkPrompt, // mainnet, testnet, devnet - validation: z.string(), - option: new Option( - '-n, --network ', - 'Kadena network (e.g. "mainnet")', - ), - expand(networkName: string) { - return loadNetworkConfig(networkName); - }, - }), -}; - -type GlobalOptions = typeof globalOptions; - -export function createCommand< - T extends ReturnType[], ->( - name: string, - description: string, - options: T, - action: (finalConfig: Record) => any, -) { - return (program: Command, version: string) => { - const command = program.command(name).description(description); - - options.forEach((option) => { - command.addOption(option.option); - }); - - command.action(async (args, ...rest) => { - try { - // collectResponses - const questionsMap = options.map(({ prompt, key }) => ({ - key, - prompt, - })); - const responses = await collectResponses(args, questionsMap); - const newArgs = { ...args, ...responses }; - // zod validatie - const zodValidationObject = options.reduce( - (zObject, { key, validation }) => { - zObject[key] = validation; - return zObject; - }, - {} as Record, - ); - - z.object(zodValidationObject).parse(newArgs); - - // const config = getFullConfigFromArgs(newArgs) - const config = { ...newArgs }; - options.forEach((option) => { - if ('expand' in option) { - // key: network - // config[networkConfig] - config[`${option.key}Config`] = option.expand(args[option.key]); - } - }); - - // execute action(finalConfig) - await action(config); - /** get-balance --chainId 0 - * - * args: { chainId: 0} - * > colletResponses - * which network to use - * () testnet - * (x) mainnet - * newArgs: { chainId: 0, network: 'mainnet' } - * - * > expandConfig - * network.expand() < ... - * - * config: { chainId: 0, network: 'mainnet', networkConfig: { - * networkHost: 'http://api.chainweb.com', - * explorer: 'explorer.chainweb.com', - * networkId: 'mainnet01' - * }} - */ - } catch (error) { - console.error(error); - console.log(chalk.red(`Error executing command ${name}: ${error})`)); - process.exit(1); - } - }); - }; -} +export type GlobalOptions = typeof globalOptions; /** * Capitalizes the first letter of a string. From d9afeed482080580a4321cac8d5a6e6e3733493d Mon Sep 17 00:00:00 2001 From: Jesse van Muijden Date: Thu, 9 Nov 2023 11:15:06 -0400 Subject: [PATCH 06/13] wip(kadena-cli): wip --- .../libs/client-utils/src/coin/get-balance.ts | 2 +- .../src/account/getBalanceCommand.ts | 22 ++-- .../tools/kadena-cli/src/constants/prompts.ts | 51 ++++++++- .../src/networks/createNetworksCommand.ts | 106 ++++-------------- .../kadena-cli/src/utils/createCommand.ts | 51 +++++---- .../kadena-cli/src/utils/globalOptions.ts | 40 ++++++- .../tools/kadena-cli/src/utils/helpers.ts | 11 +- 7 files changed, 157 insertions(+), 126 deletions(-) diff --git a/packages/libs/client-utils/src/coin/get-balance.ts b/packages/libs/client-utils/src/coin/get-balance.ts index 6a461f2c25..ccdbb9a1de 100644 --- a/packages/libs/client-utils/src/coin/get-balance.ts +++ b/packages/libs/client-utils/src/coin/get-balance.ts @@ -27,5 +27,5 @@ export const getBalance = ( }, }), ); - return balance(account).execute(); + return balance(account).execute().catch(e => console.log(e.message)); }; diff --git a/packages/tools/kadena-cli/src/account/getBalanceCommand.ts b/packages/tools/kadena-cli/src/account/getBalanceCommand.ts index fef7e5ee0b..258d0a45c3 100644 --- a/packages/tools/kadena-cli/src/account/getBalanceCommand.ts +++ b/packages/tools/kadena-cli/src/account/getBalanceCommand.ts @@ -1,20 +1,24 @@ import { getBalance } from '@kadena/client-utils/coin'; import { createCommand } from '../utils/createCommand.js'; import { globalOptions } from '../utils/globalOptions.js'; +import chalk from 'chalk'; // eslint-disable-next-line @rushstack/typedef-var export const createGetBalanceCommand = createCommand( 'get-balance', - 'get the balance of an account', + 'Get the balance of an account', [globalOptions.account(), globalOptions.network(), globalOptions.chainId()], async (config) => { - const balance = await getBalance( - config.account, - config.networkConfig.networkId, - config.chainId, - config.networkConfig.networkHost, - ); - console.log({ balance }); - return balance; + try { + const balance = await getBalance( + config.account, + config.networkConfig.networkId, + config.chainId, + config.networkConfig.networkHost, + ); + console.log(chalk.green(`\nThe balance of ${config.account} on chain ${config.chainId} on ${config.network} is: ${balance}.\n`)); + } catch (e) { + console.log(chalk.red(e.message)); + } }, ); diff --git a/packages/tools/kadena-cli/src/constants/prompts.ts b/packages/tools/kadena-cli/src/constants/prompts.ts index 7f7f7f1784..d8667bb322 100644 --- a/packages/tools/kadena-cli/src/constants/prompts.ts +++ b/packages/tools/kadena-cli/src/constants/prompts.ts @@ -1,7 +1,11 @@ import { input, select } from '@inquirer/prompts'; -import { runNetworksCreate } from '../networks/createNetworksCommand.js'; import { ICustomNetworksChoice } from '../networks/networksHelpers.js'; -import { getExistingNetworks } from '../utils/helpers.js'; +import { getExistingNetworks, getVersion } from '../utils/helpers.js'; +import path from 'path'; +import { defaultNetworksPath } from './networks.js'; +import { ensureFileExists } from '../utils/filesystem.js'; +import { program } from 'commander'; +import { createNetworksCommand } from '../networks/createNetworksCommand.js'; export const accountPrompt = async () => await input({ message: 'Enter the Kadena k:account' }); @@ -29,7 +33,46 @@ export const networkPrompt = async (): Promise => { // At this point there is either no network defined yet, // or the user chose to define a new network. // Create and select new network. - const network = await runNetworksCreate(); + // await createNetworksCommand(program, getVersion()); + // program.parse(['', '', 'networks create']); - return network || ''; + const networks = program.commands.find(command => command.name() === 'networks'); + const create = networks?.commands.find(command => command.name() === 'create'); + // console.log(create); + await create?.parseAsync(['', '', 'networks create']); + await networkPrompt() + + return ''; // network?.network || ''; }; + +export const networkNamePrompt = async (): Promise => { + const networkName = await input({ message: 'Enter a network name (e.g. "mainnet")' }); + + const filePath = path.join(defaultNetworksPath, `${networkName}.yaml`); + if (ensureFileExists(filePath)) { + const overwrite = await networkOverwritePrompt(); + if (overwrite === 'no') { + return await networkNamePrompt(); + } + } + + return networkName; +} + +export const networkOverwritePrompt = async () => + await select({ + message: `A network configuration with this name already exists. Do you want to update it?`, + choices: [ + { value: 'yes', name: 'Yes' }, + { value: 'no', name: 'No' }, + ], + }); + +export const networkIdPrompt = async () => + await input({ message: `Enter a network id (e.g. "mainnet01")` }); + +export const networkHostPrompt = async () => + await input({ message: 'Enter Kadena network host (e.g. "https://api.chainweb.com")' }); + +export const networkExplorerUrlPrompt = async () => + await input({ message: 'Enter Kadena network explorer URL (e.g. "https://explorer.chainweb.com/mainnet/tx/")' }); diff --git a/packages/tools/kadena-cli/src/networks/createNetworksCommand.ts b/packages/tools/kadena-cli/src/networks/createNetworksCommand.ts index d2773fcea4..bd2377e0c5 100644 --- a/packages/tools/kadena-cli/src/networks/createNetworksCommand.ts +++ b/packages/tools/kadena-cli/src/networks/createNetworksCommand.ts @@ -1,98 +1,36 @@ import { defaultNetworksPath } from '../constants/networks.js'; -import { network, networkExplorerUrl, networkHost, networkId } from '../constants/options.js'; import { ensureFileExists } from '../utils/filesystem.js'; -import { clearCLI, collectResponses } from '../utils/helpers.js'; -import { processZodErrors } from '../utils/processZodErrors.js'; import type { TNetworksCreateOptions } from './networksCreateQuestions.js'; -import { - NetworksCreateOptions, - networksCreateQuestions, -} from './networksCreateQuestions.js'; -import { displayNetworkConfig, writeNetworks } from './networksHelpers.js'; +import { writeNetworks } from './networksHelpers.js'; -import { select } from '@inquirer/prompts'; -import chalk from 'chalk'; -import type { Command } from 'commander'; import debug from 'debug'; import path from 'path'; -async function shouldProceedWithNetworkCreate( - network: string, -): Promise { - const filePath = path.join(defaultNetworksPath, `${network}.yaml`); - if (ensureFileExists(filePath)) { - const overwrite = await select({ - message: `Your network (config) already exists. Do you want to update it?`, - choices: [ - { value: 'yes', name: 'Yes' }, - { value: 'no', name: 'No' }, - ], - }); - return overwrite === 'yes'; - } - return true; -} - -export async function runNetworksCreate( - program?: Command, - version?: string, - args?: TNetworksCreateOptions, -): Promise { - try { - const responses = await collectResponses(args || {}, networksCreateQuestions); - - const networkConfig = { ...args, ...responses }; - - NetworksCreateOptions.parse(networkConfig); - - writeNetworks(networkConfig); - - displayNetworkConfig(networkConfig); - - const proceed = await select({ - message: 'Is the above network configuration correct?', - choices: [ - { value: 'yes', name: 'Yes' }, - { value: 'no', name: 'No' }, - ], - }); +import { createCommand } from '../utils/createCommand.js'; +import { globalOptions } from '../utils/globalOptions.js'; +import chalk from 'chalk'; +import { networkOverwritePrompt } from '../constants/prompts.js'; - if (proceed === 'no') { - clearCLI(true); - console.log(chalk.yellow("Let's restart the configuration process.")); - await runNetworksCreate(program, version, args); - } else { - console.log(chalk.green('Network configuration complete!')); - return networkConfig.network - } - } catch (e) { - console.error(e); - if (program) { - processZodErrors(program, e, args); - } - } -} +export const createNetworksCommand = createCommand( + 'create', + 'Create network', + [globalOptions.network(), globalOptions.networkId(), globalOptions.networkHost(), globalOptions.networkExplorerUrl()], + async (config) => { + debug('network-create:action')({config}); -export function createNetworksCommand(program: Command, version: string): void { - program - .command('create') - .description('Create new network') - .addOption(network) - .addOption(networkId) - .addOption(networkHost) - .addOption(networkExplorerUrl) - .action(async (args: TNetworksCreateOptions) => { - debug('network-create:action')({ args }); + const filePath = path.join(defaultNetworksPath, `${config.network}.yaml`); - if ( - args.network && - !(await shouldProceedWithNetworkCreate(args.network.toLowerCase())) - ) { - console.log(chalk.red('Network creation aborted.')); + if (ensureFileExists(filePath)) { + const overwrite = await networkOverwritePrompt(); + if (overwrite === 'no') { + console.log(chalk.yellow(`\nThe existing network configuration "${config.network}" will not be updated.\n`)); return; } + } + + writeNetworks(config as TNetworksCreateOptions); - await runNetworksCreate(program, version, args); - }); -} + console.log(chalk.green(`\nThe network configuration "${config.network}" has been saved.\n`)); + }, +); diff --git a/packages/tools/kadena-cli/src/utils/createCommand.ts b/packages/tools/kadena-cli/src/utils/createCommand.ts index 2f05fbe0b2..ff17368a2e 100644 --- a/packages/tools/kadena-cli/src/utils/createCommand.ts +++ b/packages/tools/kadena-cli/src/utils/createCommand.ts @@ -5,17 +5,28 @@ import type { GlobalOptions } from './helpers.js'; import { collectResponses } from './helpers.js'; const formatLength: 80 = 80; -const formatConfig = (key: string, value?: string | number): string => { - const valueDisplay = - value !== undefined && value.toString().trim() !== '' - ? chalk.green(value.toString()) - : chalk.red('Not Set'); +const formatConfig = (key: string, value?: string | number, prefix: string = ''): string => { + const valueDisplay = value === undefined + ? chalk.red('Not Set') + : chalk.green(value.toString()); const keyValue = `${key}: ${valueDisplay}`; const remainingWidth = formatLength - keyValue.length > 0 ? formatLength - keyValue.length : 0; return ` ${keyValue}${' '.repeat(remainingWidth)} `; }; +const displayConfig = (config: Record, indentation: string = ''): void => { + Object.getOwnPropertyNames(config).forEach((key) => { + const value = config[key]; + const isObject = typeof value === 'object'; + console.log( + formatConfig(indentation + key, isObject ? '' : value), + ); + if (isObject) { + displayConfig(value as unknown as Record, indentation + ' '); + } + }); +} // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export function createCommand< T extends ReturnType[], @@ -23,9 +34,9 @@ export function createCommand< name: string, description: string, options: T, - action: (finalConfig: Record) => any, + action: (finalConfig: Record) => Promise, ) { - return (program: Command, version: string) => { + return async (program: Command, version: string) => { const command = program.command(name).description(description); options.forEach((option) => { @@ -42,13 +53,13 @@ export function createCommand< const responses = await collectResponses(args, questionsMap); const newArgs = { ...args, ...responses }; - console.log( - `executing: kadena ${program.name()} ${name} ${Object.getOwnPropertyNames( + console.log(chalk.yellow( + `\nexecuting: kadena ${program.name()} ${name} ${Object.getOwnPropertyNames( newArgs, ) - .map((arg) => `--${arg} ${newArgs[arg]}`) + .map((arg) => `--${arg.replace(/[A-Z]/g, (match: string) => '-' + match.toLowerCase())} ${newArgs[arg]}`) .join(' ')}`, - ); + )); // zod validation const zodValidationObject = options.reduce( @@ -61,22 +72,16 @@ export function createCommand< z.object(zodValidationObject).parse(newArgs); - // const config = getFullConfigFromArgs(newArgs) const config = { ...newArgs }; - options.forEach((option) => { + for (let option of options) { if ('expand' in option) { - // write expanded config to Config - config[`${option.key}Config`] = option.expand(args[option.key]); + config[`${option.key}Config`] = await option.expand(args[option.key]); } - }); + } + + console.log(); + displayConfig(config); - Object.getOwnPropertyNames(config).forEach((key) => { - const value = config[key]; - const isObject = typeof value === 'object'; - console.log( - formatConfig(key, isObject ? JSON.stringify(value) : value), - ); - }); // execute action with config await action(config); } catch (error) { diff --git a/packages/tools/kadena-cli/src/utils/globalOptions.ts b/packages/tools/kadena-cli/src/utils/globalOptions.ts index 458ea01f0f..f01be71cae 100644 --- a/packages/tools/kadena-cli/src/utils/globalOptions.ts +++ b/packages/tools/kadena-cli/src/utils/globalOptions.ts @@ -3,9 +3,15 @@ import { z } from 'zod'; import { accountPrompt, chainIdPrompt, + networkExplorerUrlPrompt, + networkHostPrompt, + networkIdPrompt, + networkNamePrompt, networkPrompt, } from '../constants/prompts.js'; import { loadNetworkConfig } from '../networks/networksHelpers.js'; +import { ensureNetworksConfiguration } from './helpers.js'; +// import { runNetworksCreate } from '../networks/createNetworksCommand.js'; // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export const createOption = < @@ -24,7 +30,6 @@ export const createOption = < }); }; -// eslint-disable-next-line @rushstack/typedef-var export const globalOptions = { account: createOption({ key: 'account', @@ -55,8 +60,37 @@ export const globalOptions = { '-n, --network ', 'Kadena network (e.g. "mainnet")', ), - expand(networkName: string) { - return loadNetworkConfig(networkName); + expand: async (networkName: string) => { + await ensureNetworksConfiguration(); + try { + return loadNetworkConfig(networkName).network; + } catch (e) { + // await runNetworksCreate(); + } }, }), + networkName: createOption({ + key: 'network', + prompt: networkNamePrompt, + validation: z.string(), + option: new Option('-n, --network ', 'Kadena network (e.g. "mainnet")'), + }), + networkId: createOption({ + key: 'networkId', + prompt: networkIdPrompt, + validation: z.string(), + option: new Option('-nid, --network-id ', 'Kadena network Id (e.g. "mainnet01")'), + }), + networkHost: createOption({ + key: 'networkHost', + prompt: networkHostPrompt, + validation: z.string(), + option: new Option('-h, --network-host ', 'Kadena network host (e.g. "https://api.chainweb.com")'), + }), + networkExplorerUrl: createOption({ + key: 'networkExplorerUrl', + prompt: networkExplorerUrlPrompt, + validation: z.string().optional(), + option: new Option('-e, --network-explorer-url ', 'Kadena network explorer URL (e.g. "https://explorer.chainweb.com/mainnet/tx/")'), + }), } as const; diff --git a/packages/tools/kadena-cli/src/utils/helpers.ts b/packages/tools/kadena-cli/src/utils/helpers.ts index 7390fd1b54..2e9055e41d 100644 --- a/packages/tools/kadena-cli/src/utils/helpers.ts +++ b/packages/tools/kadena-cli/src/utils/helpers.ts @@ -7,9 +7,9 @@ import { select } from '@inquirer/prompts'; import chalk from 'chalk'; import clear from 'clear'; import { Command, Option } from 'commander'; -import { existsSync, mkdirSync, readdirSync } from 'fs'; +import { existsSync, mkdirSync, readdirSync, readFileSync } from 'fs'; import path from 'path'; -import { runNetworksCreate } from '../networks/createNetworksCommand.js'; +// import { runNetworksCreate } from '../networks/createNetworksCommand.js'; import { ICustomNetworksChoice, } from '../networks/networksHelpers.js'; @@ -440,3 +440,10 @@ export function clearCLI(full: boolean = false): void { } } } + +export function getVersion(): string { + const packageJson: { version: string } = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8'), + ); + return packageJson.version; +} From 890131fa3aba5c57a2d1ca70923db5ffddb2081f Mon Sep 17 00:00:00 2001 From: Albert G <516972+alber70g@users.noreply.github.com> Date: Thu, 9 Nov 2023 16:27:12 +0100 Subject: [PATCH 07/13] wip --- packages/libs/client-utils/etc/client-utils-coin.api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/libs/client-utils/etc/client-utils-coin.api.md b/packages/libs/client-utils/etc/client-utils-coin.api.md index 16c3a86263..62947c2b30 100644 --- a/packages/libs/client-utils/etc/client-utils-coin.api.md +++ b/packages/libs/client-utils/etc/client-utils-coin.api.md @@ -39,7 +39,7 @@ data: ICommandResult; export const details: (account: string, networkId: string, chainId: ChainId, host?: IClientConfig['host']) => Promise | Promise | Promise | Promise | Promise | Promise | Promise | Promise | Promise; // @alpha (undocumented) -export const getBalance: (account: string, networkId: string, chainId: ChainId, host?: IClientConfig['host']) => Promise | Promise | Promise | Promise | Promise | Promise | Promise | Promise | Promise; +export const getBalance: (account: string, networkId: string, chainId: ChainId, host?: IClientConfig['host']) => Promise | Promise | Promise | Promise | Promise | Promise | Promise | Promise | Promise; // Warning: (ae-forgotten-export) The symbol "IRotateCommandInput" needs to be exported by the entry point index.d.ts // From 4f4da26980f3cf69a2a1d81ceb5f434b2799662c Mon Sep 17 00:00:00 2001 From: Albert G <516972+alber70g@users.noreply.github.com> Date: Thu, 9 Nov 2023 17:03:08 +0100 Subject: [PATCH 08/13] fix(kadena-cli): createCommand now uses newArgs that include the responses. Use `program.parseAsync` to run another command in an action/prompt --- .../tools/kadena-cli/src/constants/prompts.ts | 44 +++++++++++++------ .../src/networks/createNetworksCommand.ts | 2 +- .../kadena-cli/src/utils/createCommand.ts | 2 +- .../kadena-cli/src/utils/globalOptions.ts | 4 +- 4 files changed, 34 insertions(+), 18 deletions(-) diff --git a/packages/tools/kadena-cli/src/constants/prompts.ts b/packages/tools/kadena-cli/src/constants/prompts.ts index d8667bb322..04afd28161 100644 --- a/packages/tools/kadena-cli/src/constants/prompts.ts +++ b/packages/tools/kadena-cli/src/constants/prompts.ts @@ -1,11 +1,12 @@ import { input, select } from '@inquirer/prompts'; +import { execSync } from 'child_process'; +import { program } from 'commander'; +import path from 'path'; +import { createNetworksCommand } from '../networks/createNetworksCommand.js'; import { ICustomNetworksChoice } from '../networks/networksHelpers.js'; +import { ensureFileExists } from '../utils/filesystem.js'; import { getExistingNetworks, getVersion } from '../utils/helpers.js'; -import path from 'path'; import { defaultNetworksPath } from './networks.js'; -import { ensureFileExists } from '../utils/filesystem.js'; -import { program } from 'commander'; -import { createNetworksCommand } from '../networks/createNetworksCommand.js'; export const accountPrompt = async () => await input({ message: 'Enter the Kadena k:account' }); @@ -33,20 +34,30 @@ export const networkPrompt = async (): Promise => { // At this point there is either no network defined yet, // or the user chose to define a new network. // Create and select new network. + + // TODO call programmatically + // execSync(`ts-node-esm -T src/index.ts networks create`, { stdio: 'inherit' }) + // program.parse(['networks', 'create'], { from: 'user' }); + await program.parseAsync(['', '', 'networks', 'create']); + + return await networkPrompt(); + // await createNetworksCommand(program, getVersion()); // program.parse(['', '', 'networks create']); - const networks = program.commands.find(command => command.name() === 'networks'); - const create = networks?.commands.find(command => command.name() === 'create'); - // console.log(create); - await create?.parseAsync(['', '', 'networks create']); - await networkPrompt() + // const networks = program.commands.find(command => command.name() === 'networks'); + // const create = networks?.commands.find(command => command.name() === 'create'); + // // console.log(create); + // await create?.parseAsync(['', '', 'networks create']); + // await networkPrompt() - return ''; // network?.network || ''; + // return ''; // network?.network || ''; }; export const networkNamePrompt = async (): Promise => { - const networkName = await input({ message: 'Enter a network name (e.g. "mainnet")' }); + const networkName = await input({ + message: 'Enter a network name (e.g. "mainnet")', + }); const filePath = path.join(defaultNetworksPath, `${networkName}.yaml`); if (ensureFileExists(filePath)) { @@ -57,7 +68,7 @@ export const networkNamePrompt = async (): Promise => { } return networkName; -} +}; export const networkOverwritePrompt = async () => await select({ @@ -72,7 +83,12 @@ export const networkIdPrompt = async () => await input({ message: `Enter a network id (e.g. "mainnet01")` }); export const networkHostPrompt = async () => - await input({ message: 'Enter Kadena network host (e.g. "https://api.chainweb.com")' }); + await input({ + message: 'Enter Kadena network host (e.g. "https://api.chainweb.com")', + }); export const networkExplorerUrlPrompt = async () => - await input({ message: 'Enter Kadena network explorer URL (e.g. "https://explorer.chainweb.com/mainnet/tx/")' }); + await input({ + message: + 'Enter Kadena network explorer URL (e.g. "https://explorer.chainweb.com/mainnet/tx/")', + }); diff --git a/packages/tools/kadena-cli/src/networks/createNetworksCommand.ts b/packages/tools/kadena-cli/src/networks/createNetworksCommand.ts index bd2377e0c5..57cd8309d2 100644 --- a/packages/tools/kadena-cli/src/networks/createNetworksCommand.ts +++ b/packages/tools/kadena-cli/src/networks/createNetworksCommand.ts @@ -15,7 +15,7 @@ import { networkOverwritePrompt } from '../constants/prompts.js'; export const createNetworksCommand = createCommand( 'create', 'Create network', - [globalOptions.network(), globalOptions.networkId(), globalOptions.networkHost(), globalOptions.networkExplorerUrl()], + [globalOptions.networkName(), globalOptions.networkId(), globalOptions.networkHost(), globalOptions.networkExplorerUrl()], async (config) => { debug('network-create:action')({config}); diff --git a/packages/tools/kadena-cli/src/utils/createCommand.ts b/packages/tools/kadena-cli/src/utils/createCommand.ts index ff17368a2e..05e3c45c75 100644 --- a/packages/tools/kadena-cli/src/utils/createCommand.ts +++ b/packages/tools/kadena-cli/src/utils/createCommand.ts @@ -75,7 +75,7 @@ export function createCommand< const config = { ...newArgs }; for (let option of options) { if ('expand' in option) { - config[`${option.key}Config`] = await option.expand(args[option.key]); + config[`${option.key}Config`] = await option.expand(newArgs[option.key]); } } diff --git a/packages/tools/kadena-cli/src/utils/globalOptions.ts b/packages/tools/kadena-cli/src/utils/globalOptions.ts index f01be71cae..d5b31132dd 100644 --- a/packages/tools/kadena-cli/src/utils/globalOptions.ts +++ b/packages/tools/kadena-cli/src/utils/globalOptions.ts @@ -60,10 +60,10 @@ export const globalOptions = { '-n, --network ', 'Kadena network (e.g. "mainnet")', ), - expand: async (networkName: string) => { + expand: async (network: string) => { await ensureNetworksConfiguration(); try { - return loadNetworkConfig(networkName).network; + return loadNetworkConfig(network).network; } catch (e) { // await runNetworksCreate(); } From 07ad0fec107b1e6ecba0714ae3c95cae60deab6d Mon Sep 17 00:00:00 2001 From: Jesse van Muijden Date: Fri, 10 Nov 2023 10:22:16 -0400 Subject: [PATCH 09/13] wip(kadena-cli): refactor networks commands --- .../kadena-cli/src/account/fundQuestions.ts | 2 +- .../kadena-cli/src/config/configHelpers.ts | 139 ------------------ .../kadena-cli/src/config/configQuestions.ts | 93 ------------ .../tools/kadena-cli/src/constants/config.ts | 12 -- .../kadena-cli/src/constants/networks.ts | 4 +- .../tools/kadena-cli/src/constants/prompts.ts | 51 ++++--- .../src/networks/createNetworksCommand.ts | 6 +- .../src/networks/deleteNetworksCommand.ts | 26 ++++ .../tools/kadena-cli/src/networks/index.ts | 19 +-- .../src/networks/listNetworksCommand.ts | 16 +- .../src/networks/manageNetworksCommand.ts | 86 +++-------- .../src/networks/networksCreateQuestions.ts | 128 ---------------- .../src/networks/networksHelpers.ts | 66 +++++---- .../kadena-cli/src/utils/createCommand.ts | 28 +++- .../tools/kadena-cli/src/utils/filesystem.ts | 12 +- .../kadena-cli/src/utils/globalOptions.ts | 31 ++-- .../tools/kadena-cli/src/utils/helpers.ts | 22 --- .../kadena-cli/src/utils/typeUtilities.ts | 24 +++ 18 files changed, 222 insertions(+), 543 deletions(-) delete mode 100644 packages/tools/kadena-cli/src/config/configHelpers.ts delete mode 100644 packages/tools/kadena-cli/src/config/configQuestions.ts create mode 100644 packages/tools/kadena-cli/src/networks/deleteNetworksCommand.ts delete mode 100644 packages/tools/kadena-cli/src/networks/networksCreateQuestions.ts create mode 100644 packages/tools/kadena-cli/src/utils/typeUtilities.ts diff --git a/packages/tools/kadena-cli/src/account/fundQuestions.ts b/packages/tools/kadena-cli/src/account/fundQuestions.ts index 36b0dac740..931a5b0be4 100644 --- a/packages/tools/kadena-cli/src/account/fundQuestions.ts +++ b/packages/tools/kadena-cli/src/account/fundQuestions.ts @@ -27,7 +27,7 @@ export type TFundQuestions = z.infer; export const fundQuestions: IQuestion[] = [ { key: 'receiver', - prompt: async (config, prevAnswers, args) => { + prompt: async (prevAnswers, args) => { const answer = await select({ message: 'Which account to use?', choices: [{ value: undefined, name: `don't select from list` }], diff --git a/packages/tools/kadena-cli/src/config/configHelpers.ts b/packages/tools/kadena-cli/src/config/configHelpers.ts deleted file mode 100644 index 16c352e5e0..0000000000 --- a/packages/tools/kadena-cli/src/config/configHelpers.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { - configDefaults, - projectPrefix, - projectRootPath, -} from '../constants/config.js'; -import { defaultNetworksPath } from '../constants/networks.js'; -import type { TNetworksCreateOptions } from '../networks/networksCreateQuestions.js'; -import { PathExists, writeFile } from '../utils/filesystem.js'; -import { mergeConfigs, sanitizeFilename } from '../utils/helpers.js'; - -import type { TConfigOptions } from './configQuestions.js'; - -import chalk from 'chalk'; -import type { WriteFileOptions } from 'fs'; -import { readFileSync } from 'fs'; -import yaml from 'js-yaml'; -import path from 'path'; - -/** - * Writes config to a file. - * - * @param {TConfigOptions} options - The set of configuration options. - * @param {string} options.projectName - The name of the project. - * @param {string} options.network - The network (e.g., 'mainnet', 'testnet') or custom network. - * @param {number} options.chainId - The ID representing the chain. - * @returns {void} - No return value; the function writes directly to a file. - */ -export function writeProjectConfig(options: TConfigOptions): void { - const { projectName } = options; - const projectFilePath = path.join( - projectRootPath, - `/${projectPrefix}${sanitizeFilename(projectName).toLowerCase()}.yaml`, - ); - - const existingConfig: TConfigOptions = (PathExists( - projectFilePath, - ) as boolean) - ? (yaml.load(readFileSync(projectFilePath, 'utf8')) as TConfigOptions) - : { ...configDefaults }; - - const projectConfig = mergeConfigs(existingConfig, options); - - writeFile( - projectFilePath, - yaml.dump(projectConfig), - 'utf8' as WriteFileOptions, - ); -} - -/** - * Displays the general configuration in a formatted manner. - * - * @param {TConfigOptions} config - The general configuration to display. - */ -export function displayGeneralConfig(config: TConfigOptions): void { - const log = console.log; - const formatLength = 80; // Maximum width for the display - - const displaySeparator = (): void => { - log(chalk.green('-'.padEnd(formatLength, '-'))); - }; - - const formatConfig = (key: string, value?: string | number): string => { - const valueDisplay = - value !== undefined && value.toString().trim() !== '' - ? chalk.green(value.toString()) - : chalk.red('Not Set'); - const keyValue = `${key}: ${valueDisplay}`; - const remainingWidth = - formatLength - keyValue.length > 0 ? formatLength - keyValue.length : 0; - return ` ${keyValue}${' '.repeat(remainingWidth)} `; - }; - - displaySeparator(); - log(formatConfig('Project Name', config.projectName)); - log(formatConfig('Network', config.network)); - log(formatConfig('Chain-ID', config.chainId)); - displaySeparator(); -} - -/** - * Loads and returns the current configuration from the default root path. - * - * @returns {IDefaultConfigOptions} - The parsed configuration object. - */ -export function getProjectConfig(projectName: string): TConfigOptions { - const projectConfigPath = path.join(projectRootPath, `${projectName}.yaml`); - - try { - return yaml.load(readFileSync(projectConfigPath, 'utf8')) as TConfigOptions; - } catch (e) { - throw new Error(`Project config file '${projectName}' not found`); - } -} - -/** - * Retrieves the current network configuration for the given project name. - * - * @function - * @export - * @param {string} projectName - The name of the project for which the network configuration is to be retrieved. - * - * @returns {TConfigOptions} The network configuration options for the provided project name. - * - * @throws Will throw an error if the network configuration file is not found or any error occurs during loading the network configuration. - - */ -export function getCurrentNetworkConfigForProject( - projectName: string, -): TConfigOptions { - const projectConfig = getProjectConfig(projectName); - const networkConfigPath = path.join( - defaultNetworksPath, - `/${projectConfig.network}.yaml`, - ); - - try { - return yaml.load(readFileSync(networkConfigPath, 'utf8')) as TConfigOptions; - } catch (e) { - console.log(chalk.red(`error loading network config: ${e}`)); - throw Error('Network config file not found'); - } -} - -type TCombinedConfigOptions = TConfigOptions & TNetworksCreateOptions; - -function combineConfigs( - projectConfig: TConfigOptions, - networkConfig: TNetworksCreateOptions, -): TCombinedConfigOptions { - return { ...projectConfig, ...networkConfig }; -} - -export function getCombinedConfig(projectName: string): TCombinedConfigOptions { - const projectConfig = getProjectConfig(projectName); - const networkConfig = getCurrentNetworkConfigForProject(projectName); - - return combineConfigs(projectConfig, networkConfig); -} diff --git a/packages/tools/kadena-cli/src/config/configQuestions.ts b/packages/tools/kadena-cli/src/config/configQuestions.ts deleted file mode 100644 index 9a0a292820..0000000000 --- a/packages/tools/kadena-cli/src/config/configQuestions.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { IQuestion } from '../utils/helpers.js'; -import { - capitalizeFirstLetter, - getExistingNetworks, - isAlphanumeric, - isNumeric, -} from '../utils/helpers.js'; - -import { input, select } from '@inquirer/prompts'; -import { z } from 'zod'; - -// eslint-disable-next-line @rushstack/typedef-var -export const ConfigOptions = z.object({ - projectName: z.string(), - network: z.string(), - chainId: z - .number({ - /* eslint-disable-next-line @typescript-eslint/naming-convention */ - invalid_type_error: 'Error: -c, --chain must be a number', - }) - .min(0) - .max(19), -}); - -export type TConfigOptions = z.infer; - -interface ICustomChoice { - value: string; - name?: string; - description?: string; - disabled?: boolean | string; -} - -export async function askForNetwork(): Promise { - const existingNetworks: ICustomChoice[] = await getExistingNetworks(); - existingNetworks - .filter((v, i, a) => a.findIndex((v2) => v2.name === v.name) === i) - .map((network) => { - return { - value: network.value, - name: capitalizeFirstLetter(network.value), - }; - }); - - const networkChoice = await select({ - message: 'Select an existing network', - choices: existingNetworks, - }); - - return networkChoice.toLowerCase(); -} - -export const configQuestions: IQuestion[] = [ - { - key: 'projectName', - prompt: async () => - await input({ - validate: function (input) { - if (input === '') { - return 'Network name cannot be empty! Please enter something.'; - } - if (!isAlphanumeric(input)) { - return 'Project name must be alphanumeric! Please enter a valid projectname.'; - } - return true; - }, - message: 'Enter your project name', - }), - }, - { - key: 'network', - prompt: async () => await askForNetwork(), - }, - { - key: 'chainId', - prompt: async () => { - const chainID = await input({ - default: '0', - validate: function (input) { - if (input === '') { - return 'ChainId cannot be empty! Please enter a number.'; - } - if (!isNumeric(input)) { - return 'ChainId must be numeric! Please enter a valid chain.'; - } - return true; - }, - message: 'Enter chainId (0-19)', - }); - return parseInt(chainID, 10); - }, - }, -]; diff --git a/packages/tools/kadena-cli/src/constants/config.ts b/packages/tools/kadena-cli/src/constants/config.ts index 7d8b5785d6..6e6be11e2c 100644 --- a/packages/tools/kadena-cli/src/constants/config.ts +++ b/packages/tools/kadena-cli/src/constants/config.ts @@ -1,15 +1,3 @@ -import type { TConfigOptions } from '../config/configQuestions.js'; - -/** - * @const configDefaults - * Provides the default configurations for current project. - */ -export const configDefaults: TConfigOptions = { - projectName: 'a-kadena-project', - network: 'mainnet', - chainId: 1, -}; - export const workPath: string = `${process.cwd()}/.kadena`; export const projectRootPath: string = `${process.cwd()}`; diff --git a/packages/tools/kadena-cli/src/constants/networks.ts b/packages/tools/kadena-cli/src/constants/networks.ts index 8d35817ab3..14936c8ff2 100644 --- a/packages/tools/kadena-cli/src/constants/networks.ts +++ b/packages/tools/kadena-cli/src/constants/networks.ts @@ -1,7 +1,7 @@ -import type { TNetworksCreateOptions } from '../networks/networksCreateQuestions.js'; +import { INetworksCreateOptions } from "../networks/networksHelpers.js"; export interface IDefaultNetworkOptions { - [key: string]: TNetworksCreateOptions; + [key: string]: INetworksCreateOptions; } /** diff --git a/packages/tools/kadena-cli/src/constants/prompts.ts b/packages/tools/kadena-cli/src/constants/prompts.ts index 04afd28161..0e4f8bbffa 100644 --- a/packages/tools/kadena-cli/src/constants/prompts.ts +++ b/packages/tools/kadena-cli/src/constants/prompts.ts @@ -1,18 +1,17 @@ import { input, select } from '@inquirer/prompts'; -import { execSync } from 'child_process'; import { program } from 'commander'; import path from 'path'; -import { createNetworksCommand } from '../networks/createNetworksCommand.js'; import { ICustomNetworksChoice } from '../networks/networksHelpers.js'; import { ensureFileExists } from '../utils/filesystem.js'; import { getExistingNetworks, getVersion } from '../utils/helpers.js'; import { defaultNetworksPath } from './networks.js'; +import { ChainId } from '@kadena/types'; export const accountPrompt = async () => await input({ message: 'Enter the Kadena k:account' }); -export const chainIdPrompt = async () => - await input({ message: 'Enter chainId (0-19)' }); +export const chainIdPrompt = async (): Promise => + await input({ message: 'Enter chainId (0-19)' }) as ChainId; export const networkPrompt = async (): Promise => { const existingNetworks: ICustomNetworksChoice[] = await getExistingNetworks(); @@ -34,24 +33,26 @@ export const networkPrompt = async (): Promise => { // At this point there is either no network defined yet, // or the user chose to define a new network. // Create and select new network. - - // TODO call programmatically - // execSync(`ts-node-esm -T src/index.ts networks create`, { stdio: 'inherit' }) - // program.parse(['networks', 'create'], { from: 'user' }); await program.parseAsync(['', '', 'networks', 'create']); return await networkPrompt(); +}; - // await createNetworksCommand(program, getVersion()); - // program.parse(['', '', 'networks create']); +export const networkSelectPrompt = async (): Promise => { + const existingNetworks: ICustomNetworksChoice[] = await getExistingNetworks(); - // const networks = program.commands.find(command => command.name() === 'networks'); - // const create = networks?.commands.find(command => command.name() === 'create'); - // // console.log(create); - // await create?.parseAsync(['', '', 'networks create']); - // await networkPrompt() + if (existingNetworks.length > 0) { + return await select({ + message: 'Select a network', + choices: existingNetworks, + }); + } - // return ''; // network?.network || ''; + // At this point there is either no network defined yet. + // Create and select new network. + await program.parseAsync(['', '', 'networks', 'create']); + + return await networkSelectPrompt(); }; export const networkNamePrompt = async (): Promise => { @@ -70,9 +71,23 @@ export const networkNamePrompt = async (): Promise => { return networkName; }; -export const networkOverwritePrompt = async () => +export const networkOverwritePrompt = async (network?: string) => { + const message = network + ? `Are you sure you want to save this configuration for network "${network}"?` + : 'A network configuration with this name already exists. Do you want to update it?' + + return await select({ + message, + choices: [ + { value: 'yes', name: 'Yes' }, + { value: 'no', name: 'No' }, + ], + }); +} + +export const networkDeletePrompt = async (network: string) => await select({ - message: `A network configuration with this name already exists. Do you want to update it?`, + message: `Are you sure you want to delete the configuration for network "${network}"?`, choices: [ { value: 'yes', name: 'Yes' }, { value: 'no', name: 'No' }, diff --git a/packages/tools/kadena-cli/src/networks/createNetworksCommand.ts b/packages/tools/kadena-cli/src/networks/createNetworksCommand.ts index 57cd8309d2..8f7b3de2e9 100644 --- a/packages/tools/kadena-cli/src/networks/createNetworksCommand.ts +++ b/packages/tools/kadena-cli/src/networks/createNetworksCommand.ts @@ -1,7 +1,5 @@ import { defaultNetworksPath } from '../constants/networks.js'; import { ensureFileExists } from '../utils/filesystem.js'; - -import type { TNetworksCreateOptions } from './networksCreateQuestions.js'; import { writeNetworks } from './networksHelpers.js'; import debug from 'debug'; @@ -22,14 +20,14 @@ export const createNetworksCommand = createCommand( const filePath = path.join(defaultNetworksPath, `${config.network}.yaml`); if (ensureFileExists(filePath)) { - const overwrite = await networkOverwritePrompt(); + const overwrite = await networkOverwritePrompt(config.network); if (overwrite === 'no') { console.log(chalk.yellow(`\nThe existing network configuration "${config.network}" will not be updated.\n`)); return; } } - writeNetworks(config as TNetworksCreateOptions); + writeNetworks(config); console.log(chalk.green(`\nThe network configuration "${config.network}" has been saved.\n`)); }, diff --git a/packages/tools/kadena-cli/src/networks/deleteNetworksCommand.ts b/packages/tools/kadena-cli/src/networks/deleteNetworksCommand.ts new file mode 100644 index 0000000000..2e5e90e6e3 --- /dev/null +++ b/packages/tools/kadena-cli/src/networks/deleteNetworksCommand.ts @@ -0,0 +1,26 @@ +import debug from 'debug'; +import { networkDeletePrompt, networkOverwritePrompt } from '../constants/prompts.js'; +import { globalOptions } from '../utils/globalOptions.js'; +import { removeNetwork } from './networksHelpers.js'; + +import chalk from 'chalk'; +import { createCommand } from '../utils/createCommand.js'; + +export const deleteNetworksCommand = createCommand( + 'delete', + 'Delete network', + [globalOptions.networkSelect()], + async (config) => { + debug('network-manage:action')({config}); + + const overwrite = await networkDeletePrompt(config.network); + if (overwrite === 'no') { + console.log(chalk.yellow(`\nThe network configuration "${config.network}" will not be deleted.\n`)); + return; + } + + removeNetwork(config); + + console.log(chalk.green(`\nThe network configuration "${config.network}" has been deleted.\n`)); + }, +); diff --git a/packages/tools/kadena-cli/src/networks/index.ts b/packages/tools/kadena-cli/src/networks/index.ts index 59f084750b..83c2a96937 100644 --- a/packages/tools/kadena-cli/src/networks/index.ts +++ b/packages/tools/kadena-cli/src/networks/index.ts @@ -1,9 +1,7 @@ -import { createSimpleSubCommand } from '../utils/helpers.js'; - import { createNetworksCommand } from './createNetworksCommand.js'; -import type { IListNetworksArgs } from './listNetworksCommand.js'; -import { listNetworksAction } from './listNetworksCommand.js'; -import { manageNetworks } from './manageNetworksCommand.js'; +import { deleteNetworksCommand } from './deleteNetworksCommand.js'; +import { listNetworksCommand } from './listNetworksCommand.js'; +import { manageNetworksCommand } from './manageNetworksCommand.js'; import type { Command } from 'commander'; @@ -17,13 +15,8 @@ export function networksCommandFactory( .command(SUBCOMMAND_ROOT) .description(`Tool to create and manage networks`); - // Attach list subcommands to the networksProgram - // createSimpleSubCommand( - // 'list', - // 'List all available networks', - // listNetworksAction, - // )(networksProgram); - - manageNetworks(networksProgram, version); + listNetworksCommand(networksProgram, version); + manageNetworksCommand(networksProgram, version); createNetworksCommand(networksProgram, version); + deleteNetworksCommand(networksProgram, version); } diff --git a/packages/tools/kadena-cli/src/networks/listNetworksCommand.ts b/packages/tools/kadena-cli/src/networks/listNetworksCommand.ts index 47781c9f9d..4e2137a009 100644 --- a/packages/tools/kadena-cli/src/networks/listNetworksCommand.ts +++ b/packages/tools/kadena-cli/src/networks/listNetworksCommand.ts @@ -1,7 +1,15 @@ import { displayNetworksConfig } from './networksHelpers.js'; -export interface IListNetworksArgs {} +import debug from 'debug'; +import { createCommand } from '../utils/createCommand.js'; -export const listNetworksAction = (args: IListNetworksArgs): void => { - displayNetworksConfig(); -}; +export const listNetworksCommand = createCommand( + 'list', + 'List all available networks', + [], + async (config) => { + debug('network-list:action')({config}); + + displayNetworksConfig(); + }, +); diff --git a/packages/tools/kadena-cli/src/networks/manageNetworksCommand.ts b/packages/tools/kadena-cli/src/networks/manageNetworksCommand.ts index 641f1b7f5d..2a495c1a02 100644 --- a/packages/tools/kadena-cli/src/networks/manageNetworksCommand.ts +++ b/packages/tools/kadena-cli/src/networks/manageNetworksCommand.ts @@ -1,66 +1,26 @@ -import { defaultNetworksPath } from '../constants/networks.js'; -import { - clearCLI, - collectResponses, - getExistingNetworks, -} from '../utils/helpers.js'; -import { processZodErrors } from '../utils/processZodErrors.js'; - -import type { TNetworksCreateOptions } from './networksCreateQuestions.js'; -import { - networkManageQuestions, - NetworksCreateOptions, -} from './networksCreateQuestions.js'; -import type { ICustomNetworksChoice } from './networksHelpers.js'; +import debug from 'debug'; +import { networkOverwritePrompt } from '../constants/prompts.js'; +import { globalOptions } from '../utils/globalOptions.js'; import { writeNetworks } from './networksHelpers.js'; -import { select } from '@inquirer/prompts'; import chalk from 'chalk'; -import type { Command } from 'commander'; -import { readFileSync } from 'fs'; -import yaml from 'js-yaml'; -import path from 'path'; - -export interface IManageNetworksOptions {} - -export async function manageNetworks(program: Command, version: string): Promise { - program - .command('manage') - .description('Manage network(s)') - .action(async (args: IManageNetworksOptions) => { - try { - const existingNetworks: ICustomNetworksChoice[] = await getExistingNetworks(); - - if (existingNetworks.length === 0) { - console.log(chalk.red('No existing networks found.')); - return; - } - - const selectedNetwork = await select({ - message: 'Select the network you want to manage:', - choices: existingNetworks, - }); - const networkFilePath = path.join( - defaultNetworksPath, - `${selectedNetwork}.yaml`, - ); - const existingConfig: TNetworksCreateOptions = yaml.load( - readFileSync(networkFilePath, 'utf8'), - ) as TNetworksCreateOptions; - - const responses = await collectResponses( - { network: selectedNetwork }, - networkManageQuestions, - ); - const networkConfig = { ...existingConfig, ...responses }; - - NetworksCreateOptions.parse(networkConfig); - - writeNetworks(networkConfig); - clearCLI(); - console.log(chalk.green('Network configurations updated.')); - } catch (e) { - processZodErrors(program, e, args); - } - }); -} +import { createCommand } from '../utils/createCommand.js'; + +export const manageNetworksCommand = createCommand( + 'manage', + 'Manage networks', + [globalOptions.networkSelect(), globalOptions.networkId(), globalOptions.networkHost(), globalOptions.networkExplorerUrl()], + async (config) => { + debug('network-manage:action')({config}); + + const overwrite = await networkOverwritePrompt(config.network); + if (overwrite === 'no') { + console.log(chalk.yellow(`\nThe network configuration "${config.network}" will not be updated.\n`)); + return; + } + + writeNetworks(config); + + console.log(chalk.green(`\nThe network configuration "${config.network}" has been updated.\n`)); + }, +); diff --git a/packages/tools/kadena-cli/src/networks/networksCreateQuestions.ts b/packages/tools/kadena-cli/src/networks/networksCreateQuestions.ts deleted file mode 100644 index 55f02359c7..0000000000 --- a/packages/tools/kadena-cli/src/networks/networksCreateQuestions.ts +++ /dev/null @@ -1,128 +0,0 @@ -// import { standardNetworks } from '../constants/networks.js'; -import type { IQuestion } from '../utils/helpers.js'; -import { - capitalizeFirstLetter, - getExistingNetworks, - isAlphabetic, - isAlphanumeric, -} from '../utils/helpers.js'; - -import { input, select } from '@inquirer/prompts'; -import { z } from 'zod'; - -// eslint-disable-next-line @rushstack/typedef-var -export const NetworksCreateOptions = z.object({ - network: z.string(), - networkId: z.string().optional(), - networkHost: z.string().optional(), - networkExplorerUrl: z.string().optional(), -}); - -export type TNetworksCreateOptions = z.infer; - -interface INetworkManageQuestionsQuestions - extends Pick, 'key' | 'prompt'> {} - -interface ICustomChoice { - value: string; - name?: string; - description?: string; - disabled?: boolean | string; -} - -export async function askForNetwork(): Promise { - const existingNetworks: ICustomChoice[] = await getExistingNetworks(); - - // const prefixedStandardNetworks: ICustomChoice[] = standardNetworks.map( - // (network) => { - // return { - // value: network, - // name: network, - // } as ICustomChoice; - // }, - // ); - - const allNetworkChoices: ICustomChoice[] = [ - ...existingNetworks, - // ...prefixedStandardNetworks, - ] - .filter((v, i, a) => a.findIndex((v2) => v2.name === v.name) === i) - .map((network) => { - return { - value: network.value, - name: capitalizeFirstLetter(network.value), - }; - }); - - const networkChoice = await select({ - message: 'Select an (default) existing network or create a new one:', - choices: [ - ...allNetworkChoices, - { value: 'CREATE_NEW', name: 'Create a New Network' } as ICustomChoice, - ], - }); - - if (networkChoice === 'CREATE_NEW') { - const newNetworkName = await input({ - default: 'testnet', - validate: function (input) { - if (input === '') { - return 'Network name cannot be empty! Please enter something.'; - } - // if (!isAlphabetic(input)) { - // return 'Network name must be alphabetic! Please enter a valid name.'; - // } - return true; - }, - message: 'Enter the name for your new network:', - }); - return newNetworkName.toLowerCase(); - } - - return networkChoice.toLowerCase(); -} - -export const networksCreateQuestions: IQuestion[] = [ - { - key: 'network', - prompt: async () => await askForNetwork(), - }, - { - key: 'networkId', - prompt: async (config, previousAnswers, args) => { - const network = - previousAnswers.network !== undefined - ? previousAnswers.network - : args.network; - return await input({ - default: `${network}01`, - message: `Enter ${network} network Id (e.g. "${network}01")`, - validate: function (input) { - // if (!isAlphanumeric(input)) { - // return 'NetworkId must be alphanumeric! Please enter a valid name.'; - // } - return true; - }, - }); - }, - }, - { - key: 'networkHost', - prompt: async () => - await input({ - message: 'Enter Kadena network host (e.g. "https://api.chainweb.com")', - }), - }, - { - key: 'networkExplorerUrl', - prompt: async () => - await input({ - message: - 'Enter Kadena network explorer URL (e.g. "https://explorer.chainweb.com/mainnet/tx/")', - }), - }, -]; - -export const networkManageQuestions: INetworkManageQuestionsQuestions[] = [ - ...networksCreateQuestions.filter((question) => question.key !== 'network'), -]; diff --git a/packages/tools/kadena-cli/src/networks/networksHelpers.ts b/packages/tools/kadena-cli/src/networks/networksHelpers.ts index 3056cd64f4..c211b6ddf3 100644 --- a/packages/tools/kadena-cli/src/networks/networksHelpers.ts +++ b/packages/tools/kadena-cli/src/networks/networksHelpers.ts @@ -1,13 +1,12 @@ import { defaultNetworksPath, networkDefaults } from '../constants/networks.js'; -import { PathExists, writeFile } from '../utils/filesystem.js'; +import { PathExists, removeFile, writeFile } from '../utils/filesystem.js'; import { + GlobalOptions, getExistingNetworks, mergeConfigs, sanitizeFilename, } from '../utils/helpers.js'; -import type { TNetworksCreateOptions } from './networksCreateQuestions.js'; - import chalk from 'chalk'; import type { WriteFileOptions } from 'fs'; import { existsSync, readFileSync } from 'fs'; @@ -21,17 +20,24 @@ export interface ICustomNetworksChoice { disabled?: boolean | string; } +export interface INetworksCreateOptions { + network: string; + networkId: string; + networkHost: string; + networkExplorerUrl: string; +} + /** * Writes the given network setting to the networks folder * - * @param {TNetworksCreateOptions} options - The set of configuration options. + * @param {INetworksCreateOptions} options - The set of configuration options. * @param {string} options.network - The network (e.g., 'mainnet', 'testnet') or custom network. * @param {number} options.networkId - The ID representing the network. * @param {string} options.networkHost - The hostname for the network. * @param {string} options.networkExplorerUrl - The URL for the network explorer. * @returns {void} - No return value; the function writes directly to a file. */ -export function writeNetworks(options: TNetworksCreateOptions): void { +export function writeNetworks(options: INetworksCreateOptions): void { const { network } = options; const sanitizedNetwork = sanitizeFilename(network).toLowerCase(); const networkFilePath = path.join( @@ -39,12 +45,12 @@ export function writeNetworks(options: TNetworksCreateOptions): void { `${sanitizedNetwork}.yaml`, ); - let existingConfig: TNetworksCreateOptions; + let existingConfig: INetworksCreateOptions; if (PathExists(networkFilePath)) { existingConfig = yaml.load( readFileSync(networkFilePath, 'utf8'), - ) as TNetworksCreateOptions; + ) as INetworksCreateOptions; } else { // Explicitly check if network key exists in networkDefaults and is not undefined existingConfig = @@ -62,13 +68,30 @@ export function writeNetworks(options: TNetworksCreateOptions): void { ); } +/** + * Removes the given network setting from the networks folder + * + * @param {Pick} options - The set of configuration options. + * @param {string} options.network - The network (e.g., 'mainnet', 'testnet') or custom network. + */ +export function removeNetwork(options: Pick): void { + const { network } = options; + const sanitizedNetwork = sanitizeFilename(network).toLowerCase(); + const networkFilePath = path.join( + defaultNetworksPath, + `${sanitizedNetwork}.yaml`, + ); + + removeFile(networkFilePath); +} + /** * Displays the network configuration in a formatted manner. * - * @param {TNetworksCreateOptions} networkConfig - The network configuration to display. + * @param {INetworksCreateOptions} networkConfig - The network configuration to display. */ export function displayNetworkConfig( - networkConfig: TNetworksCreateOptions, + networkConfig: INetworksCreateOptions, ): void { const log = console.log; const formatLength = 80; // Maximum width for the display @@ -96,7 +119,7 @@ export function displayNetworkConfig( displaySeparator(); } -export function loadNetworkConfig(network: string): TNetworksCreateOptions | never { +export function loadNetworkConfig(network: string): INetworksCreateOptions | never { const networkFilePath = path.join(defaultNetworksPath, `${network}.yaml`); if (! existsSync(networkFilePath)) { @@ -105,7 +128,7 @@ export function loadNetworkConfig(network: string): TNetworksCreateOptions | nev return (yaml.load( readFileSync(networkFilePath, 'utf8'), - ) as TNetworksCreateOptions); + ) as INetworksCreateOptions); } export async function displayNetworksConfig(): Promise { @@ -132,8 +155,8 @@ export async function displayNetworksConfig(): Promise { return ` ${keyValue}${' '.repeat(remainingWidth)} `; }; + const existingNetworks: ICustomNetworksChoice[] = await getExistingNetworks(); - const standardNetworks: string[] = ['mainnet', 'testnet']; existingNetworks.forEach(({ value }) => { const networkFilePath = path.join(defaultNetworksPath, `${value}.yaml`); @@ -141,7 +164,7 @@ export async function displayNetworksConfig(): Promise { const networkConfig = fileExists ? (yaml.load( readFileSync(networkFilePath, 'utf8'), - ) as TNetworksCreateOptions) + ) as INetworksCreateOptions) : networkDefaults[value]; displaySeparator(); @@ -157,22 +180,5 @@ export async function displayNetworksConfig(): Promise { ); }); - standardNetworks.forEach((network) => { - if (!existingNetworks.some(({ value }) => value === network)) { - const networkConfig = networkDefaults[network]; - displaySeparator(); - log(formatConfig('Network', network, true)); // as it is a standard network and does not exist in existingNetworks - log(formatConfig('Network ID', networkConfig.networkId, true)); - log(formatConfig('Network Host', networkConfig.networkHost, true)); - log( - formatConfig( - 'Network Explorer URL', - networkConfig.networkExplorerUrl, - true, - ), - ); - } - }); - displaySeparator(); } diff --git a/packages/tools/kadena-cli/src/utils/createCommand.ts b/packages/tools/kadena-cli/src/utils/createCommand.ts index 05e3c45c75..2f2fdacec1 100644 --- a/packages/tools/kadena-cli/src/utils/createCommand.ts +++ b/packages/tools/kadena-cli/src/utils/createCommand.ts @@ -3,6 +3,7 @@ import type { Command } from 'commander'; import { z } from 'zod'; import type { GlobalOptions } from './helpers.js'; import { collectResponses } from './helpers.js'; +import { Combine2, First, Prettify, Pure, Tail } from './typeUtilities.js'; const formatLength: 80 = 80; const formatConfig = (key: string, value?: string | number, prefix: string = ''): string => { @@ -27,14 +28,37 @@ const displayConfig = (config: Record, indenta } }); } + +type AsOption = T extends { + key: infer K; + prompt: (...arg: any[]) => infer R; +} + ? K extends string + ? { + [P in K]: Pure; + } & (T extends { expand: (...args: any[]) => infer Ex } + ? { + [P in `${K}Config`]: Pure; + } + : {}) + : never + : never; + +type Combine = Tuple extends [infer one] + ? AsOption + : Combine2< + AsOption>, + Tail extends any[] ? Combine> : {} + >; + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export function createCommand< T extends ReturnType[], >( name: string, description: string, - options: T, - action: (finalConfig: Record) => Promise, + options: [...T], + action: (finalConfig: Prettify>) => any, ) { return async (program: Command, version: string) => { const command = program.command(name).description(description); diff --git a/packages/tools/kadena-cli/src/utils/filesystem.ts b/packages/tools/kadena-cli/src/utils/filesystem.ts index fdc80337d2..ec1e2a81b1 100644 --- a/packages/tools/kadena-cli/src/utils/filesystem.ts +++ b/packages/tools/kadena-cli/src/utils/filesystem.ts @@ -1,5 +1,5 @@ import type { PathLike, WriteFileOptions } from 'fs'; -import { accessSync, existsSync, mkdirSync, writeFileSync } from 'fs'; +import { accessSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'; import path from 'path'; /** @@ -33,7 +33,6 @@ export function ensureFileExists(filePath: string): boolean { * @param {string | NodeJS.ArrayBufferView} data - The data to be written to the file. Can be a string or a buffer view. * @param {string | BaseEncodingOptions | undefined} options - Encoding options or a string specifying the encoding. Can be undefined. */ - export function writeFile( filePath: string, data: string | NodeJS.ArrayBufferView, @@ -46,6 +45,15 @@ export function writeFile( writeFileSync(filePath, data, options); } +/** + * Removes a file. + * + * @param {string} filePath - The path to the file. + */ +export function removeFile(filePath: string): void { + rmSync(filePath); +} + /** * Ensures that a directory exists, and if it doesn't, creates it. * diff --git a/packages/tools/kadena-cli/src/utils/globalOptions.ts b/packages/tools/kadena-cli/src/utils/globalOptions.ts index d5b31132dd..eacedc08e9 100644 --- a/packages/tools/kadena-cli/src/utils/globalOptions.ts +++ b/packages/tools/kadena-cli/src/utils/globalOptions.ts @@ -8,10 +8,12 @@ import { networkIdPrompt, networkNamePrompt, networkPrompt, + networkSelectPrompt, } from '../constants/prompts.js'; import { loadNetworkConfig } from '../networks/networksHelpers.js'; import { ensureNetworksConfiguration } from './helpers.js'; -// import { runNetworksCreate } from '../networks/createNetworksCommand.js'; +import { program } from 'commander'; +import chalk from 'chalk'; // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export const createOption = < @@ -32,7 +34,7 @@ export const createOption = < export const globalOptions = { account: createOption({ - key: 'account', + key: 'account' as const, prompt: accountPrompt, validation: z.string(), option: new Option( @@ -41,7 +43,7 @@ export const globalOptions = { ), }), chainId: createOption({ - key: 'chainId', + key: 'chainId' as const, prompt: chainIdPrompt, validation: z .string({ @@ -53,7 +55,7 @@ export const globalOptions = { option: new Option('-c, --chain-id '), }), network: createOption({ - key: 'network', + key: 'network' as const, prompt: networkPrompt, validation: z.string(), option: new Option( @@ -63,32 +65,41 @@ export const globalOptions = { expand: async (network: string) => { await ensureNetworksConfiguration(); try { - return loadNetworkConfig(network).network; + return loadNetworkConfig(network); } catch (e) { - // await runNetworksCreate(); + console.log(chalk.yellow(`\nNo configuration for network "${network}" found. Please configure the network.\n`)); + await program.parseAsync(['', '', 'networks', 'create']); + const networkName = await networkPrompt(); + return loadNetworkConfig(networkName); } }, }), + networkSelect: createOption({ + key: 'network' as const, + prompt: networkSelectPrompt, + validation: z.string(), + option: new Option('-n, --network ', 'Kadena network (e.g. "mainnet")'), + }), networkName: createOption({ - key: 'network', + key: 'network' as const, prompt: networkNamePrompt, validation: z.string(), option: new Option('-n, --network ', 'Kadena network (e.g. "mainnet")'), }), networkId: createOption({ - key: 'networkId', + key: 'networkId' as const, prompt: networkIdPrompt, validation: z.string(), option: new Option('-nid, --network-id ', 'Kadena network Id (e.g. "mainnet01")'), }), networkHost: createOption({ - key: 'networkHost', + key: 'networkHost' as const, prompt: networkHostPrompt, validation: z.string(), option: new Option('-h, --network-host ', 'Kadena network host (e.g. "https://api.chainweb.com")'), }), networkExplorerUrl: createOption({ - key: 'networkExplorerUrl', + key: 'networkExplorerUrl' as const, prompt: networkExplorerUrlPrompt, validation: z.string().optional(), option: new Option('-e, --network-explorer-url ', 'Kadena network explorer URL (e.g. "https://explorer.chainweb.com/mainnet/tx/")'), diff --git a/packages/tools/kadena-cli/src/utils/helpers.ts b/packages/tools/kadena-cli/src/utils/helpers.ts index 2e9055e41d..ba7dc88811 100644 --- a/packages/tools/kadena-cli/src/utils/helpers.ts +++ b/packages/tools/kadena-cli/src/utils/helpers.ts @@ -1,18 +1,10 @@ -import { getCombinedConfig } from '../config/configHelpers.js'; -import type { TConfigOptions } from '../config/configQuestions.js'; import { projectPrefix, projectRootPath } from '../constants/config.js'; import { defaultNetworksPath } from '../constants/networks.js'; - -import { select } from '@inquirer/prompts'; import chalk from 'chalk'; import clear from 'clear'; import { Command, Option } from 'commander'; import { existsSync, mkdirSync, readdirSync, readFileSync } from 'fs'; import path from 'path'; -// import { runNetworksCreate } from '../networks/createNetworksCommand.js'; -import { - ICustomNetworksChoice, -} from '../networks/networksHelpers.js'; import { globalOptions } from './globalOptions.js'; export interface ICustomChoice { @@ -77,14 +69,12 @@ export interface IQuestion { /** * The prompt function responsible for retrieving the answer for this question. * - * @param config - Current configuration options. * @param previousAnswers - Answers provided for previous questions. * @param args - Command line arguments. * * @returns A promise that resolves to the answer of the question. */ prompt: ( - config: Partial, previousAnswers: Partial, args: Partial, ) => Promise; @@ -119,7 +109,6 @@ export function* questionGenerator( export async function collectResponses( args: Partial, questions: IQuestion[], - config?: Partial, ): Promise { const responses: Partial = { ...args, @@ -129,9 +118,7 @@ export async function collectResponses( let result = generator.next(); while (result.done === false) { const question = result.value; - const currentConfig = config || {}; responses[question.key as keyof T] = await question.prompt( - currentConfig, responses, args, ); @@ -397,16 +384,7 @@ export async function processProject( projectName: string, keys?: string[], ): Promise> { - const combinedConfig = getCombinedConfig(projectName); - - if (!keys || keys.length === 0) return {}; - const filteredConfig: Record = {}; - for (const key of keys) { - if (key in combinedConfig) { - filteredConfig[key] = combinedConfig[key as keyof typeof combinedConfig]; - } - } return filteredConfig; } diff --git a/packages/tools/kadena-cli/src/utils/typeUtilities.ts b/packages/tools/kadena-cli/src/utils/typeUtilities.ts new file mode 100644 index 0000000000..ecd5ac2bfe --- /dev/null +++ b/packages/tools/kadena-cli/src/utils/typeUtilities.ts @@ -0,0 +1,24 @@ +export type First = T extends [infer One] + ? One + : T extends [infer HD, ...any[]] + ? HD + : never; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export type Tail = T extends [infer _] + ? [] + : // eslint-disable-next-line @typescript-eslint/no-unused-vars + T extends [infer _, ...infer TL] + ? TL + : never; + +export type Pure = T extends Promise ? R : T; + +export type Prettify = { + [K in keyof T]: T[K]; +} & {}; + +export type Combine2 = { + [K in keyof (A | B)]: A[K] | B[K]; +} & Omit & + Omit; From f0b87d49ffc7f903f16e2a46b026a80dc6baf007 Mon Sep 17 00:00:00 2001 From: Jesse van Muijden Date: Tue, 14 Nov 2023 05:43:44 -0400 Subject: [PATCH 10/13] feat(kadena-cli): create account command --- .../client-utils/etc/client-utils-coin.api.md | 25 ++ .../client-utils/src/coin/create-account.ts | 29 +- .../client-utils/src/coin/create-principle.ts | 30 ++ packages/libs/client-utils/src/coin/index.ts | 1 + .../kadena-cli/src/account/accountHelpers.ts | 115 ++++++++ .../src/account/createAccountCommand.ts | 96 +++++++ .../src/account/getBalanceCommand.ts | 4 +- .../tools/kadena-cli/src/account/index.ts | 7 +- packages/tools/kadena-cli/src/account/init.ts | 6 + .../kadena-cli/src/constants/accounts.ts | 13 + .../kadena-cli/src/constants/keypairs.ts | 9 + .../tools/kadena-cli/src/constants/keysets.ts | 10 + .../tools/kadena-cli/src/constants/prompts.ts | 258 +++++++++++++++++- packages/tools/kadena-cli/src/index.ts | 4 + .../src/keypair/createKeypairCommand.ts | 42 +++ .../src/keypair/deleteKeypairCommand.ts | 26 ++ .../tools/kadena-cli/src/keypair/index.ts | 15 + packages/tools/kadena-cli/src/keypair/init.ts | 4 + .../kadena-cli/src/keypair/keypairHelpers.ts | 110 ++++++++ .../src/keypair/listKeypairsCommand.ts | 14 + .../src/keyset/createKeysetCommand.ts | 34 +++ .../src/keyset/deleteKeysetCommand.ts | 26 ++ packages/tools/kadena-cli/src/keyset/index.ts | 15 + packages/tools/kadena-cli/src/keyset/init.ts | 4 + .../kadena-cli/src/keyset/keysetHelpers.ts | 112 ++++++++ .../src/keyset/listKeysetsCommand.ts | 14 + .../src/networks/deleteNetworksCommand.ts | 4 +- .../src/networks/networksHelpers.ts | 1 - .../kadena-cli/src/utils/createCommand.ts | 29 +- .../kadena-cli/src/utils/globalOptions.ts | 149 +++++++++- .../tools/kadena-cli/src/utils/helpers.ts | 72 +++++ 31 files changed, 1257 insertions(+), 21 deletions(-) create mode 100644 packages/libs/client-utils/src/coin/create-principle.ts create mode 100644 packages/tools/kadena-cli/src/account/accountHelpers.ts create mode 100644 packages/tools/kadena-cli/src/account/createAccountCommand.ts create mode 100644 packages/tools/kadena-cli/src/account/init.ts create mode 100644 packages/tools/kadena-cli/src/constants/accounts.ts create mode 100644 packages/tools/kadena-cli/src/constants/keypairs.ts create mode 100644 packages/tools/kadena-cli/src/constants/keysets.ts create mode 100644 packages/tools/kadena-cli/src/keypair/createKeypairCommand.ts create mode 100644 packages/tools/kadena-cli/src/keypair/deleteKeypairCommand.ts create mode 100644 packages/tools/kadena-cli/src/keypair/index.ts create mode 100644 packages/tools/kadena-cli/src/keypair/init.ts create mode 100644 packages/tools/kadena-cli/src/keypair/keypairHelpers.ts create mode 100644 packages/tools/kadena-cli/src/keypair/listKeypairsCommand.ts create mode 100644 packages/tools/kadena-cli/src/keyset/createKeysetCommand.ts create mode 100644 packages/tools/kadena-cli/src/keyset/deleteKeysetCommand.ts create mode 100644 packages/tools/kadena-cli/src/keyset/index.ts create mode 100644 packages/tools/kadena-cli/src/keyset/init.ts create mode 100644 packages/tools/kadena-cli/src/keyset/keysetHelpers.ts create mode 100644 packages/tools/kadena-cli/src/keyset/listKeysetsCommand.ts diff --git a/packages/libs/client-utils/etc/client-utils-coin.api.md b/packages/libs/client-utils/etc/client-utils-coin.api.md index 62947c2b30..9372cb3d39 100644 --- a/packages/libs/client-utils/etc/client-utils-coin.api.md +++ b/packages/libs/client-utils/etc/client-utils-coin.api.md @@ -35,6 +35,31 @@ event: "listen"; data: ICommandResult; }], [], Promise | Promise | Promise | Promise | Promise | Promise | Promise | Promise | Promise>; +// Warning: (ae-forgotten-export) The symbol "TCreatePrincipalAccountCommandInput" needs to be exported by the entry point index.d.ts +// +// @alpha (undocumented) +export const createPrincipalAccount: (inputs: TCreatePrincipalAccountCommandInput, config: IClientConfig) => Promise | Promise | Promise | Promise | Promise | Promise | Promise | Promise | Promise>>; + +// Warning: (ae-forgotten-export) The symbol "ICreatePrincipalCommandInput" needs to be exported by the entry point index.d.ts +// +// @alpha (undocumented) +export const createPrincipalCommand: (inputs: ICreatePrincipalCommandInput, config: Omit) => Promise<() => IEmitterWrapper<[{ +event: "dirtyRead"; +data: ICommandResult; +}], [], Promise | Promise | Promise | Promise | Promise | Promise | Promise | Promise | Promise>>; + // @alpha (undocumented) export const details: (account: string, networkId: string, chainId: ChainId, host?: IClientConfig['host']) => Promise | Promise | Promise | Promise | Promise | Promise | Promise | Promise | Promise; diff --git a/packages/libs/client-utils/src/coin/create-account.ts b/packages/libs/client-utils/src/coin/create-account.ts index 1248b54440..4239801af3 100644 --- a/packages/libs/client-utils/src/coin/create-account.ts +++ b/packages/libs/client-utils/src/coin/create-account.ts @@ -1,14 +1,15 @@ import type { ChainId } from '@kadena/client'; import { Pact, readKeyset } from '@kadena/client'; import { + addData, addKeyset, addSigner, composePactCommand, execution, setMeta, } from '@kadena/client/fp'; - -import { submitClient } from '../core/client-helpers'; +import { pipe } from 'ramda'; +import { dirtyReadClient, submitClient } from '../core/client-helpers'; import type { IClientConfig } from '../core/utils/helpers'; interface ICreateAccountCommandInput { @@ -21,6 +22,8 @@ interface ICreateAccountCommandInput { chainId: ChainId; } +type TCreatePrincipalAccountCommandInput = Omit; + const createAccountCommand = ({ account, keyset, @@ -35,6 +38,7 @@ const createAccountCommand = ({ addSigner(gasPayer.publicKeys, (signFor) => [signFor('coin.GAS')]), setMeta({ senderAccount: gasPayer.account, chainId }), ); + /** * @alpha */ @@ -42,3 +46,24 @@ export const createAccount = ( inputs: ICreateAccountCommandInput, config: IClientConfig, ) => submitClient(config)(createAccountCommand(inputs)); + +/** + * @alpha + */ +export const createPrincipalAccount = async ( + inputs: TCreatePrincipalAccountCommandInput, + config: IClientConfig, +) => { + const getPrincipal = pipe( + () => '(create-principal (read-keyset "ks"))', + execution, + addData('ks', inputs.keyset), + dirtyReadClient(config), + ); + + const account = await getPrincipal().execute(); + return submitClient(config)(createAccountCommand({ + account: account as string, + ...inputs + })); +} diff --git a/packages/libs/client-utils/src/coin/create-principle.ts b/packages/libs/client-utils/src/coin/create-principle.ts new file mode 100644 index 0000000000..c6018ac89e --- /dev/null +++ b/packages/libs/client-utils/src/coin/create-principle.ts @@ -0,0 +1,30 @@ +import type { ChainId } from '@kadena/client'; +import { + addData, + execution, +} from '@kadena/client/fp'; +import { pipe } from 'ramda'; +import { dirtyReadClient } from '../core/client-helpers'; +import type { IClientConfig } from '../core/utils/helpers'; + +interface ICreatePrincipalCommandInput { + keyset: { + keys: string[]; + pred: 'keys-all' | 'keys-two' | 'keys-one'; + }; + gasPayer: { account: string; publicKeys: string[] }; + chainId: ChainId; +} +/** + * @alpha + */ +export const createPrincipalCommand = async ( + inputs: ICreatePrincipalCommandInput, + config: Omit, +) => + pipe( + () => '(create-principal (read-keyset "ks"))', + execution, + addData('ks', inputs.keyset), + dirtyReadClient(config), + ); diff --git a/packages/libs/client-utils/src/coin/index.ts b/packages/libs/client-utils/src/coin/index.ts index 51bfef15d4..5b3a1abf83 100644 --- a/packages/libs/client-utils/src/coin/index.ts +++ b/packages/libs/client-utils/src/coin/index.ts @@ -1,4 +1,5 @@ export * from './create-account'; +export * from './create-principle'; export * from './details'; export * from './get-balance'; export * from './rotate'; diff --git a/packages/tools/kadena-cli/src/account/accountHelpers.ts b/packages/tools/kadena-cli/src/account/accountHelpers.ts new file mode 100644 index 0000000000..7af6495ddd --- /dev/null +++ b/packages/tools/kadena-cli/src/account/accountHelpers.ts @@ -0,0 +1,115 @@ +import { getExistingAccounts, sanitizeFilename } from "../utils/helpers.js"; +import { defaultAccountsPath, accountDefaults } from "../constants/accounts.js"; +import { removeFile, writeFile } from "../utils/filesystem.js"; +import { existsSync, readFileSync, type WriteFileOptions } from 'fs'; +import yaml from 'js-yaml'; +import path from 'path'; +import chalk from 'chalk'; +import { ChainId } from "@kadena/types"; + +export interface ICustomAccountsChoice { + value: string; + name?: string; + description?: string; + disabled?: boolean | string; +} + +export interface IAccountCreateOptions { + name: string; + account: string; + keyset: string; + network: string; + chainId: ChainId; +} + +/** + * Removes the given account from the accounts folder + * + * @param {Pick} options - The account configuration. + * @param {string} options.name - The name of the account. + */ +export function removeAccount(options: Pick): void { + const { name } = options; + const sanitizedName = sanitizeFilename(name).toLowerCase(); + const accountFilePath = path.join( + defaultAccountsPath, + `${sanitizedName}.yaml`, + ); + + removeFile(accountFilePath); +} + +export function writeAccount(options: IAccountCreateOptions): void { + const { name } = options; + const sanitizedName = sanitizeFilename(name).toLowerCase(); + const accountFilePath = path.join( + defaultAccountsPath, + `${sanitizedName}.yaml`, + ); + + writeFile( + accountFilePath, + yaml.dump(options), + 'utf8' as WriteFileOptions, + ); +} + +export async function displayAccountsConfig(): Promise { + const log = console.log; + const formatLength = 80; // Maximum width for the display + + const displaySeparator = (): void => { + log(chalk.green('-'.padEnd(formatLength, '-'))); + }; + + const formatConfig = ( + key: string, + value?: string, + isDefault?: boolean, + ): string => { + const valueDisplay = + (value?.trim() ?? '') !== '' ? chalk.green(value!) : chalk.red('Not Set'); + + const defaultIndicator = + isDefault === true ? chalk.yellow(' (Using defaults)') : ''; + const keyValue = `${key}: ${valueDisplay}${defaultIndicator}`; + const remainingWidth = + formatLength - keyValue.length > 0 ? formatLength - keyValue.length : 0; + return ` ${keyValue}${' '.repeat(remainingWidth)} `; + }; + + + const existingAccounts: ICustomAccountsChoice[] = await getExistingAccounts(); + + existingAccounts.forEach(({ value }) => { + const filePath = path.join(defaultAccountsPath, `${value}.yaml`); + if (! existsSync) { + return; + } + + const accountConfig = (yaml.load( + readFileSync(filePath, 'utf8'), + ) as IAccountCreateOptions); + + displaySeparator(); + log(formatConfig('Name', value)); + log(formatConfig('Account', accountConfig.account)); + log(formatConfig('Keyset', accountConfig.keyset)); + log(formatConfig('Network', accountConfig.network)); + log(formatConfig('Chain ID', accountConfig.chainId.toString())); + }); + + displaySeparator(); +}; + +export function loadAccountConfig(name: string): IAccountCreateOptions | never { + const filePath = path.join(defaultAccountsPath, `${name}.yaml`); + + if (! existsSync(filePath)) { + throw new Error('Account file not found.') + } + + return (yaml.load( + readFileSync(filePath, 'utf8'), + ) as IAccountCreateOptions); +}; diff --git a/packages/tools/kadena-cli/src/account/createAccountCommand.ts b/packages/tools/kadena-cli/src/account/createAccountCommand.ts new file mode 100644 index 0000000000..9b0f1023e0 --- /dev/null +++ b/packages/tools/kadena-cli/src/account/createAccountCommand.ts @@ -0,0 +1,96 @@ +import { createAccount, createPrincipalAccount, createPrincipalCommand } from '@kadena/client-utils/coin'; +import { createCommand } from '../utils/createCommand.js'; +import { globalOptions } from '../utils/globalOptions.js'; +import chalk from 'chalk'; +import { ChainId, createSignWithKeypair } from '@kadena/client'; +import { writeAccount } from './accountHelpers.js'; +import { loadKeysetConfig } from '../keyset/keysetHelpers.js'; +import { loadKeypairConfig } from '../keypair/keypairHelpers.js'; + +// eslint-disable-next-line @rushstack/typedef-var +export const createAccountCommand = createCommand( + 'create', + 'Create account', + [globalOptions.accountName(), globalOptions.keyset(), globalOptions.network(), globalOptions.chainId(), globalOptions.gasPayer()], + async (config) => { + try { + const publicKeys = config.keysetConfig.publicKeys.split(',').map(value => value.trim()).filter(value => value.length); + for (let keypair of config.keysetConfig.publicKeysFromKeypairs) { + const keypairConfig = await loadKeypairConfig(keypair) + publicKeys.push(keypairConfig.publicKey || ''); + } + + const createPrincipal = await createPrincipalCommand({ + keyset: { + pred: config.keysetConfig.predicate as 'keys-all' | 'keys-two' | 'keys-one', + keys: publicKeys, + }, + gasPayer: { account: 'dummy', publicKeys: [] }, + chainId: config.chainId as ChainId, + }, + { + host: config.networkConfig.networkHost, + defaults: { + networkId: config.networkConfig.networkId, + meta: { + chainId: config.chainId as ChainId, + } + }, + }); + + const account = await createPrincipal().execute(); + + writeAccount({ + ...config, + name: config.account, + account: account as string, + }); + + console.log(chalk.green(`\nSaved the account configuration "${config.account}".\n`)); + + // @todo: make gas payer configuration more flexible. + const gasPayerKeyset = await loadKeysetConfig(config.gasPayerConfig.keyset); + // @todo: now it is simply assumed that the gas payer account is governed with a keypair + const gasPayerKeypair = await loadKeypairConfig(gasPayerKeyset.publicKeysFromKeypairs.pop() || ''); + + // @todo: also allow other signing methods + const signWithKeyPair = createSignWithKeypair({ + publicKey: gasPayerKeypair.publicKey, + secretKey: gasPayerKeypair.secretKey, + }); + + const c = await createPrincipalAccount( + { + keyset: { + pred: 'keys-all', + keys: ['1827389ca64cb5c3c77352eb2087c2bb503061d22fb1edcadb5d90ad1dee80f5'], + }, + gasPayer: { account: 'sender00', publicKeys: [gasPayerKeypair.publicKey] }, + chainId: config.chainId as ChainId, + }, + { + host: config.networkConfig.networkHost, + defaults: { + networkId: config.networkConfig.networkId, + meta: { + chainId: config.chainId as ChainId, + } + }, + sign: signWithKeyPair, + }, + ) + + const result = await c.execute(); + + console.log(result); + + if (result === 'Write succeeded') { + console.log(chalk.green(`\nCreated account "${account}" guarded by keyset "${config.keyset}" on chain ${config.chainId} of "${config.network}".\n`)); + } else { + console.log(chalk.red(`\nFailed to created the account on "${config.network}".\n`)); + } + } catch (e) { + console.log(chalk.red(e.message)); + } + }, +); diff --git a/packages/tools/kadena-cli/src/account/getBalanceCommand.ts b/packages/tools/kadena-cli/src/account/getBalanceCommand.ts index 258d0a45c3..63a783e95d 100644 --- a/packages/tools/kadena-cli/src/account/getBalanceCommand.ts +++ b/packages/tools/kadena-cli/src/account/getBalanceCommand.ts @@ -4,14 +4,14 @@ import { globalOptions } from '../utils/globalOptions.js'; import chalk from 'chalk'; // eslint-disable-next-line @rushstack/typedef-var -export const createGetBalanceCommand = createCommand( +export const getBalanceCommand = createCommand( 'get-balance', 'Get the balance of an account', [globalOptions.account(), globalOptions.network(), globalOptions.chainId()], async (config) => { try { const balance = await getBalance( - config.account, + config.accountConfig.account, config.networkConfig.networkId, config.chainId, config.networkConfig.networkHost, diff --git a/packages/tools/kadena-cli/src/account/index.ts b/packages/tools/kadena-cli/src/account/index.ts index 28b997467e..6575998021 100644 --- a/packages/tools/kadena-cli/src/account/index.ts +++ b/packages/tools/kadena-cli/src/account/index.ts @@ -1,7 +1,8 @@ import { fundCommand } from './fundCommand.js'; import type { Command } from 'commander'; -import { createGetBalanceCommand } from './getBalanceCommand.js'; +import { getBalanceCommand } from './getBalanceCommand.js'; +import { createAccountCommand } from './createAccountCommand.js'; const SUBCOMMAND_ROOT: 'account' = 'account'; @@ -10,7 +11,7 @@ export function accountCommandFactory(program: Command, version: string): void { .command(SUBCOMMAND_ROOT) .description(`Tool to manage accounts of fungibles (e.g. 'coin')`); + createAccountCommand(accountProgram, version); fundCommand(accountProgram, version); - // getBalanceCommand(accountProgram, version); - createGetBalanceCommand(accountProgram, version); + getBalanceCommand(accountProgram, version); } diff --git a/packages/tools/kadena-cli/src/account/init.ts b/packages/tools/kadena-cli/src/account/init.ts new file mode 100644 index 0000000000..1e3bac4de4 --- /dev/null +++ b/packages/tools/kadena-cli/src/account/init.ts @@ -0,0 +1,6 @@ +import { accountDefaults } from '../constants/accounts.js'; +import { writeAccount } from './accountHelpers.js'; + +// await import ('../keyset/init.js'); + +writeAccount(accountDefaults.sender00); diff --git a/packages/tools/kadena-cli/src/constants/accounts.ts b/packages/tools/kadena-cli/src/constants/accounts.ts new file mode 100644 index 0000000000..552f0ac0e5 --- /dev/null +++ b/packages/tools/kadena-cli/src/constants/accounts.ts @@ -0,0 +1,13 @@ +import { ChainId } from "@kadena/types"; + +export const accountDefaults = { + sender00: { + name: 'sender00', + account: 'sender00', + keyset: 'sender00', + network: 'devnet', + chainId: '1' as ChainId, + }, +}; + +export const defaultAccountsPath: string = `${process.cwd()}/.kadena/accounts`; diff --git a/packages/tools/kadena-cli/src/constants/keypairs.ts b/packages/tools/kadena-cli/src/constants/keypairs.ts new file mode 100644 index 0000000000..3dd0d71568 --- /dev/null +++ b/packages/tools/kadena-cli/src/constants/keypairs.ts @@ -0,0 +1,9 @@ +export const keypairDefaults = { + sender00: { + name: 'sender00', + publicKey: '368820f80c324bbc7c2b0610688a7da43e39f91d118732671cd9c7500ff43cca', + secretKey: '251a920c403ae8c8f65f59142316af3c82b631fba46ddea92ee8c95035bd2898', + }, +}; + +export const defaultKeypairsPath: string = `${process.cwd()}/.kadena/keypairs`; diff --git a/packages/tools/kadena-cli/src/constants/keysets.ts b/packages/tools/kadena-cli/src/constants/keysets.ts new file mode 100644 index 0000000000..efe9445bfe --- /dev/null +++ b/packages/tools/kadena-cli/src/constants/keysets.ts @@ -0,0 +1,10 @@ +export const keysetDefaults = { + sender00: { + name: 'sender00', + predicate: 'keys-all', + publicKeysFromKeypairs: ['sender00'], + publicKeys: '', + } +}; + +export const defaultKeysetsPath: string = `${process.cwd()}/.kadena/keysets`; diff --git a/packages/tools/kadena-cli/src/constants/prompts.ts b/packages/tools/kadena-cli/src/constants/prompts.ts index 0e4f8bbffa..b4f901ae15 100644 --- a/packages/tools/kadena-cli/src/constants/prompts.ts +++ b/packages/tools/kadena-cli/src/constants/prompts.ts @@ -1,18 +1,245 @@ -import { input, select } from '@inquirer/prompts'; +import { checkbox, input, select } from '@inquirer/prompts'; import { program } from 'commander'; import path from 'path'; import { ICustomNetworksChoice } from '../networks/networksHelpers.js'; import { ensureFileExists } from '../utils/filesystem.js'; -import { getExistingNetworks, getVersion } from '../utils/helpers.js'; +import { getExistingAccounts, getExistingKeypairs, getExistingKeysets, getExistingNetworks } from '../utils/helpers.js'; import { defaultNetworksPath } from './networks.js'; import { ChainId } from '@kadena/types'; +import { defaultKeypairsPath } from './keypairs.js'; +import { ICustomKeypairsChoice } from '../keypair/keypairHelpers.js'; +import { defaultKeysetsPath } from './keysets.js'; +import { ICustomKeysetsChoice } from '../keyset/keysetHelpers.js'; +import { ICustomAccountsChoice } from '../account/accountHelpers.js'; -export const accountPrompt = async () => - await input({ message: 'Enter the Kadena k:account' }); +export const gasPayerPrompt = async (): Promise => { + const existingAccounts: ICustomAccountsChoice[] = await getExistingAccounts(); + + if (existingAccounts.length > 0) { + const selectedKeypair = await select({ + message: 'Select a gas payer account', + choices: [ + ...existingAccounts, + { value: undefined, name: 'Create a new account' }, + ], + }); + + if (selectedKeypair !== undefined) { + return selectedKeypair; + } + } + + // At this point there is either no account defined yet, + // or the user chose to create a new account. + // Create and select new account. + await program.parseAsync(['', '', 'account', 'create']); + + return await keypairPrompt(); +}; + +export const accountPrompt = async (): Promise => { + const existingAccounts: ICustomAccountsChoice[] = await getExistingAccounts(); + + if (existingAccounts.length > 0) { + const selectedKeypair = await select({ + message: 'Select an account', + choices: [ + ...existingAccounts, + { value: undefined, name: 'Create a new account' }, + ], + }); + + if (selectedKeypair !== undefined) { + return selectedKeypair; + } + } + + // At this point there is either no account defined yet, + // or the user chose to create a new account. + // Create and select new account. + await program.parseAsync(['', '', 'account', 'create']); + + return await keypairPrompt(); +}; + +export const accountNamePrompt = async () => + await input({ message: 'Enter the name of the account configuration' }); export const chainIdPrompt = async (): Promise => await input({ message: 'Enter chainId (0-19)' }) as ChainId; +export const keypairDeletePrompt = async (name: string) => + await select({ + message: `Are you sure you want to delete the keypair "${name}"?`, + choices: [ + { value: 'yes', name: 'Yes' }, + { value: 'no', name: 'No' }, + ], + }); + +export const keypairNamePrompt = async (): Promise => { + const name = await input({ + message: 'Enter a keypair name', + }); + + const filePath = path.join(defaultKeypairsPath, `${name}.yaml`); + if (ensureFileExists(filePath)) { + const overwrite = await keypairOverwritePrompt(); + if (overwrite === 'no') { + return await keypairNamePrompt(); + } + } + + return name; +}; + +export const keypairOverwritePrompt = async (name?: string) => { + const message = name + ? `Are you sure you want to save this keypair "${name}"?` + : 'A keypair with this name already exists. Do you want to update it?' + + return await select({ + message, + choices: [ + { value: 'yes', name: 'Yes' }, + { value: 'no', name: 'No' }, + ], + }); +}; + +export const keypairPrompt = async (): Promise => { + const existingKeypairs: ICustomKeypairsChoice[] = await getExistingKeypairs(); + + if (existingKeypairs.length > 0) { + const selectedKeypair = await select({ + message: 'Select a keypair', + choices: [ + ...existingKeypairs, + { value: undefined, name: 'Create a new keypair' }, + ], + }); + + if (selectedKeypair !== undefined) { + return selectedKeypair; + } + } + + // At this point there is either no keypair defined yet, + // or the user chose to create a new keypair. + // Create and select new keypair. + await program.parseAsync(['', '', 'keypair', 'create']); + + return await keypairPrompt(); +}; + +export const keypairSelectPrompt = async (): Promise => { + const existingKeypairs: ICustomKeypairsChoice[] = await getExistingKeypairs(); + + if (existingKeypairs.length > 0) { + return await select({ + message: 'Select a keypair', + choices: existingKeypairs, + }); + } + + // At this point there is no keypair defined yet. + // Create and select a new keypair. + await program.parseAsync(['', '', 'keypair', 'create']); + + return await networkSelectPrompt(); +}; + +export const keysetDeletePrompt = async (name: string) => + await select({ + message: `Are you sure you want to delete the keyset "${name}"?`, + choices: [ + { value: 'yes', name: 'Yes' }, + { value: 'no', name: 'No' }, + ], + }); + +export const keysetNamePrompt = async (): Promise => { + const name = await input({ + message: 'Enter a keyset name', + }); + + const filePath = path.join(defaultKeysetsPath, `${name}.yaml`); + if (ensureFileExists(filePath)) { + const overwrite = await keysetOverwritePrompt(); + if (overwrite === 'no') { + return await keysetNamePrompt(); + } + } + + return name; +}; + +export const keysetOverwritePrompt = async (name?: string) => { + const message = name + ? `Are you sure you want to save the keyset "${name}"?` + : 'A keyset with this name already exists. Do you want to update it?' + + return await select({ + message, + choices: [ + { value: 'yes', name: 'Yes' }, + { value: 'no', name: 'No' }, + ], + }); +}; + +export const keysetPredicatePrompt = async (): Promise => + await select({ + message: 'Select a keyset predicate', + choices: [ + { value: 'keys-all', name: 'keys-all' }, + { value: 'keys-one', name: 'keys-one' }, + { value: 'keys-two', name: 'keys-two' }, + ], + }); + +export const keysetPrompt = async (): Promise => { + const existingKeysets: ICustomKeysetsChoice[] = await getExistingKeysets(); + + if (existingKeysets.length > 0) { + const selectedKeyset = await select({ + message: 'Select a keyset', + choices: [ + ...existingKeysets, + { value: undefined, name: 'Create a new keyset' }, + ], + }); + + if (selectedKeyset !== undefined) { + return selectedKeyset; + } + } + + // At this point there is either no keyset defined yet, + // or the user chose to create a new keyset. + // Create and select new keyset. + await program.parseAsync(['', '', 'keyset', 'create']); + + return await keysetPrompt(); +}; + +export const keysetSelectPrompt = async (): Promise => { + const existingKeysets: ICustomKeysetsChoice[] = await getExistingKeysets(); + + if (existingKeysets.length > 0) { + return await select({ + message: 'Select a keyset', + choices: existingKeysets, + }); + } + + // At this point there is no keyset defined yet. + // Create and select a new keyset. + await program.parseAsync(['', '', 'keyset', 'create']); + + return await networkSelectPrompt(); +}; + export const networkPrompt = async (): Promise => { const existingNetworks: ICustomNetworksChoice[] = await getExistingNetworks(); @@ -48,8 +275,8 @@ export const networkSelectPrompt = async (): Promise => { }); } - // At this point there is either no network defined yet. - // Create and select new network. + // At this point there is no network defined yet. + // Create and select a new network. await program.parseAsync(['', '', 'networks', 'create']); return await networkSelectPrompt(); @@ -107,3 +334,22 @@ export const networkExplorerUrlPrompt = async () => message: 'Enter Kadena network explorer URL (e.g. "https://explorer.chainweb.com/mainnet/tx/")', }); + +export const publicKeysPrompt = async () => + await input({ + message: + 'Enter zero or more public keys (comma separated).', + }); + +export const selectKeypairsPrompt = async () => { + const existingKeypairs: ICustomKeypairsChoice[] = await getExistingKeypairs(); + + if (existingKeypairs.length === 0) { + return []; + } + + return await checkbox({ + message: 'Select zero or more keypairs', + choices: existingKeypairs, + }) +} diff --git a/packages/tools/kadena-cli/src/index.ts b/packages/tools/kadena-cli/src/index.ts index 95818261d3..a6c4f0ec14 100644 --- a/packages/tools/kadena-cli/src/index.ts +++ b/packages/tools/kadena-cli/src/index.ts @@ -4,7 +4,9 @@ import { configCommandFactory } from './config/index.js'; import { contractCommandFactory } from './contract/index.js'; // import { dappCommandFactory } from './dapp/index.js'; import { devnetCommandFactory } from './devnet/index.js'; +import { keypairCommandFactory } from './keypair/index.js'; import { keysCommandFactory } from './keys/index.js'; +import { keysetCommandFactory } from './keyset/index.js'; import { marmaladeCommandFactory } from './marmalade/index.js'; import { networksCommandFactory } from './networks/index.js'; import { txCommandFactory } from './tx/index.js'; @@ -25,7 +27,9 @@ const packageJson: { version: string } = JSON.parse( configCommandFactory, networksCommandFactory, devnetCommandFactory, + keypairCommandFactory, keysCommandFactory, + keysetCommandFactory, accountCommandFactory, txCommandFactory, contractCommandFactory, diff --git a/packages/tools/kadena-cli/src/keypair/createKeypairCommand.ts b/packages/tools/kadena-cli/src/keypair/createKeypairCommand.ts new file mode 100644 index 0000000000..e6e3718fc9 --- /dev/null +++ b/packages/tools/kadena-cli/src/keypair/createKeypairCommand.ts @@ -0,0 +1,42 @@ +import { ensureFileExists } from '../utils/filesystem.js'; + +import debug from 'debug'; +import path from 'path'; + +import { createCommand } from '../utils/createCommand.js'; +import { globalOptions } from '../utils/globalOptions.js'; +import chalk from 'chalk'; +import { keypairOverwritePrompt } from '../constants/prompts.js'; +import { defaultKeypairsPath } from '../constants/keypairs.js'; +import { writeKeypair } from './keypairHelpers.js'; +import * as cryptoService from '../keys/utils/service.js'; + +export const createKeypairCommand = createCommand( + 'create', + 'Create keypair', + [globalOptions.keypairName()], + async (config) => { + debug('keypair-create:action')({config}); + + const filePath = path.join(defaultKeypairsPath, `${config.name}.yaml`); + + if (ensureFileExists(filePath)) { + const overwrite = await keypairOverwritePrompt(config.name); + if (overwrite === 'no') { + console.log(chalk.yellow(`\nThe existing keypair "${config.name}" will not be updated.\n`)); + return; + } + } + + const plainKeyPairs = cryptoService.generateKeyPairsFromRandom(); + const plainKeyPair = plainKeyPairs[0]; + const keypairConfig = { + ...config, + ...plainKeyPair, + } + + writeKeypair(keypairConfig); + + console.log(chalk.green(`\nThe keypair "${config.name}" has been saved.\n`)); + }, +); diff --git a/packages/tools/kadena-cli/src/keypair/deleteKeypairCommand.ts b/packages/tools/kadena-cli/src/keypair/deleteKeypairCommand.ts new file mode 100644 index 0000000000..0c745a920c --- /dev/null +++ b/packages/tools/kadena-cli/src/keypair/deleteKeypairCommand.ts @@ -0,0 +1,26 @@ +import debug from 'debug'; +import { keypairDeletePrompt } from '../constants/prompts.js'; +import { globalOptions } from '../utils/globalOptions.js'; + +import chalk from 'chalk'; +import { createCommand } from '../utils/createCommand.js'; +import { removeKeypair } from './keypairHelpers.js'; + +export const deleteKeypairCommand = createCommand( + 'delete', + 'Delete keypair', + [globalOptions.keypairSelect()], + async (config) => { + debug('keypair-delete:action')({config}); + + const overwrite = await keypairDeletePrompt(config.name); + if (overwrite === 'no') { + console.log(chalk.yellow(`\nThe keypair "${config.name}" will not be deleted.\n`)); + return; + } + + removeKeypair(config); + + console.log(chalk.green(`\nThe keypair "${config.name}" has been deleted.\n`)); + }, +); diff --git a/packages/tools/kadena-cli/src/keypair/index.ts b/packages/tools/kadena-cli/src/keypair/index.ts new file mode 100644 index 0000000000..db6bc4b11d --- /dev/null +++ b/packages/tools/kadena-cli/src/keypair/index.ts @@ -0,0 +1,15 @@ +import type { Command } from 'commander'; +import { createKeypairCommand } from './createKeypairCommand.js'; +import { deleteKeypairCommand } from './deleteKeypairCommand.js'; +import { listKeypairsCommand } from './listKeypairsCommand.js'; +const SUBCOMMAND_ROOT: 'keypair' = 'keypair'; + +export function keypairCommandFactory(program: Command, version: string): void { + const keypairProgram = program + .command(SUBCOMMAND_ROOT) + .description(`Tool to manage keypairs`); + + listKeypairsCommand(keypairProgram, version); + createKeypairCommand(keypairProgram, version); + deleteKeypairCommand(keypairProgram, version); +} diff --git a/packages/tools/kadena-cli/src/keypair/init.ts b/packages/tools/kadena-cli/src/keypair/init.ts new file mode 100644 index 0000000000..4669c6a6d4 --- /dev/null +++ b/packages/tools/kadena-cli/src/keypair/init.ts @@ -0,0 +1,4 @@ +import { keypairDefaults } from '../constants/keypairs.js'; +import { writeKeypair } from './keypairHelpers.js'; + +writeKeypair(keypairDefaults.sender00); diff --git a/packages/tools/kadena-cli/src/keypair/keypairHelpers.ts b/packages/tools/kadena-cli/src/keypair/keypairHelpers.ts new file mode 100644 index 0000000000..ede289e9c3 --- /dev/null +++ b/packages/tools/kadena-cli/src/keypair/keypairHelpers.ts @@ -0,0 +1,110 @@ +import { getExistingKeypairs, sanitizeFilename } from "../utils/helpers.js"; +import { defaultKeypairsPath, keypairDefaults } from "../constants/keypairs.js"; +import { removeFile, writeFile } from "../utils/filesystem.js"; +import { existsSync, readFileSync, type WriteFileOptions } from 'fs'; +import yaml from 'js-yaml'; +import path from 'path'; +import chalk from 'chalk'; + +export interface ICustomKeypairsChoice { + value: string; + name?: string; + description?: string; + disabled?: boolean | string; +} + +export interface IKeypairCreateOptions { + name: string; + publicKey: string; + secretKey: string; +} + +/** + * Removes the given keypair from the keypairs folder + * + * @param {Pick} options - The keypair configuration. + * @param {string} options.name - The name of the keypair. + */ +export function removeKeypair(options: Pick): void { + const { name } = options; + const sanitizedName = sanitizeFilename(name).toLowerCase(); + const keypairFilePath = path.join( + defaultKeypairsPath, + `${sanitizedName}.yaml`, + ); + + removeFile(keypairFilePath); +} + +export function writeKeypair(options: IKeypairCreateOptions): void { + const { name } = options; + const sanitizedName = sanitizeFilename(name).toLowerCase(); + const keypairFilePath = path.join( + defaultKeypairsPath, + `${sanitizedName}.yaml`, + ); + + writeFile( + keypairFilePath, + yaml.dump(options), + 'utf8' as WriteFileOptions, + ); +} + +export async function displayKeypairsConfig(): Promise { + const log = console.log; + const formatLength = 80; // Maximum width for the display + + const displaySeparator = (): void => { + log(chalk.green('-'.padEnd(formatLength, '-'))); + }; + + const formatConfig = ( + key: string, + value?: string, + isDefault?: boolean, + ): string => { + const valueDisplay = + (value?.trim() ?? '') !== '' ? chalk.green(value!) : chalk.red('Not Set'); + + const defaultIndicator = + isDefault === true ? chalk.yellow(' (Using defaults)') : ''; + const keyValue = `${key}: ${valueDisplay}${defaultIndicator}`; + const remainingWidth = + formatLength - keyValue.length > 0 ? formatLength - keyValue.length : 0; + return ` ${keyValue}${' '.repeat(remainingWidth)} `; + }; + + + const existingKeypairs: ICustomKeypairsChoice[] = await getExistingKeypairs(); + + existingKeypairs.forEach(({ value }) => { + const filePath = path.join(defaultKeypairsPath, `${value}.yaml`); + if (! existsSync) { + return; + } + + const keypairConfig = (yaml.load( + readFileSync(filePath, 'utf8'), + ) as IKeypairCreateOptions); + + displaySeparator(); + log(formatConfig('Name', value)); + log(formatConfig('Public key', keypairConfig.publicKey)); + log(formatConfig('Secret key', keypairConfig.secretKey)); + }); + + displaySeparator(); +}; + +export function loadKeypairConfig(name: string): IKeypairCreateOptions | never { + const filePath = path.join(defaultKeypairsPath, `${name}.yaml`); + + if (! existsSync(filePath)) { + throw new Error('Keypair file not found.') + } + + return (yaml.load( + readFileSync(filePath, 'utf8'), + ) as IKeypairCreateOptions); +}; diff --git a/packages/tools/kadena-cli/src/keypair/listKeypairsCommand.ts b/packages/tools/kadena-cli/src/keypair/listKeypairsCommand.ts new file mode 100644 index 0000000000..53f4a282ec --- /dev/null +++ b/packages/tools/kadena-cli/src/keypair/listKeypairsCommand.ts @@ -0,0 +1,14 @@ +import debug from 'debug'; +import { createCommand } from '../utils/createCommand.js'; +import { displayKeypairsConfig } from './keypairHelpers.js'; + +export const listKeypairsCommand = createCommand( + 'list', + 'List all available keypairs', + [], + async (config) => { + debug('keypair-list:action')({config}); + + displayKeypairsConfig(); + }, +); diff --git a/packages/tools/kadena-cli/src/keyset/createKeysetCommand.ts b/packages/tools/kadena-cli/src/keyset/createKeysetCommand.ts new file mode 100644 index 0000000000..8f2045b7ca --- /dev/null +++ b/packages/tools/kadena-cli/src/keyset/createKeysetCommand.ts @@ -0,0 +1,34 @@ +import { ensureFileExists } from '../utils/filesystem.js'; + +import debug from 'debug'; +import path from 'path'; + +import { createCommand } from '../utils/createCommand.js'; +import { globalOptions } from '../utils/globalOptions.js'; +import chalk from 'chalk'; +import { keysetOverwritePrompt } from '../constants/prompts.js'; +import { defaultKeysetsPath } from '../constants/keysets.js'; +import { writeKeyset } from './keysetHelpers.js'; + +export const createKeysetCommand = createCommand( + 'create', + 'Create keyset', + [globalOptions.keysetName(), globalOptions.keysetPredicate(), globalOptions.publicKeysFromKeypairs(), globalOptions.otherPublicKeys()], + async (config) => { + debug('keyset-create:action')({ config }); + + const filePath = path.join(defaultKeysetsPath, `${config.name}.yaml`); + + if (ensureFileExists(filePath)) { + const overwrite = await keysetOverwritePrompt(); + if (overwrite === 'no') { + console.log(chalk.yellow(`\nThe existing keyset "${config.name}" will not be updated.\n`)); + return; + } + } + + writeKeyset(config); + + console.log(chalk.green(`\nThe keyset "${config.name}" has been saved.\n`)); + }, +); diff --git a/packages/tools/kadena-cli/src/keyset/deleteKeysetCommand.ts b/packages/tools/kadena-cli/src/keyset/deleteKeysetCommand.ts new file mode 100644 index 0000000000..47c55c4fc8 --- /dev/null +++ b/packages/tools/kadena-cli/src/keyset/deleteKeysetCommand.ts @@ -0,0 +1,26 @@ +import debug from 'debug'; +import { keysetDeletePrompt } from '../constants/prompts.js'; +import { globalOptions } from '../utils/globalOptions.js'; + +import chalk from 'chalk'; +import { createCommand } from '../utils/createCommand.js'; +import { removeKeyset } from './keysetHelpers.js'; + +export const deleteKeysetCommand = createCommand( + 'delete', + 'Delete keyset', + [globalOptions.keysetSelect()], + async (config) => { + debug('keyset-delete:action')({config}); + + const overwrite = await keysetDeletePrompt(config.name); + if (overwrite === 'no') { + console.log(chalk.yellow(`\nThe keyset "${config.name}" will not be deleted.\n`)); + return; + } + + removeKeyset(config); + + console.log(chalk.green(`\nThe keyset "${config.name}" has been deleted.\n`)); + }, +); diff --git a/packages/tools/kadena-cli/src/keyset/index.ts b/packages/tools/kadena-cli/src/keyset/index.ts new file mode 100644 index 0000000000..1b39d38b0f --- /dev/null +++ b/packages/tools/kadena-cli/src/keyset/index.ts @@ -0,0 +1,15 @@ +import type { Command } from 'commander'; +import { createKeysetCommand } from './createKeysetCommand.js'; +import { deleteKeysetCommand } from './deleteKeysetCommand.js'; +import { listKeysetsCommand } from './listKeysetsCommand.js'; +const SUBCOMMAND_ROOT: 'keyset' = 'keyset'; + +export function keysetCommandFactory(program: Command, version: string): void { + const keysetProgram = program + .command(SUBCOMMAND_ROOT) + .description(`Tool to manage keysets`); + + listKeysetsCommand(keysetProgram, version); + createKeysetCommand(keysetProgram, version); + deleteKeysetCommand(keysetProgram, version); +} diff --git a/packages/tools/kadena-cli/src/keyset/init.ts b/packages/tools/kadena-cli/src/keyset/init.ts new file mode 100644 index 0000000000..4eb60990fb --- /dev/null +++ b/packages/tools/kadena-cli/src/keyset/init.ts @@ -0,0 +1,4 @@ +import { keysetDefaults } from '../constants/keysets.js'; +import { writeKeyset } from './keysetHelpers.js'; + +writeKeyset(keysetDefaults.sender00); diff --git a/packages/tools/kadena-cli/src/keyset/keysetHelpers.ts b/packages/tools/kadena-cli/src/keyset/keysetHelpers.ts new file mode 100644 index 0000000000..573bfa95a0 --- /dev/null +++ b/packages/tools/kadena-cli/src/keyset/keysetHelpers.ts @@ -0,0 +1,112 @@ +import { getExistingKeysets, sanitizeFilename } from "../utils/helpers.js"; +import { defaultKeysetsPath } from "../constants/keysets.js"; +import { removeFile, writeFile } from "../utils/filesystem.js"; +import { existsSync, readFileSync, type WriteFileOptions } from 'fs'; +import yaml from 'js-yaml'; +import path from 'path'; +import chalk from 'chalk'; + +export interface ICustomKeysetsChoice { + value: string; + name?: string; + description?: string; + disabled?: boolean | string; +} + +export interface IKeysetCreateOptions { + name: string; + predicate: string; + publicKeysFromKeypairs: string[]; + publicKeys: string; +} + +/** + * Removes the given keyset from the keysets folder + * + * @param {Pick} options - The keyset configuration. + * @param {string} options.name - The name of the keyset. + */ +export function removeKeyset(options: Pick): void { + const { name } = options; + const sanitizedName = sanitizeFilename(name).toLowerCase(); + const keysetFilePath = path.join( + defaultKeysetsPath, + `${sanitizedName}.yaml`, + ); + + removeFile(keysetFilePath); +} + +export function writeKeyset(options: IKeysetCreateOptions): void { + const { name } = options; + const sanitizedName = sanitizeFilename(name).toLowerCase(); + const keysetFilePath = path.join( + defaultKeysetsPath, + `${sanitizedName}.yaml`, + ); + + writeFile( + keysetFilePath, + yaml.dump(options), + 'utf8' as WriteFileOptions, + ); +} + +export async function displayKeysetsConfig(): Promise { + const log = console.log; + const formatLength = 80; // Maximum width for the display + + const displaySeparator = (): void => { + log(chalk.green('-'.padEnd(formatLength, '-'))); + }; + + const formatConfig = ( + key: string, + value?: string, + isDefault?: boolean, + ): string => { + const valueDisplay = + (value?.trim() ?? '') !== '' ? chalk.green(value!) : chalk.red('Not Set'); + + const defaultIndicator = + isDefault === true ? chalk.yellow(' (Using defaults)') : ''; + const keyValue = `${key}: ${valueDisplay}${defaultIndicator}`; + const remainingWidth = + formatLength - keyValue.length > 0 ? formatLength - keyValue.length : 0; + return ` ${keyValue}${' '.repeat(remainingWidth)} `; + }; + + + const existingKeysets: ICustomKeysetsChoice[] = await getExistingKeysets(); + + existingKeysets.forEach(({ value }) => { + const filePath = path.join(defaultKeysetsPath, `${value}.yaml`); + if (! existsSync) { + return; + } + + const keysetConfig = (yaml.load( + readFileSync(filePath, 'utf8'), + ) as IKeysetCreateOptions); + + displaySeparator(); + log(formatConfig('Name', value)); + log(formatConfig('Predicate', keysetConfig.predicate)); + log(formatConfig('Public keys from keypairs', keysetConfig.publicKeysFromKeypairs.toString())); + log(formatConfig('Other public keys', keysetConfig.publicKeys)); + }); + + displaySeparator(); +}; + +export function loadKeysetConfig(name: string): IKeysetCreateOptions | never { + const filePath = path.join(defaultKeysetsPath, `${name}.yaml`); + + if (! existsSync(filePath)) { + throw new Error('Keyset file not found.') + } + + return (yaml.load( + readFileSync(filePath, 'utf8'), + ) as IKeysetCreateOptions); +}; diff --git a/packages/tools/kadena-cli/src/keyset/listKeysetsCommand.ts b/packages/tools/kadena-cli/src/keyset/listKeysetsCommand.ts new file mode 100644 index 0000000000..28bc098125 --- /dev/null +++ b/packages/tools/kadena-cli/src/keyset/listKeysetsCommand.ts @@ -0,0 +1,14 @@ +import debug from 'debug'; +import { createCommand } from '../utils/createCommand.js'; +import { displayKeysetsConfig } from './keysetHelpers.js'; + +export const listKeysetsCommand = createCommand( + 'list', + 'List all available keysets', + [], + async (config) => { + debug('keyset-list:action')({config}); + + displayKeysetsConfig(); + }, +); diff --git a/packages/tools/kadena-cli/src/networks/deleteNetworksCommand.ts b/packages/tools/kadena-cli/src/networks/deleteNetworksCommand.ts index 2e5e90e6e3..c786dae136 100644 --- a/packages/tools/kadena-cli/src/networks/deleteNetworksCommand.ts +++ b/packages/tools/kadena-cli/src/networks/deleteNetworksCommand.ts @@ -1,5 +1,5 @@ import debug from 'debug'; -import { networkDeletePrompt, networkOverwritePrompt } from '../constants/prompts.js'; +import { networkDeletePrompt } from '../constants/prompts.js'; import { globalOptions } from '../utils/globalOptions.js'; import { removeNetwork } from './networksHelpers.js'; @@ -11,7 +11,7 @@ export const deleteNetworksCommand = createCommand( 'Delete network', [globalOptions.networkSelect()], async (config) => { - debug('network-manage:action')({config}); + debug('network-delete:action')({config}); const overwrite = await networkDeletePrompt(config.network); if (overwrite === 'no') { diff --git a/packages/tools/kadena-cli/src/networks/networksHelpers.ts b/packages/tools/kadena-cli/src/networks/networksHelpers.ts index c211b6ddf3..564bb7e349 100644 --- a/packages/tools/kadena-cli/src/networks/networksHelpers.ts +++ b/packages/tools/kadena-cli/src/networks/networksHelpers.ts @@ -1,7 +1,6 @@ import { defaultNetworksPath, networkDefaults } from '../constants/networks.js'; import { PathExists, removeFile, writeFile } from '../utils/filesystem.js'; import { - GlobalOptions, getExistingNetworks, mergeConfigs, sanitizeFilename, diff --git a/packages/tools/kadena-cli/src/utils/createCommand.ts b/packages/tools/kadena-cli/src/utils/createCommand.ts index 2f2fdacec1..977371507f 100644 --- a/packages/tools/kadena-cli/src/utils/createCommand.ts +++ b/packages/tools/kadena-cli/src/utils/createCommand.ts @@ -19,12 +19,16 @@ const formatConfig = (key: string, value?: string | number, prefix: string = '') const displayConfig = (config: Record, indentation: string = ''): void => { Object.getOwnPropertyNames(config).forEach((key) => { const value = config[key]; - const isObject = typeof value === 'object'; + const isArray = Array.isArray(value) + const displayValue = isArray + ? JSON.stringify(value) + : value + const isObject = typeof displayValue === 'object'; console.log( - formatConfig(indentation + key, isObject ? '' : value), + formatConfig(indentation + key, isObject ? '' : displayValue), ); if (isObject) { - displayConfig(value as unknown as Record, indentation + ' '); + displayConfig(displayValue as unknown as Record, indentation + ' '); } }); } @@ -81,7 +85,24 @@ export function createCommand< `\nexecuting: kadena ${program.name()} ${name} ${Object.getOwnPropertyNames( newArgs, ) - .map((arg) => `--${arg.replace(/[A-Z]/g, (match: string) => '-' + match.toLowerCase())} ${newArgs[arg]}`) + .map((arg) => { + let displayValue: string | null = null; + const value = newArgs[arg]; + + if (Array.isArray(value)) { + displayValue = value.join(' '); + } + + if (typeof value === 'string') { + displayValue = `"${value}"` + } + + if (typeof value === 'number') { + displayValue = value.toString(); + } + + return `--${arg.replace(/[A-Z]/g, (match: string) => '-' + match.toLowerCase())} ${displayValue || ''}`; + }) .join(' ')}`, )); diff --git a/packages/tools/kadena-cli/src/utils/globalOptions.ts b/packages/tools/kadena-cli/src/utils/globalOptions.ts index eacedc08e9..1ea8215704 100644 --- a/packages/tools/kadena-cli/src/utils/globalOptions.ts +++ b/packages/tools/kadena-cli/src/utils/globalOptions.ts @@ -1,19 +1,33 @@ import { Option } from 'commander'; import { z } from 'zod'; import { + accountNamePrompt, accountPrompt, chainIdPrompt, + gasPayerPrompt, + keypairNamePrompt, + keypairPrompt, + keypairSelectPrompt, + keysetNamePrompt, + keysetPredicatePrompt, + keysetPrompt, + keysetSelectPrompt, networkExplorerUrlPrompt, networkHostPrompt, networkIdPrompt, networkNamePrompt, networkPrompt, networkSelectPrompt, + publicKeysPrompt, + selectKeypairsPrompt, } from '../constants/prompts.js'; import { loadNetworkConfig } from '../networks/networksHelpers.js'; -import { ensureNetworksConfiguration } from './helpers.js'; +import { ensureAccountsConfiguration, ensureKeypairsConfiguration, ensureKeysetsConfiguration, ensureNetworksConfiguration } from './helpers.js'; import { program } from 'commander'; import chalk from 'chalk'; +import { loadKeypairConfig } from '../keypair/keypairHelpers.js'; +import { loadKeysetConfig } from '../keyset/keysetHelpers.js'; +import { loadAccountConfig } from '../account/accountHelpers.js'; // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export const createOption = < @@ -37,6 +51,26 @@ export const globalOptions = { key: 'account' as const, prompt: accountPrompt, validation: z.string(), + option: new Option( + '-k, --account ', + 'Keypair name', + ), + expand: async (account: string) => { + await ensureAccountsConfiguration(); + try { + return loadAccountConfig(account); + } catch (e) { + console.log(chalk.yellow(`\nNo account "${account}" found. Please create the account.\n`)); + await program.parseAsync(['', '', 'account', 'create']); + const accountName = await accountPrompt(); + return loadAccountConfig(accountName); + } + }, + }), + accountName: createOption({ + key: 'account' as const, + prompt: accountNamePrompt, + validation: z.string(), option: new Option( '-a, --account ', 'Receiver (k:) wallet address', @@ -54,6 +88,96 @@ export const globalOptions = { .max(19), option: new Option('-c, --chain-id '), }), + keypair: createOption({ + key: 'keypair' as const, + prompt: keypairPrompt, + validation: z.string(), + option: new Option( + '-k, --keypair ', + 'Keypair name', + ), + expand: async (keypair: string) => { + await ensureKeypairsConfiguration(); + try { + return loadKeypairConfig(keypair); + } catch (e) { + console.log(chalk.yellow(`\nNo keypair "${keypair}" found. Please create the keypair.\n`)); + await program.parseAsync(['', '', 'keypair', 'create']); + const keypairName = await keypairPrompt(); + return loadKeypairConfig(keypairName); + } + }, + }), + gasPayer: createOption({ + key: 'gasPayer' as const, + prompt: gasPayerPrompt, + validation: z.string(), + option: new Option( + '-g, --gas-payer ', + 'Gas payer account', + ), + expand: async (gasPayer: string) => { + await ensureAccountsConfiguration(); + try { + return loadAccountConfig(gasPayer); + } catch (e) { + console.log(chalk.yellow(`\nNo account "${gasPayer}" found. Please create the account.\n`)); + await program.parseAsync(['', '', 'account', 'create']); + const accountName = await accountPrompt(); + return loadAccountConfig(accountName); + } + }, + }), + keypairName: createOption({ + key: 'name' as const, + prompt: keypairNamePrompt, + validation: z.string(), + option: new Option('-n, --name ', 'Keypair name'), + }), + keypairSelect: createOption({ + key: 'name' as const, + prompt: keypairSelectPrompt, + validation: z.string(), + option: new Option('-n, --name ', 'Keypair name'), + }), + keyset: createOption({ + key: 'keyset' as const, + prompt: keysetPrompt, + validation: z.string(), + option: new Option( + '-k, --keyset ', + 'Keyset name', + ), + expand: async (keyset: string) => { + await ensureKeysetsConfiguration(); + try { + return loadKeysetConfig(keyset); + } catch (e) { + console.log(chalk.yellow(`\nNo keyset "${keyset}" found. Please create the keyset.\n`)); + await program.parseAsync(['', '', 'keyset', 'create']); + const keysetName = await keysetPrompt(); + return loadKeysetConfig(keysetName); + } + }, + }), + keysetName: createOption({ + key: 'name' as const, + prompt: keysetNamePrompt, + validation: z.string(), + option: new Option('-n, --name ', 'Keyset name'), + }), + keysetPredicate: createOption({ + key: 'predicate' as const, + prompt: keysetPredicatePrompt, + validation: z.string(), + option: new Option('-p, --predicate ', 'Keyset predicate'), + }), + keysetSelect: createOption({ + key: 'name' as const, + prompt: keysetSelectPrompt, + validation: z.string(), + option: new Option('-n, --name ', 'Keyset name'), + }), network: createOption({ key: 'network' as const, prompt: networkPrompt, @@ -104,4 +228,27 @@ export const globalOptions = { validation: z.string().optional(), option: new Option('-e, --network-explorer-url ', 'Kadena network explorer URL (e.g. "https://explorer.chainweb.com/mainnet/tx/")'), }), + otherPublicKeys: createOption({ + key: 'publicKeys' as const, + prompt: publicKeysPrompt, + validation: z.string().optional(), + option: new Option('-p, --public-keys ', 'Public keys (comma separated)'), + expand: async (publicKeys: string) => { + return publicKeys.split(',').map(value => value.trim()); + }, + }), + publicKeysFromKeypairs: createOption({ + key: 'publicKeysFromKeypairs' as const, + prompt: selectKeypairsPrompt, + validation: z.array(z.string()), + option: new Option('-k, --public-keys-from-keypairs ', 'Public keys from keypairs'), + expand: async (publicKeysFromKeypairs) => { + let publicKeys: string[] = [] + for (let keypair of publicKeysFromKeypairs) { + const keypairConfig = await loadKeypairConfig(keypair) + publicKeys.push(keypairConfig.publicKey || ''); + } + return publicKeys + }, + }), } as const; diff --git a/packages/tools/kadena-cli/src/utils/helpers.ts b/packages/tools/kadena-cli/src/utils/helpers.ts index ba7dc88811..22b60731e1 100644 --- a/packages/tools/kadena-cli/src/utils/helpers.ts +++ b/packages/tools/kadena-cli/src/utils/helpers.ts @@ -6,6 +6,9 @@ import { Command, Option } from 'commander'; import { existsSync, mkdirSync, readdirSync, readFileSync } from 'fs'; import path from 'path'; import { globalOptions } from './globalOptions.js'; +import { defaultKeypairsPath } from '../constants/keypairs.js'; +import { defaultKeysetsPath } from '../constants/keysets.js'; +import { defaultAccountsPath } from '../constants/accounts.js'; export interface ICustomChoice { value: string; @@ -233,6 +236,75 @@ export function capitalizeFirstLetter(str: string): string { return str.charAt(0).toUpperCase() + str.slice(1); } +export async function ensureAccountsConfiguration(): Promise { + if (existsSync(defaultAccountsPath)) { + return; + } + + mkdirSync(defaultAccountsPath, { recursive: true }); + await import('./../account/init.js'); +} + +export async function getExistingAccounts(): Promise { + await ensureAccountsConfiguration(); + + try { + return readdirSync(defaultAccountsPath).map((filename) => ({ + value: path.basename(filename.toLowerCase(), '.yaml'), + name: path.basename(filename.toLowerCase(), '.yaml'), + })); + } catch (error) { + console.error('Error reading account directory:', error); + return []; + } +} + +export async function ensureKeypairsConfiguration(): Promise { + if (existsSync(defaultKeypairsPath)) { + return; + } + + mkdirSync(defaultKeypairsPath, { recursive: true }); + await import('./../keypair/init.js'); +} + +export async function getExistingKeypairs(): Promise { + await ensureKeypairsConfiguration(); + + try { + return readdirSync(defaultKeypairsPath).map((filename) => ({ + value: path.basename(filename.toLowerCase(), '.yaml'), + name: path.basename(filename.toLowerCase(), '.yaml'), + })); + } catch (error) { + console.error('Error reading keypair directory:', error); + return []; + } +} + +export async function ensureKeysetsConfiguration(): Promise { + if (existsSync(defaultKeysetsPath)) { + return; + } + + mkdirSync(defaultKeysetsPath, { recursive: true }); + await import('./../keyset/init.js'); +} + +export async function getExistingKeysets(): Promise { + await ensureKeysetsConfiguration(); + + try { + return readdirSync(defaultKeysetsPath).map((filename) => ({ + value: path.basename(filename.toLowerCase(), '.yaml'), + name: path.basename(filename.toLowerCase(), '.yaml'), + })); + } catch (error) { + console.error('Error reading keyset directory:', error); + return []; + } +} + export async function ensureNetworksConfiguration(): Promise { if (existsSync(defaultNetworksPath)) { return; From 8e95215d1e2645d4d2dde37b9325b97bfc89c5ee Mon Sep 17 00:00:00 2001 From: Jesse van Muijden Date: Thu, 16 Nov 2023 04:14:02 -0400 Subject: [PATCH 11/13] feat(kadena-cli): create account --- .../tools/kadena-cli/src/account/createAccountCommand.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/tools/kadena-cli/src/account/createAccountCommand.ts b/packages/tools/kadena-cli/src/account/createAccountCommand.ts index 9b0f1023e0..ed439c4196 100644 --- a/packages/tools/kadena-cli/src/account/createAccountCommand.ts +++ b/packages/tools/kadena-cli/src/account/createAccountCommand.ts @@ -59,13 +59,14 @@ export const createAccountCommand = createCommand( secretKey: gasPayerKeypair.secretKey, }); - const c = await createPrincipalAccount( + const c = await createAccount( { + account: account as string, keyset: { - pred: 'keys-all', - keys: ['1827389ca64cb5c3c77352eb2087c2bb503061d22fb1edcadb5d90ad1dee80f5'], + pred: config.keysetConfig.predicate as 'keys-all' | 'keys-two' | 'keys-one', + keys: publicKeys, }, - gasPayer: { account: 'sender00', publicKeys: [gasPayerKeypair.publicKey] }, + gasPayer: { account: config.gasPayerConfig.account, publicKeys: [gasPayerKeypair.publicKey] }, chainId: config.chainId as ChainId, }, { From f4282bb71ed4cea6d930da35da6c4c866621d818 Mon Sep 17 00:00:00 2001 From: Jesse van Muijden Date: Mon, 30 Oct 2023 15:33:23 -0400 Subject: [PATCH 12/13] feat(kadena-cli): add kadena-cli devnet commands --- .changeset/large-snails-reply.md | 5 + .../tools/kadena-cli/src/constants/devnets.ts | 30 +++ .../src/devnet/createDevnetsCommand.ts | 107 +++++++++ .../src/devnet/devnetsCreateQuestions.ts | 127 ++++++++++ .../kadena-cli/src/devnet/devnetsHelpers.ts | 220 ++++++++++++++++++ .../tools/kadena-cli/src/devnet/docker.ts | 156 +++++++++++++ packages/tools/kadena-cli/src/devnet/index.ts | 31 ++- .../src/devnet/listDevnetsCommand.ts | 7 + .../src/devnet/manageDevnetsCommand.ts | 66 ++++++ .../src/devnet/removeDevnetCommand.ts | 49 ++++ .../kadena-cli/src/devnet/runDevnetCommand.ts | 83 +++++++ packages/tools/kadena-cli/src/devnet/start.ts | 23 -- .../src/devnet/stopDevnetCommand.ts | 32 +++ .../src/devnet/updateDevnetCommand.ts | 34 +++ packages/tools/kadena-cli/src/index.js | 40 ++++ .../tools/kadena-cli/src/utils/helpers.ts | 13 +- 16 files changed, 994 insertions(+), 29 deletions(-) create mode 100644 .changeset/large-snails-reply.md create mode 100644 packages/tools/kadena-cli/src/constants/devnets.ts create mode 100644 packages/tools/kadena-cli/src/devnet/createDevnetsCommand.ts create mode 100644 packages/tools/kadena-cli/src/devnet/devnetsCreateQuestions.ts create mode 100644 packages/tools/kadena-cli/src/devnet/devnetsHelpers.ts create mode 100644 packages/tools/kadena-cli/src/devnet/docker.ts create mode 100644 packages/tools/kadena-cli/src/devnet/listDevnetsCommand.ts create mode 100644 packages/tools/kadena-cli/src/devnet/manageDevnetsCommand.ts create mode 100644 packages/tools/kadena-cli/src/devnet/removeDevnetCommand.ts create mode 100644 packages/tools/kadena-cli/src/devnet/runDevnetCommand.ts delete mode 100644 packages/tools/kadena-cli/src/devnet/start.ts create mode 100644 packages/tools/kadena-cli/src/devnet/stopDevnetCommand.ts create mode 100644 packages/tools/kadena-cli/src/devnet/updateDevnetCommand.ts create mode 100644 packages/tools/kadena-cli/src/index.js diff --git a/.changeset/large-snails-reply.md b/.changeset/large-snails-reply.md new file mode 100644 index 0000000000..9cc8c0a0c1 --- /dev/null +++ b/.changeset/large-snails-reply.md @@ -0,0 +1,5 @@ +--- +'@kadena/kadena-cli': patch +--- + +add devnet commands to kadena cli diff --git a/packages/tools/kadena-cli/src/constants/devnets.ts b/packages/tools/kadena-cli/src/constants/devnets.ts new file mode 100644 index 0000000000..fdd22a4b89 --- /dev/null +++ b/packages/tools/kadena-cli/src/constants/devnets.ts @@ -0,0 +1,30 @@ +import type { TDevnetsCreateOptions } from '../devnet/devnetsCreateQuestions.js'; + +export interface IDefaultDevnetOptions { + [key: string]: TDevnetsCreateOptions; +} + +/** + * @const devnetDefaults + * Provides the default devnet configurations. + */ +export const devnetDefaults: IDefaultDevnetOptions = { + devnet: { + name: 'devnet', + port: 8080, + useVolume: false, + mountPactFolder: '', + version: 'latest', + }, + other: { + name: '', + port: 8080, + useVolume: false, + mountPactFolder: '', + version: '', + }, +}; + +export const defaultDevnetsPath: string = `${process.cwd()}/.kadena/devnets`; +export const standardDevnets: string[] = ['devnet']; +export const defaultDevnet: string = 'devnet'; diff --git a/packages/tools/kadena-cli/src/devnet/createDevnetsCommand.ts b/packages/tools/kadena-cli/src/devnet/createDevnetsCommand.ts new file mode 100644 index 0000000000..73f169a560 --- /dev/null +++ b/packages/tools/kadena-cli/src/devnet/createDevnetsCommand.ts @@ -0,0 +1,107 @@ +import { defaultDevnetsPath } from '../constants/devnets.js'; +import { ensureFileExists } from '../utils/filesystem.js'; +import { clearCLI, collectResponses } from '../utils/helpers.js'; +import { processZodErrors } from '../utils/processZodErrors.js'; + +import type { TDevnetsCreateOptions } from './devnetsCreateQuestions.js'; +import { + DevnetsCreateOptions, + devnetsCreateQuestions, +} from './devnetsCreateQuestions.js'; +import { displayDevnetConfig, writeDevnets } from './devnetsHelpers.js'; + +import { select } from '@inquirer/prompts'; +import chalk from 'chalk'; +import { Option, type Command } from 'commander'; +import debug from 'debug'; +import path from 'path'; + +async function shouldProceedWithDevnetCreate(devnet: string): Promise { + const filePath = path.join(defaultDevnetsPath, `${devnet}.yaml`); + if (ensureFileExists(filePath)) { + const overwrite = await select({ + message: `Your devnet (config) already exists. Do you want to update it?`, + choices: [ + { value: 'yes', name: 'Yes' }, + { value: 'no', name: 'No' }, + ], + }); + return overwrite === 'yes'; + } + return true; +} + +export async function runDevnetsCreate( + program: Command, + version: string, + args: TDevnetsCreateOptions, +): Promise { + try { + const responses = await collectResponses(args, devnetsCreateQuestions); + + const devnetConfig = { ...args, ...responses }; + + DevnetsCreateOptions.parse(devnetConfig); + + writeDevnets(devnetConfig); + + displayDevnetConfig(devnetConfig); + + const proceed = await select({ + message: 'Is the above devnet configuration correct?', + choices: [ + { value: 'yes', name: 'Yes' }, + { value: 'no', name: 'No' }, + ], + }); + + if (proceed === 'no') { + clearCLI(true); + console.log(chalk.yellow("Let's restart the configuration process.")); + await runDevnetsCreate(program, version, args); + } else { + console.log(chalk.green('Configuration complete. Goodbye!')); + } + } catch (e) { + console.error(e); + processZodErrors(program, e, args); + } +} + +export function createDevnetsCommand(program: Command, version: string): void { + program + .command('create') + .description('Create new devnet') + .option('-n, --name ', 'Container name (e.g. "devnet")') + .addOption( + new Option( + '-p, --port ', + 'Port to forward to the Chainweb node API (e.g. 8080)', + ).argParser((value) => parseInt(value, 10)), + ) + .option( + '-u, --useVolume', + 'Create a persistent volume to mount to the container', + ) + .option( + '-m, --mountPactFolder ', + 'Mount a folder containing Pact files to the container (e.g. "./pact")', + ) + .option( + '-v, --version ', + 'Version of the kadena/devnet Docker image to use (e.g. "latest")', + ) + .action(async (args: TDevnetsCreateOptions) => { + debug('devnet-create:action')({ args }); + + if ( + args.name && + !(await shouldProceedWithDevnetCreate(args.name.toLowerCase())) + ) { + console.log(chalk.red('Devnet creation aborted.')); + return; + } + + await runDevnetsCreate(program, version, args); + }); +} diff --git a/packages/tools/kadena-cli/src/devnet/devnetsCreateQuestions.ts b/packages/tools/kadena-cli/src/devnet/devnetsCreateQuestions.ts new file mode 100644 index 0000000000..79d1fcac5a --- /dev/null +++ b/packages/tools/kadena-cli/src/devnet/devnetsCreateQuestions.ts @@ -0,0 +1,127 @@ +import type { IQuestion } from '../utils/helpers.js'; +import { + capitalizeFirstLetter, + getExistingDevnets, + isAlphabetic, +} from '../utils/helpers.js'; + +import { input, select } from '@inquirer/prompts'; +import { z } from 'zod'; + +// eslint-disable-next-line @rushstack/typedef-var +export const DevnetsCreateOptions = z.object({ + name: z.string(), + port: z.number().optional(), + useVolume: z.boolean().optional(), + mountPactFolder: z.string().optional(), + version: z.string().optional(), +}); + +export type TDevnetsCreateOptions = z.infer; + +interface IDevnetManageQuestionsQuestions + extends Pick, 'key' | 'prompt'> {} + +interface ICustomChoice { + value: string; + name?: string; + description?: string; + disabled?: boolean | string; +} + +export async function askForDevnet(): Promise { + const existingDevnets: ICustomChoice[] = getExistingDevnets(); + + const allDevnetChoices: ICustomChoice[] = [...existingDevnets] + .filter((v, i, a) => a.findIndex((v2) => v2.name === v.name) === i) + .map((devnet) => { + return { + value: devnet.value, + name: capitalizeFirstLetter(devnet.value), + }; + }); + + const devnetChoice = await select({ + message: + 'Select an (default) existing devnet configuration or create a new one:', + choices: [ + ...allDevnetChoices, + { value: 'CREATE_NEW', name: 'Create a New Devnet' } as ICustomChoice, + ], + }); + + if (devnetChoice === 'CREATE_NEW') { + const newDevnetName = await input({ + validate: function (input) { + if (input === '') { + return 'Devnet name cannot be empty! Please enter something.'; + } + if (!isAlphabetic(input)) { + return 'Devnet name must be alphabetic! Please enter a valid name.'; + } + return true; + }, + message: 'Enter the name for your new devnet container:', + }); + return newDevnetName.toLowerCase(); + } + + return devnetChoice.toLowerCase(); +} + +export const devnetsCreateQuestions: IQuestion[] = [ + { + key: 'name', + prompt: async () => await askForDevnet(), + }, + { + key: 'port', + prompt: async () => { + const port = await input({ + default: '8080', + message: 'Enter a port number to forward to the Chainweb node API', + validate: function (input) { + const port = parseInt(input); + if (isNaN(port)) { + return 'Port must be a number! Please enter a valid port number.'; + } + return true; + }, + }); + return parseInt(port); + }, + }, + { + key: 'useVolume', + prompt: async () => + await select({ + message: 'Would you like to create a persistent volume?', + choices: [ + { value: false, name: 'No' }, + { value: true, name: 'Yes' }, + ], + }), + }, + { + key: 'mountPactFolder', + prompt: async () => + await input({ + default: '', + message: + 'Enter the relative path to a folder containing your Pact files to mount (e.g. ./pact) or leave empty to skip.', + }), + }, + { + key: 'version', + prompt: async () => + await input({ + default: 'latest', + message: + 'Enter the version of the kadena/devnet image you would like to use.', + }), + }, +]; + +export const devnetManageQuestions: IDevnetManageQuestionsQuestions[] = [ + ...devnetsCreateQuestions.filter((question) => question.key !== 'name'), +]; diff --git a/packages/tools/kadena-cli/src/devnet/devnetsHelpers.ts b/packages/tools/kadena-cli/src/devnet/devnetsHelpers.ts new file mode 100644 index 0000000000..e431c948cc --- /dev/null +++ b/packages/tools/kadena-cli/src/devnet/devnetsHelpers.ts @@ -0,0 +1,220 @@ +import { defaultDevnet, defaultDevnetsPath, devnetDefaults } from '../constants/devnets.js'; +import { PathExists, writeFile } from '../utils/filesystem.js'; +import { + getExistingDevnets, + mergeConfigs, + sanitizeFilename, +} from '../utils/helpers.js'; + +import type { TDevnetsCreateOptions } from './devnetsCreateQuestions.js'; + +import chalk from 'chalk'; +import type { WriteFileOptions } from 'fs'; +import { existsSync, readFileSync, rmSync } from 'fs'; +import yaml, { load } from 'js-yaml'; +import path from 'path'; + +export interface ICustomDevnetsChoice { + value: string; + name?: string; + description?: string; + disabled?: boolean | string; +} + +/** + * Writes the given devnet setting to the devnet folder + * + * @param {TDevnetsCreateOptions} options - The set of configuration options. + * @param {string} options.name - The name of your devnet container. + * @param {number} options.port - The port to forward to the Chainweb node API. + * @param {boolean} options.useVolume - Whether or not to mount a persistent volume to the container. + * @param {string} options.mountPactFolder - The folder containing Pact files to mount to the container. + * @param {string} options.version - The version of the kadena/devnet image to use. + * @returns {void} - No return value; the function writes directly to a file. + */ +export function writeDevnets(options: TDevnetsCreateOptions): void { + const { name } = options; + const sanitizedName = sanitizeFilename(name).toLowerCase(); + const devnetFilePath = path.join(defaultDevnetsPath, `${sanitizedName}.yaml`); + + let existingConfig: TDevnetsCreateOptions; + + if (PathExists(devnetFilePath)) { + existingConfig = yaml.load( + readFileSync(devnetFilePath, 'utf8'), + ) as TDevnetsCreateOptions; + } else { + // Explicitly check if devnet key exists in devnetDefaults and is not undefined + existingConfig = + typeof devnetDefaults[name] !== 'undefined' + ? { ...devnetDefaults[name] } + : { ...devnetDefaults.other }; + } + + const devnetConfig = mergeConfigs(existingConfig, options); + + devnetConfig.mountPactFolder = options.mountPactFolder; + + writeFile( + devnetFilePath, + yaml.dump(devnetConfig), + 'utf8' as WriteFileOptions, + ); +} + +export function loadDevnet(name: string): TDevnetsCreateOptions | null { + const devnetFilePath = path.join(defaultDevnetsPath, `${name}.yaml`); + const fileExists = existsSync(devnetFilePath); + + if (!fileExists) { + return null; + } + + return yaml.load( + readFileSync(devnetFilePath, 'utf8'), + ) as TDevnetsCreateOptions; +} + +export function removeDevnet(name: string): void { + const devnetFilePath = path.join(defaultDevnetsPath, `${name}.yaml`); + const fileExists = existsSync(devnetFilePath); + + if (!fileExists) { + return; + } + + rmSync(devnetFilePath); +} + +export function defaultDevnetIsConfigured(): boolean { + return PathExists(path.join(defaultDevnetsPath, `${defaultDevnet}.yaml`)); +} + +export function getDevnetConfiguration(name: string): TDevnetsCreateOptions | null { + const devnetFilePath = path.join(defaultDevnetsPath, `${name}.yaml`); + + if (! PathExists(devnetFilePath)) { + return null; + } + + return yaml.load(readFileSync(devnetFilePath, 'utf8')) as TDevnetsCreateOptions; +} + +/** + * Displays the devnet configuration in a formatted manner. + * + * @param {TDevnetsCreateOptions} devnetConfig - The devnet configuration to display. + */ +export function displayDevnetConfig(devnetConfig: TDevnetsCreateOptions): void { + const log = console.log; + const formatLength = 80; // Maximum width for the display + + const displaySeparator = (): void => { + log(chalk.green('-'.padEnd(formatLength, '-'))); + }; + + const formatConfig = (key: string, value?: string): string => { + const valueDisplay = + value !== undefined && value.trim() !== '' + ? chalk.green(value) + : chalk.red('Not Set'); + const keyValue = `${key}: ${valueDisplay}`; + const remainingWidth = + formatLength - keyValue.length > 0 ? formatLength - keyValue.length : 0; + return ` ${keyValue}${' '.repeat(remainingWidth)} `; + }; + + displaySeparator(); + log(formatConfig('Name', devnetConfig.name)); + log(formatConfig('Port', devnetConfig.port?.toString())); + log( + formatConfig( + 'Volume', + devnetConfig.useVolume ? `kadena_${devnetConfig.name}` : 'N/A', + ), + ); + log(formatConfig('Pact folder mount', devnetConfig.mountPactFolder || 'N/A')); + log(formatConfig('kadena/devnet version', devnetConfig.version)); + displaySeparator(); +} + +export function displayDevnetsConfig(): void { + const log = console.log; + const formatLength = 80; // Maximum width for the display + + const displaySeparator = (): void => { + log(chalk.green('-'.padEnd(formatLength, '-'))); + }; + + const formatConfig = ( + key: string, + value?: string, + isDefault?: boolean, + ): string => { + const valueDisplay = + (value?.trim() ?? '') !== '' ? chalk.green(value!) : chalk.red('Not Set'); + + const defaultIndicator = + isDefault === true ? chalk.yellow(' (Using defaults)') : ''; + const keyValue = `${key}: ${valueDisplay}${defaultIndicator}`; + const remainingWidth = + formatLength - keyValue.length > 0 ? formatLength - keyValue.length : 0; + return ` ${keyValue}${' '.repeat(remainingWidth)} `; + }; + + const existingDevnets: ICustomDevnetsChoice[] = getExistingDevnets(); + const standardDevnets: string[] = ['devnet']; + + existingDevnets.forEach(({ value }) => { + const storedConfig = loadDevnet(value); + const fileExists = storedConfig !== null; + const devnetConfig = fileExists ? storedConfig : devnetDefaults[value]; + + displaySeparator(); + log(formatConfig('Name', value, !fileExists)); + log(formatConfig('Port', devnetConfig.port?.toString(), !fileExists)); + log( + formatConfig( + 'Volume', + devnetConfig.useVolume ? `kadena_${devnetConfig.name}` : 'N/A', + !fileExists, + ), + ); + log( + formatConfig( + 'Pact folder mount', + devnetConfig.mountPactFolder || 'N/A', + !fileExists, + ), + ); + log( + formatConfig('kadena/devnet version', devnetConfig.version, !fileExists), + ); + }); + + standardDevnets.forEach((devnet) => { + if (!existingDevnets.some(({ value }) => value === devnet)) { + const devnetConfig = devnetDefaults[devnet]; + displaySeparator(); + log(formatConfig('Name', devnet, true)); + log(formatConfig('Port', devnetConfig.port?.toString(), true)); + log( + formatConfig( + 'Volume', + devnetConfig.useVolume ? `kadena_${devnetConfig.name}` : 'N/A', + true, + ), + ); + log( + formatConfig( + 'Pact folder mount', + devnetConfig.mountPactFolder || 'N/A', + true, + ), + ); + log(formatConfig('kadena/devnet version', devnetConfig.version, true)); + } + }); + + displaySeparator(); +} diff --git a/packages/tools/kadena-cli/src/devnet/docker.ts b/packages/tools/kadena-cli/src/devnet/docker.ts new file mode 100644 index 0000000000..cf543bbf3d --- /dev/null +++ b/packages/tools/kadena-cli/src/devnet/docker.ts @@ -0,0 +1,156 @@ +import chalk from 'chalk'; +import { execSync } from 'child_process'; +import { TDevnetsCreateOptions } from './devnetsCreateQuestions.js'; + +const volumePrefix = 'kadena_'; +const containerDataFolder = '/data'; +const containerPactFolder = '/pact-cli'; +const containerPactFolderPermissions = 'ro'; +const chainwebNodeApiPort = '8080'; +const devnetImageName = 'kadena/devnet'; + +export function isDockerInstalled(): boolean { + try { + execSync('docker -v'); + return true; + } catch (error) { + return false; + } +} + +export const dockerVolumeName = (containerName: string): string => + `${volumePrefix}${containerName}`; + +const maybeCreateVolume = (useVolume: boolean, containerName: string): void => { + if (!useVolume) { + console.log( + chalk.green('Not creating persistent volume as per configuration.'), + ); + return; + } + + const volumeName = dockerVolumeName(containerName); + + try { + const existingVolumes = execSync('docker volume ls --format "{{.Name}}"') + .toString() + .trim() + .split('\n'); + + if (existingVolumes.includes(volumeName)) { + console.log(chalk.green(`Using existing volume: ${volumeName}`)); + return; + } + + console.log(chalk.green(`Creating volume: ${volumeName}`)); + + execSync(`docker volume create ${volumeName}`); + + console.log(chalk.green(`Successfully created volume: ${volumeName}`)); + } catch (error) { + console.log( + chalk.red( + `Something went wrong with the Docker volume: ${error.message}`, + ), + ); + } +}; + +const formatDockerRunOptions = ( + configuration: TDevnetsCreateOptions, +): string => { + const options = ['-d']; + + if (configuration.port) { + options.push('-p'); + options.push(`${configuration.port.toString()}:${chainwebNodeApiPort}`); + } + + if (configuration.useVolume) { + options.push('-v'); + options.push( + `${dockerVolumeName(configuration.name)}:${containerDataFolder}`, + ); + } + + if (configuration.mountPactFolder) { + options.push('-v'); + options.push( + `${configuration.mountPactFolder}:${containerPactFolder}:${containerPactFolderPermissions}`, + ); + } + + options.push('--name'); + options.push(configuration.name); + + const version = configuration.version ? `:${configuration.version}` : ''; + + options.push(`${devnetImageName}${version}`); + + return options.join(' '); +}; + +const containerExists = (name: string): boolean => { + try { + const existingContainers = execSync('docker ps -a --format "{{.Names}}"') + .toString() + .trim() + .split('\n'); + return existingContainers.includes(name); + } catch (error) { + console.log( + chalk.red( + `Error checking if the container "${name}" already exists: ${error.message}`, + ), + ); + return false; + } +}; + +export function runDevnet(configuration: TDevnetsCreateOptions): void { + maybeCreateVolume(!!configuration.useVolume, configuration.name); + const dockerRunOptions = formatDockerRunOptions(configuration); + + try { + if (containerExists(configuration.name)) { + execSync(`docker start ${configuration.name}`); + console.log( + chalk.green(`Started existing container: ${configuration.name}`), + ); + return; + } + execSync(`docker run ${dockerRunOptions}`); + console.log( + chalk.green(`New devnet container "${configuration.name}" is running`), + ); + } catch (error) { + console.log(chalk.red(`Failed to run devnet: ${error.message}`)); + } +} + +export function stopDevnet(containerName: string): void { + try { + execSync(`docker stop ${containerName}`); + console.log(chalk.green(`Stopped devnet container: ${containerName}`)); + } catch (error) { + console.log(chalk.red(`Failed to stop devnet: ${error.message}`)); + } +} + +export function removeDevnet(containerName: string): void { + execSync(`docker rm -v ${containerName}`); +} + +export function removeVolume(containerName: string): void { + execSync(`docker volume rm ${dockerVolumeName(containerName)}`); +} + +export function updateDevnet(version?: string): void { + const image = `${devnetImageName}:${version || 'latest'}`; + try { + execSync(`docker pull ${image}`); + console.log(chalk.green(`Updated ${image}`)); + } catch (error) { + console.log(chalk.red(`Failed to update ${image}`)); + } +} diff --git a/packages/tools/kadena-cli/src/devnet/index.ts b/packages/tools/kadena-cli/src/devnet/index.ts index b199ae6ea0..5023054f13 100644 --- a/packages/tools/kadena-cli/src/devnet/index.ts +++ b/packages/tools/kadena-cli/src/devnet/index.ts @@ -1,13 +1,36 @@ -import { startCommand } from './start.js'; +import { createSimpleSubCommand } from '../utils/helpers.js'; + +import { createDevnetsCommand } from './createDevnetsCommand.js'; +import type { IListDevnetsArgs } from './listDevnetsCommand.js'; +import { listDevnetsAction } from './listDevnetsCommand.js'; +import { manageDevnets } from './manageDevnetsCommand.js'; +import { runDevnetCommand } from './runDevnetCommand.js'; import type { Command } from 'commander'; +import { removeDevnetCommand } from './removeDevnetCommand.js'; +import { stopDevnetCommand } from './stopDevnetCommand.js'; +import { updateDevnetCommand } from './updateDevnetCommand.js'; const SUBCOMMAND_ROOT: 'devnet' = 'devnet'; export function devnetCommandFactory(program: Command, version: string): void { - const devnetProgram = program + const devnetsProgram = program .command(SUBCOMMAND_ROOT) - .description(`Tool for starting, stopping and managing the local devnet`); + .description( + `Tool for configuring, starting, stopping and managing local devnet containers`, + ); + + // Attach list subcommands to the devnetsProgram + createSimpleSubCommand( + 'list', + 'List all available devnet container configurations', + listDevnetsAction, + )(devnetsProgram); - startCommand(devnetProgram, version); + manageDevnets(devnetsProgram, version); + createDevnetsCommand(devnetsProgram, version); + runDevnetCommand(devnetsProgram, version); + stopDevnetCommand(devnetsProgram, version); + removeDevnetCommand(devnetsProgram, version); + updateDevnetCommand(devnetsProgram, version); } diff --git a/packages/tools/kadena-cli/src/devnet/listDevnetsCommand.ts b/packages/tools/kadena-cli/src/devnet/listDevnetsCommand.ts new file mode 100644 index 0000000000..a1336a3ac2 --- /dev/null +++ b/packages/tools/kadena-cli/src/devnet/listDevnetsCommand.ts @@ -0,0 +1,7 @@ +import { displayDevnetsConfig } from './devnetsHelpers.js'; + +export interface IListDevnetsArgs {} + +export const listDevnetsAction = (args: IListDevnetsArgs): void => { + displayDevnetsConfig(); +}; diff --git a/packages/tools/kadena-cli/src/devnet/manageDevnetsCommand.ts b/packages/tools/kadena-cli/src/devnet/manageDevnetsCommand.ts new file mode 100644 index 0000000000..99df9729e6 --- /dev/null +++ b/packages/tools/kadena-cli/src/devnet/manageDevnetsCommand.ts @@ -0,0 +1,66 @@ +import { defaultDevnetsPath } from '../constants/devnets.js'; +import { + clearCLI, + collectResponses, + getExistingDevnets, +} from '../utils/helpers.js'; +import { processZodErrors } from '../utils/processZodErrors.js'; + +import type { TDevnetsCreateOptions } from './devnetsCreateQuestions.js'; +import { + devnetManageQuestions, + DevnetsCreateOptions, +} from './devnetsCreateQuestions.js'; +import type { ICustomDevnetsChoice } from './devnetsHelpers.js'; +import { writeDevnets } from './devnetsHelpers.js'; + +import { select } from '@inquirer/prompts'; +import chalk from 'chalk'; +import type { Command } from 'commander'; +import { readFileSync } from 'fs'; +import yaml from 'js-yaml'; +import path from 'path'; + +export interface IManageDevnetsOptions {} + +export function manageDevnets(program: Command, version: string): void { + program + .command('manage') + .description('Manage devnet(s)') + .action(async (args: IManageDevnetsOptions) => { + try { + const existingDevnets: ICustomDevnetsChoice[] = getExistingDevnets(); + + if (existingDevnets.length === 0) { + console.log(chalk.red('No existing devnets found.')); + return; + } + + const selectedDevnet = await select({ + message: 'Select the devnet you want to manage:', + choices: existingDevnets, + }); + const devnetFilePath = path.join( + defaultDevnetsPath, + `${selectedDevnet}.yaml`, + ); + const existingConfig: TDevnetsCreateOptions = yaml.load( + readFileSync(devnetFilePath, 'utf8'), + ) as TDevnetsCreateOptions; + + const responses = await collectResponses( + { name: selectedDevnet }, + devnetManageQuestions, + ); + const devnetConfig = { ...existingConfig, ...responses }; + + DevnetsCreateOptions.parse(devnetConfig); + + writeDevnets(devnetConfig); + clearCLI(); + console.log(chalk.green('Devnet configurations updated.')); + } catch (e) { + processZodErrors(program, e, args); + } + }); +} diff --git a/packages/tools/kadena-cli/src/devnet/removeDevnetCommand.ts b/packages/tools/kadena-cli/src/devnet/removeDevnetCommand.ts new file mode 100644 index 0000000000..a694e2fcbf --- /dev/null +++ b/packages/tools/kadena-cli/src/devnet/removeDevnetCommand.ts @@ -0,0 +1,49 @@ +import chalk from 'chalk'; +import { type Command } from 'commander'; +import debug from 'debug'; +import { defaultDevnet } from '../constants/devnets.js'; +import { processZodErrors } from '../utils/processZodErrors.js'; +import { getDevnetConfiguration, removeDevnet as removeDevnetConfiguration } from './devnetsHelpers.js'; +import { dockerVolumeName, isDockerInstalled, removeDevnet, removeVolume } from './docker.js'; + +export function removeDevnetCommand(program: Command, version: string): void { + program + .command('remove') + .description('Remove devnet container and configuration') + .option('-n, --name ', 'Container name (e.g. "devnet")') + .action(async (args: { name?: string }) => { + debug('devnet-remove:action')({ args }); + + try { + // Abort if Docker is not installed + if (!isDockerInstalled()) { + console.log( + chalk.red( + 'Stopping devnet requires Docker. Please install Docker and try again.', + ), + ); + return; + } + + const name = args.name || defaultDevnet; + removeDevnet(name); + console.log(chalk.green(`Removed devnet container: ${name}`)); + + const configuration = getDevnetConfiguration(name); + + if (configuration?.useVolume) { + removeVolume(name); + console.log( + chalk.green( + `Removed volume: ${dockerVolumeName(name)}`, + ), + ); + } + + removeDevnetConfiguration(name); + console.log(chalk.green(`Successfully removed devnet configuration: ${name}`)); + } catch (e) { + console.log(chalk.yellow(`Removing devnet did not go as planned: ${e.message}`)); + } + }); +} diff --git a/packages/tools/kadena-cli/src/devnet/runDevnetCommand.ts b/packages/tools/kadena-cli/src/devnet/runDevnetCommand.ts new file mode 100644 index 0000000000..273a5b753c --- /dev/null +++ b/packages/tools/kadena-cli/src/devnet/runDevnetCommand.ts @@ -0,0 +1,83 @@ +import { defaultDevnet, devnetDefaults } from '../constants/devnets.js'; +import { getExistingDevnets } from '../utils/helpers.js'; + +import type { TDevnetsCreateOptions } from './devnetsCreateQuestions.js'; +import { defaultDevnetIsConfigured, loadDevnet, writeDevnets } from './devnetsHelpers.js'; + +import chalk from 'chalk'; +import { Option, type Command } from 'commander'; +import debug from 'debug'; +import { processZodErrors } from '../utils/processZodErrors.js'; +import { runDevnetsCreate } from './createDevnetsCommand.js'; +import { isDockerInstalled, runDevnet } from './docker.js'; + +export function runDevnetCommand(program: Command, version: string): void { + program + .command('run') + .description('Run devnet') + .option('-n, --name ', 'Container name (e.g. "devnet")') + .addOption( + new Option( + '-p, --port ', + 'Port to forward to the Chainweb node API (e.g. 8080)', + ).argParser((value) => parseInt(value, 10)), + ) + .option( + '-u, --useVolume', + 'Create a persistent volume to mount to the container', + ) + .option( + '-m, --mountPactFolder ', + 'Mount a folder containing Pact files to the container (e.g. "./pact")', + ) + .option( + '-v, --version ', + 'Version of the kadena/devnet Docker image to use (e.g. "latest")', + ) + .action(async (args: TDevnetsCreateOptions) => { + debug('devnet-run:action')({ args }); + + try { + // Abort if Docker is not installed + if (!isDockerInstalled()) { + console.log( + chalk.red( + 'Running devnet requires Docker. Please install Docker and try again.', + ), + ); + return; + } + + /** + * `kadena devnet run` # run default devnet configuration + * `kadena devnet run --name exists` # run custom devnet configuration + * `kadena devnet run --name does-not-exist` # create new devnet configuration to run + */ + const name = args.name || defaultDevnet; + + if (name == defaultDevnet && !defaultDevnetIsConfigured()) { + writeDevnets(devnetDefaults[defaultDevnet]); + } + + const existingDevnets = getExistingDevnets(); + if (!existingDevnets.map((d) => d.name).find((n) => n === name)) { + await runDevnetsCreate(program, version, args); + } + const devnetConfig = loadDevnet(name); + + // This should never be true, but just in case. + if (devnetConfig === null) { + console.log( + chalk.red( + `Missing devnet configuration: ${name}. Cannot run devnet.`, + ), + ); + return; + } + + runDevnet(devnetConfig); + } catch (e) { + processZodErrors(program, e, args); + } + }); +} diff --git a/packages/tools/kadena-cli/src/devnet/start.ts b/packages/tools/kadena-cli/src/devnet/start.ts deleted file mode 100644 index 6e083747ea..0000000000 --- a/packages/tools/kadena-cli/src/devnet/start.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { processZodErrors } from '../utils/processZodErrors.js'; - -import type { Command } from 'commander'; - -export interface IStartOptions {} - -export function startCommand(program: Command, version: string): void { - program - .command('start') - .description('start the local devnet') - .action((args: IStartOptions) => { - try { - // TODO: use @inquirer/prompts to interactively get missing flags - // TODO: create zod validator - // Options.parse(args); - } catch (e) { - processZodErrors(program, e, args); - } - - // TODO: implement - throw new Error('Not Implemented Yet'); - }); -} diff --git a/packages/tools/kadena-cli/src/devnet/stopDevnetCommand.ts b/packages/tools/kadena-cli/src/devnet/stopDevnetCommand.ts new file mode 100644 index 0000000000..126909b39a --- /dev/null +++ b/packages/tools/kadena-cli/src/devnet/stopDevnetCommand.ts @@ -0,0 +1,32 @@ +import chalk from 'chalk'; +import { type Command } from 'commander'; +import debug from 'debug'; +import { defaultDevnet } from '../constants/devnets.js'; +import { processZodErrors } from '../utils/processZodErrors.js'; +import { isDockerInstalled, stopDevnet } from './docker.js'; + +export function stopDevnetCommand(program: Command, version: string): void { + program + .command('stop') + .description('Stop devnet') + .option('-n, --name ', 'Container name (e.g. "devnet")') + .action(async (args: { name?: string }) => { + debug('devnet-stop:action')({ args }); + + try { + // Abort if Docker is not installed + if (!isDockerInstalled()) { + console.log( + chalk.red( + 'Stopping devnet requires Docker. Please install Docker and try again.', + ), + ); + return; + } + + stopDevnet(args.name || defaultDevnet); + } catch (e) { + processZodErrors(program, e, args); + } + }); +} diff --git a/packages/tools/kadena-cli/src/devnet/updateDevnetCommand.ts b/packages/tools/kadena-cli/src/devnet/updateDevnetCommand.ts new file mode 100644 index 0000000000..8434bb4b0e --- /dev/null +++ b/packages/tools/kadena-cli/src/devnet/updateDevnetCommand.ts @@ -0,0 +1,34 @@ +import chalk from 'chalk'; +import { type Command } from 'commander'; +import debug from 'debug'; +import { processZodErrors } from '../utils/processZodErrors.js'; +import { isDockerInstalled, updateDevnet } from './docker.js'; + +export function updateDevnetCommand(program: Command, version: string): void { + program + .command('update') + .description('Update devnet container image') + .option( + '-v, --version ', + 'The version of kadena/devnet to update (e.g. "latest")', + ) + .action(async (args: { version?: string }) => { + debug('devnet-update:action')({ args }); + + try { + // Abort if Docker is not installed + if (!isDockerInstalled()) { + console.log( + chalk.red( + 'Stopping devnet requires Docker. Please install Docker and try again.', + ), + ); + return; + } + + updateDevnet(args.version || 'latest'); + } catch (e) { + processZodErrors(program, e, args); + } + }); +} diff --git a/packages/tools/kadena-cli/src/index.js b/packages/tools/kadena-cli/src/index.js new file mode 100644 index 0000000000..1f81ede582 --- /dev/null +++ b/packages/tools/kadena-cli/src/index.js @@ -0,0 +1,40 @@ +#!/usr/bin/env node +import { accountCommandFactory } from './account/index.js'; +import { configCommandFactory } from './config/index.js'; +import { contractCommandFactory } from './contract/index.js'; +// import { dappCommandFactory } from './dapp/index.js'; +import { program } from 'commander'; +import { readFileSync } from 'node:fs'; +import { devnetCommandFactory } from './devnet/index.js'; +import { keysCommandFactory } from './keys/index.js'; +import { marmaladeCommandFactory } from './marmalade/index.js'; +import { networksCommandFactory } from './networks/index.js'; +import { txCommandFactory } from './tx/index.js'; +import { typescriptCommandFactory } from './typescript/index.js'; +import { clearCLI } from './utils/helpers.js'; +const packageJson = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8'), +); +// TODO: introduce root flag --no-interactive +// TODO: introduce root flag --ci +[ + configCommandFactory, + networksCommandFactory, + devnetCommandFactory, + keysCommandFactory, + accountCommandFactory, + txCommandFactory, + contractCommandFactory, + marmaladeCommandFactory, + typescriptCommandFactory, + // dappCommandFactory, +] + .flat() + .forEach(async (fn) => { + fn(program, packageJson.version); + }); +clearCLI(); +program + .description('CLI to interact with Kadena and its ecosystem') + .version(packageJson.version) + .parse(); diff --git a/packages/tools/kadena-cli/src/utils/helpers.ts b/packages/tools/kadena-cli/src/utils/helpers.ts index 22b60731e1..6467bcb285 100644 --- a/packages/tools/kadena-cli/src/utils/helpers.ts +++ b/packages/tools/kadena-cli/src/utils/helpers.ts @@ -9,6 +9,7 @@ import { globalOptions } from './globalOptions.js'; import { defaultKeypairsPath } from '../constants/keypairs.js'; import { defaultKeysetsPath } from '../constants/keysets.js'; import { defaultAccountsPath } from '../constants/accounts.js'; +import { defaultDevnetsPath } from '../constants/devnets.js'; export interface ICustomChoice { value: string; @@ -318,16 +319,24 @@ export async function getExistingNetworks(): Promise { await ensureNetworksConfiguration(); try { - return readdirSync(defaultNetworksPath).map((filename) => ({ + return readdirSync(configurationPath).map((filename) => ({ value: path.basename(filename.toLowerCase(), '.yaml'), name: path.basename(filename.toLowerCase(), '.yaml'), })); } catch (error) { - console.error('Error reading networks directory:', error); + console.error(`Error reading ${configurationPath} directory:`, error); return []; } } +export function getExistingNetworks(): ICustomChoice[] { + return getConfiguration(defaultNetworksPath); +} + +export function getExistingDevnets(): ICustomChoice[] { + return getConfiguration(defaultDevnetsPath); +} + export function getExistingProjects(): ICustomChoice[] { if (!existsSync(projectRootPath)) { return []; From bfe0e2b35355f734379d9a39b4e329c1beb0fab7 Mon Sep 17 00:00:00 2001 From: Jesse van Muijden Date: Thu, 16 Nov 2023 09:31:56 -0400 Subject: [PATCH 13/13] chore(kadena-cli): refactor devnet commands to createCommand pattern --- .../src/config/initConfigCommand.ts | 3 + .../tools/kadena-cli/src/constants/devnets.ts | 11 +- .../tools/kadena-cli/src/constants/options.ts | 14 -- .../tools/kadena-cli/src/constants/prompts.ts | 123 ++++++++++++- .../kadena-cli/src/constants/questions.ts | 40 ----- .../src/devnet/createDevnetCommand.ts | 40 +++++ .../src/devnet/createDevnetsCommand.ts | 107 ------------ .../src/devnet/deleteDevnetCommand.ts | 52 ++++++ .../{devnetsHelpers.ts => devnetHelpers.ts} | 165 ++++++++---------- .../src/devnet/devnetsCreateQuestions.ts | 127 -------------- .../tools/kadena-cli/src/devnet/docker.ts | 6 +- packages/tools/kadena-cli/src/devnet/index.ts | 38 ++-- packages/tools/kadena-cli/src/devnet/init.ts | 4 + .../src/devnet/listDevnetsCommand.ts | 18 +- .../src/devnet/manageDevnetsCommand.ts | 94 ++++------ .../src/devnet/removeDevnetCommand.ts | 49 ------ .../kadena-cli/src/devnet/runDevnetCommand.ts | 107 +++--------- .../src/devnet/stopDevnetCommand.ts | 50 +++--- .../src/devnet/updateDevnetCommand.ts | 48 +++-- packages/tools/kadena-cli/src/index.js | 40 ----- packages/tools/kadena-cli/src/index.ts | 4 +- .../kadena-cli/src/utils/globalOptions.ts | 78 ++++++++- .../tools/kadena-cli/src/utils/helpers.ts | 21 ++- 23 files changed, 523 insertions(+), 716 deletions(-) delete mode 100644 packages/tools/kadena-cli/src/constants/options.ts delete mode 100644 packages/tools/kadena-cli/src/constants/questions.ts create mode 100644 packages/tools/kadena-cli/src/devnet/createDevnetCommand.ts delete mode 100644 packages/tools/kadena-cli/src/devnet/createDevnetsCommand.ts create mode 100644 packages/tools/kadena-cli/src/devnet/deleteDevnetCommand.ts rename packages/tools/kadena-cli/src/devnet/{devnetsHelpers.ts => devnetHelpers.ts} (50%) delete mode 100644 packages/tools/kadena-cli/src/devnet/devnetsCreateQuestions.ts create mode 100644 packages/tools/kadena-cli/src/devnet/init.ts delete mode 100644 packages/tools/kadena-cli/src/devnet/removeDevnetCommand.ts delete mode 100644 packages/tools/kadena-cli/src/index.js diff --git a/packages/tools/kadena-cli/src/config/initConfigCommand.ts b/packages/tools/kadena-cli/src/config/initConfigCommand.ts index e266b7c00c..719f392d91 100644 --- a/packages/tools/kadena-cli/src/config/initConfigCommand.ts +++ b/packages/tools/kadena-cli/src/config/initConfigCommand.ts @@ -12,6 +12,9 @@ export function initCommand(program: Command, version: string): void { await import('./../networks/init.js'); console.log(chalk.green('Configured default networks.')); + await import('./../devnet/init.js'); + console.log(chalk.green('Configured default devnets.')); + console.log(chalk.green('Configuration complete!')); }); } diff --git a/packages/tools/kadena-cli/src/constants/devnets.ts b/packages/tools/kadena-cli/src/constants/devnets.ts index fdd22a4b89..689cd89019 100644 --- a/packages/tools/kadena-cli/src/constants/devnets.ts +++ b/packages/tools/kadena-cli/src/constants/devnets.ts @@ -1,7 +1,7 @@ -import type { TDevnetsCreateOptions } from '../devnet/devnetsCreateQuestions.js'; +import { IDevnetsCreateOptions } from "../devnet/devnetHelpers.js"; export interface IDefaultDevnetOptions { - [key: string]: TDevnetsCreateOptions; + [key: string]: IDevnetsCreateOptions; } /** @@ -16,13 +16,6 @@ export const devnetDefaults: IDefaultDevnetOptions = { mountPactFolder: '', version: 'latest', }, - other: { - name: '', - port: 8080, - useVolume: false, - mountPactFolder: '', - version: '', - }, }; export const defaultDevnetsPath: string = `${process.cwd()}/.kadena/devnets`; diff --git a/packages/tools/kadena-cli/src/constants/options.ts b/packages/tools/kadena-cli/src/constants/options.ts deleted file mode 100644 index 85303435a7..0000000000 --- a/packages/tools/kadena-cli/src/constants/options.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Option } from "commander"; - -export const account = new Option('-a, --account ', 'Receiver (k:) wallet address'); - -export const chainId = new Option('-c, --chainId ', 'Chain to retrieve from (default 1)') - .argParser((value) => parseInt(value, 10)); - -export const network = new Option('-n, --network ', 'Kadena network (e.g. "mainnet")'); - -export const networkId = new Option('-nid, --networkId ', 'Kadena network Id (e.g. "mainnet01")'); - -export const networkHost = new Option('-h, --networkHost ', 'Kadena network host (e.g. "https://api.chainweb.com")'); - -export const networkExplorerUrl = new Option('-e, --networkExplorerUrl ', 'Kadena network explorer URL (e.g. "https://explorer.chainweb.com/mainnet/tx/")'); diff --git a/packages/tools/kadena-cli/src/constants/prompts.ts b/packages/tools/kadena-cli/src/constants/prompts.ts index b4f901ae15..9e358103bd 100644 --- a/packages/tools/kadena-cli/src/constants/prompts.ts +++ b/packages/tools/kadena-cli/src/constants/prompts.ts @@ -3,7 +3,7 @@ import { program } from 'commander'; import path from 'path'; import { ICustomNetworksChoice } from '../networks/networksHelpers.js'; import { ensureFileExists } from '../utils/filesystem.js'; -import { getExistingAccounts, getExistingKeypairs, getExistingKeysets, getExistingNetworks } from '../utils/helpers.js'; +import { getExistingAccounts, getExistingDevnets, getExistingKeypairs, getExistingKeysets, getExistingNetworks } from '../utils/helpers.js'; import { defaultNetworksPath } from './networks.js'; import { ChainId } from '@kadena/types'; import { defaultKeypairsPath } from './keypairs.js'; @@ -11,6 +11,8 @@ import { ICustomKeypairsChoice } from '../keypair/keypairHelpers.js'; import { defaultKeysetsPath } from './keysets.js'; import { ICustomKeysetsChoice } from '../keyset/keysetHelpers.js'; import { ICustomAccountsChoice } from '../account/accountHelpers.js'; +import { defaultDevnetsPath } from './devnets.js'; +import { ICustomDevnetsChoice } from '../devnet/devnetHelpers.js'; export const gasPayerPrompt = async (): Promise => { const existingAccounts: ICustomAccountsChoice[] = await getExistingAccounts(); @@ -353,3 +355,122 @@ export const selectKeypairsPrompt = async () => { choices: existingKeypairs, }) } + +export const devnetNamePrompt = async (): Promise => { + const containerName = await input({ + message: 'Enter a devnet name (e.g. "devnet")', + }); + + const filePath = path.join(defaultDevnetsPath, `${containerName}.yaml`); + if (ensureFileExists(filePath)) { + const overwrite = await devnetOverwritePrompt(); + if (overwrite === 'no') { + return await devnetNamePrompt(); + } + } + + return containerName; +}; + +export const devnetOverwritePrompt = async (name?: string) => { + const message = name + ? `Are you sure you want to save this configuration for devnet "${name}"?` + : 'A devnet configuration with this name already exists. Do you want to update it?' + + return await select({ + message, + choices: [ + { value: 'yes', name: 'Yes' }, + { value: 'no', name: 'No' }, + ], + }); +}; + +export const devnetPortPrompt = async (): Promise => { + const port = await input({ + default: '8080', + message: 'Enter a port number to forward to the Chainweb node API', + validate: function (input) { + const port = parseInt(input); + if (isNaN(port)) { + return 'Port must be a number! Please enter a valid port number.'; + } + return true; + }, + }); + return parseInt(port); +}; + +export const devnetUseVolumePrompt = async (): Promise => + await select({ + message: 'Would you like to create a persistent volume?', + choices: [ + { value: false, name: 'No' }, + { value: true, name: 'Yes' }, + ], + }); + +export const devnetMountPactFolderPrompt = async (): Promise => + await input({ + default: '', + message: + 'Enter the relative path to a folder containing your Pact files to mount (e.g. ./pact) or leave empty to skip.', + }); + + export const devnetVersionPrompt = async (): Promise => + await input({ + default: 'latest', + message: + 'Enter the version of the kadena/devnet image you would like to use.', + }); + +export const devnetSelectPrompt = async (): Promise => { + const existingDevnets: ICustomDevnetsChoice[] = await getExistingDevnets(); + + if (existingDevnets.length > 0) { + return await select({ + message: 'Select a devnet', + choices: existingDevnets, + }); + } + + // At this point there is no devnet defined yet. + // Create and select a new devnet. + await program.parseAsync(['', '', 'devnet', 'create']); + + return await networkSelectPrompt(); +}; + +export const devnetDeletePrompt = async (name: string) => + await select({ + message: `Are you sure you want to delete the devnet "${name}"?`, + choices: [ + { value: 'yes', name: 'Yes' }, + { value: 'no', name: 'No' }, + ], + }); + +export const devnetPrompt = async (): Promise => { + const existingDevnets: ICustomDevnetsChoice[] = await getExistingDevnets(); + + if (existingDevnets.length > 0) { + const selectedDevnet = await select({ + message: 'Select a devnet', + choices: [ + ...existingDevnets, + { value: undefined, name: 'Create a new devnet' }, + ], + }); + + if (selectedDevnet !== undefined) { + return selectedDevnet; + } + } + + // At this point there is either no devnet defined yet, + // or the user chose to create a new devnet. + // Create and select new devnet. + await program.parseAsync(['', '', 'devnet', 'create']); + + return await devnetPrompt(); +}; diff --git a/packages/tools/kadena-cli/src/constants/questions.ts b/packages/tools/kadena-cli/src/constants/questions.ts deleted file mode 100644 index 552eabe3f4..0000000000 --- a/packages/tools/kadena-cli/src/constants/questions.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { z } from "zod"; -import { accountPrompt } from "./prompts.js"; -import { account } from "./options.js"; - -// eslint-disable-next-line @rushstack/typedef-var -export const Questions = z.object({ - account: z - .string(), - // .min(60, { message: 'Account must be 60 or more characters long' }) - // .startsWith('k:', { message: 'Account should start with k:' }), - chainId: z - .number({ - /* eslint-disable-next-line @typescript-eslint/naming-convention */ - invalid_type_error: 'Error: -c, --chain must be a number', - }) - .min(0) - .max(19), - network: z.string({}), - networkId: z.string().optional(), - networkHost: z.string().optional(), - networkExplorerUrl: z.string().optional(), -}); - -export const options = { - account: { - prompt: accountPrompt, - validation: z.string(), - option: new Option('-a, --account ', 'Receiver (k:) wallet address'), - }, - chainId: { - - }, - network: z.string({}), -}; - -export const chainId = z.number(); - -export type TQuestions = z.infer; - - diff --git a/packages/tools/kadena-cli/src/devnet/createDevnetCommand.ts b/packages/tools/kadena-cli/src/devnet/createDevnetCommand.ts new file mode 100644 index 0000000000..1cfc531cae --- /dev/null +++ b/packages/tools/kadena-cli/src/devnet/createDevnetCommand.ts @@ -0,0 +1,40 @@ +import { defaultDevnetsPath } from '../constants/devnets.js'; +import { ensureFileExists } from '../utils/filesystem.js'; +import { writeDevnet } from './devnetHelpers.js'; + +import debug from 'debug'; +import path from 'path'; + +import { createCommand } from '../utils/createCommand.js'; +import { globalOptions } from '../utils/globalOptions.js'; +import chalk from 'chalk'; +import { devnetOverwritePrompt } from '../constants/prompts.js'; + +export const createDevnetCommand = createCommand( + 'create', + 'Create devnet', + [ + globalOptions.devnetName(), + globalOptions.devnetPort(), + globalOptions.devnetUseVolume(), + globalOptions.devnetMountPactFolder(), + globalOptions.devnetVersion(), + ], + async (config) => { + debug('devnet-create:action')({config}); + + const filePath = path.join(defaultDevnetsPath, `${config.name}.yaml`); + + if (ensureFileExists(filePath)) { + const overwrite = await devnetOverwritePrompt(config.name); + if (overwrite === 'no') { + console.log(chalk.yellow(`\nThe existing devnet configuration "${config.name}" will not be updated.\n`)); + return; + } + } + + writeDevnet(config); + + console.log(chalk.green(`\nThe devnet configuration "${config.name}" has been saved.\n`)); + }, +); diff --git a/packages/tools/kadena-cli/src/devnet/createDevnetsCommand.ts b/packages/tools/kadena-cli/src/devnet/createDevnetsCommand.ts deleted file mode 100644 index 73f169a560..0000000000 --- a/packages/tools/kadena-cli/src/devnet/createDevnetsCommand.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { defaultDevnetsPath } from '../constants/devnets.js'; -import { ensureFileExists } from '../utils/filesystem.js'; -import { clearCLI, collectResponses } from '../utils/helpers.js'; -import { processZodErrors } from '../utils/processZodErrors.js'; - -import type { TDevnetsCreateOptions } from './devnetsCreateQuestions.js'; -import { - DevnetsCreateOptions, - devnetsCreateQuestions, -} from './devnetsCreateQuestions.js'; -import { displayDevnetConfig, writeDevnets } from './devnetsHelpers.js'; - -import { select } from '@inquirer/prompts'; -import chalk from 'chalk'; -import { Option, type Command } from 'commander'; -import debug from 'debug'; -import path from 'path'; - -async function shouldProceedWithDevnetCreate(devnet: string): Promise { - const filePath = path.join(defaultDevnetsPath, `${devnet}.yaml`); - if (ensureFileExists(filePath)) { - const overwrite = await select({ - message: `Your devnet (config) already exists. Do you want to update it?`, - choices: [ - { value: 'yes', name: 'Yes' }, - { value: 'no', name: 'No' }, - ], - }); - return overwrite === 'yes'; - } - return true; -} - -export async function runDevnetsCreate( - program: Command, - version: string, - args: TDevnetsCreateOptions, -): Promise { - try { - const responses = await collectResponses(args, devnetsCreateQuestions); - - const devnetConfig = { ...args, ...responses }; - - DevnetsCreateOptions.parse(devnetConfig); - - writeDevnets(devnetConfig); - - displayDevnetConfig(devnetConfig); - - const proceed = await select({ - message: 'Is the above devnet configuration correct?', - choices: [ - { value: 'yes', name: 'Yes' }, - { value: 'no', name: 'No' }, - ], - }); - - if (proceed === 'no') { - clearCLI(true); - console.log(chalk.yellow("Let's restart the configuration process.")); - await runDevnetsCreate(program, version, args); - } else { - console.log(chalk.green('Configuration complete. Goodbye!')); - } - } catch (e) { - console.error(e); - processZodErrors(program, e, args); - } -} - -export function createDevnetsCommand(program: Command, version: string): void { - program - .command('create') - .description('Create new devnet') - .option('-n, --name ', 'Container name (e.g. "devnet")') - .addOption( - new Option( - '-p, --port ', - 'Port to forward to the Chainweb node API (e.g. 8080)', - ).argParser((value) => parseInt(value, 10)), - ) - .option( - '-u, --useVolume', - 'Create a persistent volume to mount to the container', - ) - .option( - '-m, --mountPactFolder ', - 'Mount a folder containing Pact files to the container (e.g. "./pact")', - ) - .option( - '-v, --version ', - 'Version of the kadena/devnet Docker image to use (e.g. "latest")', - ) - .action(async (args: TDevnetsCreateOptions) => { - debug('devnet-create:action')({ args }); - - if ( - args.name && - !(await shouldProceedWithDevnetCreate(args.name.toLowerCase())) - ) { - console.log(chalk.red('Devnet creation aborted.')); - return; - } - - await runDevnetsCreate(program, version, args); - }); -} diff --git a/packages/tools/kadena-cli/src/devnet/deleteDevnetCommand.ts b/packages/tools/kadena-cli/src/devnet/deleteDevnetCommand.ts new file mode 100644 index 0000000000..96e82e60f1 --- /dev/null +++ b/packages/tools/kadena-cli/src/devnet/deleteDevnetCommand.ts @@ -0,0 +1,52 @@ +import debug from 'debug'; +import { devnetDeletePrompt } from '../constants/prompts.js'; +import { globalOptions } from '../utils/globalOptions.js'; +import { getDevnetConfiguration, removeDevnetConfiguration } from './devnetHelpers.js'; + +import chalk from 'chalk'; +import { createCommand } from '../utils/createCommand.js'; +import { dockerVolumeName, isDockerInstalled, removeDevnet, removeVolume } from './docker.js'; + +export const deleteDevnetCommand = createCommand( + 'delete', + 'Delete devnet', + [globalOptions.devnetSelect()], + async (config) => { + debug('devnet-delete:action')({config}); + + const deleteDevnet = await devnetDeletePrompt(config.name); + if (deleteDevnet === 'no') { + console.log(chalk.yellow(`\nThe devnet configuration "${config.name}" will not be deleted.\n`)); + return; + } + + if (!isDockerInstalled()) { + console.log( + chalk.red( + 'Stopping devnet requires Docker. Please install Docker and try again.', + ), + ); + return; + } + + removeDevnet(config.name); + console.log(chalk.green(`Removed devnet container: ${config.name}`)); + + const configuration = getDevnetConfiguration(config.name); + + if (configuration?.useVolume) { + removeVolume(config.name); + console.log( + chalk.green( + `Removed volume: ${dockerVolumeName(config.name)}`, + ), + ); + } + + console.log(chalk.green(`Successfully removed devnet container for configuration: ${config.name}`)); + + removeDevnetConfiguration(config); + + console.log(chalk.green(`Successfully removed devnet configuration: ${config.name}`)); + }, +); diff --git a/packages/tools/kadena-cli/src/devnet/devnetsHelpers.ts b/packages/tools/kadena-cli/src/devnet/devnetHelpers.ts similarity index 50% rename from packages/tools/kadena-cli/src/devnet/devnetsHelpers.ts rename to packages/tools/kadena-cli/src/devnet/devnetHelpers.ts index e431c948cc..2de92811fc 100644 --- a/packages/tools/kadena-cli/src/devnet/devnetsHelpers.ts +++ b/packages/tools/kadena-cli/src/devnet/devnetHelpers.ts @@ -1,17 +1,15 @@ import { defaultDevnet, defaultDevnetsPath, devnetDefaults } from '../constants/devnets.js'; -import { PathExists, writeFile } from '../utils/filesystem.js'; +import { PathExists, removeFile, writeFile } from '../utils/filesystem.js'; import { getExistingDevnets, mergeConfigs, sanitizeFilename, } from '../utils/helpers.js'; -import type { TDevnetsCreateOptions } from './devnetsCreateQuestions.js'; - import chalk from 'chalk'; import type { WriteFileOptions } from 'fs'; -import { existsSync, readFileSync, rmSync } from 'fs'; -import yaml, { load } from 'js-yaml'; +import { existsSync, readFileSync } from 'fs'; +import yaml from 'js-yaml'; import path from 'path'; export interface ICustomDevnetsChoice { @@ -21,28 +19,39 @@ export interface ICustomDevnetsChoice { disabled?: boolean | string; } +export interface IDevnetsCreateOptions { + name: string; + port: number; + useVolume: boolean; + mountPactFolder: string; + version: string; +} + /** - * Writes the given devnet setting to the devnet folder - * - * @param {TDevnetsCreateOptions} options - The set of configuration options. - * @param {string} options.name - The name of your devnet container. - * @param {number} options.port - The port to forward to the Chainweb node API. - * @param {boolean} options.useVolume - Whether or not to mount a persistent volume to the container. - * @param {string} options.mountPactFolder - The folder containing Pact files to mount to the container. - * @param {string} options.version - The version of the kadena/devnet image to use. - * @returns {void} - No return value; the function writes directly to a file. - */ -export function writeDevnets(options: TDevnetsCreateOptions): void { +* Writes the given devnet setting to the devnet folder +* +* @param {TDevnetsCreateOptions} options - The set of configuration options. +* @param {string} options.name - The name of your devnet container. +* @param {number} options.port - The port to forward to the Chainweb node API. +* @param {boolean} options.useVolume - Whether or not to mount a persistent volume to the container. +* @param {string} options.mountPactFolder - The folder containing Pact files to mount to the container. +* @param {string} options.version - The version of the kadena/devnet image to use. +* @returns {void} - No return value; the function writes directly to a file. +*/ +export function writeDevnet(options: IDevnetsCreateOptions): void { const { name } = options; - const sanitizedName = sanitizeFilename(name).toLowerCase(); - const devnetFilePath = path.join(defaultDevnetsPath, `${sanitizedName}.yaml`); + const sanitizedDevnet = sanitizeFilename(name).toLowerCase(); + const devnetFilePath = path.join( + defaultDevnetsPath, + `${sanitizedDevnet}.yaml`, + ); - let existingConfig: TDevnetsCreateOptions; + let existingConfig: IDevnetsCreateOptions; if (PathExists(devnetFilePath)) { existingConfig = yaml.load( readFileSync(devnetFilePath, 'utf8'), - ) as TDevnetsCreateOptions; + ) as IDevnetsCreateOptions; } else { // Explicitly check if devnet key exists in devnetDefaults and is not undefined existingConfig = @@ -53,8 +62,6 @@ export function writeDevnets(options: TDevnetsCreateOptions): void { const devnetConfig = mergeConfigs(existingConfig, options); - devnetConfig.mountPactFolder = options.mountPactFolder; - writeFile( devnetFilePath, yaml.dump(devnetConfig), @@ -62,50 +69,45 @@ export function writeDevnets(options: TDevnetsCreateOptions): void { ); } -export function loadDevnet(name: string): TDevnetsCreateOptions | null { - const devnetFilePath = path.join(defaultDevnetsPath, `${name}.yaml`); - const fileExists = existsSync(devnetFilePath); - - if (!fileExists) { - return null; - } - - return yaml.load( - readFileSync(devnetFilePath, 'utf8'), - ) as TDevnetsCreateOptions; -} - -export function removeDevnet(name: string): void { - const devnetFilePath = path.join(defaultDevnetsPath, `${name}.yaml`); - const fileExists = existsSync(devnetFilePath); - - if (!fileExists) { - return; - } +/** + * Removes the given devnet setting from the devnets folder + * + * @param {Pick} options - The set of configuration options. + * @param {string} options.name - The name of the devnet configuration. + */ +export function removeDevnetConfiguration(options: Pick): void { + const { name } = options; + const sanitizedDevnet = sanitizeFilename(name).toLowerCase(); + const devnetFilePath = path.join( + defaultDevnetsPath, + `${sanitizedDevnet}.yaml`, + ); - rmSync(devnetFilePath); + removeFile(devnetFilePath); } export function defaultDevnetIsConfigured(): boolean { return PathExists(path.join(defaultDevnetsPath, `${defaultDevnet}.yaml`)); } -export function getDevnetConfiguration(name: string): TDevnetsCreateOptions | null { +export function getDevnetConfiguration(name: string): IDevnetsCreateOptions | null { const devnetFilePath = path.join(defaultDevnetsPath, `${name}.yaml`); if (! PathExists(devnetFilePath)) { return null; } - return yaml.load(readFileSync(devnetFilePath, 'utf8')) as TDevnetsCreateOptions; + return yaml.load(readFileSync(devnetFilePath, 'utf8')) as IDevnetsCreateOptions; } /** * Displays the devnet configuration in a formatted manner. * - * @param {TDevnetsCreateOptions} devnetConfig - The devnet configuration to display. + * @param {IDevnetsCreateOptions} devnetConfig - The devnet configuration to display. */ -export function displayDevnetConfig(devnetConfig: TDevnetsCreateOptions): void { +export function displayDevnetConfig( + devnetConfig: IDevnetsCreateOptions, +): void { const log = console.log; const formatLength = 80; // Maximum width for the display @@ -138,7 +140,19 @@ export function displayDevnetConfig(devnetConfig: TDevnetsCreateOptions): void { displaySeparator(); } -export function displayDevnetsConfig(): void { +export function loadDevnetConfig(devnet: string): IDevnetsCreateOptions | never { + const devnetFilePath = path.join(defaultDevnetsPath, `${devnet}.yaml`); + + if (! existsSync(devnetFilePath)) { + throw new Error('Devnet configuration file not found.') + } + + return (yaml.load( + readFileSync(devnetFilePath, 'utf8'), + ) as IDevnetsCreateOptions); +} + +export async function displayDevnetsConfig(): Promise { const log = console.log; const formatLength = 80; // Maximum width for the display @@ -162,58 +176,29 @@ export function displayDevnetsConfig(): void { return ` ${keyValue}${' '.repeat(remainingWidth)} `; }; - const existingDevnets: ICustomDevnetsChoice[] = getExistingDevnets(); - const standardDevnets: string[] = ['devnet']; + + const existingDevnets: ICustomDevnetsChoice[] = await getExistingDevnets(); existingDevnets.forEach(({ value }) => { - const storedConfig = loadDevnet(value); - const fileExists = storedConfig !== null; - const devnetConfig = fileExists ? storedConfig : devnetDefaults[value]; + const devnetFilePath = path.join(defaultDevnetsPath, `${value}.yaml`); + const fileExists = existsSync(devnetFilePath); + const devnetConfig = fileExists + ? (yaml.load( + readFileSync(devnetFilePath, 'utf8'), + ) as IDevnetsCreateOptions) + : devnetDefaults[value]; displaySeparator(); - log(formatConfig('Name', value, !fileExists)); - log(formatConfig('Port', devnetConfig.port?.toString(), !fileExists)); + log(formatConfig('Name', devnetConfig.name)); + log(formatConfig('Port', devnetConfig.port?.toString())); log( formatConfig( 'Volume', devnetConfig.useVolume ? `kadena_${devnetConfig.name}` : 'N/A', - !fileExists, - ), - ); - log( - formatConfig( - 'Pact folder mount', - devnetConfig.mountPactFolder || 'N/A', - !fileExists, ), ); - log( - formatConfig('kadena/devnet version', devnetConfig.version, !fileExists), - ); - }); - - standardDevnets.forEach((devnet) => { - if (!existingDevnets.some(({ value }) => value === devnet)) { - const devnetConfig = devnetDefaults[devnet]; - displaySeparator(); - log(formatConfig('Name', devnet, true)); - log(formatConfig('Port', devnetConfig.port?.toString(), true)); - log( - formatConfig( - 'Volume', - devnetConfig.useVolume ? `kadena_${devnetConfig.name}` : 'N/A', - true, - ), - ); - log( - formatConfig( - 'Pact folder mount', - devnetConfig.mountPactFolder || 'N/A', - true, - ), - ); - log(formatConfig('kadena/devnet version', devnetConfig.version, true)); - } + log(formatConfig('Pact folder mount', devnetConfig.mountPactFolder || 'N/A')); + log(formatConfig('kadena/devnet version', devnetConfig.version)); }); displaySeparator(); diff --git a/packages/tools/kadena-cli/src/devnet/devnetsCreateQuestions.ts b/packages/tools/kadena-cli/src/devnet/devnetsCreateQuestions.ts deleted file mode 100644 index 79d1fcac5a..0000000000 --- a/packages/tools/kadena-cli/src/devnet/devnetsCreateQuestions.ts +++ /dev/null @@ -1,127 +0,0 @@ -import type { IQuestion } from '../utils/helpers.js'; -import { - capitalizeFirstLetter, - getExistingDevnets, - isAlphabetic, -} from '../utils/helpers.js'; - -import { input, select } from '@inquirer/prompts'; -import { z } from 'zod'; - -// eslint-disable-next-line @rushstack/typedef-var -export const DevnetsCreateOptions = z.object({ - name: z.string(), - port: z.number().optional(), - useVolume: z.boolean().optional(), - mountPactFolder: z.string().optional(), - version: z.string().optional(), -}); - -export type TDevnetsCreateOptions = z.infer; - -interface IDevnetManageQuestionsQuestions - extends Pick, 'key' | 'prompt'> {} - -interface ICustomChoice { - value: string; - name?: string; - description?: string; - disabled?: boolean | string; -} - -export async function askForDevnet(): Promise { - const existingDevnets: ICustomChoice[] = getExistingDevnets(); - - const allDevnetChoices: ICustomChoice[] = [...existingDevnets] - .filter((v, i, a) => a.findIndex((v2) => v2.name === v.name) === i) - .map((devnet) => { - return { - value: devnet.value, - name: capitalizeFirstLetter(devnet.value), - }; - }); - - const devnetChoice = await select({ - message: - 'Select an (default) existing devnet configuration or create a new one:', - choices: [ - ...allDevnetChoices, - { value: 'CREATE_NEW', name: 'Create a New Devnet' } as ICustomChoice, - ], - }); - - if (devnetChoice === 'CREATE_NEW') { - const newDevnetName = await input({ - validate: function (input) { - if (input === '') { - return 'Devnet name cannot be empty! Please enter something.'; - } - if (!isAlphabetic(input)) { - return 'Devnet name must be alphabetic! Please enter a valid name.'; - } - return true; - }, - message: 'Enter the name for your new devnet container:', - }); - return newDevnetName.toLowerCase(); - } - - return devnetChoice.toLowerCase(); -} - -export const devnetsCreateQuestions: IQuestion[] = [ - { - key: 'name', - prompt: async () => await askForDevnet(), - }, - { - key: 'port', - prompt: async () => { - const port = await input({ - default: '8080', - message: 'Enter a port number to forward to the Chainweb node API', - validate: function (input) { - const port = parseInt(input); - if (isNaN(port)) { - return 'Port must be a number! Please enter a valid port number.'; - } - return true; - }, - }); - return parseInt(port); - }, - }, - { - key: 'useVolume', - prompt: async () => - await select({ - message: 'Would you like to create a persistent volume?', - choices: [ - { value: false, name: 'No' }, - { value: true, name: 'Yes' }, - ], - }), - }, - { - key: 'mountPactFolder', - prompt: async () => - await input({ - default: '', - message: - 'Enter the relative path to a folder containing your Pact files to mount (e.g. ./pact) or leave empty to skip.', - }), - }, - { - key: 'version', - prompt: async () => - await input({ - default: 'latest', - message: - 'Enter the version of the kadena/devnet image you would like to use.', - }), - }, -]; - -export const devnetManageQuestions: IDevnetManageQuestionsQuestions[] = [ - ...devnetsCreateQuestions.filter((question) => question.key !== 'name'), -]; diff --git a/packages/tools/kadena-cli/src/devnet/docker.ts b/packages/tools/kadena-cli/src/devnet/docker.ts index cf543bbf3d..4ac9578dbc 100644 --- a/packages/tools/kadena-cli/src/devnet/docker.ts +++ b/packages/tools/kadena-cli/src/devnet/docker.ts @@ -1,6 +1,6 @@ import chalk from 'chalk'; import { execSync } from 'child_process'; -import { TDevnetsCreateOptions } from './devnetsCreateQuestions.js'; +import { IDevnetsCreateOptions } from './devnetHelpers.js'; const volumePrefix = 'kadena_'; const containerDataFolder = '/data'; @@ -57,7 +57,7 @@ const maybeCreateVolume = (useVolume: boolean, containerName: string): void => { }; const formatDockerRunOptions = ( - configuration: TDevnetsCreateOptions, + configuration: IDevnetsCreateOptions, ): string => { const options = ['-d']; @@ -107,7 +107,7 @@ const containerExists = (name: string): boolean => { } }; -export function runDevnet(configuration: TDevnetsCreateOptions): void { +export function runDevnet(configuration: IDevnetsCreateOptions): void { maybeCreateVolume(!!configuration.useVolume, configuration.name); const dockerRunOptions = formatDockerRunOptions(configuration); diff --git a/packages/tools/kadena-cli/src/devnet/index.ts b/packages/tools/kadena-cli/src/devnet/index.ts index 5023054f13..5e342f75eb 100644 --- a/packages/tools/kadena-cli/src/devnet/index.ts +++ b/packages/tools/kadena-cli/src/devnet/index.ts @@ -1,36 +1,28 @@ -import { createSimpleSubCommand } from '../utils/helpers.js'; - -import { createDevnetsCommand } from './createDevnetsCommand.js'; -import type { IListDevnetsArgs } from './listDevnetsCommand.js'; -import { listDevnetsAction } from './listDevnetsCommand.js'; -import { manageDevnets } from './manageDevnetsCommand.js'; +import { createDevnetCommand } from './createDevnetCommand.js'; +import { deleteDevnetCommand } from './deleteDevnetCommand.js'; +import { listDevnetsCommand } from './listDevnetsCommand.js'; +import { manageDevnetsCommand } from './manageDevnetsCommand.js'; import { runDevnetCommand } from './runDevnetCommand.js'; - -import type { Command } from 'commander'; -import { removeDevnetCommand } from './removeDevnetCommand.js'; import { stopDevnetCommand } from './stopDevnetCommand.js'; import { updateDevnetCommand } from './updateDevnetCommand.js'; +import type { Command } from 'commander'; + const SUBCOMMAND_ROOT: 'devnet' = 'devnet'; -export function devnetCommandFactory(program: Command, version: string): void { +export function devnetsCommandFactory( + program: Command, + version: string, +): void { const devnetsProgram = program .command(SUBCOMMAND_ROOT) - .description( - `Tool for configuring, starting, stopping and managing local devnet containers`, - ); - - // Attach list subcommands to the devnetsProgram - createSimpleSubCommand( - 'list', - 'List all available devnet container configurations', - listDevnetsAction, - )(devnetsProgram); + .description(`Tool to create and manage devnets`); - manageDevnets(devnetsProgram, version); - createDevnetsCommand(devnetsProgram, version); + listDevnetsCommand(devnetsProgram, version); + manageDevnetsCommand(devnetsProgram, version); + createDevnetCommand(devnetsProgram, version); + deleteDevnetCommand(devnetsProgram, version); runDevnetCommand(devnetsProgram, version); stopDevnetCommand(devnetsProgram, version); - removeDevnetCommand(devnetsProgram, version); updateDevnetCommand(devnetsProgram, version); } diff --git a/packages/tools/kadena-cli/src/devnet/init.ts b/packages/tools/kadena-cli/src/devnet/init.ts new file mode 100644 index 0000000000..833f4d26c2 --- /dev/null +++ b/packages/tools/kadena-cli/src/devnet/init.ts @@ -0,0 +1,4 @@ +import { devnetDefaults } from '../constants/devnets.js'; +import { writeDevnet } from './devnetHelpers.js'; + +writeDevnet(devnetDefaults.devnet); diff --git a/packages/tools/kadena-cli/src/devnet/listDevnetsCommand.ts b/packages/tools/kadena-cli/src/devnet/listDevnetsCommand.ts index a1336a3ac2..f9cbd2d89b 100644 --- a/packages/tools/kadena-cli/src/devnet/listDevnetsCommand.ts +++ b/packages/tools/kadena-cli/src/devnet/listDevnetsCommand.ts @@ -1,7 +1,15 @@ -import { displayDevnetsConfig } from './devnetsHelpers.js'; +import { displayDevnetsConfig } from './devnetHelpers.js'; -export interface IListDevnetsArgs {} +import debug from 'debug'; +import { createCommand } from '../utils/createCommand.js'; -export const listDevnetsAction = (args: IListDevnetsArgs): void => { - displayDevnetsConfig(); -}; +export const listDevnetsCommand = createCommand( + 'list', + 'List all available devnets', + [], + async (config) => { + debug('devnet-list:action')({config}); + + displayDevnetsConfig(); + }, +); diff --git a/packages/tools/kadena-cli/src/devnet/manageDevnetsCommand.ts b/packages/tools/kadena-cli/src/devnet/manageDevnetsCommand.ts index 99df9729e6..85872cf112 100644 --- a/packages/tools/kadena-cli/src/devnet/manageDevnetsCommand.ts +++ b/packages/tools/kadena-cli/src/devnet/manageDevnetsCommand.ts @@ -1,66 +1,32 @@ -import { defaultDevnetsPath } from '../constants/devnets.js'; -import { - clearCLI, - collectResponses, - getExistingDevnets, -} from '../utils/helpers.js'; -import { processZodErrors } from '../utils/processZodErrors.js'; +import debug from 'debug'; +import { devnetOverwritePrompt } from '../constants/prompts.js'; +import { globalOptions } from '../utils/globalOptions.js'; +import { writeDevnet } from './devnetHelpers.js'; -import type { TDevnetsCreateOptions } from './devnetsCreateQuestions.js'; -import { - devnetManageQuestions, - DevnetsCreateOptions, -} from './devnetsCreateQuestions.js'; -import type { ICustomDevnetsChoice } from './devnetsHelpers.js'; -import { writeDevnets } from './devnetsHelpers.js'; - -import { select } from '@inquirer/prompts'; import chalk from 'chalk'; -import type { Command } from 'commander'; -import { readFileSync } from 'fs'; -import yaml from 'js-yaml'; -import path from 'path'; - -export interface IManageDevnetsOptions {} - -export function manageDevnets(program: Command, version: string): void { - program - .command('manage') - .description('Manage devnet(s)') - .action(async (args: IManageDevnetsOptions) => { - try { - const existingDevnets: ICustomDevnetsChoice[] = getExistingDevnets(); - - if (existingDevnets.length === 0) { - console.log(chalk.red('No existing devnets found.')); - return; - } - - const selectedDevnet = await select({ - message: 'Select the devnet you want to manage:', - choices: existingDevnets, - }); - const devnetFilePath = path.join( - defaultDevnetsPath, - `${selectedDevnet}.yaml`, - ); - const existingConfig: TDevnetsCreateOptions = yaml.load( - readFileSync(devnetFilePath, 'utf8'), - ) as TDevnetsCreateOptions; - - const responses = await collectResponses( - { name: selectedDevnet }, - devnetManageQuestions, - ); - const devnetConfig = { ...existingConfig, ...responses }; - - DevnetsCreateOptions.parse(devnetConfig); - - writeDevnets(devnetConfig); - clearCLI(); - console.log(chalk.green('Devnet configurations updated.')); - } catch (e) { - processZodErrors(program, e, args); - } - }); -} +import { createCommand } from '../utils/createCommand.js'; + +export const manageDevnetsCommand = createCommand( + 'manage', + 'Manage devnets', + [ + globalOptions.devnetSelect(), + globalOptions.devnetPort(), + globalOptions.devnetUseVolume(), + globalOptions.devnetMountPactFolder(), + globalOptions.devnetVersion(), + ], + async (config) => { + debug('devnet-manage:action')({config}); + + const overwrite = await devnetOverwritePrompt(config.name); + if (overwrite === 'no') { + console.log(chalk.yellow(`\nThe devnet configuration "${config.name}" will not be updated.\n`)); + return; + } + + writeDevnet(config); + + console.log(chalk.green(`\nThe devnet configuration "${config.name}" has been updated.\n`)); + }, +); diff --git a/packages/tools/kadena-cli/src/devnet/removeDevnetCommand.ts b/packages/tools/kadena-cli/src/devnet/removeDevnetCommand.ts deleted file mode 100644 index a694e2fcbf..0000000000 --- a/packages/tools/kadena-cli/src/devnet/removeDevnetCommand.ts +++ /dev/null @@ -1,49 +0,0 @@ -import chalk from 'chalk'; -import { type Command } from 'commander'; -import debug from 'debug'; -import { defaultDevnet } from '../constants/devnets.js'; -import { processZodErrors } from '../utils/processZodErrors.js'; -import { getDevnetConfiguration, removeDevnet as removeDevnetConfiguration } from './devnetsHelpers.js'; -import { dockerVolumeName, isDockerInstalled, removeDevnet, removeVolume } from './docker.js'; - -export function removeDevnetCommand(program: Command, version: string): void { - program - .command('remove') - .description('Remove devnet container and configuration') - .option('-n, --name ', 'Container name (e.g. "devnet")') - .action(async (args: { name?: string }) => { - debug('devnet-remove:action')({ args }); - - try { - // Abort if Docker is not installed - if (!isDockerInstalled()) { - console.log( - chalk.red( - 'Stopping devnet requires Docker. Please install Docker and try again.', - ), - ); - return; - } - - const name = args.name || defaultDevnet; - removeDevnet(name); - console.log(chalk.green(`Removed devnet container: ${name}`)); - - const configuration = getDevnetConfiguration(name); - - if (configuration?.useVolume) { - removeVolume(name); - console.log( - chalk.green( - `Removed volume: ${dockerVolumeName(name)}`, - ), - ); - } - - removeDevnetConfiguration(name); - console.log(chalk.green(`Successfully removed devnet configuration: ${name}`)); - } catch (e) { - console.log(chalk.yellow(`Removing devnet did not go as planned: ${e.message}`)); - } - }); -} diff --git a/packages/tools/kadena-cli/src/devnet/runDevnetCommand.ts b/packages/tools/kadena-cli/src/devnet/runDevnetCommand.ts index 273a5b753c..7a8e92b4c0 100644 --- a/packages/tools/kadena-cli/src/devnet/runDevnetCommand.ts +++ b/packages/tools/kadena-cli/src/devnet/runDevnetCommand.ts @@ -1,83 +1,28 @@ -import { defaultDevnet, devnetDefaults } from '../constants/devnets.js'; -import { getExistingDevnets } from '../utils/helpers.js'; - -import type { TDevnetsCreateOptions } from './devnetsCreateQuestions.js'; -import { defaultDevnetIsConfigured, loadDevnet, writeDevnets } from './devnetsHelpers.js'; - -import chalk from 'chalk'; -import { Option, type Command } from 'commander'; import debug from 'debug'; -import { processZodErrors } from '../utils/processZodErrors.js'; -import { runDevnetsCreate } from './createDevnetsCommand.js'; -import { isDockerInstalled, runDevnet } from './docker.js'; - -export function runDevnetCommand(program: Command, version: string): void { - program - .command('run') - .description('Run devnet') - .option('-n, --name ', 'Container name (e.g. "devnet")') - .addOption( - new Option( - '-p, --port ', - 'Port to forward to the Chainweb node API (e.g. 8080)', - ).argParser((value) => parseInt(value, 10)), - ) - .option( - '-u, --useVolume', - 'Create a persistent volume to mount to the container', - ) - .option( - '-m, --mountPactFolder ', - 'Mount a folder containing Pact files to the container (e.g. "./pact")', - ) - .option( - '-v, --version ', - 'Version of the kadena/devnet Docker image to use (e.g. "latest")', - ) - .action(async (args: TDevnetsCreateOptions) => { - debug('devnet-run:action')({ args }); - - try { - // Abort if Docker is not installed - if (!isDockerInstalled()) { - console.log( - chalk.red( - 'Running devnet requires Docker. Please install Docker and try again.', - ), - ); - return; - } +import { globalOptions } from '../utils/globalOptions.js'; - /** - * `kadena devnet run` # run default devnet configuration - * `kadena devnet run --name exists` # run custom devnet configuration - * `kadena devnet run --name does-not-exist` # create new devnet configuration to run - */ - const name = args.name || defaultDevnet; - - if (name == defaultDevnet && !defaultDevnetIsConfigured()) { - writeDevnets(devnetDefaults[defaultDevnet]); - } - - const existingDevnets = getExistingDevnets(); - if (!existingDevnets.map((d) => d.name).find((n) => n === name)) { - await runDevnetsCreate(program, version, args); - } - const devnetConfig = loadDevnet(name); - - // This should never be true, but just in case. - if (devnetConfig === null) { - console.log( - chalk.red( - `Missing devnet configuration: ${name}. Cannot run devnet.`, - ), - ); - return; - } - - runDevnet(devnetConfig); - } catch (e) { - processZodErrors(program, e, args); - } - }); -} +import chalk from 'chalk'; +import { createCommand } from '../utils/createCommand.js'; +import { isDockerInstalled, runDevnet, stopDevnet } from './docker.js'; + +export const runDevnetCommand = createCommand( + 'run', + 'Run devnet', + [globalOptions.devnet()], + async (config) => { + debug('devnet-run:action')({config}); + + if (!isDockerInstalled()) { + console.log( + chalk.red( + 'Running devnet requires Docker. Please install Docker and try again.', + ), + ); + return; + } + + runDevnet(config.devnetConfig); + + console.log(chalk.green(`\nThe devnet configuration "${config.devnet}" is running.\n`)); + }, +); diff --git a/packages/tools/kadena-cli/src/devnet/stopDevnetCommand.ts b/packages/tools/kadena-cli/src/devnet/stopDevnetCommand.ts index 126909b39a..693c772b61 100644 --- a/packages/tools/kadena-cli/src/devnet/stopDevnetCommand.ts +++ b/packages/tools/kadena-cli/src/devnet/stopDevnetCommand.ts @@ -1,32 +1,28 @@ -import chalk from 'chalk'; -import { type Command } from 'commander'; import debug from 'debug'; -import { defaultDevnet } from '../constants/devnets.js'; -import { processZodErrors } from '../utils/processZodErrors.js'; +import { globalOptions } from '../utils/globalOptions.js'; + +import chalk from 'chalk'; +import { createCommand } from '../utils/createCommand.js'; import { isDockerInstalled, stopDevnet } from './docker.js'; -export function stopDevnetCommand(program: Command, version: string): void { - program - .command('stop') - .description('Stop devnet') - .option('-n, --name ', 'Container name (e.g. "devnet")') - .action(async (args: { name?: string }) => { - debug('devnet-stop:action')({ args }); +export const stopDevnetCommand = createCommand( + 'stop', + 'Stop devnet', + [globalOptions.devnetSelect()], + async (config) => { + debug('devnet-stop:action')({config}); + + if (!isDockerInstalled()) { + console.log( + chalk.red( + 'Stopping devnet requires Docker. Please install Docker and try again.', + ), + ); + return; + } - try { - // Abort if Docker is not installed - if (!isDockerInstalled()) { - console.log( - chalk.red( - 'Stopping devnet requires Docker. Please install Docker and try again.', - ), - ); - return; - } + stopDevnet(config.name); - stopDevnet(args.name || defaultDevnet); - } catch (e) { - processZodErrors(program, e, args); - } - }); -} + console.log(chalk.green(`\nThe devnet configuration "${config.name}" has been stopped.\n`)); + }, +); diff --git a/packages/tools/kadena-cli/src/devnet/updateDevnetCommand.ts b/packages/tools/kadena-cli/src/devnet/updateDevnetCommand.ts index 8434bb4b0e..e5732c73ee 100644 --- a/packages/tools/kadena-cli/src/devnet/updateDevnetCommand.ts +++ b/packages/tools/kadena-cli/src/devnet/updateDevnetCommand.ts @@ -1,34 +1,26 @@ import chalk from 'chalk'; -import { type Command } from 'commander'; import debug from 'debug'; -import { processZodErrors } from '../utils/processZodErrors.js'; +import { createCommand } from '../utils/createCommand.js'; +import { globalOptions } from '../utils/globalOptions.js'; import { isDockerInstalled, updateDevnet } from './docker.js'; -export function updateDevnetCommand(program: Command, version: string): void { - program - .command('update') - .description('Update devnet container image') - .option( - '-v, --version ', - 'The version of kadena/devnet to update (e.g. "latest")', - ) - .action(async (args: { version?: string }) => { - debug('devnet-update:action')({ args }); +export const updateDevnetCommand = createCommand( + 'update', + 'Update the Docker image of a given devnet container image', + [globalOptions.devnetVersion()], + async (config) => { + debug('devnet-update:action')({config}); - try { - // Abort if Docker is not installed - if (!isDockerInstalled()) { - console.log( - chalk.red( - 'Stopping devnet requires Docker. Please install Docker and try again.', - ), - ); - return; - } + // Abort if Docker is not installed + if (!isDockerInstalled()) { + console.log( + chalk.red( + 'Updating devnet requires Docker. Please install Docker and try again.', + ), + ); + return; + } - updateDevnet(args.version || 'latest'); - } catch (e) { - processZodErrors(program, e, args); - } - }); -} + updateDevnet(config.version || 'latest'); + }, +); diff --git a/packages/tools/kadena-cli/src/index.js b/packages/tools/kadena-cli/src/index.js deleted file mode 100644 index 1f81ede582..0000000000 --- a/packages/tools/kadena-cli/src/index.js +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env node -import { accountCommandFactory } from './account/index.js'; -import { configCommandFactory } from './config/index.js'; -import { contractCommandFactory } from './contract/index.js'; -// import { dappCommandFactory } from './dapp/index.js'; -import { program } from 'commander'; -import { readFileSync } from 'node:fs'; -import { devnetCommandFactory } from './devnet/index.js'; -import { keysCommandFactory } from './keys/index.js'; -import { marmaladeCommandFactory } from './marmalade/index.js'; -import { networksCommandFactory } from './networks/index.js'; -import { txCommandFactory } from './tx/index.js'; -import { typescriptCommandFactory } from './typescript/index.js'; -import { clearCLI } from './utils/helpers.js'; -const packageJson = JSON.parse( - readFileSync(new URL('../package.json', import.meta.url), 'utf8'), -); -// TODO: introduce root flag --no-interactive -// TODO: introduce root flag --ci -[ - configCommandFactory, - networksCommandFactory, - devnetCommandFactory, - keysCommandFactory, - accountCommandFactory, - txCommandFactory, - contractCommandFactory, - marmaladeCommandFactory, - typescriptCommandFactory, - // dappCommandFactory, -] - .flat() - .forEach(async (fn) => { - fn(program, packageJson.version); - }); -clearCLI(); -program - .description('CLI to interact with Kadena and its ecosystem') - .version(packageJson.version) - .parse(); diff --git a/packages/tools/kadena-cli/src/index.ts b/packages/tools/kadena-cli/src/index.ts index a6c4f0ec14..c04105dfce 100644 --- a/packages/tools/kadena-cli/src/index.ts +++ b/packages/tools/kadena-cli/src/index.ts @@ -3,7 +3,7 @@ import { accountCommandFactory } from './account/index.js'; import { configCommandFactory } from './config/index.js'; import { contractCommandFactory } from './contract/index.js'; // import { dappCommandFactory } from './dapp/index.js'; -import { devnetCommandFactory } from './devnet/index.js'; +import { devnetsCommandFactory } from './devnet/index.js'; import { keypairCommandFactory } from './keypair/index.js'; import { keysCommandFactory } from './keys/index.js'; import { keysetCommandFactory } from './keyset/index.js'; @@ -26,7 +26,7 @@ const packageJson: { version: string } = JSON.parse( [ configCommandFactory, networksCommandFactory, - devnetCommandFactory, + devnetsCommandFactory, keypairCommandFactory, keysCommandFactory, keysetCommandFactory, diff --git a/packages/tools/kadena-cli/src/utils/globalOptions.ts b/packages/tools/kadena-cli/src/utils/globalOptions.ts index 1ea8215704..3c67026099 100644 --- a/packages/tools/kadena-cli/src/utils/globalOptions.ts +++ b/packages/tools/kadena-cli/src/utils/globalOptions.ts @@ -4,6 +4,13 @@ import { accountNamePrompt, accountPrompt, chainIdPrompt, + devnetPrompt, + devnetMountPactFolderPrompt, + devnetNamePrompt, + devnetPortPrompt, + devnetSelectPrompt, + devnetUseVolumePrompt, + devnetVersionPrompt, gasPayerPrompt, keypairNamePrompt, keypairPrompt, @@ -22,12 +29,13 @@ import { selectKeypairsPrompt, } from '../constants/prompts.js'; import { loadNetworkConfig } from '../networks/networksHelpers.js'; -import { ensureAccountsConfiguration, ensureKeypairsConfiguration, ensureKeysetsConfiguration, ensureNetworksConfiguration } from './helpers.js'; +import { ensureAccountsConfiguration, ensureDevnetsConfiguration, ensureKeypairsConfiguration, ensureKeysetsConfiguration, ensureNetworksConfiguration } from './helpers.js'; import { program } from 'commander'; import chalk from 'chalk'; import { loadKeypairConfig } from '../keypair/keypairHelpers.js'; import { loadKeysetConfig } from '../keyset/keysetHelpers.js'; import { loadAccountConfig } from '../account/accountHelpers.js'; +import { loadDevnetConfig } from '../devnet/devnetHelpers.js'; // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export const createOption = < @@ -88,6 +96,74 @@ export const globalOptions = { .max(19), option: new Option('-c, --chain-id '), }), + devnet: createOption({ + key: 'devnet' as const, + prompt: devnetPrompt, + validation: z.string(), + option: new Option( + '-d, --devnet ', + 'Devnet name', + ), + expand: async (devnet: string) => { + await ensureDevnetsConfiguration(); + try { + return loadDevnetConfig(devnet); + } catch (e) { + console.log(chalk.yellow(`\nNo devnet "${devnet}" found. Please create the devnet.\n`)); + await program.parseAsync(['', '', 'devnet', 'create']); + const devnetName = await devnetPrompt(); + return loadDevnetConfig(devnetName); + } + }, + }), + devnetName: createOption({ + key: 'name' as const, + prompt: devnetNamePrompt, + validation: z.string(), + option: new Option('-n, --name ', 'Devnet name (e.g. "devnet")'), + }), + devnetPort: createOption({ + key: 'port' as const, + prompt: devnetPortPrompt, + validation: z.number(), + option: new Option( + '-p, --port ', + 'Port to forward to the Chainweb node API (e.g. 8080)', + ).argParser((value) => parseInt(value, 10)), + }), + devnetUseVolume: createOption({ + key: 'useVolume' as const, + prompt: devnetUseVolumePrompt, + validation: z.boolean(), + option: new Option( + '-u, --useVolume', + 'Create a persistent volume to mount to the container' + ), + }), + devnetMountPactFolder: createOption({ + key: 'mountPactFolder' as const, + prompt: devnetMountPactFolderPrompt, + validation: z.string(), + option: new Option( + '-m, --mountPactFolder ', + 'Mount a folder containing Pact files to the container (e.g. "./pact")', + ), + }), + devnetSelect: createOption({ + key: 'name' as const, + prompt: devnetSelectPrompt, + validation: z.string(), + option: new Option('-n, --name ', 'Devnet name'), + }), + devnetVersion: createOption({ + key: 'version' as const, + prompt: devnetVersionPrompt, + validation: z.string(), + option: new Option( + '-v, --version ', + 'Version of the kadena/devnet Docker image to use (e.g. "latest")', + ), + }), keypair: createOption({ key: 'keypair' as const, prompt: keypairPrompt, diff --git a/packages/tools/kadena-cli/src/utils/helpers.ts b/packages/tools/kadena-cli/src/utils/helpers.ts index 6467bcb285..e82286d863 100644 --- a/packages/tools/kadena-cli/src/utils/helpers.ts +++ b/packages/tools/kadena-cli/src/utils/helpers.ts @@ -315,9 +315,7 @@ export async function ensureNetworksConfiguration(): Promise { await import('./../networks/init.js'); } -export async function getExistingNetworks(): Promise { - await ensureNetworksConfiguration(); - +export async function getConfiguration(configurationPath: string): Promise { try { return readdirSync(configurationPath).map((filename) => ({ value: path.basename(filename.toLowerCase(), '.yaml'), @@ -329,11 +327,24 @@ export async function getExistingNetworks(): Promise { } } -export function getExistingNetworks(): ICustomChoice[] { +export async function getExistingNetworks(): Promise { + await ensureNetworksConfiguration(); + return getConfiguration(defaultNetworksPath); } -export function getExistingDevnets(): ICustomChoice[] { +export async function ensureDevnetsConfiguration(): Promise { + if (existsSync(defaultDevnetsPath)) { + return; + } + + mkdirSync(defaultDevnetsPath, { recursive: true }); + await import('./../devnet/init.js'); +} + +export async function getExistingDevnets(): Promise { + await ensureDevnetsConfiguration(); + return getConfiguration(defaultDevnetsPath); }