Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm ci --legacy-peer-deps
- run: npm run typecheck
- run: npm run lint
- run: npm run format:check
Expand Down
1 change: 1 addition & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
legacy-peer-deps=true
34 changes: 25 additions & 9 deletions src/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export function parseRecipeMeta(json: string): RecipeMeta {

export function resolveRecipeFiles(
files: { path: string; content: string }[],
chain: ChainFilter
chain: ChainFilter,
): { path: string; content: string }[] {
return files.filter((f) => {
const name = basename(f.path);
Expand Down Expand Up @@ -51,7 +51,7 @@ export function createAddCommand(): Command {

async function addRecipe(
name: string | undefined,
options: { chain?: string; learn: boolean }
options: { chain?: string; learn: boolean },
): Promise<void> {
if (!name) {
const spinner = ora("Fetching available recipes...").start();
Expand All @@ -71,7 +71,10 @@ async function addRecipe(
choices: recipes.map((r) => ({ title: r, value: r })),
});

if (!response.recipe) { console.log("Cancelled."); return; }
if (!response.recipe) {
console.log("Cancelled.");
return;
}
name = response.recipe as string;
}

Expand All @@ -88,7 +91,10 @@ async function addRecipe(
],
});
chain = response.chain;
if (!chain) { console.log("Cancelled."); return; }
if (!chain) {
console.log("Cancelled.");
return;
}
}

const spinner = ora(`Downloading ${name}...`).start();
Expand Down Expand Up @@ -116,8 +122,10 @@ async function addRecipe(
try {
const meta = parseRecipeMeta(metaFile.content);
const deps: string[] = [];
if ((chain === "evm" || chain === "both") && meta.dependencies.evm) deps.push(...meta.dependencies.evm);
if ((chain === "solana" || chain === "both") && meta.dependencies.solana) deps.push(...meta.dependencies.solana);
if ((chain === "evm" || chain === "both") && meta.dependencies.evm)
deps.push(...meta.dependencies.evm);
if ((chain === "solana" || chain === "both") && meta.dependencies.solana)
deps.push(...meta.dependencies.solana);

if (deps.length > 0) {
const depSpinner = ora(`Installing dependencies (${deps.join(", ")})...`).start();
Expand All @@ -128,7 +136,9 @@ async function addRecipe(
depSpinner.fail(`Failed to install. Run manually: npm install ${deps.join(" ")}`);
}
}
} catch { /* No valid meta.json — skip deps */ }
} catch {
/* No valid meta.json — skip deps */
}
}

if (options.learn) {
Expand All @@ -150,7 +160,10 @@ async function addContract(name: string | undefined): Promise<void> {
process.exit(1);
}

if (contracts.length === 0) { console.log("No contract templates available yet."); return; }
if (contracts.length === 0) {
console.log("No contract templates available yet.");
return;
}

const response = await prompts({
type: "autocomplete",
Expand All @@ -159,7 +172,10 @@ async function addContract(name: string | undefined): Promise<void> {
choices: contracts.map((c) => ({ title: c, value: c })),
});

if (!response.contract) { console.log("Cancelled."); return; }
if (!response.contract) {
console.log("Cancelled.");
return;
}
name = response.contract as string;
}

Expand Down
77 changes: 60 additions & 17 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,33 @@ import { detectPackageManager, runInstall } from "../utils/deps.js";
import type { ChainFilter, TemplateMeta } from "../types.js";

const TEMPLATES: TemplateMeta[] = [
{ name: "nextjs-evm", description: "Next.js dApp with wagmi + viem + RainbowKit", chain: "evm", stack: "Next.js + wagmi + viem + RainbowKit" },
{ name: "script-evm", description: "TypeScript script with viem", chain: "evm", stack: "TypeScript + viem" },
{ name: "nextjs-solana", description: "Next.js dApp with wallet-adapter", chain: "solana", stack: "Next.js + wallet-adapter" },
{ name: "script-solana", description: "TypeScript script with @solana/web3.js", chain: "solana", stack: "TypeScript + @solana/web3.js" },
{
name: "nextjs-evm",
description: "Next.js dApp with wagmi + viem + RainbowKit",
chain: "evm",
stack: "Next.js + wagmi + viem + RainbowKit",
},
{
name: "script-evm",
description: "TypeScript script with viem",
chain: "evm",
stack: "TypeScript + viem",
},
{
name: "nextjs-solana",
description: "Next.js dApp with wallet-adapter",
chain: "solana",
stack: "Next.js + wallet-adapter",
},
{
name: "script-solana",
description: "TypeScript script with @solana/web3.js",
chain: "solana",
stack: "TypeScript + @solana/web3.js",
},
];

export function filterTemplates(
templates: TemplateMeta[],
chain: ChainFilter
): TemplateMeta[] {
export function filterTemplates(templates: TemplateMeta[], chain: ChainFilter): TemplateMeta[] {
if (chain === "both") return templates;
return templates.filter((t) => t.chain === chain);
}
Expand All @@ -38,12 +55,20 @@ export function createInitCommand(): Command {

async function runInit(
name: string | undefined,
options: { template?: string; chain?: string; pm?: string }
options: { template?: string; chain?: string; pm?: string },
): Promise<void> {
if (!name) {
const response = await prompts({ type: "text", name: "name", message: "Project name:", initial: "my-dapp" });
const response = await prompts({
type: "text",
name: "name",
message: "Project name:",
initial: "my-dapp",
});
name = response.name;
if (!name) { console.log("Cancelled."); return; }
if (!name) {
console.log("Cancelled.");
return;
}
}

const projectDir = join(process.cwd(), name);
Expand All @@ -55,26 +80,36 @@ async function runInit(
let chain = options.chain as ChainFilter | undefined;
if (!chain) {
const response = await prompts({
type: "select", name: "chain", message: "Which chain?",
type: "select",
name: "chain",
message: "Which chain?",
choices: [
{ title: "EVM (Ethereum, Arbitrum, Base, Polygon...)", value: "evm" },
{ title: "Solana", value: "solana" },
{ title: "Both", value: "both" },
],
});
chain = response.chain;
if (!chain) { console.log("Cancelled."); return; }
if (!chain) {
console.log("Cancelled.");
return;
}
}

let templateName = options.template;
if (!templateName) {
const available = filterTemplates(TEMPLATES, chain);
const response = await prompts({
type: "select", name: "template", message: "Pick a template:",
type: "select",
name: "template",
message: "Pick a template:",
choices: available.map((t) => ({ title: `${t.name} — ${t.description}`, value: t.name })),
});
templateName = response.template;
if (!templateName) { console.log("Cancelled."); return; }
if (!templateName) {
console.log("Cancelled.");
return;
}
}

const spinner = ora("Fetching template...").start();
Expand All @@ -90,10 +125,18 @@ async function runInit(
const prefix = `templates/${templateName}/`;

for (const file of files) {
const relativePath = file.path.startsWith(prefix) ? file.path.slice(prefix.length) : basename(file.path);
const relativePath = file.path.startsWith(prefix)
? file.path.slice(prefix.length)
: basename(file.path);
let content = file.content;
if (relativePath === "package.json") {
try { const pkg = JSON.parse(content); pkg.name = name; content = JSON.stringify(pkg, null, 2) + "\n"; } catch {}
try {
const pkg = JSON.parse(content);
pkg.name = name;
content = JSON.stringify(pkg, null, 2) + "\n";
} catch {
// ignore invalid JSON
}
}
const destPath = join(projectDir, relativePath);
mkdirSync(dirname(destPath), { recursive: true });
Expand Down
73 changes: 56 additions & 17 deletions src/commands/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import { formatTable, formatKeyValue, formatJson } from "../utils/display.js";

export function formatChainsTable(chains: Chain[]) {
const headers = ["Chain", "Chain ID", "Type", "Native Token"];
const rows = chains.map((c) => [c.name, String(c.chainId), c.testnet ? "testnet" : c.ecosystem, c.nativeCurrency.symbol]);
const rows = chains.map((c) => [
c.name,
String(c.chainId),
c.testnet ? "testnet" : c.ecosystem,
c.nativeCurrency.symbol,
]);
return { headers, rows };
}

Expand All @@ -14,7 +19,10 @@ export function formatChainDetail(chain: Chain): [string, string][] {
["Chain", chain.name],
["Chain ID", String(chain.chainId)],
["Ecosystem", chain.ecosystem],
["Native Token", `${chain.nativeCurrency.name} (${chain.nativeCurrency.symbol}, ${chain.nativeCurrency.decimals} decimals)`],
[
"Native Token",
`${chain.nativeCurrency.name} (${chain.nativeCurrency.symbol}, ${chain.nativeCurrency.decimals} decimals)`,
],
["RPC", chain.rpcUrls[0] || "—"],
["Explorer", chain.blockExplorers[0] || "—"],
["Testnet", chain.testnet ? "Yes" : "No"],
Expand All @@ -25,45 +33,76 @@ export function formatTokenTable(token: Token) {
const chains = getAllChains();
const chainMap = new Map(chains.map((c) => [c.chainId, c.name]));
const headers = ["Chain", "Address", "Decimals"];
const rows = token.chains.map((c) => [chainMap.get(c.chainId) || String(c.chainId), c.address, String(token.decimals)]);
const rows = token.chains.map((c) => [
chainMap.get(c.chainId) || String(c.chainId),
c.address,
String(token.decimals),
]);
return { headers, rows };
}

export function createRegistryCommand(): Command {
const registry = new Command("registry").description("Query chain and token data");

registry.command("chains").description("List all supported chains").option("--json", "Output as JSON")
registry
.command("chains")
.description("List all supported chains")
.option("--json", "Output as JSON")
.action((options) => {
const chains = getAllChains();
if (options.json) { console.log(formatJson(chains)); }
else { const { headers, rows } = formatChainsTable(chains); console.log(formatTable(headers, rows)); }
if (options.json) {
console.log(formatJson(chains));
} else {
const { headers, rows } = formatChainsTable(chains);
console.log(formatTable(headers, rows));
}
});

registry.command("chain <id>").description("Show details for a specific chain").option("--json", "Output as JSON")
registry
.command("chain <id>")
.description("Show details for a specific chain")
.option("--json", "Output as JSON")
.action((id, options) => {
const chain = getChain(Number(id));
if (!chain) { console.error(`Chain with ID ${id} not found.`); process.exit(1); }
if (options.json) { console.log(formatJson(chain)); }
else { console.log(formatKeyValue(formatChainDetail(chain))); }
if (!chain) {
console.error(`Chain with ID ${id} not found.`);
process.exit(1);
}
if (options.json) {
console.log(formatJson(chain));
} else {
console.log(formatKeyValue(formatChainDetail(chain)));
}
});

registry.command("tokens").description("List all supported tokens").option("--json", "Output as JSON")
registry
.command("tokens")
.description("List all supported tokens")
.option("--json", "Output as JSON")
.action((options) => {
const tokens = getAllTokens();
if (options.json) { console.log(formatJson(tokens)); }
else {
if (options.json) {
console.log(formatJson(tokens));
} else {
const headers = ["Token", "Symbol", "Chains"];
const rows = tokens.map((t) => [t.name, t.symbol, String(t.chains.length)]);
console.log(formatTable(headers, rows));
}
});

registry.command("token <symbol>").description("Show token addresses across chains").option("--json", "Output as JSON")
registry
.command("token <symbol>")
.description("Show token addresses across chains")
.option("--json", "Output as JSON")
.action((symbol, options) => {
const token = getToken(symbol);
if (!token) { console.error(`Token "${symbol}" not found.`); process.exit(1); }
if (options.json) { console.log(formatJson(token)); }
else {
if (!token) {
console.error(`Token "${symbol}" not found.`);
process.exit(1);
}
if (options.json) {
console.log(formatJson(token));
} else {
const { headers, rows } = formatTokenTable(token);
console.log(`\n${token.name} (${token.symbol})\n`);
console.log(formatTable(headers, rows));
Expand Down
14 changes: 9 additions & 5 deletions src/utils/deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,18 @@ export function detectPackageManager(projectDir: string): PackageManager {

export function buildInstallCommand(
pm: PackageManager,
packages: string[]
packages: string[],
): { command: string; args: string[] } | null {
if (packages.length === 0) return null;
switch (pm) {
case "npm": return { command: "npm", args: ["install", ...packages] };
case "pnpm": return { command: "pnpm", args: ["add", ...packages] };
case "yarn": return { command: "yarn", args: ["add", ...packages] };
case "bun": return { command: "bun", args: ["add", ...packages] };
case "npm":
return { command: "npm", args: ["install", ...packages] };
case "pnpm":
return { command: "pnpm", args: ["add", ...packages] };
case "yarn":
return { command: "yarn", args: ["add", ...packages] };
case "bun":
return { command: "bun", args: ["add", ...packages] };
}
}

Expand Down
8 changes: 2 additions & 6 deletions src/utils/display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@ import chalk from "chalk";

export function formatTable(headers: string[], rows: string[][]): string {
const allRows = [headers, ...rows];
const colWidths = headers.map((_, i) =>
Math.max(...allRows.map((row) => (row[i] || "").length))
);
const colWidths = headers.map((_, i) => Math.max(...allRows.map((row) => (row[i] || "").length)));

const topBorder = "┌" + colWidths.map((w) => "─".repeat(w + 2)).join("┬") + "┐";
const midBorder = "├" + colWidths.map((w) => "─".repeat(w + 2)).join("┼") + "┤";
Expand All @@ -26,9 +24,7 @@ export function formatTable(headers: string[], rows: string[][]): string {

export function formatKeyValue(pairs: [string, string][]): string {
const maxKeyLen = Math.max(...pairs.map(([k]) => k.length));
return pairs
.map(([key, value]) => `${chalk.bold(key.padEnd(maxKeyLen))} ${value}`)
.join("\n");
return pairs.map(([key, value]) => `${chalk.bold(key.padEnd(maxKeyLen))} ${value}`).join("\n");
}

export function formatJson(data: unknown): string {
Expand Down
Loading
Loading