diff --git a/.env.example b/.env.example index 1f8cd05..db836f3 100644 --- a/.env.example +++ b/.env.example @@ -2,5 +2,4 @@ KV_REST_API_TOKEN= KV_REST_API_URL= NEXT_PUBLIC_URL= NEXTAUTH_URL= -NEXTAUTH_SECERT= NEYNAR_API_KEY=FARCASTER_V2_FRAMES_DEMO diff --git a/README.md b/README.md index 7fb1714..2d08f09 100644 --- a/README.md +++ b/README.md @@ -9,4 +9,8 @@ This is a [NextJS](https://nextjs.org/) + TypeScript + React app. To create a new frames project, run: ```{bash} npx frames-v2-quickstart -``` \ No newline at end of file +``` + +## TODO +* try ngrok locally, then integrate with localtunnel if possible +* ask for seed phrase in setup and generate manifest \ No newline at end of file diff --git a/bin/index.js b/bin/index.js index d442366..af66185 100755 --- a/bin/index.js +++ b/bin/index.js @@ -6,25 +6,59 @@ import { dirname } from 'path'; import { execSync } from 'child_process'; import fs from 'fs'; import path from 'path'; +import { generateManifest } from './manifest.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const REPO_URL = 'https://github.com/lucas-neynar/frames-v2-quickstart.git'; -const SCRIPT_VERSION = '0.1.0'; +const SCRIPT_VERSION = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8')).version; async function init() { const answers = await inquirer.prompt([ { type: 'input', name: 'projectName', - message: 'What is the name of your project?', + message: 'What is the name of your frame?', validate: (input) => { if (input.trim() === '') { return 'Project name cannot be empty'; } return true; } + }, + { + type: 'input', + name: 'description', + message: 'Give a one-line description of your frame:', + validate: (input) => { + if (input.trim() === '') { + return 'Description cannot be empty'; + } + return true; + } + }, + { + type: 'input', + name: 'fid', + message: 'Enter your Farcaster FID:', + validate: (input) => { + if (input.trim() === '') { + return 'FID cannot be empty'; + } + return true; + } + }, + { + type: 'password', + name: 'seedPhrase', + message: 'Enter your Farcaster account seed phrase:', + validate: (input) => { + if (input.trim() === '') { + return 'Seed phrase cannot be empty'; + } + return true; + } } ]); @@ -40,6 +74,14 @@ async function init() { console.log('\nRemoving .git directory...'); fs.rmSync(path.join(projectPath, '.git'), { recursive: true, force: true }); + // Generate manifest and write to public folder + console.log('\nGenerating manifest...'); + const manifest = await generateManifest(answers.fid, answers.seedPhrase); + fs.writeFileSync( + path.join(projectPath, 'public/manifest.json'), + JSON.stringify(manifest) + ); + // Update package.json console.log('\nUpdating package.json...'); const packageJsonPath = path.join(projectPath, 'package.json'); @@ -68,6 +110,9 @@ async function init() { if (fs.existsSync(envExamplePath)) { fs.copyFileSync(envExamplePath, envPath); fs.unlinkSync(envExamplePath); + // Append project name and description to .env + fs.appendFileSync(envPath, `\nNEXT_PUBLIC_FRAME_NAME="${answers.projectName}"`); + fs.appendFileSync(envPath, `\nNEXT_PUBLIC_FRAME_DESCRIPTION="${answers.description}"`); console.log('\nCreated .env file from .env.example'); } else { console.log('\n.env.example does not exist, skipping copy and remove operations'); diff --git a/bin/manifest.js b/bin/manifest.js new file mode 100644 index 0000000..7844ee1 --- /dev/null +++ b/bin/manifest.js @@ -0,0 +1,43 @@ +// utils to generate a manifest.json file for a frames v2 app +const { mnemonicToAccount } = require('viem/accounts'); + +async function generateManifest(fid, seedPhrase) { + if (!Number.isInteger(fid) || fid <= 0) { + throw new Error('FID must be a positive integer'); + } + + let account; + try { + account = mnemonicToAccount(seedPhrase); + } catch (error) { + throw new Error('Invalid seed phrase'); + } + const custodyAddress = account.address; + + const header = { + fid, + type: 'custody', // question: do we want to support type of 'app_key', which indicates the signature is from a registered App Key for the FID + key: custodyAddress, + }; + const encodedHeader = Buffer.from(JSON.stringify(header), 'utf-8').toString('base64'); + + const payload = { + domain: 'warpcast.com' + }; + const encodedPayload = Buffer.from(JSON.stringify(payload), 'utf-8').toString('base64url'); + + const signature = await account.signMessage({ + message: `${encodedHeader}.${encodedPayload}` + }); + const encodedSignature = Buffer.from(signature, 'utf-8').toString('base64url'); + + const jsonJfs = { + header: encodedHeader, + payload: encodedPayload, + signature: encodedSignature + }; + + return jsonJfs; +} + +module.exports = { generateManifest }; diff --git a/package-lock.json b/package-lock.json index 3a8a1fe..7f8d151 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "inquirer": "^12.4.3", + "localtunnel": "^2.0.2", "lucide-react": "^0.469.0", "next": "15.0.3", "next-auth": "^4.24.11", @@ -31,7 +32,7 @@ "wagmi": "^2.14.12" }, "bin": { - "frames-v2-quickstart": "bin/index.js" + "test-frames-v2-quickstart": "bin/index.js" }, "devDependencies": { "@types/node": "^20", @@ -3859,6 +3860,15 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "0.21.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", + "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.14.0" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -4929,6 +4939,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -5582,9 +5601,9 @@ } }, "node_modules/ethers": { - "version": "6.13.4", - "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.13.4.tgz", - "integrity": "sha512-21YtnZVg4/zKkCQPjrDj38B1r4nQvTZLopUGMLQ1ePU2zV/joCfDC3t3iKQjWRzjjjbzR+mdAIoikeBRNkdllA==", + "version": "6.13.5", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.13.5.tgz", + "integrity": "sha512-+knKNieu5EKRThQJWwqaJ10a6HE9sSehGeqWN65//wE7j47ZpFhKAnHB/JJFibwwg61I/koxaPsXbXpD/skNOQ==", "funding": [ { "type": "individual", @@ -5872,6 +5891,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -7247,6 +7286,111 @@ "@types/trusted-types": "^2.0.2" } }, + "node_modules/localtunnel": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/localtunnel/-/localtunnel-2.0.2.tgz", + "integrity": "sha512-n418Cn5ynvJd7m/N1d9WVJISLJF/ellZnfsLnx8WBWGzxv/ntNcFkJ1o6se5quUhCplfLGBNL5tYHiq5WF3Nug==", + "license": "MIT", + "dependencies": { + "axios": "0.21.4", + "debug": "4.3.2", + "openurl": "1.1.1", + "yargs": "17.1.1" + }, + "bin": { + "lt": "bin/lt.js" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/localtunnel/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/localtunnel/node_modules/debug": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", + "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==", + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/localtunnel/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "license": "MIT" + }, + "node_modules/localtunnel/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/localtunnel/node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/localtunnel/node_modules/yargs": { + "version": "17.1.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.1.1.tgz", + "integrity": "sha512-c2k48R0PwKIqKhPMWjeiF6y2xY/gPMUlro0sgxqXpbOIohWiLNXWslsootttv7E1e73QPAMQSg5FeySbVcpsPQ==", + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/localtunnel/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -7995,6 +8139,12 @@ "node": ">=10" } }, + "node_modules/openurl": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/openurl/-/openurl-1.1.1.tgz", + "integrity": "sha512-d/gTkTb1i1GKz5k3XE3XFV/PxQ1k45zDqGP2OA7YhgsaLoqm6qRvARAZOFer1fcXritWlGBRCu/UgeS4HAnXAA==", + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", diff --git a/package.json b/package.json index cdad302..7a15dc3 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,9 @@ "name": "frames-v2-demo", "version": "0.1.0", "type": "module", - "files": ["bin/index.js"], + "files": [ + "bin/index.js" + ], "keywords": [ "farcaster", "frames", @@ -12,13 +14,13 @@ "web3" ], "scripts": { - "dev": "next dev", + "dev": "lt --port 3000 & next dev", "build": "next build", "start": "next start", "lint": "next lint" }, "bin": { - "testing-frames-v2-quickstart": "./bin/index.js" + "test-frames-v2-quickstart": "./bin/index.js" }, "dependencies": { "@farcaster/auth-kit": "^0.6.0", @@ -32,6 +34,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "inquirer": "^12.4.3", + "localtunnel": "^2.0.2", "lucide-react": "^0.469.0", "next": "15.0.3", "next-auth": "^4.24.11", diff --git a/src/app/.well-known/farcaster.json/route.ts b/src/app/.well-known/farcaster.json/route.ts index b67d63b..cec0a9a 100644 --- a/src/app/.well-known/farcaster.json/route.ts +++ b/src/app/.well-known/farcaster.json/route.ts @@ -1,17 +1,24 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; + export async function GET() { const appUrl = process.env.NEXT_PUBLIC_URL; + let accountAssociation; // TODO: add type + try { + const manifestPath = join(process.cwd(), 'public/manifest.json'); + const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')); + accountAssociation = manifest; + } catch (error) { + console.warn('Warning: manifest.json not found or invalid. Frame will not be associated with an account.'); + accountAssociation = null; + } + const config = { - accountAssociation: { - header: - "eyJmaWQiOjM2MjEsInR5cGUiOiJjdXN0b2R5Iiwia2V5IjoiMHgyY2Q4NWEwOTMyNjFmNTkyNzA4MDRBNkVBNjk3Q2VBNENlQkVjYWZFIn0", - payload: "eyJkb21haW4iOiJmcmFtZXMtdjIudmVyY2VsLmFwcCJ9", - signature: - "MHhiNDIwMzQ1MGZkNzgzYTExZjRiOTllZTFlYjA3NmMwOTdjM2JkOTY1NGM2ODZjYjkyZTAyMzk2Y2Q0YjU2MWY1MjY5NjI5ZGQ5NTliYjU0YzEwOGI4OGVmNjdjMTVlZTdjZDc2YTRiMGU5NzkzNzA3YzkxYzFkOWFjNTg0YmQzNjFi", - }, + ...(accountAssociation && { accountAssociation }), frame: { version: "1", - name: "Frames v2 Demo", + name: process.env.NEXT_PUBLIC_FRAME_NAME || "Frames v2 Demo", iconUrl: `${appUrl}/icon.png`, homeUrl: appUrl, imageUrl: `${appUrl}/frames/hello/opengraph-image`, diff --git a/src/app/app.tsx b/src/app/app.tsx index f5757df..612a20d 100644 --- a/src/app/app.tsx +++ b/src/app/app.tsx @@ -2,6 +2,8 @@ import dynamic from "next/dynamic"; + +// note: dynamic import is required for components that use the Frame SDK const Demo = dynamic(() => import("~/components/Demo"), { ssr: false, }); diff --git a/src/app/frames/hello/[name]/page.tsx b/src/app/frames/hello/[name]/page.tsx index b41a47d..10196aa 100644 --- a/src/app/frames/hello/[name]/page.tsx +++ b/src/app/frames/hello/[name]/page.tsx @@ -19,7 +19,7 @@ export async function generateMetadata({ params }: Props): Promise { title: "Launch Frame", action: { type: "launch_frame", - name: "Farcaster Frames v2 Demo", + name: process.env.NEXT_PUBLIC_FRAME_NAME || "Frames v2 Demo", url: `${appUrl}/frames/hello/${name}/`, splashImageUrl: `${appUrl}/splash.png`, splashBackgroundColor: "#f7f7f7", diff --git a/src/app/frames/hello/page.tsx b/src/app/frames/hello/page.tsx index 4d5ba19..bfd2e56 100644 --- a/src/app/frames/hello/page.tsx +++ b/src/app/frames/hello/page.tsx @@ -10,7 +10,7 @@ const frame = { title: "Launch Frame", action: { type: "launch_frame", - name: "Farcaster Frames v2 Demo", + name: process.env.NEXT_PUBLIC_FRAME_NAME || "Frames v2 Demo", url: `${appUrl}/frames/hello/`, splashImageUrl: `${appUrl}/splash.png`, splashBackgroundColor: "#f7f7f7", diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 4a62331..382bd79 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,8 +5,8 @@ import "~/app/globals.css"; import { Providers } from "~/app/providers"; export const metadata: Metadata = { - title: "Farcaster Frames v2 Demo", - description: "A Farcaster Frames v2 demo app", + title: process.env.NEXT_PUBLIC_FRAME_NAME || "Frames v2 Demo", + description: process.env.NEXT_PUBLIC_FRAME_DESCRIPTION || "A Farcaster Frames v2 demo app", }; export default async function RootLayout({ diff --git a/src/app/opengraph-image.tsx b/src/app/opengraph-image.tsx index ea6bf54..6848343 100644 --- a/src/app/opengraph-image.tsx +++ b/src/app/opengraph-image.tsx @@ -1,6 +1,6 @@ import { ImageResponse } from "next/og"; -export const alt = "Farcaster Frames V2 Demo"; +export const alt = process.env.NEXT_PUBLIC_FRAME_NAME || "Frames V2 Demo"; export const size = { width: 600, height: 400, @@ -8,11 +8,13 @@ export const size = { export const contentType = "image/png"; +// dynamically generated OG image for frame preview +// TODO: make this dynamic with user info (like robin's example) export default async function Image() { return new ImageResponse( (
-

Frames v2 Demo

+

{alt}

), { diff --git a/src/app/page.tsx b/src/app/page.tsx index 46b15b9..e38a910 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,6 +3,9 @@ import App from "./app"; const appUrl = process.env.NEXT_PUBLIC_URL; +// frame preview metadata +const appName = process.env.NEXT_PUBLIC_FRAME_NAME || "Frames v2 Demo"; + const frame = { version: "next", imageUrl: `${appUrl}/opengraph-image`, @@ -10,7 +13,7 @@ const frame = { title: "Launch Frame", action: { type: "launch_frame", - name: "Farcaster Frames v2 Demo", + name: appName, url: appUrl, splashImageUrl: `${appUrl}/splash.png`, splashBackgroundColor: "#f7f7f7", @@ -22,10 +25,10 @@ export const revalidate = 300; export async function generateMetadata(): Promise { return { - title: "Farcaster Frames v2 Demo", + title: appName, openGraph: { - title: "Farcaster Frames v2 Demo", - description: "A Farcaster Frames v2 demo app.", + title: appName, + description: process.env.NEXT_PUBLIC_FRAME_DESCRIPTION || "A Farcaster Frames v2 demo app.", }, other: { "fc:frame": JSON.stringify(frame), diff --git a/src/auth.ts b/src/auth.ts index 91695b4..23dc1f3 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -29,6 +29,7 @@ export const authOptions: AuthOptions = { // In a production app with a server, these should be fetched from // your Farcaster data indexer rather than have them accepted as part // of credentials. + // question: should these natively use the Neynar API? name: { label: "Name", type: "text", @@ -49,6 +50,7 @@ export const authOptions: AuthOptions = { const verifyResponse = await appClient.verifySignInMessage({ message: credentials?.message as string, signature: credentials?.signature as `0x${string}`, + // question: what domain should this be? domain: new URL(process.env.NEXTAUTH_URL ?? '').hostname, nonce: csrfToken, });