@consensys/create-web3-app
Version:
CLI tool for generating Web3 starter projects, streamlining the setup of monorepo structures with a frontend (Next.js or React) and blockchain tooling (HardHat or Foundry). It leverages the commander library for command-line interactions and guides users
602 lines (544 loc) • 20 kB
text/typescript
import path from "path";
import { promises as fs } from "fs";
import {
addShadcnButton,
addShadcnCard,
createWagmiConfigFile,
execAsync,
pathOrProjectName,
updatePackageJsonDependencies,
usePackageManager,
addShadcnDropdownMenu,
addShadcnSeparator,
createNoise,
createArrow,
createMetamaskLogo,
createComponentsFolder,
createUtils,
} from "./index.js";
export const createNextApp = async (
options: ProjectOptions,
projectPath?: string
) => {
console.log("Creating Next.js project...");
try {
const { projectName, packageManager } = options;
const projectPathOrName = pathOrProjectName(projectName, projectPath);
const command = `npx create-next-app ${projectPathOrName} --ts --tailwind --eslint --app --src-dir --skip-install --import-alias "@/*" ${usePackageManager(
packageManager
)} --turbopack`;
await execAsync(command);
await updatePackageJsonDependencies(
{
"@tanstack/react-query": "^5.51.23",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.3",
"@radix-ui/react-separator": "^1.1.1",
"lucide-react": "^0.468.0",
"class-variance-authority": "^0.7.1",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
clsx: "^2.1.1",
viem: "2.x",
wagmi: "^2.14.8",
},
projectPathOrName
);
await updateTsConfig(projectPathOrName);
await createComponentsFolder(projectPathOrName);
await updateLayoutFile(projectPathOrName);
await createProvider(projectPathOrName);
await createWagmiConfigFile(projectPathOrName, true);
await createUtils(projectPathOrName);
await addShadcnButton(projectPathOrName);
await addShadcnCard(projectPathOrName);
await addShadcnDropdownMenu(projectPathOrName);
await addShadcnSeparator(projectPathOrName);
await createNoise(projectPathOrName);
await createArrow(projectPathOrName);
await createMetamaskLogo(projectPathOrName);
await createHero(projectPathOrName);
await createNavbar(projectPathOrName);
await updateGlobalStyles(projectPathOrName);
await updatePageFile(projectPathOrName);
console.log("Next.js project created successfully!");
} catch (error) {
console.error("An unexpected error occurred:", error);
}
};
const updateTsConfig = async (projectPath: string) => {
const tsConfigPath = path.join(projectPath, "tsconfig.json");
const tsConfigContent = await fs.readFile(tsConfigPath, "utf-8");
const tsConfig = JSON.parse(tsConfigContent);
tsConfig.compilerOptions.paths = {
"@/*": ["./*"],
};
const newTsConfigContent = JSON.stringify(tsConfig, null, 2);
await fs.writeFile(tsConfigPath, newTsConfigContent, "utf-8");
};
const updateLayoutFile = async (projectPath: string) => {
const layoutFilePath = path.join(projectPath, "src", "app", "layout.tsx");
await fs.writeFile(
layoutFilePath,
`
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { headers } from "next/headers";
import { cookieToInitialState } from "wagmi";
import "./globals.css";
import { getConfig } from "@/wagmi.config";
import { Providers } from "@/src/providers/WagmiProvider";
import { Navbar } from "@/src/components/navbar";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "MetaMask SDK Quickstart",
description: "MetaMask SDK Quickstart app",
};
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const initialState = cookieToInitialState(
getConfig(),
(await headers()).get("cookie") ?? ""
);
return (
<html lang="en">
<body
className={
geistSans.variable +
" " +
geistMono.variable +
" " +
"bg-black bg-opacity-90 text-foreground antialiased"
}
>
<div className="fixed inset-0 w-full h-full bg-repeat bg-noise opacity-25 bg-[length:350px] z-[-20] before:content-[''] before:absolute before:w-[2500px] before:h-[2500px] before:rounded-full before:blur-[100px] before:-left-[1000px] before:-top-[2000px] before:bg-white before:opacity-50 before:z-[-100]"></div>
<main className="flex flex-col max-w-screen-lg mx-auto pb-20">
<Providers initialState={initialState}>
<Navbar />
{children}
</Providers>
</main>
</body>
</html>
);
}
`
);
};
const createProvider = async (projectPath: string) => {
await fs.mkdir(path.join(projectPath, "src", "providers"));
const providerFilePath = path.join(
projectPath,
"src",
"providers",
"WagmiProvider.tsx"
);
await fs.writeFile(
providerFilePath,
`
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { type ReactNode, useState } from "react";
import { type State, WagmiProvider } from "wagmi";
import { getConfig } from "@/wagmi.config";
type Props = {
children: ReactNode;
initialState: State | undefined;
};
export function Providers({ children, initialState }: Props) {
const [config] = useState(() => getConfig());
const [queryClient] = useState(() => new QueryClient());
return (
<WagmiProvider config={config} initialState={initialState}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</WagmiProvider>
);
}
`
);
};
const updatePageFile = async (projectPath: string) => {
const pageFilePath = path.join(projectPath, "src", "app", "page.tsx");
await fs.writeFile(
pageFilePath,
`
import { Separator } from "@/src/components/ui/separator";
import { Card, CardContent, CardHeader, CardTitle } from "@/src/components/ui/card";
import { ArrowRight } from "lucide-react";
import { Hero } from "@/src/components/Hero";
export default function Home() {
return (
<main className="">
<div className="flex flex-col gap-8 items-center sm:items-start w-full px-3 md:px-0">
<Hero />
<Separator className="w-full my-14 opacity-15" />
<section className="flex flex-col items-center md:flex-row gap-10 w-full justify-center max-w-5xl">
<div className="flex flex-col gap-10">
{/* Docs Card */}
<a
href="https://docs.metamask.io/sdk/"
target="_blank"
className="relative bg-indigo-500 rounded-tr-sm rounded-bl-sm rounded-tl-xl rounded-br-xl bg-opacity-40 max-w-md text-white border-none transition-colors h-full"
>
<div className="bg-indigo-500/20 h-[107%] w-[104%] rounded-xl -z-20 absolute right-0 bottom-0"></div>
<div className="bg-indigo-500/20 h-[107%] w-[104%] rounded-xl -z-20 absolute top-0 left-0"></div>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-2xl">
Docs
<ArrowRight className="h-5 w-5" />
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-lg text-indigo-100">
Find in-depth information about the SDK features
</p>
</CardContent>
</a>
{/* Get ETH Card */}
<a
href="https://docs.metamask.io/developer-tools/faucet/"
target="_blank"
className="bg-teal-300 bg-opacity-60 rounded-tr-sm rounded-bl-sm rounded-tl-xl rounded-br-xl relative max-w-md h-full text-white border-none transition-colors"
>
<div className="bg-teal-300/20 h-[107%] w-[104%] rounded-xl -z-20 absolute right-0 bottom-0"></div>
<div className="bg-teal-300/20 h-[107%] w-[104%] rounded-xl -z-20 absolute top-0 left-0"></div>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-2xl">
Get ETH on testnet
<ArrowRight className="h-5 w-5" />
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-lg text-emerald-100">
Get testnet tokens to use when testing your smart contracts.
</p>
</CardContent>
</a>
</div>
<Card className="relative bg-pink-500 bg-opacity-35 rounded-tr-sm rounded-bl-sm text-white border-none h-full w-full max-w-xl self-start h-[360px]">
<div className="bg-pink-500/20 h-[104%] w-[103%] md:h-[103%] md:w-[102%] rounded-xl -z-20 absolute right-0 bottom-0"></div>
<div className="bg-pink-500/20 h-[104%] w-[103%] md:h-[103%] md:w-[102%] rounded-xl -z-20 absolute top-0 left-0"></div>
<CardHeader>
<CardTitle className="text-2xl">
Add your own functionality
</CardTitle>
</CardHeader>
<CardContent className="space-y-7">
<div className="space-y-1">
<h3 className="text-lg font-semibold">Guides</h3>
<div className="space-y-2">
{[
{url: "https://docs.metamask.io/sdk/guides/network-management/", text: "Manage Networks"},
{url: "https://docs.metamask.io/sdk/guides/transaction-handling/", text: "Handle Transactions"},
{url: "https://docs.metamask.io/sdk/guides/interact-with-contracts/", text: "Interact with Smart Contracts"},
].map((item) => (
<a
href={item.url}
key={item.text}
target="_blank"
className="flex items-center gap-2 w-fit text-white text-opacity-80 cursor-pointer transition-colors"
>
<span className="hover:mr-1 duration-300">{item.text}</span>
<ArrowRight className="h-5 w-5" />
</a>
))}
</div>
</div>
<div className="space-y-1">
<h3 className="text-lg font-semibold">Examples</h3>
<div className="space-y-1">
{[
{url: "https://github.com/MetaMask/metamask-sdk-examples/tree/main/examples/quickstart", text: "Next.js + Wagmi"},
].map((item) => (
<a
href={item.url}
key={item.text}
target="_blank"
className="flex items-center gap-2 w-fit text-white text-opacity-80 cursor-pointer transition-colors"
>
<span className="hover:mr-1 duration-300">{item.text}</span>
<ArrowRight className="h-5 w-5" />
</a>
))}
</div>
</div>
</CardContent>
</Card>
</section>
</div>
</main>
);
}
`
);
};
const createNavbar = async (projectPath: string) => {
const navbarFilePath = path.join(
projectPath,
"src",
"components",
"navbar.tsx"
);
await fs.writeFile(
navbarFilePath,
`
"use client";
import Image from "next/image";
import { useAccount, useConnect, useDisconnect, useSwitchChain } from "wagmi";
import { Button } from "./ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/src/components/ui/dropdown-menu";
import { formatAddress } from "@/src/lib/utils";
import { ChevronDown } from "lucide-react";
export function Navbar() {
const { address, isConnected, chain } = useAccount();
const { connect, connectors } = useConnect();
const { disconnect } = useDisconnect();
const { switchChain, chains } = useSwitchChain();
const connector = connectors[0];
return (
<nav className="flex w-full px-3 md:px-0 h-fit py-10 justify-between items-center">
<Image
src="/metamask-logo.svg"
alt="Metamask Logo"
width={180}
height={180}
/>
{isConnected ? (
<div className="flex-col md:flex-row flex gap-2">
<DropdownMenu>
<DropdownMenuTrigger className="bg-white h-fit md:px-3 py-2 rounded-2xl font-semibold flex justify-center items-center gap-1">
{chain?.name.split(" ").slice(0, 2).join(" ")} <ChevronDown />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-full justify-center rounded-2xl">
{chains.map(
(c) =>
c.id !== chain?.id && (
<DropdownMenuItem
key={c.id}
onClick={() => switchChain({ chainId: c.id })}
className="cursor-pointer w-full flex justify-center rounded-2xl font-semibold"
>
{c.name}
</DropdownMenuItem>
)
)}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger className="bg-white h-fit px-7 py-2 rounded-2xl font-semibold flex items-center gap-1">
{formatAddress(address)} <ChevronDown />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-full flex justify-center rounded-2xl">
<DropdownMenuItem
onClick={() => disconnect()}
className="text-red-400 cursor-pointer w-full flex justify-center rounded-2xl font-semibold"
>
Disconnect
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
) : (
<Button
className="bg-blue-500 rounded-xl hover:bg-blue-600 shadow-xl md:px-10 font-semibold"
onClick={() => connect({ connector })}
>
Connect Wallet
</Button>
)}
</nav>
);
}
`
);
};
const createHero = async (projectPath: string) => {
const heroFilePath = path.join(projectPath, "src", "components", "Hero.tsx");
await fs.writeFile(
heroFilePath,
`
"use client";
import Image from "next/image";
import { useAccount } from "wagmi";
export const Hero = () => {
const { isConnected } = useAccount();
if (isConnected) {
return (
<section className="relative mx-auto mt-28">
<h1 className="text-7xl text-zinc-100 font-bold">Welcome</h1>
<p className="text-white opacity-70 text-center text-lg">
to the <strong>MetaMask SDK</strong> quick start app!
<br /> Add your functionality.
</p>
<Image
src="/arrow.svg"
alt="Arrow pointing to the connect wallet button"
className="absolute scale-y-[-1] hidden md:block md:bottom-[-65px] md:right-[-95px]"
width={130}
height={130}
/>
</section>
);
}
return (
<section className="relative mx-auto mt-28">
<h1 className="text-7xl text-zinc-100 font-bold">Welcome</h1>
<p className="text-white opacity-70 text-center text-lg">
to the <strong>MetaMask SDK</strong> quick start app!
<br /> Connect your wallet to get started.
</p>
<Image
src="/arrow.svg"
alt="Arrow pointing to the connect wallet button"
className="absolute hidden md:block md:bottom-5 md:-right-48"
width={150}
height={150}
/>
</section>
);
};
`
);
};
const updateGlobalStyles = async (projectPath: string) => {
const globalStylesFilePath = path.join(
projectPath,
"src",
"app",
"globals.css"
);
await fs.writeFile(
globalStylesFilePath,
`
@import 'tailwindcss';
@plugin 'tailwindcss-animate';
@custom-variant dark (&:is(.dark *));
@theme {
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-chart-1: hsl(var(--chart-1));
--color-chart-2: hsl(var(--chart-2));
--color-chart-3: hsl(var(--chart-3));
--color-chart-4: hsl(var(--chart-4));
--color-chart-5: hsl(var(--chart-5));
--background-image-noise: url('/noise.svg');
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
}
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
@layer base {
@font-face {
font-family: "Cedarville Cursive";
src: url("/fonts/cedarville-cursive-regular.woff2") format("woff2");
}
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer utilities {
body {
font-family: Arial, Helvetica, sans-serif;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
`
);
};