lili-solana-cli
Version:
Production-ready CLI tool for Solana developers - Build, Deploy, and Manage Solana programs
249 lines (221 loc) ⢠8.57 kB
JavaScript
// 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);
});