From 3c7c4bb25368356f41dc6a4ef65ede58ae72c851 Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Fri, 24 Oct 2025 10:03:27 -0400 Subject: [PATCH] docs(README.sonrctl.md): Add sonrctl README documentation --- README.sonrctl.md | 396 ++++++++++++++++++++++++++++++++++++++++ bun.lock | 34 ++++ package.json | 23 +++ src/commands/config.ts | 75 ++++++++ src/commands/init.ts | 120 ++++++++++++ src/commands/install.ts | 92 ++++++++++ src/commands/start.ts | 148 +++++++++++++++ src/commands/status.ts | 172 +++++++++++++++++ src/commands/stop.ts | 103 +++++++++++ src/index.ts | 161 ++++++++++++++++ src/lib/config.ts | 120 ++++++++++++ src/lib/constants.ts | 39 ++++ src/lib/docker.ts | 165 +++++++++++++++++ src/lib/logger.ts | 102 +++++++++++ src/lib/node.ts | 173 ++++++++++++++++++ src/lib/toml.ts | 77 ++++++++ src/types.ts | 53 ++++++ tsconfig.json | 29 +++ 18 files changed, 2082 insertions(+) create mode 100644 README.sonrctl.md create mode 100644 bun.lock create mode 100644 package.json create mode 100644 src/commands/config.ts create mode 100644 src/commands/init.ts create mode 100644 src/commands/install.ts create mode 100644 src/commands/start.ts create mode 100644 src/commands/status.ts create mode 100644 src/commands/stop.ts create mode 100755 src/index.ts create mode 100644 src/lib/config.ts create mode 100644 src/lib/constants.ts create mode 100644 src/lib/docker.ts create mode 100644 src/lib/logger.ts create mode 100644 src/lib/node.ts create mode 100644 src/lib/toml.ts create mode 100644 src/types.ts create mode 100644 tsconfig.json diff --git a/README.sonrctl.md b/README.sonrctl.md new file mode 100644 index 000000000..626449301 --- /dev/null +++ b/README.sonrctl.md @@ -0,0 +1,396 @@ +# sonrctl 🚀 + +**Blazingly fast CLI for managing Sonr blockchain nodes, validators, and networks** + +Built with [Bun](https://bun.sh) - the all-in-one JavaScript runtime. + +## Features + +- ⚡ **Blazingly Fast** - Built on Bun for instant startup and execution +- 🎯 **Simple API** - Clean, intuitive commands for node management +- 🐳 **Docker Integration** - Seamless container orchestration for testnets +- 💾 **SQLite Config** - Lightweight, fast configuration management +- 🎨 **Beautiful Output** - Colored, formatted CLI output +- 🔧 **Node Management** - Initialize, start, stop, and monitor nodes +- 🌐 **Network Operations** - Full testnet deployment and management + +## Installation + +### Install Bun (if not already installed) + +```bash +curl -fsSL https://bun.sh/install | bash +``` + +### Install sonrctl + +```bash +# Clone the repository +git clone https://github.com/sonr-io/sonr.git +cd sonr + +# Install dependencies +bun install + +# Link the CLI globally +bun link +``` + +### Install snrd binary + +```bash +sonrctl install latest +``` + +## Quick Start + +```bash +# Show help +sonrctl help + +# Initialize a validator node +sonrctl init validator val-naruto --chain-id sonrtest_1-1 + +# Start the entire testnet network +sonrctl start network --detach + +# Check network status +sonrctl status network + +# Stop the network +sonrctl stop network +``` + +## Commands + +### `sonrctl install [version]` + +Install or update the snrd binary. + +```bash +# Install latest version +sonrctl install latest + +# Install specific version (coming soon) +sonrctl install v1.0.0 +``` + +### `sonrctl init [options]` + +Initialize a new node configuration. + +**Types:** `validator`, `sentry`, `full` + +**Options:** +- `--chain-id ` - Chain ID (default: sonrtest_1-1) +- `--home ` - Home directory (default: ~/.sonr/) +- `--rpc-port ` - RPC port (default: 26657) +- `--rest-port ` - REST API port (default: 1317) +- `--grpc-port ` - gRPC port (default: 9090) +- `--grpc-web-port ` - gRPC-Web port (default: 9091) +- `--json-rpc-port ` - JSON-RPC port (default: 8545) +- `--json-rpc-ws-port ` - JSON-RPC WebSocket port (default: 8546) + +**Examples:** + +```bash +# Initialize a validator +sonrctl init validator val-naruto + +# Initialize a sentry with custom home +sonrctl init sentry sentry-naruto --home ~/.sonr/custom-sentry + +# Initialize with custom ports +sonrctl init validator my-val --rpc-port 26658 --rest-port 1318 +``` + +### `sonrctl start [options]` + +Start a node or the entire network. + +**Targets:** +- `` - Start a specific registered node +- `network` - Start the entire testnet using docker-compose + +**Options:** +- `--detach`, `-d` - Run in background (network only) + +**Examples:** + +```bash +# Start a specific node +sonrctl start val-naruto + +# Start network in foreground +sonrctl start network + +# Start network in background +sonrctl start network --detach +``` + +### `sonrctl stop ` + +Stop a node, network, or all containers. + +**Targets:** +- `` - Stop a specific container +- `network` - Stop the entire testnet +- `all` - Stop all running containers + +**Examples:** + +```bash +# Stop a specific node +sonrctl stop val-naruto + +# Stop the network +sonrctl stop network + +# Stop all containers +sonrctl stop all +``` + +### `sonrctl status [target]` + +Check status of nodes and network. + +**Targets:** +- (none) - Show overall status +- `` - Show specific node status +- `network` - Show network status + +**Examples:** + +```bash +# Show overall status +sonrctl status + +# Show specific node status +sonrctl status val-naruto + +# Show network status +sonrctl status network +``` + +### `sonrctl config [args]` + +Manage sonrctl configuration. + +**Commands:** +- `list` - List all configuration +- `get ` - Get a specific value +- `set ` - Set a configuration value + +**Examples:** + +```bash +# List all configuration +sonrctl config list + +# Get chain ID +sonrctl config get chain_id + +# Set home directory +sonrctl config set home ~/.sonr-custom +``` + +## Configuration + +sonrctl stores its configuration in: +- **Config Database:** `~/.config/sonr/config.db` +- **Node Home:** `~/.sonr/` (default) + +The SQLite database stores: +- Global configuration (chain ID, binary path, etc.) +- Registered nodes +- Node metadata + +## Project Structure + +``` +src/ +├── index.ts # Main CLI entry point +├── types.ts # TypeScript type definitions +├── commands/ # Command implementations +│ ├── install.ts # Install snrd binary +│ ├── init.ts # Initialize nodes +│ ├── start.ts # Start nodes/network +│ ├── stop.ts # Stop nodes/network +│ ├── status.ts # Check status +│ └── config.ts # Configuration management +└── lib/ # Utility libraries + ├── constants.ts # Constants and defaults + ├── logger.ts # Beautiful logging + ├── config.ts # SQLite configuration manager + ├── toml.ts # TOML file utilities + ├── docker.ts # Docker operations + └── node.ts # Node operations +``` + +## Architecture + +sonrctl is built with a clean, modular architecture: + +1. **Command Layer** - Handles user input and command routing +2. **Library Layer** - Provides utilities for common operations +3. **Storage Layer** - SQLite database for configuration persistence + +### Key Design Decisions + +- **Bun Runtime** - For blazingly fast execution and built-in tools +- **SQLite Storage** - Lightweight, fast, and portable +- **No External Dependencies** - Minimal deps, maximum performance +- **Clean Separation** - Commands, libraries, and utilities are separate +- **Type Safety** - Full TypeScript support + +## Docker Integration + +sonrctl integrates seamlessly with Docker for testnet deployment: + +- Start/stop docker-compose networks +- Manage individual containers +- Monitor container status +- Execute commands in containers +- View container logs + +The CLI can work as a drop-in replacement for manual docker-compose operations. + +## Development + +### Prerequisites + +- [Bun](https://bun.sh) v1.0+ +- Docker (for network operations) +- Git + +### Running Locally + +```bash +# Run directly with Bun +bun run src/index.ts help + +# Run a specific command +bun run src/index.ts status +``` + +### Adding New Commands + +1. Create a new file in `src/commands/` +2. Export an async function that takes `args: string[]` +3. Import and add to the switch statement in `src/index.ts` +4. Update the help text + +Example: + +```typescript +// src/commands/mycommand.ts +export async function mycommand(args: string[]) { + Logger.header('My Command'); + // Implementation +} + +// src/index.ts +import { mycommand } from './commands/mycommand'; + +// Add to switch statement +case 'mycommand': + await mycommand(commandArgs); + break; +``` + +## Examples + +### Setting Up a Testnet + +```bash +# 1. Install the binary +sonrctl install + +# 2. Start the testnet network +sonrctl start network --detach + +# 3. Check status +sonrctl status network + +# 4. View logs +docker logs -f val-naruto + +# 5. Stop when done +sonrctl stop network +``` + +### Creating a Custom Node + +```bash +# 1. Initialize a custom validator +sonrctl init validator my-validator \ + --chain-id mychain-1 \ + --home ~/.sonr/my-validator \ + --rpc-port 26667 + +# 2. Configure genesis (manual step) +# Edit ~/.sonr/my-validator/config/genesis.json + +# 3. Start the node +sonrctl start my-validator + +# 4. Check status +sonrctl status my-validator +``` + +## Troubleshooting + +### Command not found + +```bash +# Make sure Bun's bin directory is in your PATH +export PATH="$HOME/.bun/bin:$PATH" + +# Re-link the CLI +bun link +``` + +### Docker permission denied + +```bash +# Add your user to the docker group +sudo usermod -aG docker $USER + +# Log out and back in, or run: +newgrp docker +``` + +### Binary not found after install + +```bash +# Check if ~/.local/bin is in your PATH +export PATH="$HOME/.local/bin:$PATH" + +# Add to your shell profile +echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc +``` + +## Contributing + +Contributions are welcome! Please: + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Add tests if applicable +5. Submit a pull request + +## License + +Apache 2.0 - See LICENSE file for details + +## Links + +- [Sonr Network](https://sonr.io) +- [Documentation](https://docs.sonr.io) +- [GitHub](https://github.com/sonr-io/sonr) +- [Bun](https://bun.sh) + +--- + +**Built with ❤️ using Bun** diff --git a/bun.lock b/bun.lock new file mode 100644 index 000000000..5a358acb2 --- /dev/null +++ b/bun.lock @@ -0,0 +1,34 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "sonr", + "dependencies": { + "@iarna/toml": "^2.2.5", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@iarna/toml": ["@iarna/toml@2.2.5", "", {}, "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg=="], + + "@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="], + + "@types/node": ["@types/node@24.9.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg=="], + + "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="], + + "bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="], + + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..b3fff6e66 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "sonrctl", + "version": "0.1.0", + "module": "src/index.ts", + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + }, + "bin": { + "sonrctl": "./src/index.ts" + }, + "description": "Sonr Network Control CLI - Manage Sonr blockchain nodes, validators, and networks", + "private": false, + "publishConfig": { + "access": "public" + }, + "type": "module", + "dependencies": { + "@iarna/toml": "^2.2.5" + } +} diff --git a/src/commands/config.ts b/src/commands/config.ts new file mode 100644 index 000000000..0f66ac353 --- /dev/null +++ b/src/commands/config.ts @@ -0,0 +1,75 @@ +// Config command - Manage sonrctl configuration +import { Logger } from '../lib/logger'; +import { ConfigManager } from '../lib/config'; + +export async function config(args: string[]) { + const subcommand = args[0]; + + if (!subcommand || !['get', 'set', 'list'].includes(subcommand)) { + Logger.error('Usage: sonrctl config [key] [value]'); + Logger.info(''); + Logger.info('Examples:'); + Logger.info(' sonrctl config list # List all configuration'); + Logger.info(' sonrctl config get chain_id # Get a specific value'); + Logger.info(' sonrctl config set home ~/.sonr # Set a configuration value'); + process.exit(1); + } + + const manager = new ConfigManager(); + + try { + if (subcommand === 'list') { + await listConfig(manager); + } else if (subcommand === 'get') { + await getConfig(manager, args[1]); + } else if (subcommand === 'set') { + await setConfig(manager, args[1], args[2]); + } + } catch (error) { + Logger.error(`Config operation failed: ${error}`); + process.exit(1); + } finally { + manager.close(); + } +} + +async function listConfig(manager: ConfigManager) { + Logger.header('Sonrctl Configuration'); + + const allConfig = manager.getAll(); + + if (Object.keys(allConfig).length === 0) { + Logger.info('No configuration set'); + return; + } + + Logger.table(allConfig); +} + +async function getConfig(manager: ConfigManager, key?: string) { + if (!key) { + Logger.error('Key is required'); + Logger.info('Usage: sonrctl config get '); + process.exit(1); + } + + const value = manager.get(key); + + if (value === null) { + Logger.warn(`Configuration key '${key}' not found`); + process.exit(1); + } + + console.log(value); +} + +async function setConfig(manager: ConfigManager, key?: string, value?: string) { + if (!key || !value) { + Logger.error('Key and value are required'); + Logger.info('Usage: sonrctl config set '); + process.exit(1); + } + + manager.set(key, value); + Logger.success(`Set ${key} = ${value}`); +} diff --git a/src/commands/init.ts b/src/commands/init.ts new file mode 100644 index 000000000..9056cfc6d --- /dev/null +++ b/src/commands/init.ts @@ -0,0 +1,120 @@ +// Init command - Initialize node configurations +import { Logger } from '../lib/logger'; +import { ConfigManager } from '../lib/config'; +import { NodeManager } from '../lib/node'; +import { DEFAULT_CHAIN_ID, DEFAULT_HOME_DIR, DEFAULT_PORTS } from '../lib/constants'; +import type { NodeConfig } from '../types'; + +export async function init(args: string[]) { + const subcommand = args[0]; + + if (!subcommand || !['validator', 'sentry', 'full'].includes(subcommand)) { + Logger.error('Usage: sonrctl init [options]'); + Logger.info(''); + Logger.info('Examples:'); + Logger.info(' sonrctl init validator val-naruto'); + Logger.info(' sonrctl init sentry sentry-naruto --home ~/.sonr/sentry'); + Logger.info(' sonrctl init full my-node --chain-id sonrtest_1-1'); + process.exit(1); + } + + const name = args[1]; + if (!name) { + Logger.error('Node name is required'); + Logger.info('Usage: sonrctl init '); + process.exit(1); + } + + // Parse options + const options: Record = {}; + for (let i = 2; i < args.length; i += 2) { + const key = args[i].replace(/^--/, ''); + const value = args[i + 1]; + options[key] = value; + } + + const config = new ConfigManager(); + + // Build node configuration + const nodeConfig: NodeConfig = { + moniker: name, + chainId: options['chain-id'] || config.get('chain_id') || DEFAULT_CHAIN_ID, + home: options.home || `${DEFAULT_HOME_DIR}/${name}`, + nodeType: subcommand as 'validator' | 'sentry' | 'full', + ports: { + rpc: parseInt(options['rpc-port']) || DEFAULT_PORTS.rpc, + rest: parseInt(options['rest-port']) || DEFAULT_PORTS.rest, + grpc: parseInt(options['grpc-port']) || DEFAULT_PORTS.grpc, + grpcWeb: parseInt(options['grpc-web-port']) || DEFAULT_PORTS.grpcWeb, + jsonRpc: parseInt(options['json-rpc-port']) || DEFAULT_PORTS.jsonRpc, + jsonRpcWs: parseInt(options['json-rpc-ws-port']) || DEFAULT_PORTS.jsonRpcWs, + }, + }; + + Logger.header(`Initialize ${nodeConfig.nodeType.toUpperCase()} Node`); + + try { + // Check if binary is available + const binary = config.get('binary') || 'snrd'; + const available = await NodeManager.isBinaryAvailable(binary); + + if (!available) { + Logger.error(`Binary '${binary}' not found`); + Logger.info('Install it using: sonrctl install'); + process.exit(1); + } + + // Check if home directory already exists + const homeExists = await Bun.file(nodeConfig.home).exists(); + if (homeExists) { + Logger.warn(`Home directory already exists: ${nodeConfig.home}`); + const response = await confirmPrompt('Overwrite existing configuration?'); + + if (!response) { + Logger.info('Initialization cancelled'); + process.exit(0); + } + + // Remove existing directory + await Bun.$`rm -rf ${nodeConfig.home}`; + } + + // Initialize node + await NodeManager.init(nodeConfig, binary); + + // Register node in database + config.addNode(name, nodeConfig.nodeType, nodeConfig.chainId, nodeConfig.home, nodeConfig.moniker); + + Logger.success('Node initialized successfully!'); + Logger.table({ + Name: name, + Type: nodeConfig.nodeType, + 'Chain ID': nodeConfig.chainId, + Home: nodeConfig.home, + Moniker: nodeConfig.moniker, + }); + + Logger.info(''); + Logger.info('Next steps:'); + Logger.info(` 1. Configure genesis: ${nodeConfig.home}/config/genesis.json`); + Logger.info(` 2. Start the node: sonrctl start ${name}`); + Logger.info(` 3. Check status: sonrctl status ${name}`); + } catch (error) { + Logger.error(`Initialization failed: ${error}`); + process.exit(1); + } finally { + config.close(); + } +} + +// Simple confirmation prompt +async function confirmPrompt(message: string): Promise { + process.stdout.write(`${message} (y/N): `); + + for await (const line of console) { + const answer = line.trim().toLowerCase(); + return answer === 'y' || answer === 'yes'; + } + + return false; +} diff --git a/src/commands/install.ts b/src/commands/install.ts new file mode 100644 index 000000000..29325bbd3 --- /dev/null +++ b/src/commands/install.ts @@ -0,0 +1,92 @@ +// Install command - Install or update snrd binary +import { Logger } from '../lib/logger'; +import { ConfigManager } from '../lib/config'; + +const GITHUB_REPO = 'sonr-io/sonr'; +const BINARY_NAME = 'snrd'; + +export async function install(args: string[]) { + Logger.header('Install Sonr Node Binary'); + + const version = args[0] || 'latest'; + + Logger.info(`Installing ${BINARY_NAME} version: ${version}`); + + try { + // Check if running on supported platform + const platform = process.platform; + const arch = process.arch; + + Logger.info(`Detected platform: ${platform}/${arch}`); + + if (version === 'latest') { + // Get latest release from GitHub + Logger.step(1, 3, 'Fetching latest release information'); + const response = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`); + + if (!response.ok) { + throw new Error('Failed to fetch release information'); + } + + const release = await response.json(); + const latestVersion = release.tag_name; + + Logger.info(`Latest version: ${latestVersion}`); + + // Download binary + Logger.step(2, 3, `Downloading ${BINARY_NAME}`); + const assetName = `${BINARY_NAME}-${platform}-${arch}`; + const asset = release.assets.find((a: any) => a.name.includes(assetName)); + + if (!asset) { + throw new Error(`No binary found for ${platform}/${arch}`); + } + + const stop = Logger.loading('Downloading binary...'); + const binaryResponse = await fetch(asset.browser_download_url); + + if (!binaryResponse.ok) { + stop(); + throw new Error('Failed to download binary'); + } + + stop(); + + // Save binary to /usr/local/bin or ~/.local/bin + Logger.step(3, 3, 'Installing binary'); + const binaryData = await binaryResponse.arrayBuffer(); + + try { + // Try to install to /usr/local/bin first (system-wide) + await Bun.write(`/usr/local/bin/${BINARY_NAME}`, binaryData); + await Bun.$`chmod +x /usr/local/bin/${BINARY_NAME}`; + Logger.success(`Installed ${BINARY_NAME} to /usr/local/bin`); + } catch { + // Fall back to ~/.local/bin (user-level) + const localBinDir = `${process.env.HOME}/.local/bin`; + await Bun.$`mkdir -p ${localBinDir}`; + await Bun.write(`${localBinDir}/${BINARY_NAME}`, binaryData); + await Bun.$`chmod +x ${localBinDir}/${BINARY_NAME}`; + Logger.success(`Installed ${BINARY_NAME} to ${localBinDir}`); + Logger.warn(`Make sure ${localBinDir} is in your PATH`); + } + + // Verify installation + const versionCheck = await Bun.$`${BINARY_NAME} version`.quiet(); + const installedVersion = (await versionCheck.text()).trim(); + + Logger.success(`Successfully installed ${BINARY_NAME} ${installedVersion}`); + + // Update config + const config = new ConfigManager(); + config.set('binary', BINARY_NAME); + config.set('version', installedVersion); + config.close(); + } else { + throw new Error('Specific version installation not yet supported. Use "latest" for now.'); + } + } catch (error) { + Logger.error(`Installation failed: ${error}`); + process.exit(1); + } +} diff --git a/src/commands/start.ts b/src/commands/start.ts new file mode 100644 index 000000000..521f4c7fb --- /dev/null +++ b/src/commands/start.ts @@ -0,0 +1,148 @@ +// Start command - Start nodes or network +import { Logger } from '../lib/logger'; +import { ConfigManager } from '../lib/config'; +import { DockerManager } from '../lib/docker'; +import { NodeManager } from '../lib/node'; +import { join } from 'path'; + +export async function start(args: string[]) { + const subcommand = args[0]; + + if (!subcommand) { + Logger.error('Usage: sonrctl start '); + Logger.info(''); + Logger.info('Examples:'); + Logger.info(' sonrctl start val-naruto # Start a specific node'); + Logger.info(' sonrctl start network # Start entire testnet with docker-compose'); + Logger.info(' sonrctl start network --detach # Start network in background'); + process.exit(1); + } + + const config = new ConfigManager(); + + try { + if (subcommand === 'network') { + await startNetwork(args.slice(1)); + } else { + await startNode(subcommand, config); + } + } catch (error) { + Logger.error(`Failed to start: ${error}`); + process.exit(1); + } finally { + config.close(); + } +} + +async function startNode(name: string, config: ConfigManager) { + Logger.header(`Start Node: ${name}`); + + // Get node from registry + const node = config.getNode(name); + + if (!node) { + Logger.error(`Node '${name}' not found`); + Logger.info('Initialize it first using: sonrctl init '); + Logger.info(''); + Logger.info('Registered nodes:'); + const nodes = config.listNodes(); + if (nodes.length === 0) { + Logger.info(' (none)'); + } else { + nodes.forEach((n: any) => Logger.info(` - ${n.name} (${n.type})`)); + } + process.exit(1); + } + + const binary = config.get('binary') || 'snrd'; + + Logger.info(`Starting ${node.type} node: ${node.moniker}`); + Logger.table({ + Name: node.name, + Type: node.type, + 'Chain ID': node.chain_id, + Home: node.home, + }); + + // Check if binary is available + const available = await NodeManager.isBinaryAvailable(binary); + if (!available) { + Logger.error(`Binary '${binary}' not found`); + Logger.info('Install it using: sonrctl install'); + process.exit(1); + } + + Logger.info(''); + Logger.info('Starting node...'); + Logger.info('Press Ctrl+C to stop'); + Logger.info(''); + + // Start node (this will block) + await NodeManager.start(node.home, node.chain_id, binary); +} + +async function startNetwork(args: string[]) { + Logger.header('Start Sonr Testnet Network'); + + // Check if docker is available + const dockerAvailable = await DockerManager.isAvailable(); + if (!dockerAvailable) { + Logger.error('Docker is not available or not running'); + Logger.info('Install Docker: https://docs.docker.com/get-docker/'); + process.exit(1); + } + + const composeAvailable = await DockerManager.isComposeAvailable(); + if (!composeAvailable) { + Logger.error('docker-compose is not available'); + Logger.info('Install docker-compose: https://docs.docker.com/compose/install/'); + process.exit(1); + } + + // Parse options + const detached = args.includes('--detach') || args.includes('-d'); + + // Find docker-compose file + const cwd = process.cwd(); + const composePath = join(cwd, 'networks', 'testnet', 'docker-compose.yml'); + + const composeExists = await Bun.file(composePath).exists(); + if (!composeExists) { + Logger.error(`docker-compose.yml not found at: ${composePath}`); + Logger.info('Make sure you are in the sonr repository root directory'); + process.exit(1); + } + + Logger.info('Starting network with docker-compose...'); + Logger.info(`Compose file: ${composePath}`); + Logger.info(''); + + try { + await DockerManager.composeUp(composePath, detached); + + if (detached) { + Logger.success('Network started in background'); + Logger.info(''); + Logger.info('Check status with: docker ps'); + Logger.info('View logs with: docker logs -f '); + Logger.info('Stop network with: sonrctl stop network'); + } else { + Logger.success('Network started'); + Logger.info('Press Ctrl+C to stop'); + } + + // Show running containers + Logger.info(''); + Logger.section('Running Containers'); + const containers = await DockerManager.listContainers('sonr-testnet'); + + if (containers.length > 0) { + for (const container of containers) { + Logger.info(` ${container.name}: ${container.status}`); + } + } + } catch (error) { + Logger.error(`Failed to start network: ${error}`); + throw error; + } +} diff --git a/src/commands/status.ts b/src/commands/status.ts new file mode 100644 index 000000000..c215139c4 --- /dev/null +++ b/src/commands/status.ts @@ -0,0 +1,172 @@ +// Status command - Check node and network status +import { Logger } from '../lib/logger'; +import { ConfigManager } from '../lib/config'; +import { DockerManager } from '../lib/docker'; +import { NodeManager } from '../lib/node'; + +export async function status(args: string[]) { + const target = args[0]; + + if (!target) { + // Show overall status + await showOverallStatus(); + } else if (target === 'network') { + await showNetworkStatus(); + } else { + await showNodeStatus(target); + } +} + +async function showOverallStatus() { + Logger.header('Sonr Network Status'); + + const config = new ConfigManager(); + + // Show registered nodes + Logger.section('Registered Nodes'); + const nodes = config.listNodes(); + + if (nodes.length === 0) { + Logger.info('No nodes registered'); + Logger.info('Initialize a node using: sonrctl init '); + } else { + console.log(); + console.log(' Name Type Chain ID Home'); + console.log(' ───────────────── ────────── ──────────────── ─────────────────────'); + + for (const node of nodes) { + const name = node.name.padEnd(17); + const type = node.type.padEnd(10); + const chainId = node.chain_id.padEnd(16); + console.log(` ${name} ${type} ${chainId} ${node.home}`); + } + console.log(); + } + + // Show docker containers + const dockerAvailable = await DockerManager.isAvailable(); + if (dockerAvailable) { + Logger.section('Docker Containers'); + const containers = await DockerManager.listContainers(); + + if (containers.length === 0) { + Logger.info('No containers running'); + } else { + console.log(); + console.log(' Name Status Image'); + console.log(' ────────────────────── ──────────────────────── ────────────────────'); + + for (const container of containers) { + const name = container.name.padEnd(22); + const status = container.status.substring(0, 24).padEnd(24); + console.log(` ${name} ${status} ${container.image}`); + } + console.log(); + } + } + + config.close(); +} + +async function showNetworkStatus() { + Logger.header('Testnet Network Status'); + + const dockerAvailable = await DockerManager.isAvailable(); + if (!dockerAvailable) { + Logger.error('Docker is not available'); + process.exit(1); + } + + // Get all testnet containers + const containers = await DockerManager.listContainers('sonr-testnet'); + + if (containers.length === 0) { + Logger.info('No network containers found'); + Logger.info('Start the network using: sonrctl start network'); + return; + } + + Logger.section('Network Containers'); + console.log(); + + for (const container of containers) { + const { running, status } = await DockerManager.getContainerStatus(container.name); + const statusIcon = running ? '✓' : '✗'; + const statusColor = running ? '\x1b[32m' : '\x1b[31m'; + + console.log(` ${statusColor}${statusIcon}\x1b[0m ${container.name}`); + console.log(` Status: ${status}`); + console.log(` Image: ${container.image}`); + + // Try to get node status if running + if (running) { + try { + const rpcPort = container.name.includes('sentry') ? '26657' : null; + if (rpcPort) { + const nodeStatus = await NodeManager.getStatus(`http://localhost:${rpcPort}`); + const syncInfo = nodeStatus.result.sync_info; + const catchingUp = syncInfo.catching_up ? 'Yes' : 'No'; + + console.log(` Height: ${syncInfo.latest_block_height}`); + console.log(` Syncing: ${catchingUp}`); + } + } catch { + // Ignore errors getting node status + } + } + console.log(); + } +} + +async function showNodeStatus(name: string) { + Logger.header(`Node Status: ${name}`); + + const config = new ConfigManager(); + const node = config.getNode(name); + + if (!node) { + Logger.error(`Node '${name}' not found`); + Logger.info('List registered nodes using: sonrctl status'); + config.close(); + process.exit(1); + } + + // Show node info + Logger.section('Node Information'); + Logger.table({ + Name: node.name, + Type: node.type, + 'Chain ID': node.chain_id, + Home: node.home, + Moniker: node.moniker, + }); + + // Check docker container status + const dockerAvailable = await DockerManager.isAvailable(); + if (dockerAvailable) { + const { running, status } = await DockerManager.getContainerStatus(name); + + Logger.section('Container Status'); + Logger.info(`Running: ${running ? 'Yes' : 'No'}`); + Logger.info(`Status: ${status}`); + + if (running) { + // Try to get node status + try { + const nodeStatus = await NodeManager.getStatus('http://localhost:26657'); + const syncInfo = nodeStatus.result.sync_info; + + Logger.section('Blockchain Status'); + Logger.table({ + 'Latest Height': syncInfo.latest_block_height, + 'Latest Block Time': new Date(syncInfo.latest_block_time).toLocaleString(), + 'Catching Up': syncInfo.catching_up ? 'Yes' : 'No', + }); + } catch (error) { + Logger.warn('Unable to fetch node status'); + } + } + } + + config.close(); +} diff --git a/src/commands/stop.ts b/src/commands/stop.ts new file mode 100644 index 000000000..a84bc5acf --- /dev/null +++ b/src/commands/stop.ts @@ -0,0 +1,103 @@ +// Stop command - Stop nodes or network +import { Logger } from '../lib/logger'; +import { ConfigManager } from '../lib/config'; +import { DockerManager } from '../lib/docker'; +import { join } from 'path'; + +export async function stop(args: string[]) { + const target = args[0]; + + if (!target) { + Logger.error('Usage: sonrctl stop '); + Logger.info(''); + Logger.info('Examples:'); + Logger.info(' sonrctl stop val-naruto # Stop a specific node container'); + Logger.info(' sonrctl stop network # Stop entire testnet network'); + Logger.info(' sonrctl stop all # Stop all running containers'); + process.exit(1); + } + + try { + if (target === 'network') { + await stopNetwork(); + } else if (target === 'all') { + await stopAll(); + } else { + await stopNode(target); + } + } catch (error) { + Logger.error(`Failed to stop: ${error}`); + process.exit(1); + } +} + +async function stopNode(name: string) { + Logger.header(`Stop Node: ${name}`); + + const dockerAvailable = await DockerManager.isAvailable(); + if (!dockerAvailable) { + Logger.error('Docker is not available'); + process.exit(1); + } + + // Check if container is running + const running = await DockerManager.isContainerRunning(name); + if (!running) { + Logger.warn(`Container '${name}' is not running`); + return; + } + + await DockerManager.stopContainer(name); +} + +async function stopNetwork() { + Logger.header('Stop Sonr Testnet Network'); + + const dockerAvailable = await DockerManager.isAvailable(); + if (!dockerAvailable) { + Logger.error('Docker is not available'); + process.exit(1); + } + + // Find docker-compose file + const cwd = process.cwd(); + const composePath = join(cwd, 'networks', 'testnet', 'docker-compose.yml'); + + const composeExists = await Bun.file(composePath).exists(); + if (!composeExists) { + Logger.error(`docker-compose.yml not found at: ${composePath}`); + Logger.info('Make sure you are in the sonr repository root directory'); + process.exit(1); + } + + await DockerManager.composeDown(composePath); +} + +async function stopAll() { + Logger.header('Stop All Containers'); + + const dockerAvailable = await DockerManager.isAvailable(); + if (!dockerAvailable) { + Logger.error('Docker is not available'); + process.exit(1); + } + + // Get all containers + const containers = await DockerManager.listContainers(); + + if (containers.length === 0) { + Logger.info('No running containers found'); + return; + } + + Logger.info(`Found ${containers.length} containers`); + + for (const container of containers) { + if (container.status.startsWith('Up')) { + Logger.info(`Stopping ${container.name}...`); + await DockerManager.stopContainer(container.name); + } + } + + Logger.success('All containers stopped'); +} diff --git a/src/index.ts b/src/index.ts new file mode 100755 index 000000000..5db0c163b --- /dev/null +++ b/src/index.ts @@ -0,0 +1,161 @@ +#!/usr/bin/env bun + +// Sonrctl - Sonr Network Control CLI +import { Logger } from './lib/logger'; +import { install } from './commands/install'; +import { init } from './commands/init'; +import { start } from './commands/start'; +import { stop } from './commands/stop'; +import { status } from './commands/status'; +import { config } from './commands/config'; + +const VERSION = '0.1.0'; + +// Main CLI router +async function main() { + const args = process.argv.slice(2); + + if (args.length === 0 || args[0] === 'help' || args[0] === '--help' || args[0] === '-h') { + showHelp(); + process.exit(0); + } + + if (args[0] === 'version' || args[0] === '--version' || args[0] === '-v') { + console.log(VERSION); + process.exit(0); + } + + const command = args[0]; + const commandArgs = args.slice(1); + + try { + switch (command) { + case 'install': + await install(commandArgs); + break; + + case 'init': + await init(commandArgs); + break; + + case 'start': + await start(commandArgs); + break; + + case 'stop': + await stop(commandArgs); + break; + + case 'status': + await status(commandArgs); + break; + + case 'config': + await config(commandArgs); + break; + + default: + Logger.error(`Unknown command: ${command}`); + Logger.info('Run "sonrctl help" for usage information'); + process.exit(1); + } + } catch (error) { + Logger.error(`Command failed: ${error}`); + process.exit(1); + } +} + +function showHelp() { + const COLORS = { + reset: '\x1b[0m', + bright: '\x1b[1m', + cyan: '\x1b[36m', + blue: '\x1b[34m', + gray: '\x1b[90m', + }; + + console.log(` +${COLORS.bright}${COLORS.cyan}sonrctl${COLORS.reset} - Sonr Network Control CLI + +${COLORS.bright}USAGE${COLORS.reset} + ${COLORS.cyan}sonrctl${COLORS.reset} [options] + +${COLORS.bright}COMMANDS${COLORS.reset} + ${COLORS.cyan}install${COLORS.reset} [version] + Install or update the snrd binary + ${COLORS.gray}Example: sonrctl install latest${COLORS.reset} + + ${COLORS.cyan}init${COLORS.reset} [options] + Initialize a new node configuration + ${COLORS.gray}Example: sonrctl init validator val-naruto${COLORS.reset} + ${COLORS.gray}Options:${COLORS.reset} + ${COLORS.gray} --chain-id Chain ID (default: sonrtest_1-1)${COLORS.reset} + ${COLORS.gray} --home Home directory${COLORS.reset} + ${COLORS.gray} --rpc-port RPC port (default: 26657)${COLORS.reset} + ${COLORS.gray} --rest-port REST API port (default: 1317)${COLORS.reset} + ${COLORS.gray} --grpc-port gRPC port (default: 9090)${COLORS.reset} + + ${COLORS.cyan}start${COLORS.reset} [options] + Start a node or the entire network + ${COLORS.gray}Example: sonrctl start val-naruto${COLORS.reset} + ${COLORS.gray}Example: sonrctl start network${COLORS.reset} + ${COLORS.gray}Options:${COLORS.reset} + ${COLORS.gray} --detach, -d Run in background${COLORS.reset} + + ${COLORS.cyan}stop${COLORS.reset} + Stop a node, network, or all containers + ${COLORS.gray}Example: sonrctl stop val-naruto${COLORS.reset} + ${COLORS.gray}Example: sonrctl stop network${COLORS.reset} + + ${COLORS.cyan}status${COLORS.reset} [node-name|network] + Check status of nodes and network + ${COLORS.gray}Example: sonrctl status${COLORS.reset} + ${COLORS.gray}Example: sonrctl status val-naruto${COLORS.reset} + + ${COLORS.cyan}config${COLORS.reset} [key] [value] + Manage sonrctl configuration + ${COLORS.gray}Example: sonrctl config list${COLORS.reset} + ${COLORS.gray}Example: sonrctl config get chain_id${COLORS.reset} + ${COLORS.gray}Example: sonrctl config set home ~/.sonr${COLORS.reset} + + ${COLORS.cyan}help${COLORS.reset} + Show this help message + + ${COLORS.cyan}version${COLORS.reset} + Show version information + +${COLORS.bright}GLOBAL OPTIONS${COLORS.reset} + ${COLORS.gray}-h, --help Show help${COLORS.reset} + ${COLORS.gray}-v, --version Show version${COLORS.reset} + +${COLORS.bright}EXAMPLES${COLORS.reset} + ${COLORS.gray}# Install the snrd binary${COLORS.reset} + ${COLORS.blue}sonrctl install${COLORS.reset} + + ${COLORS.gray}# Initialize a validator node${COLORS.reset} + ${COLORS.blue}sonrctl init validator val-naruto --chain-id sonrtest_1-1${COLORS.reset} + + ${COLORS.gray}# Start the entire testnet network${COLORS.reset} + ${COLORS.blue}sonrctl start network --detach${COLORS.reset} + + ${COLORS.gray}# Check network status${COLORS.reset} + ${COLORS.blue}sonrctl status network${COLORS.reset} + + ${COLORS.gray}# Stop the network${COLORS.reset} + ${COLORS.blue}sonrctl stop network${COLORS.reset} + +${COLORS.bright}CONFIGURATION${COLORS.reset} + Config directory: ${COLORS.cyan}~/.config/sonr${COLORS.reset} + Node home: ${COLORS.cyan}~/.sonr${COLORS.reset} + +${COLORS.bright}MORE INFO${COLORS.reset} + Docs: ${COLORS.blue}https://docs.sonr.io${COLORS.reset} + Repo: ${COLORS.blue}https://github.com/sonr-io/sonr${COLORS.reset} +`); +} + +// Run the CLI +main().catch((error) => { + Logger.error(`Fatal error: ${error}`); + process.exit(1); +}); \ No newline at end of file diff --git a/src/lib/config.ts b/src/lib/config.ts new file mode 100644 index 000000000..ccf08c446 --- /dev/null +++ b/src/lib/config.ts @@ -0,0 +1,120 @@ +// Configuration management using bun:sqlite +import { Database } from 'bun:sqlite'; +import { homedir } from 'os'; +import { join } from 'path'; +import { DEFAULT_CHAIN_ID, DEFAULT_HOME_DIR, DEFAULT_BINARY } from './constants'; + +const CONFIG_DIR = join(homedir(), '.config', 'sonr'); +const CONFIG_DB = join(CONFIG_DIR, 'config.db'); + +export class ConfigManager { + private db: Database; + + constructor() { + // Ensure config directory exists + if (!Bun.file(CONFIG_DIR).size) { + Bun.spawnSync(['mkdir', '-p', CONFIG_DIR]); + } + + this.db = new Database(CONFIG_DB, { create: true }); + this.init(); + } + + /** + * Initialize database tables + */ + private init() { + this.db.run(` + CREATE TABLE IF NOT EXISTS config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + `); + + this.db.run(` + CREATE TABLE IF NOT EXISTS nodes ( + name TEXT PRIMARY KEY, + type TEXT NOT NULL, + chain_id TEXT NOT NULL, + home TEXT NOT NULL, + moniker TEXT NOT NULL, + created_at INTEGER NOT NULL + ) + `); + + // Set defaults if not exists + const defaults = { + chain_id: DEFAULT_CHAIN_ID, + home: DEFAULT_HOME_DIR, + binary: DEFAULT_BINARY, + }; + + for (const [key, value] of Object.entries(defaults)) { + const existing = this.get(key); + if (!existing) { + this.set(key, value); + } + } + } + + /** + * Get a configuration value + */ + get(key: string): string | null { + const result = this.db.query('SELECT value FROM config WHERE key = ?').get(key) as { value: string } | null; + return result?.value || null; + } + + /** + * Set a configuration value + */ + set(key: string, value: string) { + this.db.run('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)', [key, value]); + } + + /** + * Get all configuration + */ + getAll(): Record { + const rows = this.db.query('SELECT key, value FROM config').all() as Array<{ key: string; value: string }>; + return Object.fromEntries(rows.map(row => [row.key, row.value])); + } + + /** + * Add a node to the registry + */ + addNode(name: string, type: string, chainId: string, home: string, moniker: string) { + this.db.run( + 'INSERT OR REPLACE INTO nodes (name, type, chain_id, home, moniker, created_at) VALUES (?, ?, ?, ?, ?, ?)', + [name, type, chainId, home, moniker, Date.now()] + ); + } + + /** + * Get a node from the registry + */ + getNode(name: string): any { + return this.db.query('SELECT * FROM nodes WHERE name = ?').get(name); + } + + /** + * List all nodes + */ + listNodes(): any[] { + return this.db.query('SELECT * FROM nodes ORDER BY created_at DESC').all(); + } + + /** + * Remove a node + */ + removeNode(name: string) { + this.db.run('DELETE FROM nodes WHERE name = ?', [name]); + } + + /** + * Close database connection + */ + close() { + this.db.close(); + } +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts new file mode 100644 index 000000000..c4feaa455 --- /dev/null +++ b/src/lib/constants.ts @@ -0,0 +1,39 @@ +// Constants for sonrctl +import { homedir } from 'os'; +import { join } from 'path'; + +export const DEFAULT_CHAIN_ID = 'sonrtest_1-1'; +export const DEFAULT_DENOM = 'usnr'; +export const DEFAULT_HOME_DIR = join(homedir(), '.sonr'); +export const DEFAULT_KEYRING_BACKEND = 'test'; +export const DEFAULT_BINARY = 'snrd'; +export const DEFAULT_DOCKER_IMAGE = 'onsonr/snrd:latest'; + +export const DEFAULT_PORTS = { + rpc: 26657, + rest: 1317, + grpc: 9090, + grpcWeb: 9091, + jsonRpc: 8545, + jsonRpcWs: 8546, + p2p: 26656, +}; + +export const CONSENSUS_TIMEOUTS = { + propose: '5s', + prevote: '1s', + precommit: '1s', + commit: '1s', +}; + +export const TESTNET_VALIDATORS = [ + { name: 'val-naruto', network: 'net-naruto' }, + { name: 'val-senku', network: 'net-senku' }, + { name: 'val-yaeger', network: 'net-yaeger' }, +]; + +export const TESTNET_SENTRIES = [ + { name: 'sentry-naruto', network: 'net-naruto', validator: 'val-naruto' }, + { name: 'sentry-senku', network: 'net-senku', validator: 'val-senku' }, + { name: 'sentry-yaeger', network: 'net-yaeger', validator: 'val-yaeger' }, +]; diff --git a/src/lib/docker.ts b/src/lib/docker.ts new file mode 100644 index 000000000..4fdb400ff --- /dev/null +++ b/src/lib/docker.ts @@ -0,0 +1,165 @@ +// Docker utilities using Bun.$ +import type { DockerNode } from '../types'; +import { Logger } from './logger'; + +export class DockerManager { + /** + * Check if Docker is installed and running + */ + static async isAvailable(): Promise { + try { + const result = await Bun.$`docker info`.quiet(); + return result.exitCode === 0; + } catch { + return false; + } + } + + /** + * Check if docker-compose is available + */ + static async isComposeAvailable(): Promise { + try { + const result = await Bun.$`docker compose version`.quiet(); + return result.exitCode === 0; + } catch { + return false; + } + } + + /** + * Pull a Docker image + */ + static async pullImage(image: string): Promise { + Logger.info(`Pulling Docker image: ${image}`); + const stop = Logger.loading('Pulling image...'); + + try { + await Bun.$`docker pull ${image}`.quiet(); + stop(); + Logger.success(`Image pulled: ${image}`); + } catch (error) { + stop(); + throw new Error(`Failed to pull image: ${error}`); + } + } + + /** + * Check if a container is running + */ + static async isContainerRunning(name: string): Promise { + try { + const result = await Bun.$`docker ps --filter name=${name} --format {{.Names}}`.quiet(); + const output = await result.text(); + return output.trim() === name; + } catch { + return false; + } + } + + /** + * Start a container + */ + static async startContainer(name: string): Promise { + Logger.info(`Starting container: ${name}`); + await Bun.$`docker start ${name}`; + Logger.success(`Container started: ${name}`); + } + + /** + * Stop a container + */ + static async stopContainer(name: string): Promise { + Logger.info(`Stopping container: ${name}`); + await Bun.$`docker stop ${name}`; + Logger.success(`Container stopped: ${name}`); + } + + /** + * Remove a container + */ + static async removeContainer(name: string, force: boolean = false): Promise { + Logger.info(`Removing container: ${name}`); + const forceFlag = force ? '-f' : ''; + await Bun.$`docker rm ${forceFlag} ${name}`; + Logger.success(`Container removed: ${name}`); + } + + /** + * Run a command in a container + */ + static async exec(container: string, command: string): Promise { + const result = await Bun.$`docker exec ${container} sh -c ${command}`.quiet(); + return await result.text(); + } + + /** + * Get container logs + */ + static async logs(container: string, tail: number = 50): Promise { + const result = await Bun.$`docker logs ${container} --tail ${tail}`.quiet(); + return await result.text(); + } + + /** + * Run docker-compose command + */ + static async composeUp(composePath: string, detached: boolean = true): Promise { + const cwd = Bun.file(composePath).name ? composePath.split('/').slice(0, -1).join('/') : composePath; + const flags = detached ? '-d' : ''; + + Logger.info('Starting network with docker-compose...'); + await Bun.$`cd ${cwd} && docker compose up ${flags}`; + Logger.success('Network started successfully'); + } + + /** + * Stop docker-compose services + */ + static async composeDown(composePath: string): Promise { + const cwd = Bun.file(composePath).name ? composePath.split('/').slice(0, -1).join('/') : composePath; + + Logger.info('Stopping network...'); + await Bun.$`cd ${cwd} && docker compose down`; + Logger.success('Network stopped successfully'); + } + + /** + * Get container status + */ + static async getContainerStatus(name: string): Promise<{ running: boolean; status: string }> { + try { + const result = await Bun.$`docker ps -a --filter name=${name} --format {{.Status}}`.quiet(); + const status = (await result.text()).trim(); + const running = status.startsWith('Up'); + return { running, status }; + } catch { + return { running: false, status: 'Not found' }; + } + } + + /** + * List all containers with a prefix + */ + static async listContainers(prefix?: string): Promise> { + try { + const filter = prefix ? `--filter name=${prefix}` : ''; + const result = await Bun.$`docker ps -a ${filter} --format {{.Names}}|||{{.Status}}|||{{.Image}}`.quiet(); + const output = await result.text(); + + if (!output.trim()) { + return []; + } + + return output + .trim() + .split('\n') + .map(line => { + const [name, status, image] = line.split('|||'); + return { name, status, image }; + }); + } catch { + return []; + } + } +} diff --git a/src/lib/logger.ts b/src/lib/logger.ts new file mode 100644 index 000000000..c1f72e6d7 --- /dev/null +++ b/src/lib/logger.ts @@ -0,0 +1,102 @@ +// Beautiful logging utilities for sonrctl + +const COLORS = { + reset: '\x1b[0m', + bright: '\x1b[1m', + dim: '\x1b[2m', + + // Foreground colors + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + magenta: '\x1b[35m', + cyan: '\x1b[36m', + white: '\x1b[37m', + gray: '\x1b[90m', +} as const; + +const ICONS = { + info: 'ℹ', + success: '✓', + warning: '⚠', + error: '✗', + rocket: '🚀', + gear: '⚙', + chain: '⛓', + node: '🔗', + docker: '🐳', +}; + +export class Logger { + static info(message: string, ...args: any[]) { + console.log(`${COLORS.blue}${ICONS.info}${COLORS.reset} ${message}`, ...args); + } + + static success(message: string, ...args: any[]) { + console.log(`${COLORS.green}${ICONS.success}${COLORS.reset} ${message}`, ...args); + } + + static warn(message: string, ...args: any[]) { + console.log(`${COLORS.yellow}${ICONS.warning}${COLORS.reset} ${message}`, ...args); + } + + static error(message: string, ...args: any[]) { + console.error(`${COLORS.red}${ICONS.error}${COLORS.reset} ${message}`, ...args); + } + + static debug(message: string, ...args: any[]) { + if (process.env.DEBUG) { + console.log(`${COLORS.gray}[DEBUG]${COLORS.reset} ${message}`, ...args); + } + } + + static header(message: string) { + const line = '═'.repeat(message.length + 4); + console.log(`\n${COLORS.cyan}${line}${COLORS.reset}`); + console.log(`${COLORS.cyan} ${message}${COLORS.reset}`); + console.log(`${COLORS.cyan}${line}${COLORS.reset}\n`); + } + + static section(message: string) { + console.log(`\n${COLORS.bright}${COLORS.cyan}▸ ${message}${COLORS.reset}`); + } + + static step(step: number, total: number, message: string) { + console.log(`${COLORS.dim}[${step}/${total}]${COLORS.reset} ${message}`); + } + + static command(cmd: string) { + console.log(`${COLORS.dim}$ ${cmd}${COLORS.reset}`); + } + + static json(obj: any) { + console.log(JSON.stringify(obj, null, 2)); + } + + static table(data: Record) { + const maxKeyLength = Math.max(...Object.keys(data).map(k => k.length)); + + console.log(); + for (const [key, value] of Object.entries(data)) { + const paddedKey = key.padEnd(maxKeyLength); + console.log(` ${COLORS.cyan}${paddedKey}${COLORS.reset} : ${value}`); + } + console.log(); + } + + static loading(message: string): () => void { + const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + let i = 0; + + const timer = setInterval(() => { + process.stdout.write(`\r${COLORS.blue}${frames[i]}${COLORS.reset} ${message}`); + i = (i + 1) % frames.length; + }, 80); + + return () => { + clearInterval(timer); + process.stdout.write('\r\x1b[K'); // Clear line + }; + } +} diff --git a/src/lib/node.ts b/src/lib/node.ts new file mode 100644 index 000000000..e1f0a9d14 --- /dev/null +++ b/src/lib/node.ts @@ -0,0 +1,173 @@ +// Node operations utilities +import { join } from 'path'; +import { Logger } from './logger'; +import { TomlManager } from './toml'; +import { DEFAULT_PORTS, CONSENSUS_TIMEOUTS } from './constants'; +import type { NodeConfig } from '../types'; + +export class NodeManager { + /** + * Check if snrd binary is available + */ + static async isBinaryAvailable(binary: string = 'snrd'): Promise { + try { + const result = await Bun.$`which ${binary}`.quiet(); + return result.exitCode === 0; + } catch { + return false; + } + } + + /** + * Get snrd version + */ + static async getVersion(binary: string = 'snrd'): Promise { + try { + const result = await Bun.$`${binary} version`.quiet(); + return (await result.text()).trim(); + } catch (error) { + throw new Error(`Failed to get version: ${error}`); + } + } + + /** + * Initialize a node + */ + static async init(config: NodeConfig, binary: string = 'snrd'): Promise { + Logger.section(`Initializing ${config.nodeType} node: ${config.moniker}`); + + // Initialize chain + Logger.step(1, 3, 'Initializing chain directory'); + await Bun.$`${binary} init ${config.moniker} --chain-id ${config.chainId} --home ${config.home}`; + + // Configure node + Logger.step(2, 3, 'Configuring node settings'); + await this.configureNode(config); + + // Set VRF keys if needed + Logger.step(3, 3, 'Generating VRF keypair'); + try { + await this.generateVRFKey(config.home, binary); + } catch (error) { + Logger.warn('VRF key generation failed, but continuing...'); + } + + Logger.success(`Node initialized: ${config.moniker}`); + } + + /** + * Configure node settings + */ + static async configureNode(config: NodeConfig): Promise { + const configToml = join(config.home, 'config', 'config.toml'); + const appToml = join(config.home, 'config', 'app.toml'); + + // Configure RPC + if (config.ports?.rpc) { + await TomlManager.setValue(configToml, 'rpc.laddr', `tcp://0.0.0.0:${config.ports.rpc}`); + await TomlManager.setValue(configToml, 'rpc.cors_allowed_origins', ['*']); + } + + // Configure P2P + await TomlManager.setValue(configToml, 'p2p.laddr', `tcp://0.0.0.0:${DEFAULT_PORTS.p2p}`); + + // Set consensus timeouts + await TomlManager.setValue(configToml, 'consensus.timeout_propose', CONSENSUS_TIMEOUTS.propose); + await TomlManager.setValue(configToml, 'consensus.timeout_prevote', CONSENSUS_TIMEOUTS.prevote); + await TomlManager.setValue(configToml, 'consensus.timeout_precommit', CONSENSUS_TIMEOUTS.precommit); + await TomlManager.setValue(configToml, 'consensus.timeout_commit', CONSENSUS_TIMEOUTS.commit); + + // Configure API + if (config.ports?.rest) { + await TomlManager.setValue(appToml, 'api.enable', true); + await TomlManager.setValue(appToml, 'api.address', `tcp://0.0.0.0:${config.ports.rest}`); + await TomlManager.setValue(appToml, 'api.enabled-unsafe-cors', true); + } + + // Configure gRPC + if (config.ports?.grpc) { + await TomlManager.setValue(appToml, 'grpc.address', `0.0.0.0:${config.ports.grpc}`); + } + + // Configure gRPC-Web + if (config.ports?.grpcWeb) { + await TomlManager.setValue(appToml, 'grpc-web.address', `0.0.0.0:${config.ports.grpcWeb}`); + } + + // Configure JSON-RPC + if (config.ports?.jsonRpc && config.ports?.jsonRpcWs) { + await TomlManager.setValue(appToml, 'json-rpc.enable', true); + await TomlManager.setValue(appToml, 'json-rpc.address', `0.0.0.0:${config.ports.jsonRpc}`); + await TomlManager.setValue(appToml, 'json-rpc.ws-address', `0.0.0.0:${config.ports.jsonRpcWs}`); + await TomlManager.setValue(appToml, 'json-rpc.api', 'eth,txpool,personal,net,debug,web3'); + } + + // Set pruning + await TomlManager.setValue(appToml, 'pruning', 'nothing'); + + // Set minimum gas prices + await TomlManager.setValue(appToml, 'minimum-gas-prices', '0usnr'); + } + + /** + * Generate VRF keypair + */ + static async generateVRFKey(home: string, binary: string = 'snrd'): Promise { + try { + await Bun.$`${binary} keys vrf generate --home ${home}`.quiet(); + } catch (error) { + throw new Error(`Failed to generate VRF key: ${error}`); + } + } + + /** + * Start a node + */ + static async start(home: string, chainId: string, binary: string = 'snrd'): Promise { + Logger.info(`Starting node from: ${home}`); + await Bun.$`${binary} start --home ${home} --chain-id ${chainId} --pruning nothing --minimum-gas-prices 0usnr`; + } + + /** + * Get node status + */ + static async getStatus(rpcUrl: string = 'http://localhost:26657'): Promise { + try { + const response = await fetch(`${rpcUrl}/status`); + return await response.json(); + } catch (error) { + throw new Error(`Failed to get node status: ${error}`); + } + } + + /** + * Wait for node to be ready + */ + static async waitForReady(rpcUrl: string = 'http://localhost:26657', maxAttempts: number = 30): Promise { + Logger.info('Waiting for node to be ready...'); + + for (let i = 0; i < maxAttempts; i++) { + try { + const response = await fetch(`${rpcUrl}/health`); + if (response.ok) { + Logger.success('Node is ready!'); + return; + } + } catch { + // Ignore errors and retry + } + + await Bun.sleep(1000); + } + + throw new Error('Node failed to become ready'); + } + + /** + * Check if node is syncing + */ + static async isSyncing(rpcUrl: string = 'http://localhost:26657'): Promise { + const status = await this.getStatus(rpcUrl); + return status.result.sync_info.catching_up; + } +} diff --git a/src/lib/toml.ts b/src/lib/toml.ts new file mode 100644 index 000000000..2f4d742ae --- /dev/null +++ b/src/lib/toml.ts @@ -0,0 +1,77 @@ +// TOML utilities for configuration management +import TOML from '@iarna/toml'; + +export class TomlManager { + /** + * Read and parse a TOML file + */ + static async read(filePath: string): Promise { + const file = Bun.file(filePath); + const content = await file.text(); + return TOML.parse(content); + } + + /** + * Write an object to a TOML file + */ + static async write(filePath: string, data: any): Promise { + const content = TOML.stringify(data); + await Bun.write(filePath, content); + } + + /** + * Set a value in a TOML file (supports nested paths with dot notation) + */ + static async setValue(filePath: string, path: string, value: any): Promise { + const data = await this.read(filePath); + const keys = path.split('.'); + + let current = data; + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + if (!current[key]) { + current[key] = {}; + } + current = current[key]; + } + + current[keys[keys.length - 1]] = value; + + await this.write(filePath, data); + } + + /** + * Get a value from a TOML file + */ + static async getValue(filePath: string, path: string): Promise { + const data = await this.read(filePath); + const keys = path.split('.'); + + let current = data; + for (const key of keys) { + if (current[key] === undefined) { + return undefined; + } + current = current[key]; + } + + return current; + } + + /** + * Merge configurations + */ + static merge(base: any, override: any): any { + const result = { ...base }; + + for (const [key, value] of Object.entries(override)) { + if (typeof value === 'object' && !Array.isArray(value) && value !== null) { + result[key] = this.merge(result[key] || {}, value); + } else { + result[key] = value; + } + } + + return result; + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 000000000..ec84b2ddd --- /dev/null +++ b/src/types.ts @@ -0,0 +1,53 @@ +// Type definitions for sonrctl + +export interface NodeConfig { + moniker: string; + chainId: string; + home: string; + nodeType: 'validator' | 'sentry' | 'full'; + ports?: { + rpc?: number; + rest?: number; + grpc?: number; + grpcWeb?: number; + jsonRpc?: number; + jsonRpcWs?: number; + }; +} + +export interface NetworkConfig { + name: string; + chainId: string; + validators: ValidatorConfig[]; + sentries: SentryConfig[]; +} + +export interface ValidatorConfig { + name: string; + moniker: string; + network: string; + home: string; +} + +export interface SentryConfig { + name: string; + moniker: string; + network: string; + home: string; + exposedPorts: boolean; +} + +export interface DockerNode { + name: string; + image: string; + command: string; + environment: Record; + volumes: string[]; + networks: string[]; + ports?: string[]; +} + +export interface CommandContext { + args: string[]; + options: Record; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..bfa0fead5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}