UNPKG

lili-solana-cli

Version:

Production-ready CLI tool for Solana developers - Build, Deploy, and Manage Solana programs

249 lines (221 loc) • 8.57 kB
#!/usr/bin/env node // Interactive CLI to create a custom Solana Name Service TLD on devnet // Requirements: inquirer, chalk, ora, @solana/web3.js, @bonfida/spl-name-service, fs import path from 'path'; import fs from 'fs'; import os from 'os'; import inquirer from 'inquirer'; import chalk from 'chalk'; import ora from 'ora'; import { Connection, Keypair, LAMPORTS_PER_SOL, sendAndConfirmTransaction, Transaction, } from '@solana/web3.js'; import * as sns from '@bonfida/spl-name-service'; // Default devnet RPC (will be overridden by Lili CLI config if available) const DEFAULT_RPC = process.env.SOLANA_RPC_URL || 'https://api.devnet.solana.com'; // Lili CLI config and wallets integration const HOME = os.homedir(); const CONFIG_DIR = path.join(HOME, '.lili-cli'); const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json'); const WALLETS_DIR = path.join(CONFIG_DIR, 'wallets'); function loadCliConfig() { try { const raw = fs.readFileSync(CONFIG_FILE, 'utf8'); const cfg = JSON.parse(raw); return { network: cfg.network || 'devnet', rpcUrl: cfg.rpcUrl || DEFAULT_RPC, defaultWallet: cfg.defaultWallet || null, lastUsed: cfg.lastUsed || null, }; } catch { return { network: 'devnet', rpcUrl: DEFAULT_RPC, defaultWallet: null, lastUsed: null }; } } function getCliWalletFiles() { try { return fs.readdirSync(WALLETS_DIR).filter((f) => f.endsWith('.json')); } catch { return []; } } async function selectCliWallet(config, wallets) { let choices = wallets.map((w) => ({ name: w.replace('.json', ''), value: w })); const def = config.defaultWallet ? `${config.defaultWallet}.json` : null; if (def && wallets.includes(def)) { choices = [{ name: `${config.defaultWallet} (default)`, value: def }, new inquirer.Separator(), ...choices.filter((c) => c.value !== def)]; } const { walletFile } = await inquirer.prompt([ { type: 'list', name: 'walletFile', message: 'Select payer wallet', choices } ]); return walletFile; } async function ensureBalance(connection, pubkey, minSol = 0.05) { const bal = await connection.getBalance(pubkey); if (bal >= minSol * LAMPORTS_PER_SOL) return; try { const airdropSig = await connection.requestAirdrop(pubkey, Math.ceil(minSol * LAMPORTS_PER_SOL)); await connection.confirmTransaction(airdropSig, 'confirmed'); } catch (e) { // ignore; user may have their own funds } } function parseKeypairFromJson(json) { const arr = Array.isArray(json) ? json : json.secretKey || json._keypair || undefined; if (!arr) throw new Error('Invalid keypair file: expected array of 64 numbers'); const secret = Uint8Array.from(arr); return Keypair.fromSecretKey(secret); } async function loadOrCreateKeypair() { const cfg = loadCliConfig(); const wallets = getCliWalletFiles(); if (wallets.length) { const walletFile = await selectCliWallet(cfg, wallets); const secret = JSON.parse(fs.readFileSync(path.join(WALLETS_DIR, walletFile), 'utf8')); return Keypair.fromSecretKey(new Uint8Array(secret)); } const { method } = await inquirer.prompt([ { type: 'list', name: 'method', message: 'Select wallet option:', choices: [ { name: 'Load from a JSON file (array of 64 numbers)', value: 'file' }, { name: 'Paste JSON secret key (array)', value: 'paste' }, { name: 'Generate a new keypair and save locally', value: 'gen' }, ], }, ]); if (method === 'file') { const { filePath } = await inquirer.prompt([ { type: 'input', name: 'filePath', message: 'Path to keypair JSON:', default: path.join(os.homedir(), '.config', 'solana', 'id.json') }, ]); const raw = fs.readFileSync(path.resolve(filePath), 'utf8'); const json = JSON.parse(raw); return parseKeypairFromJson(json); } if (method === 'paste') { const { raw } = await inquirer.prompt([ { type: 'editor', name: 'raw', message: 'Paste the keypair JSON (array):' }, ]); const json = JSON.parse(raw.trim()); return parseKeypairFromJson(json); } // gen const kp = Keypair.generate(); const saveDefault = path.join(process.cwd(), 'id.json'); const { savePath } = await inquirer.prompt([ { type: 'input', name: 'savePath', message: 'Save new keypair to:', default: saveDefault }, ]); fs.writeFileSync(path.resolve(savePath), JSON.stringify(Array.from(kp.secretKey), null, 2)); console.log(chalk.green(`Saved new keypair to ${savePath}`)); return kp; } async function promptTld() { const { tldRaw } = await inquirer.prompt([ { type: 'input', name: 'tldRaw', message: 'Enter your desired TLD (without the leading dot), e.g. example:' }, ]); const tld = String(tldRaw || '').trim().toLowerCase().replace(/^\./, ''); if (!tld) throw new Error('TLD cannot be empty'); if (!/^[a-z0-9-]{1,63}$/.test(tld)) throw new Error('TLD must be 1-63 chars: a-z, 0-9, dash'); return tld; } async function checkAvailability(connection, tld) { const hashed = await sns.getHashedName(tld); const nameKey = await sns.getNameAccountKey(hashed, undefined, sns.ROOT_DOMAIN_ACCOUNT); const info = await connection.getAccountInfo(nameKey); return { available: !info, nameKey }; } async function registerTld(connection, payer, tld) { // Support SDK v2/v3 by falling back if needed const registerFn = sns.registerTopLevelDomainV2 || sns.registerTopLevelDomain || sns.registerTopLevelDomainV3; if (typeof registerFn !== 'function') throw new Error('SNS SDK does not expose a TLD registration function'); const ix = await registerFn(tld, payer.publicKey, payer.publicKey, payer.publicKey); const tx = new Transaction().add(ix); tx.feePayer = payer.publicKey; const sig = await sendAndConfirmTransaction(connection, tx, [payer], { commitment: 'confirmed' }); return sig; } async function main() { const cfg = loadCliConfig(); const rpc = cfg.rpcUrl || DEFAULT_RPC; const connection = new Connection(rpc, 'confirmed'); console.log(chalk.cyan.bold(`\nSolana Name Service — Custom TLD Creator (${cfg.network || 'devnet'})\n`)); // Wallet let spinner = ora('Preparing wallet...').start(); let keypair; try { spinner.stop(); keypair = await loadOrCreateKeypair(); spinner = ora('Requesting airdrop if needed...').start(); await ensureBalance(connection, keypair.publicKey, 0.1); spinner.succeed('Wallet ready'); console.log(chalk.gray(`Public key: ${keypair.publicKey.toBase58()}`)); } catch (e) { spinner.fail('Failed to prepare wallet'); console.error(chalk.red(e.message || String(e))); process.exit(1); } // TLD input let tld; try { tld = await promptTld(); } catch (e) { console.error(chalk.red(e.message || String(e))); process.exit(1); } // Availability spinner = ora(`šŸ”¹ Checking availability for .${tld}...`).start(); let nameKey; try { const { available, nameKey: k } = await checkAvailability(connection, tld); nameKey = k; if (!available) { spinner.fail(`TLD .${tld} is already taken`); process.exit(1); } spinner.succeed(`.${tld} is available`); } catch (e) { spinner.fail('Could not check availability'); console.error(chalk.red(e.message || String(e))); process.exit(1); } // Confirm const { confirm } = await inquirer.prompt([ { type: 'confirm', name: 'confirm', message: `Register .${tld} now on devnet?`, default: true }, ]); if (!confirm) { console.log(chalk.yellow('Cancelled.')); process.exit(0); } // Register spinner = ora('šŸ”¹ Sending transaction to register TLD...').start(); let signature; try { signature = await registerTld(connection, keypair, tld); spinner.succeed('Transaction sent'); } catch (e) { spinner.fail('Registration failed'); console.error(chalk.red(e.message || String(e))); try { if (e?.name === 'SendTransactionError' && typeof e.getLogs === 'function') { const logs = await e.getLogs(); console.error(chalk.gray('Logs:'), logs); } else if (e?.logs) { console.error(chalk.gray('Logs:'), e.logs); } } catch {} process.exit(1); } console.log(chalk.green(`\nName account: ${nameKey.toBase58()}`)); console.log(chalk.green(`Transaction: https://explorer.solana.com/tx/${signature}?cluster=devnet`)); console.log(chalk.bold.green(`\nāœ… Your custom domain .${tld} has been successfully created on Solana devnet!\n`)); } main().catch((e) => { console.error(chalk.red(e?.message || String(e))); process.exit(1); });