diff --git a/packages/tools/kadena-cli/src/config/commands/configInit.ts b/packages/tools/kadena-cli/src/config/commands/configInit.ts index 0105b83760..d2315a0c6a 100644 --- a/packages/tools/kadena-cli/src/config/commands/configInit.ts +++ b/packages/tools/kadena-cli/src/config/commands/configInit.ts @@ -16,6 +16,9 @@ export const createConfigInitCommand: ( 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 new file mode 100644 index 0000000000..e8020b519c --- /dev/null +++ b/packages/tools/kadena-cli/src/constants/devnets.ts @@ -0,0 +1,23 @@ +import { IDevnetsCreateOptions } from '../devnet/utils/devnetHelpers.js'; + +export interface IDefaultDevnetOptions { + [key: string]: IDevnetsCreateOptions; +} + +/** + * @const devnetDefaults + * Provides the default devnet configurations. + */ +export const devnetDefaults: IDefaultDevnetOptions = { + devnet: { + name: 'devnet', + port: 8080, + useVolume: false, + mountPactFolder: '', + version: 'latest', + }, +}; + +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/commands/devnetCreate.ts b/packages/tools/kadena-cli/src/devnet/commands/devnetCreate.ts new file mode 100644 index 0000000000..e80068b953 --- /dev/null +++ b/packages/tools/kadena-cli/src/devnet/commands/devnetCreate.ts @@ -0,0 +1,44 @@ +import { defaultDevnetsPath } from '../../constants/devnets.js'; +import { ensureFileExists } from '../../utils/filesystem.js'; +import { writeDevnet } from '../utils/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 '../../prompts/devnet.js'; +import { createExternalPrompt } from '../../prompts/generic.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 externalPrompt = createExternalPrompt({ + devnetOverwritePrompt, + }); + const overwrite = await externalPrompt.devnetOverwritePrompt(); + 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/commands/devnetDelete.ts b/packages/tools/kadena-cli/src/devnet/commands/devnetDelete.ts new file mode 100644 index 0000000000..005bd4e2ae --- /dev/null +++ b/packages/tools/kadena-cli/src/devnet/commands/devnetDelete.ts @@ -0,0 +1,57 @@ +import debug from 'debug'; +import { devnetDeletePrompt } from '../../prompts/devnet.js'; +import { globalOptions } from '../../utils/globalOptions.js'; +import { getDevnetConfiguration, removeDevnetConfiguration } from '../utils/devnetHelpers.js'; + +import chalk from 'chalk'; +import { createCommand } from '../../utils/createCommand.js'; +import { dockerVolumeName, isDockerInstalled, removeDevnet, removeVolume } from '../utils/docker.js'; +import { createExternalPrompt } from '../../prompts/generic.js'; + +export const deleteDevnetCommand = createCommand( + 'delete', + 'Delete devnet', + [globalOptions.devnetSelect()], + async (config) => { + debug('devnet-delete:action')({config}); + + const externalPrompt = createExternalPrompt({ + devnetDeletePrompt, + }); + const deleteDevnet = await externalPrompt.devnetDeletePrompt(); + + 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/commands/devnetList.ts b/packages/tools/kadena-cli/src/devnet/commands/devnetList.ts new file mode 100644 index 0000000000..418d60b677 --- /dev/null +++ b/packages/tools/kadena-cli/src/devnet/commands/devnetList.ts @@ -0,0 +1,14 @@ +import debug from 'debug'; +import { createCommand } from '../../utils/createCommand.js'; +import { displayDevnetsConfig } from '../utils/devnetDisplay.js'; + +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/commands/devnetManage.ts b/packages/tools/kadena-cli/src/devnet/commands/devnetManage.ts new file mode 100644 index 0000000000..298cd9e8b3 --- /dev/null +++ b/packages/tools/kadena-cli/src/devnet/commands/devnetManage.ts @@ -0,0 +1,37 @@ +import debug from 'debug'; +import { devnetOverwritePrompt } from '../../prompts/devnet.js'; +import { globalOptions } from '../../utils/globalOptions.js'; +import { writeDevnet } from '../utils/devnetHelpers.js'; + +import chalk from 'chalk'; +import { createCommand } from '../../utils/createCommand.js'; +import { createExternalPrompt } from '../../prompts/generic.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 externalPrompt = createExternalPrompt({ + devnetOverwritePrompt, + }); + const overwrite = await externalPrompt.devnetOverwritePrompt(); + + 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/commands/devnetRun.ts b/packages/tools/kadena-cli/src/devnet/commands/devnetRun.ts new file mode 100644 index 0000000000..a2d9a79e88 --- /dev/null +++ b/packages/tools/kadena-cli/src/devnet/commands/devnetRun.ts @@ -0,0 +1,28 @@ +import debug from 'debug'; +import { globalOptions } from '../../utils/globalOptions.js'; + +import chalk from 'chalk'; +import { createCommand } from '../../utils/createCommand.js'; +import { isDockerInstalled, runDevnet, stopDevnet } from '../utils/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/commands/devnetStop.ts b/packages/tools/kadena-cli/src/devnet/commands/devnetStop.ts new file mode 100644 index 0000000000..a9a14b813e --- /dev/null +++ b/packages/tools/kadena-cli/src/devnet/commands/devnetStop.ts @@ -0,0 +1,28 @@ +import debug from 'debug'; +import { globalOptions } from '../../utils/globalOptions.js'; + +import chalk from 'chalk'; +import { createCommand } from '../../utils/createCommand.js'; +import { isDockerInstalled, stopDevnet } from '../utils/docker.js'; + +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; + } + + stopDevnet(config.name); + + console.log(chalk.green(`\nThe devnet configuration "${config.name}" has been stopped.\n`)); + }, +); diff --git a/packages/tools/kadena-cli/src/devnet/commands/devnetUpdate.ts b/packages/tools/kadena-cli/src/devnet/commands/devnetUpdate.ts new file mode 100644 index 0000000000..3c8cfdcee3 --- /dev/null +++ b/packages/tools/kadena-cli/src/devnet/commands/devnetUpdate.ts @@ -0,0 +1,26 @@ +import chalk from 'chalk'; +import debug from 'debug'; +import { createCommand } from '../../utils/createCommand.js'; +import { globalOptions } from '../../utils/globalOptions.js'; +import { isDockerInstalled, updateDevnet } from '../utils/docker.js'; + +export const updateDevnetCommand = createCommand( + 'update', + 'Update the Docker image of a given devnet container image', + [globalOptions.devnetVersion()], + async (config) => { + debug('devnet-update:action')({config}); + + // Abort if Docker is not installed + if (!isDockerInstalled()) { + console.log( + chalk.red( + 'Updating devnet requires Docker. Please install Docker and try again.', + ), + ); + return; + } + + updateDevnet(config.version || 'latest'); + }, +); diff --git a/packages/tools/kadena-cli/src/devnet/commands/start.ts b/packages/tools/kadena-cli/src/devnet/commands/start.ts deleted file mode 100644 index e69c5bb23b..0000000000 --- a/packages/tools/kadena-cli/src/devnet/commands/start.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { Command } from 'commander'; -import debug from 'debug'; -import { createCommand } from '../../utils/createCommand.js'; - -export const devnetStartCommand: (program: Command, version: string) => void = - createCommand('start', 'start the local devnet', [], async (config) => { - debug('marmalade-mint:action')({ config }); - // startDevnet - }); diff --git a/packages/tools/kadena-cli/src/devnet/index.ts b/packages/tools/kadena-cli/src/devnet/index.ts index f3ecacac4a..22bf643335 100644 --- a/packages/tools/kadena-cli/src/devnet/index.ts +++ b/packages/tools/kadena-cli/src/devnet/index.ts @@ -1,13 +1,28 @@ -import { devnetStartCommand } from './commands/start.js'; +import { createDevnetCommand } from './commands/devnetCreate.js'; +import { deleteDevnetCommand } from './commands/devnetDelete.js'; +import { listDevnetsCommand } from './commands/devnetList.js'; +import { manageDevnetsCommand } from './commands/devnetManage.js'; +import { runDevnetCommand } from './commands/devnetRun.js'; +import { stopDevnetCommand } from './commands/devnetStop.js'; +import { updateDevnetCommand } from './commands/devnetUpdate.js'; import type { Command } from 'commander'; const SUBCOMMAND_ROOT: 'devnet' = 'devnet'; -export function devnetCommandFactory(program: Command, version: string): void { - const devnetProgram = program +export function devnetCommandFactory( + program: Command, + version: string, +): void { + const devnetsProgram = program .command(SUBCOMMAND_ROOT) - .description(`Tool for starting, stopping and managing the local devnet`); + .description(`Tool to create and manage devnets`); - devnetStartCommand(devnetProgram, version); + listDevnetsCommand(devnetsProgram, version); + manageDevnetsCommand(devnetsProgram, version); + createDevnetCommand(devnetsProgram, version); + deleteDevnetCommand(devnetsProgram, version); + runDevnetCommand(devnetsProgram, version); + stopDevnetCommand(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..7052e49479 --- /dev/null +++ b/packages/tools/kadena-cli/src/devnet/init.ts @@ -0,0 +1,4 @@ +import { devnetDefaults } from '../constants/devnets.js'; +import { writeDevnet } from './utils/devnetHelpers.js'; + +writeDevnet(devnetDefaults.devnet); diff --git a/packages/tools/kadena-cli/src/devnet/utils/devnetDisplay.ts b/packages/tools/kadena-cli/src/devnet/utils/devnetDisplay.ts new file mode 100644 index 0000000000..09df9a261d --- /dev/null +++ b/packages/tools/kadena-cli/src/devnet/utils/devnetDisplay.ts @@ -0,0 +1,100 @@ +import { defaultDevnetsPath, devnetDefaults } from '../../constants/devnets.js'; +import { getExistingDevnets } from '../../utils/helpers.js'; + +import chalk from 'chalk'; +import { existsSync, readFileSync } from 'fs'; +import yaml from 'js-yaml'; +import path from 'path'; +import { ICustomDevnetsChoice, IDevnetsCreateOptions } from './devnetHelpers.js'; + +/** + * Displays the devnet configuration in a formatted manner. + * + * @param {IDevnetsCreateOptions} devnetConfig - The devnet configuration to display. + */ +export function displayDevnetConfig( + devnetConfig: IDevnetsCreateOptions, +): 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 async function displayDevnetsConfig(): 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 existingDevnets: ICustomDevnetsChoice[] = await getExistingDevnets(); + + existingDevnets.forEach(({ 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', 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(); +} diff --git a/packages/tools/kadena-cli/src/devnet/utils/devnetHelpers.ts b/packages/tools/kadena-cli/src/devnet/utils/devnetHelpers.ts new file mode 100644 index 0000000000..e30a633573 --- /dev/null +++ b/packages/tools/kadena-cli/src/devnet/utils/devnetHelpers.ts @@ -0,0 +1,111 @@ +import { defaultDevnet, defaultDevnetsPath, devnetDefaults } from '../../constants/devnets.js'; +import { PathExists, removeFile, writeFile } from '../../utils/filesystem.js'; +import { + mergeConfigs, + sanitizeFilename, +} from '../../utils/helpers.js'; + +import type { WriteFileOptions } from 'fs'; +import { existsSync, readFileSync } from 'fs'; +import yaml from 'js-yaml'; +import path from 'path'; + +export interface ICustomDevnetsChoice { + value: string; + name?: string; + description?: string; + 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 writeDevnet(options: IDevnetsCreateOptions): void { + const { name } = options; + const sanitizedDevnet = sanitizeFilename(name).toLowerCase(); + const devnetFilePath = path.join( + defaultDevnetsPath, + `${sanitizedDevnet}.yaml`, + ); + + let existingConfig: IDevnetsCreateOptions; + + if (PathExists(devnetFilePath)) { + existingConfig = yaml.load( + readFileSync(devnetFilePath, 'utf8'), + ) as IDevnetsCreateOptions; + } 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); + + writeFile( + devnetFilePath, + yaml.dump(devnetConfig), + 'utf8' as WriteFileOptions, + ); +} + +/** + * 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`, + ); + + removeFile(devnetFilePath); +} + +export function defaultDevnetIsConfigured(): boolean { + return PathExists(path.join(defaultDevnetsPath, `${defaultDevnet}.yaml`)); +} + +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 IDevnetsCreateOptions; +} + +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); +} diff --git a/packages/tools/kadena-cli/src/devnet/utils/docker.ts b/packages/tools/kadena-cli/src/devnet/utils/docker.ts new file mode 100644 index 0000000000..4ac9578dbc --- /dev/null +++ b/packages/tools/kadena-cli/src/devnet/utils/docker.ts @@ -0,0 +1,156 @@ +import chalk from 'chalk'; +import { execSync } from 'child_process'; +import { IDevnetsCreateOptions } from './devnetHelpers.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: IDevnetsCreateOptions, +): 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: IDevnetsCreateOptions): 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/prompts/devnet.ts b/packages/tools/kadena-cli/src/prompts/devnet.ts index e69de29bb2..1b40691aec 100644 --- a/packages/tools/kadena-cli/src/prompts/devnet.ts +++ b/packages/tools/kadena-cli/src/prompts/devnet.ts @@ -0,0 +1,159 @@ +import { input, select } from '@inquirer/prompts'; +import { ensureFileExists } from '../utils/filesystem.js'; +import { defaultDevnetsPath } from '../constants/devnets.js'; +import { ICustomDevnetsChoice } from '../devnet/utils/devnetHelpers.js'; +import { getExistingDevnets } from '../utils/helpers.js'; +import { program } from 'commander'; +import path from 'path'; +import { IPrompt } from '../utils/createOption.js'; + +export const devnetNamePrompt: IPrompt = async ( + previousQuestions, + args, + isOptional, +) => { + 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(previousQuestions, args, isOptional); + if (overwrite === 'no') { + return await devnetNamePrompt(previousQuestions, args, isOptional); + } + } + + return containerName; +}; + +export const devnetOverwritePrompt: IPrompt = async ( + previousQuestions, + args, + isOptional, +) => { + return await select({ + message: 'A devnet configuration with this name already exists. Do you want to update it?', + choices: [ + { value: 'yes', name: 'Yes' }, + { value: 'no', name: 'No' }, + ], + }); +}; + +export const devnetPortPrompt: IPrompt = async ( + previousQuestions, + args, + isOptional, +) => { + 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: IPrompt = async ( + previousQuestions, + args, + isOptional, +) => + await select({ + message: 'Would you like to create a persistent volume?', + choices: [ + { value: false, name: 'No' }, + { value: true, name: 'Yes' }, + ], + }); + +export const devnetMountPactFolderPrompt: IPrompt = async ( + previousQuestions, + args, + isOptional, +) => + 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: IPrompt = async ( + previousQuestions, + args, + isOptional, +) => + await input({ + default: 'latest', + message: + 'Enter the version of the kadena/devnet image you would like to use.', + }); + +export const devnetSelectPrompt: IPrompt = async ( + previousQuestions, + args, + isOptional, +) => { + 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 devnetSelectPrompt(previousQuestions, args, isOptional); +}; + +export const devnetDeletePrompt: IPrompt = async ( + previousQuestions, + args, + isOptional, +) => + await select({ + message: 'Are you sure you want to delete this devnet?', + choices: [ + { value: 'yes', name: 'Yes' }, + { value: 'no', name: 'No' }, + ], + }); + +export const devnetPrompt: IPrompt = async ( + previousQuestions, + args, + isOptional, +) => { + 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(previousQuestions, args, isOptional); +}; diff --git a/packages/tools/kadena-cli/src/utils/globalOptions.ts b/packages/tools/kadena-cli/src/utils/globalOptions.ts index e847b8089f..44f7d2bbc6 100644 --- a/packages/tools/kadena-cli/src/utils/globalOptions.ts +++ b/packages/tools/kadena-cli/src/utils/globalOptions.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { // account, // contract, - // devnet, + devnet, generic, genericActionsPrompts, keys, @@ -16,9 +16,85 @@ import { loadNetworkConfig } from '../networks/utils/networkHelpers.js'; import { createExternalPrompt } from '../prompts/generic.js'; import { networkNamePrompt } from '../prompts/network.js'; import { createOption } from './createOption.js'; +import { devnetMountPactFolderPrompt, devnetNamePrompt, devnetPortPrompt, devnetPrompt, devnetSelectPrompt, devnetUseVolumePrompt, devnetVersionPrompt } from '../prompts/devnet.js'; +import { ensureDevnetsConfiguration } from './helpers.js'; +import { loadDevnetConfig } from '../devnet/utils/devnetHelpers.js'; +import chalk from 'chalk'; // eslint-disable-next-line @rushstack/typedef-var export const globalOptions = { + // Devnet + 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 externalPrompt = createExternalPrompt({ + devnetPrompt, + }); + const devnetName = await externalPrompt.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")', + ), + }), // Network networkName: createOption({ key: 'network' as const, diff --git a/packages/tools/kadena-cli/src/utils/helpers.ts b/packages/tools/kadena-cli/src/utils/helpers.ts index eae1165d23..e7ccbaeffb 100644 --- a/packages/tools/kadena-cli/src/utils/helpers.ts +++ b/packages/tools/kadena-cli/src/utils/helpers.ts @@ -2,10 +2,12 @@ import clear from 'clear'; import { existsSync, mkdirSync, readdirSync } from 'fs'; import path from 'path'; import sanitize from 'sanitize-filename'; +import { defaultDevnetsPath } from '../constants/devnets.js'; import { defaultKeysetsPath } from '../constants/keysets.js'; import { defaultNetworksPath } from '../constants/networks.js'; import type { ICustomKeysetsChoice } from '../keys/utils/keysetHelpers.js'; import type { ICustomNetworkChoice } from '../networks/utils/networkHelpers.js'; +import { ICustomDevnetsChoice } from '../devnet/utils/devnetHelpers.js'; /** * Assigns a value to an object's property if the value is neither undefined nor an empty string. @@ -181,6 +183,35 @@ export function getExistingNetworks(): ICustomNetworkChoice[] { } } +type ICustomChoice = ICustomDevnetsChoice | ICustomNetworkChoice; + +export async function getConfiguration(configurationPath: string): Promise { + try { + return readdirSync(configurationPath).map((filename) => ({ + value: path.basename(filename.toLowerCase(), '.yaml'), + name: path.basename(filename.toLowerCase(), '.yaml'), + })); + } catch (error) { + console.error(`Error reading ${configurationPath} directory:`, error); + return []; + } +} + +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); +} + export async function getExistingKeysets(): Promise { try { return readdirSync(defaultKeysetsPath).map((filename) => ({