From 583054cefb5b879c222486bf4b6bf8c1df324bdc Mon Sep 17 00:00:00 2001 From: lucas-neynar Date: Tue, 18 Mar 2025 09:46:37 -0700 Subject: [PATCH] feat: add build script --- package.json | 4 +- scripts/build.js | 225 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 227 insertions(+), 2 deletions(-) create mode 100755 scripts/build.js diff --git a/package.json b/package.json index e1bfcaa..2393d0f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "create-neynar-farcaster-frame", - "version": "1.0.10", + "version": "1.0.11", "type": "module", "files": [ "bin/index.js" @@ -15,7 +15,7 @@ ], "scripts": { "dev": "node scripts/dev.js", - "build": "next build", + "build": "node scripts/build.js", "start": "next start", "lint": "next lint" }, diff --git a/scripts/build.js b/scripts/build.js new file mode 100755 index 0000000..8be2a5c --- /dev/null +++ b/scripts/build.js @@ -0,0 +1,225 @@ +import { execSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import { mnemonicToAccount } from 'viem/accounts'; +import { fileURLToPath } from 'url'; +import inquirer from 'inquirer'; +import dotenv from 'dotenv'; + +// Load environment variables +dotenv.config({ path: '.env.local' }); +dotenv.config({ path: '.env', override: true }); + +// TODO: validate this file +// TODO: add other stuff to .env necessary for prod deployment +// TODO: update app to use saved manifest from .env if not running in dev mode + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.join(__dirname, '..'); + +async function validateDomain(domain) { + // Remove http:// or https:// if present + const cleanDomain = domain.replace(/^https?:\/\//, ''); + + // Basic domain validation + if (!cleanDomain.match(/^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9](?:\.[a-zA-Z]{2,})+$/)) { + throw new Error('Invalid domain format'); + } + + return cleanDomain; +} + +async function validateSeedPhrase(seedPhrase) { + try { + // Try to create an account from the seed phrase + const account = mnemonicToAccount(seedPhrase); + console.log('āœ… Seed phrase validated successfully'); + return account.address; + } catch (error) { + throw new Error('Invalid seed phrase'); + } +} + +async function generateFarcasterMetadata(domain, accountAddress, seedPhrase) { + const header = { + type: 'custody', + key: accountAddress, + }; + const encodedHeader = Buffer.from(JSON.stringify(header), 'utf-8').toString('base64'); + + const payload = { + domain + }; + const encodedPayload = Buffer.from(JSON.stringify(payload), 'utf-8').toString('base64url'); + + const account = mnemonicToAccount(seedPhrase); + const signature = await account.signMessage({ + message: `${encodedHeader}.${encodedPayload}` + }); + const encodedSignature = Buffer.from(signature, 'utf-8').toString('base64url'); + + return { + accountAssociation: { + header: encodedHeader, + payload: encodedPayload, + signature: encodedSignature + }, + frame: { + version: "1", + name: process.env.NEXT_PUBLIC_FRAME_NAME || "Frames v2 Demo", + iconUrl: `${domain}/icon.png`, + homeUrl: domain, + imageUrl: `${domain}/opengraph-image`, + buttonTitle: process.env.NEXT_PUBLIC_FRAME_BUTTON_TEXT || "Launch Frame", + splashImageUrl: `${domain}/splash.png`, + splashBackgroundColor: "#f7f7f7", + webhookUrl: `${domain}/api/webhook`, + }, + }; +} + +async function main() { + try { + // Get domain from user + const { domain } = await inquirer.prompt([ + { + type: 'input', + name: 'domain', + message: 'Enter the domain where your frame will be deployed (e.g., example.com):', + validate: async (input) => { + try { + await validateDomain(input); + return true; + } catch (error) { + return error.message; + } + } + } + ]); + + // Get frame name from user + const { frameName } = await inquirer.prompt([ + { + type: 'input', + name: 'frameName', + message: 'Enter the name for your frame (e.g., My Cool Frame):', + default: process.env.NEXT_PUBLIC_FRAME_NAME || 'Frames v2 Demo', + validate: (input) => { + if (input.trim() === '') { + return 'Frame name cannot be empty'; + } + return true; + } + } + ]); + + // Get button text from user + const { buttonText } = await inquirer.prompt([ + { + type: 'input', + name: 'buttonText', + message: 'Enter the text for your frame button (e.g., Launch Frame):', + default: process.env.NEXT_PUBLIC_FRAME_BUTTON_TEXT || 'Launch Frame', + validate: (input) => { + if (input.trim() === '') { + return 'Button text cannot be empty'; + } + return true; + } + } + ]); + + // Get seed phrase from user + const { seedPhrase } = await inquirer.prompt([ + { + type: 'password', + name: 'seedPhrase', + message: 'Enter your seed phrase (this will only be used to sign the frame manifest):', + validate: async (input) => { + try { + await validateSeedPhrase(input); + return true; + } catch (error) { + return error.message; + } + } + } + ]); + + // Validate seed phrase and get account address + const accountAddress = await validateSeedPhrase(seedPhrase); + console.log('āœ… Seed phrase validated successfully'); + + // Generate and sign manifest + console.log('\nšŸ”Ø Generating frame manifest...'); + const metadata = await generateFarcasterMetadata(domain, accountAddress, seedPhrase); + console.log('\nāœ… Frame manifest generated' + (seedPhrase ? ' and signed' : '')); + + // Read existing .env file or create new one + const envPath = path.join(projectRoot, '.env'); + let envContent = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf8') : ''; + + // Add or update environment variables + const newEnvVars = [ + // Base URL + `NEXT_PUBLIC_URL=https://${domain}`, + + // Frame metadata + `NEXT_PUBLIC_FRAME_NAME="${frameName}"`, + `NEXT_PUBLIC_FRAME_DESCRIPTION="${process.env.NEXT_PUBLIC_FRAME_DESCRIPTION || ''}"`, + `NEXT_PUBLIC_FRAME_BUTTON_TEXT="${buttonText}"`, + + // Image URLs (if they exist in current env) + ...(process.env.NEXT_PUBLIC_FRAME_SPLASH_IMAGE_URL ? + [`NEXT_PUBLIC_FRAME_SPLASH_IMAGE_URL="${process.env.NEXT_PUBLIC_FRAME_SPLASH_IMAGE_URL}"`] : []), + ...(process.env.NEXT_PUBLIC_FRAME_ICON_IMAGE_URL ? + [`NEXT_PUBLIC_FRAME_ICON_IMAGE_URL="${process.env.NEXT_PUBLIC_FRAME_ICON_IMAGE_URL}"`] : []), + + // Neynar configuration (if it exists in current env) + ...(process.env.NEYNAR_API_KEY ? + [`NEYNAR_API_KEY="${process.env.NEYNAR_API_KEY}"`] : []), + ...(process.env.NEYNAR_CLIENT_ID ? + [`NEYNAR_CLIENT_ID="${process.env.NEYNAR_CLIENT_ID}"`] : []), + + // FID (if it exists in current env) + ...(process.env.FID ? [`FID="${process.env.FID}"`] : []), + + // Frame manifest with signature + `FRAME_METADATA=${JSON.stringify(metadata)}`, + ]; + + // Filter out empty values and join with newlines + const validEnvVars = newEnvVars.filter(line => { + const [, value] = line.split('='); + return value && value !== '""'; + }); + + // Update or append each environment variable + validEnvVars.forEach(varLine => { + const [key] = varLine.split('='); + if (envContent.includes(`${key}=`)) { + envContent = envContent.replace(new RegExp(`${key}=.*`), varLine); + } else { + envContent += `\n${varLine}`; + } + }); + + // Write updated .env file + fs.writeFileSync(envPath, envContent); + + console.log('\nāœ… Environment variables updated'); + + // Run next build + console.log('\nBuilding Next.js application...'); + execSync('next build', { cwd: projectRoot, stdio: 'inherit' }); + + console.log('\n✨ Build complete! Your frame is ready for deployment. 🪐'); + console.log('šŸ“ Make sure to configure the environment variables from .env in your hosting provider'); + + } catch (error) { + console.error('\nāŒ Error:', error.message); + process.exit(1); + } +} + +main();