diff --git a/bin/init.js b/bin/init.js index f177adf..a6cdee3 100644 --- a/bin/init.js +++ b/bin/init.js @@ -351,14 +351,7 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe type: 'confirm', name: 'useSponsoredSigner', message: - 'Would you like to use Neynar Sponsored Signers and/or Sign In With Neynar (SIWN)?\n' + - 'This enables the simplest, most secure, and most user-friendly Farcaster authentication for your app.\n\n' + - 'Benefits of using Neynar Sponsored Signers/SIWN:\n' + - '- No auth buildout or signer management required for developers\n' + - '- Cost-effective for users (no gas for signers)\n' + - '- Users can revoke signers at any time\n' + - '- Plug-and-play for web and React Native\n' + - '- Recommended for most developers\n' + + 'Would you like to write data to Farcaster on behalf of your miniapp users? This involves using Neynar Sponsored Signers and SIWN.\n' + '\n⚠️ A seed phrase is required for this option.\n', default: false, }, @@ -453,13 +446,14 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe delete packageJson.devDependencies; // Add dependencies + // question: remove auth-client? packageJson.dependencies = { '@farcaster/auth-client': '>=0.3.0 <1.0.0', - '@farcaster/auth-kit': '>=0.6.0 <1.0.0', '@farcaster/miniapp-node': '>=0.1.5 <1.0.0', '@farcaster/miniapp-sdk': '>=0.1.6 <1.0.0', '@farcaster/miniapp-wagmi-connector': '^1.0.0', '@farcaster/mini-app-solana': '>=0.0.17 <1.0.0', + '@farcaster/quick-auth': '>=0.0.7 <1.0.0', '@neynar/react': '^1.2.5', '@radix-ui/react-label': '^2.1.1', '@solana/wallet-adapter-react': '^0.15.38', @@ -471,7 +465,6 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe 'lucide-react': '^0.469.0', mipd: '^0.0.7', next: '^15', - 'next-auth': '^4.24.11', react: '^19', 'react-dom': '^19', 'tailwind-merge': '^2.6.0', @@ -483,6 +476,7 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe }; packageJson.devDependencies = { + "@types/inquirer": "^9.0.8", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -494,8 +488,8 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe "pino-pretty": "^13.0.0", "postcss": "^8", "tailwindcss": "^3.4.1", - "typescript": "^5", - "ts-node": "^10.9.2" + "ts-node": "^10.9.2", + "typescript": "^5" }; // Add Neynar SDK if selected @@ -503,6 +497,12 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe packageJson.dependencies['@neynar/nodejs-sdk'] = '^2.19.0'; } + // Add auth-kit and next-auth dependencies if useSponsoredSigner is true + if (answers.useSponsoredSigner) { + packageJson.dependencies['@farcaster/auth-kit'] = '>=0.6.0 <1.0.0'; + packageJson.dependencies['next-auth'] = '^4.24.11'; + } + fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)); // Handle .env file @@ -632,10 +632,7 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe console.log('⚠️ constants.ts not found, skipping constants update'); } - fs.appendFileSync( - envPath, - `\nNEXTAUTH_SECRET="${crypto.randomBytes(32).toString('hex')}"` - ); + if (useNeynar && neynarApiKey && neynarClientId) { fs.appendFileSync(envPath, `\nNEYNAR_API_KEY="${neynarApiKey}"`); fs.appendFileSync(envPath, `\nNEYNAR_CLIENT_ID="${neynarClientId}"`); @@ -648,6 +645,13 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe fs.appendFileSync(envPath, `\nSEED_PHRASE="${answers.seedPhrase}"`); } fs.appendFileSync(envPath, `\nUSE_TUNNEL="${answers.useTunnel}"`); + if (answers.useSponsoredSigner) { + fs.appendFileSync(envPath, `\nSPONSOR_SIGNER="${answers.useSponsoredSigner}"`); + fs.appendFileSync( + envPath, + `\nNEXTAUTH_SECRET="${crypto.randomBytes(32).toString('hex')}"` + ); + } fs.unlinkSync(envExamplePath); } else { @@ -691,6 +695,42 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe fs.rmSync(binPath, { recursive: true, force: true }); } + // Remove NeynarAuthButton directory, NextAuth API routes, and auth directory if useSponsoredSigner is false + if (!answers.useSponsoredSigner) { + console.log('\nRemoving NeynarAuthButton directory, NextAuth API routes, and auth directory (useSponsoredSigner is false)...'); + const neynarAuthButtonPath = path.join(projectPath, 'src', 'components', 'ui', 'NeynarAuthButton'); + if (fs.existsSync(neynarAuthButtonPath)) { + fs.rmSync(neynarAuthButtonPath, { recursive: true, force: true }); + } + + // Remove NextAuth API routes + const nextAuthRoutePath = path.join(projectPath, 'src', 'app', 'api', 'auth', '[...nextauth]', 'route.ts'); + if (fs.existsSync(nextAuthRoutePath)) { + fs.rmSync(nextAuthRoutePath, { force: true }); + // Remove the directory if it's empty + const nextAuthDir = path.dirname(nextAuthRoutePath); + if (fs.readdirSync(nextAuthDir).length === 0) { + fs.rmSync(nextAuthDir, { recursive: true, force: true }); + } + } + + const updateSessionRoutePath = path.join(projectPath, 'src', 'app', 'api', 'auth', 'update-session', 'route.ts'); + if (fs.existsSync(updateSessionRoutePath)) { + fs.rmSync(updateSessionRoutePath, { force: true }); + // Remove the directory if it's empty + const updateSessionDir = path.dirname(updateSessionRoutePath); + if (fs.readdirSync(updateSessionDir).length === 0) { + fs.rmSync(updateSessionDir, { recursive: true, force: true }); + } + } + + // Remove src/auth.ts file + const authFilePath = path.join(projectPath, 'src', 'auth.ts'); + if (fs.existsSync(authFilePath)) { + fs.rmSync(authFilePath, { force: true }); + } + } + // Initialize git repository console.log('\nInitializing git repository...'); execSync('git init', { cwd: projectPath }); diff --git a/scripts/deploy.ts b/scripts/deploy.ts index cfc629d..fbbd792 100755 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -73,18 +73,20 @@ async function checkRequiredEnvVars(): Promise { name: 'NEXT_PUBLIC_MINI_APP_NAME', message: 'Enter the name for your frame (e.g., My Cool Mini App):', default: APP_NAME, - validate: (input: string) => input.trim() !== '' || 'Mini app name cannot be empty' + validate: (input: string) => + input.trim() !== '' || 'Mini app name cannot be empty', }, { name: 'NEXT_PUBLIC_MINI_APP_BUTTON_TEXT', message: 'Enter the text for your frame button:', default: APP_BUTTON_TEXT ?? 'Launch Mini App', - validate: (input: string) => input.trim() !== '' || 'Button text cannot be empty' - } + validate: (input: string) => + input.trim() !== '' || 'Button text cannot be empty', + }, ]; const missingVars = requiredVars.filter( - (varConfig) => !process.env[varConfig.name] + varConfig => !process.env[varConfig.name], ); if (missingVars.length > 0) { @@ -110,7 +112,7 @@ async function checkRequiredEnvVars(): Promise { const newLine = envContent ? '\n' : ''; fs.appendFileSync( '.env', - `${newLine}${varConfig.name}="${value.trim()}"` + `${newLine}${varConfig.name}="${value.trim()}"`, ); } @@ -130,10 +132,10 @@ async function checkRequiredEnvVars(): Promise { process.env.SPONSOR_SIGNER = sponsorSigner.toString(); - if (storeSeedPhrase) { + if (process.env.SEED_PHRASE) { fs.appendFileSync( '.env.local', - `\nSPONSOR_SIGNER="${sponsorSigner}"` + `\nSPONSOR_SIGNER="${sponsorSigner}"`, ); console.log('✅ Sponsor signer preference stored in .env.local'); } @@ -171,8 +173,8 @@ async function getGitRemote(): Promise { async function checkVercelCLI(): Promise { try { - execSync('vercel --version', { - stdio: 'ignore' + execSync('vercel --version', { + stdio: 'ignore', }); return true; } catch (error: unknown) { @@ -185,8 +187,8 @@ async function checkVercelCLI(): Promise { async function installVercelCLI(): Promise { console.log('Installing Vercel CLI...'); - execSync('npm install -g vercel', { - stdio: 'inherit' + execSync('npm install -g vercel', { + stdio: 'inherit', }); } @@ -222,7 +224,9 @@ async function getVercelToken(): Promise { return null; // We'll fall back to CLI operations } catch (error: unknown) { if (error instanceof Error) { - throw new Error('Not logged in to Vercel CLI. Please run this script again to login.'); + throw new Error( + 'Not logged in to Vercel CLI. Please run this script again to login.', + ); } throw error; } @@ -239,7 +243,7 @@ async function loginToVercel(): Promise { console.log('3. Complete the Vercel account setup in your browser'); console.log('4. Return here once your Vercel account is created\n'); console.log( - '\nNote: you may need to cancel this script with ctrl+c and run it again if creating a new vercel account' + '\nNote: you may need to cancel this script with ctrl+c and run it again if creating a new vercel account', ); const child = spawn('vercel', ['login'], { @@ -247,14 +251,14 @@ async function loginToVercel(): Promise { }); await new Promise((resolve, reject) => { - child.on('close', (code) => { + child.on('close', code => { resolve(); }); }); console.log('\n📱 Waiting for login to complete...'); console.log( - "If you're creating a new account, please complete the Vercel account setup in your browser first." + "If you're creating a new account, please complete the Vercel account setup in your browser first.", ); for (let i = 0; i < 150; i++) { @@ -263,10 +267,13 @@ async function loginToVercel(): Promise { console.log('✅ Successfully logged in to Vercel!'); return true; } catch (error: unknown) { - if (error instanceof Error && error.message.includes('Account not found')) { + if ( + error instanceof Error && + error.message.includes('Account not found') + ) { console.log('ℹ️ Waiting for Vercel account setup to complete...'); } - await new Promise((resolve) => setTimeout(resolve, 2000)); + await new Promise(resolve => setTimeout(resolve, 2000)); } } @@ -277,7 +284,12 @@ async function loginToVercel(): Promise { return false; } -async function setVercelEnvVarSDK(vercelClient: Vercel, projectId: string, key: string, value: string | object): Promise { +async function setVercelEnvVarSDK( + vercelClient: Vercel, + projectId: string, + key: string, + value: string | object, +): Promise { try { let processedValue: string; if (typeof value === 'object') { @@ -287,17 +299,26 @@ async function setVercelEnvVarSDK(vercelClient: Vercel, projectId: string, key: } // Get existing environment variables - const existingVars = await vercelClient.projects.getEnvironmentVariables({ + const existingVars = await vercelClient.projects.filterProjectEnvs({ idOrName: projectId, }); - const existingVar = existingVars.envs?.find((env: any) => - env.key === key && env.target?.includes('production') + // Handle different response types + let envs: any[] = []; + if ('envs' in existingVars && Array.isArray(existingVars.envs)) { + envs = existingVars.envs; + } else if ('target' in existingVars && 'key' in existingVars) { + // Single environment variable response + envs = [existingVars]; + } + + const existingVar = envs.find( + (env: any) => env.key === key && env.target?.includes('production'), ); - if (existingVar) { + if (existingVar && existingVar.id) { // Update existing variable - await vercelClient.projects.editEnvironmentVariable({ + await vercelClient.projects.editProjectEnv({ idOrName: projectId, id: existingVar.id, requestBody: { @@ -308,7 +329,7 @@ async function setVercelEnvVarSDK(vercelClient: Vercel, projectId: string, key: console.log(`✅ Updated environment variable: ${key}`); } else { // Create new variable - await vercelClient.projects.createEnvironmentVariable({ + await vercelClient.projects.createProjectEnv({ idOrName: projectId, requestBody: { key: key, @@ -323,14 +344,21 @@ async function setVercelEnvVarSDK(vercelClient: Vercel, projectId: string, key: return true; } catch (error: unknown) { if (error instanceof Error) { - console.warn(`⚠️ Warning: Failed to set environment variable ${key}:`, error.message); + console.warn( + `⚠️ Warning: Failed to set environment variable ${key}:`, + error.message, + ); return false; } throw error; } } -async function setVercelEnvVarCLI(key: string, value: string | object, projectRoot: string): Promise { +async function setVercelEnvVarCLI( + key: string, + value: string | object, + projectRoot: string, +): Promise { try { // Remove existing env var try { @@ -365,7 +393,7 @@ async function setVercelEnvVarCLI(key: string, value: string | object, projectRo execSync(command, { cwd: projectRoot, stdio: 'pipe', // Changed from 'inherit' to avoid interactive prompts - env: process.env + env: process.env, }); fs.unlinkSync(tempFilePath); @@ -377,18 +405,26 @@ async function setVercelEnvVarCLI(key: string, value: string | object, projectRo fs.unlinkSync(tempFilePath); } if (error instanceof Error) { - console.warn(`⚠️ Warning: Failed to set environment variable ${key}:`, error.message); + console.warn( + `⚠️ Warning: Failed to set environment variable ${key}:`, + error.message, + ); return false; } throw error; } } -async function setEnvironmentVariables(vercelClient: Vercel | null, projectId: string | null, envVars: Record, projectRoot: string): Promise> { +async function setEnvironmentVariables( + vercelClient: Vercel | null, + projectId: string | null, + envVars: Record, + projectRoot: string, +): Promise> { console.log('\n📝 Setting up environment variables...'); - + const results: Array<{ key: string; success: boolean }> = []; - + for (const [key, value] of Object.entries(envVars)) { if (!value) continue; @@ -408,29 +444,34 @@ async function setEnvironmentVariables(vercelClient: Vercel | null, projectId: s } // Report results - const failed = results.filter((r) => !r.success); + const failed = results.filter(r => !r.success); if (failed.length > 0) { console.warn(`\n⚠️ Failed to set ${failed.length} environment variables:`); - failed.forEach((r) => console.warn(` - ${r.key}`)); + failed.forEach(r => console.warn(` - ${r.key}`)); console.warn( - '\nYou may need to set these manually in the Vercel dashboard.' + '\nYou may need to set these manually in the Vercel dashboard.', ); } return results; } -async function waitForDeployment(vercelClient: Vercel | null, projectId: string, maxWaitTime = 300000): Promise { // 5 minutes +async function waitForDeployment( + vercelClient: Vercel | null, + projectId: string, + maxWaitTime = 300000, +): Promise { + // 5 minutes console.log('\n⏳ Waiting for deployment to complete...'); const startTime = Date.now(); while (Date.now() - startTime < maxWaitTime) { try { - const deployments = await vercelClient?.deployments.list({ + const deployments = await vercelClient?.deployments.getDeployments({ projectId: projectId, limit: 1, }); - + if (deployments?.deployments?.[0]) { const deployment = deployments.deployments[0]; console.log(`📊 Deployment status: ${deployment.state}`); @@ -445,10 +486,10 @@ async function waitForDeployment(vercelClient: Vercel | null, projectId: string, } // Still building, wait and check again - await new Promise((resolve) => setTimeout(resolve, 5000)); // Wait 5 seconds + await new Promise(resolve => setTimeout(resolve, 5000)); // Wait 5 seconds } else { console.log('⏳ No deployment found yet, waiting...'); - await new Promise((resolve) => setTimeout(resolve, 5000)); + await new Promise(resolve => setTimeout(resolve, 5000)); } } catch (error: unknown) { if (error instanceof Error) { @@ -478,58 +519,60 @@ async function deployToVercel(useGitHub = false): Promise { framework: 'nextjs', }, null, - 2 - ) + 2, + ), ); } // Set up Vercel project console.log('\n📦 Setting up Vercel project...'); console.log( - 'An initial deployment is required to get an assigned domain that can be used in the mini app manifest\n' + 'An initial deployment is required to get an assigned domain that can be used in the mini app manifest\n', ); console.log( - '\n⚠️ Note: choosing a longer, more unique project name will help avoid conflicts with other existing domains\n' + '\n⚠️ Note: choosing a longer, more unique project name will help avoid conflicts with other existing domains\n', ); // Use spawn instead of execSync for better error handling const { spawn } = await import('child_process'); - const vercelSetup = spawn('vercel', [], { - cwd: projectRoot, - stdio: 'inherit', - shell: process.platform === 'win32' ? true : undefined - }); + const vercelSetup = spawn('vercel', [], { + cwd: projectRoot, + stdio: 'inherit', + shell: process.platform === 'win32' ? true : undefined, + }); await new Promise((resolve, reject) => { - vercelSetup.on('close', (code) => { + vercelSetup.on('close', code => { if (code === 0 || code === null) { console.log('✅ Vercel project setup completed'); resolve(); } else { - console.log('⚠️ Vercel setup command completed (this is normal)'); + console.log('⚠️ Vercel setup command completed (this is normal)'); resolve(); // Don't reject, as this is often expected } }); - vercelSetup.on('error', (error) => { + vercelSetup.on('error', error => { console.log('⚠️ Vercel setup command completed (this is normal)'); resolve(); // Don't reject, as this is often expected }); }); // Wait a moment for project files to be written - await new Promise((resolve) => setTimeout(resolve, 2000)); + await new Promise(resolve => setTimeout(resolve, 2000)); // Load project info let projectId: string; try { const projectJson = JSON.parse( - fs.readFileSync('.vercel/project.json', 'utf8') + fs.readFileSync('.vercel/project.json', 'utf8'), ); projectId = projectJson.projectId; } catch (error: unknown) { if (error instanceof Error) { - throw new Error('Failed to load project info. Please ensure the Vercel project was created successfully.'); + throw new Error( + 'Failed to load project info. Please ensure the Vercel project was created successfully.', + ); } throw error; } @@ -540,13 +583,15 @@ async function deployToVercel(useGitHub = false): Promise { const token = await getVercelToken(); if (token) { vercelClient = new Vercel({ - bearerToken: token + bearerToken: token, }); console.log('✅ Initialized Vercel SDK client'); } } catch (error: unknown) { if (error instanceof Error) { - console.warn('⚠️ Could not initialize Vercel SDK, falling back to CLI operations'); + console.warn( + '⚠️ Could not initialize Vercel SDK, falling back to CLI operations', + ); } throw error; } @@ -558,15 +603,22 @@ async function deployToVercel(useGitHub = false): Promise { if (vercelClient) { try { - const project = await vercelClient.projects.get({ - idOrName: projectId, - }); - projectName = project.name; - domain = `${projectName}.vercel.app`; - console.log('🌐 Using project name for domain:', domain); + const projects = await vercelClient.projects.getProjects({}); + const project = projects.projects.find( + (p: any) => p.id === projectId || p.name === projectId, + ); + if (project) { + projectName = project.name; + domain = `${projectName}.vercel.app`; + console.log('🌐 Using project name for domain:', domain); + } else { + throw new Error('Project not found'); + } } catch (error: unknown) { if (error instanceof Error) { - console.warn('⚠️ Could not get project details via SDK, using CLI fallback'); + console.warn( + '⚠️ Could not get project details via SDK, using CLI fallback', + ); } throw error; } @@ -580,7 +632,7 @@ async function deployToVercel(useGitHub = false): Promise { { cwd: projectRoot, encoding: 'utf8', - } + }, ); const nameMatch = inspectOutput.match(/Name\s+([^\n]+)/); @@ -596,7 +648,7 @@ async function deployToVercel(useGitHub = false): Promise { console.log('🌐 Using project name for domain:', domain); } else { console.warn( - '⚠️ Could not determine project name from inspection, using fallback' + '⚠️ Could not determine project name from inspection, using fallback', ); // Use a fallback domain based on project ID domain = `project-${projectId.slice(-8)}.vercel.app`; @@ -618,19 +670,29 @@ async function deployToVercel(useGitHub = false): Promise { const nextAuthSecret = process.env.NEXTAUTH_SECRET || crypto.randomBytes(32).toString('hex'); const vercelEnv = { - NEXTAUTH_SECRET: nextAuthSecret, - AUTH_SECRET: nextAuthSecret, - NEXTAUTH_URL: `https://${domain}`, NEXT_PUBLIC_URL: `https://${domain}`, - - ...(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 }), - ...(process.env.SPONSOR_SIGNER && { SPONSOR_SIGNER: process.env.SPONSOR_SIGNER }), - + + ...(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, + }), + ...(process.env.SPONSOR_SIGNER && { + SPONSOR_SIGNER: process.env.SPONSOR_SIGNER, + }), + + // Include NextAuth environment variables if SEED_PHRASE is present or SPONSOR_SIGNER is true + ...((process.env.SEED_PHRASE || process.env.SPONSOR_SIGNER === 'true') && { + NEXTAUTH_SECRET: nextAuthSecret, + AUTH_SECRET: nextAuthSecret, + NEXTAUTH_URL: `https://${domain}`, + }), + ...Object.fromEntries( Object.entries(process.env).filter(([key]) => - key.startsWith('NEXT_PUBLIC_') - ) + key.startsWith('NEXT_PUBLIC_'), + ), ), }; @@ -639,7 +701,7 @@ async function deployToVercel(useGitHub = false): Promise { vercelClient, projectId, vercelEnv, - projectRoot + projectRoot, ); // Deploy the project @@ -663,7 +725,7 @@ async function deployToVercel(useGitHub = false): Promise { }); await new Promise((resolve, reject) => { - vercelDeploy.on('close', (code) => { + vercelDeploy.on('close', code => { if (code === 0) { console.log('✅ Vercel deployment command completed'); resolve(); @@ -673,7 +735,7 @@ async function deployToVercel(useGitHub = false): Promise { } }); - vercelDeploy.on('error', (error) => { + vercelDeploy.on('error', error => { console.error('❌ Vercel deployment error:', error.message); reject(error); }); @@ -686,7 +748,10 @@ async function deployToVercel(useGitHub = false): Promise { deployment = await waitForDeployment(vercelClient, projectId); } catch (error: unknown) { if (error instanceof Error) { - console.warn('⚠️ Could not verify deployment completion:', error.message); + console.warn( + '⚠️ Could not verify deployment completion:', + error.message, + ); console.log('ℹ️ Proceeding with domain verification...'); } throw error; @@ -700,10 +765,12 @@ async function deployToVercel(useGitHub = false): Promise { if (vercelClient && deployment) { try { actualDomain = deployment.url || domain; - console.log('🌐 Verified actual domain:', actualDomain); + console.log('🌐 Verified actual domain:', actualDomain); } catch (error: unknown) { if (error instanceof Error) { - console.warn('⚠️ Could not verify domain via SDK, using assumed domain'); + console.warn( + '⚠️ Could not verify domain via SDK, using assumed domain', + ); } throw error; } @@ -714,11 +781,20 @@ async function deployToVercel(useGitHub = false): Promise { console.log('🔄 Updating environment variables with correct domain...'); const updatedEnv: Record = { - NEXTAUTH_URL: `https://${actualDomain}`, NEXT_PUBLIC_URL: `https://${actualDomain}`, }; - await setEnvironmentVariables(vercelClient, projectId, updatedEnv, projectRoot); + // Include NextAuth URL if SEED_PHRASE is present or SPONSOR_SIGNER is true + if (process.env.SEED_PHRASE || process.env.SPONSOR_SIGNER === 'true') { + updatedEnv.NEXTAUTH_URL = `https://${actualDomain}`; + } + + await setEnvironmentVariables( + vercelClient, + projectId, + updatedEnv, + projectRoot, + ); console.log('\n📦 Redeploying with correct domain...'); const vercelRedeploy = spawn('vercel', ['deploy', '--prod'], { @@ -728,7 +804,7 @@ async function deployToVercel(useGitHub = false): Promise { }); await new Promise((resolve, reject) => { - vercelRedeploy.on('close', (code) => { + vercelRedeploy.on('close', code => { if (code === 0) { console.log('✅ Redeployment completed'); resolve(); @@ -738,7 +814,7 @@ async function deployToVercel(useGitHub = false): Promise { } }); - vercelRedeploy.on('error', (error) => { + vercelRedeploy.on('error', error => { console.error('❌ Redeployment error:', error.message); reject(error); }); @@ -749,13 +825,24 @@ async function deployToVercel(useGitHub = false): Promise { console.log('\n✨ Deployment complete! Your mini app is now live at:'); console.log(`🌐 https://${domain}`); - console.log('\n📝 You can manage your project at https://vercel.com/dashboard'); + console.log( + '\n📝 You can manage your project at https://vercel.com/dashboard', + ); // Prompt user to sign manifest in browser and paste accountAssociation - console.log(`\n⚠️ To complete your mini app manifest, you must sign it using the Farcaster developer portal.`); - console.log('1. Go to: https://farcaster.xyz/~/developers/mini-apps/manifest?domain=' + domain); - console.log('2. Click "Transfer Ownership" and follow the instructions to sign the manifest.'); - console.log('3. Copy the resulting accountAssociation JSON from the browser.'); + console.log( + `\n⚠️ To complete your mini app manifest, you must sign it using the Farcaster developer portal.`, + ); + console.log( + '1. Go to: https://farcaster.xyz/~/developers/mini-apps/manifest?domain=' + + domain, + ); + console.log( + '2. Click "Transfer Ownership" and follow the instructions to sign the manifest.', + ); + console.log( + '3. Copy the resulting accountAssociation JSON from the browser.', + ); console.log('4. Paste it below when prompted.'); const { userAccountAssociation } = await inquirer.prompt([ @@ -773,8 +860,8 @@ async function deployToVercel(useGitHub = false): Promise { } catch (e) { return 'Invalid JSON'; } - } - } + }, + }, ]); const parsedAccountAssociation = JSON.parse(userAccountAssociation); @@ -786,11 +873,10 @@ async function deployToVercel(useGitHub = false): Promise { const newAccountAssociation = `export const APP_ACCOUNT_ASSOCIATION: AccountAssociation | undefined = ${JSON.stringify(parsedAccountAssociation, null, 2)};`; constantsContent = constantsContent.replace( /^export const APP_ACCOUNT_ASSOCIATION\s*:\s*AccountAssociation \| undefined\s*=\s*[^;]*;/m, - newAccountAssociation + newAccountAssociation, ); fs.writeFileSync(constantsPath, constantsContent); console.log('\n✅ APP_ACCOUNT_ASSOCIATION updated in src/lib/constants.ts'); - } catch (error: unknown) { if (error instanceof Error) { console.error('\n❌ Deployment failed:', error.message); @@ -804,7 +890,7 @@ async function main(): Promise { try { console.log('🚀 Vercel Mini App Deployment (SDK Edition)'); console.log( - 'This script will deploy your mini app to Vercel using the Vercel SDK.' + 'This script will deploy your mini app to Vercel using the Vercel SDK.', ); console.log('\nThe script will:'); console.log('1. Check for required environment variables'); @@ -818,9 +904,9 @@ async function main(): Promise { } catch (error: unknown) { if (error instanceof Error) { console.log('📦 Installing @vercel/sdk...'); - execSync('npm install @vercel/sdk', { + execSync('npm install @vercel/sdk', { cwd: projectRoot, - stdio: 'inherit' + stdio: 'inherit', }); console.log('✅ @vercel/sdk installed successfully'); } @@ -880,7 +966,6 @@ async function main(): Promise { } await deployToVercel(useGitHub); - } catch (error: unknown) { if (error instanceof Error) { console.error('\n❌ Error:', error.message); diff --git a/src/app/api/auth/validate/route.ts b/src/app/api/auth/validate/route.ts new file mode 100644 index 0000000..5a67b10 --- /dev/null +++ b/src/app/api/auth/validate/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from 'next/server'; +import { createClient, Errors } from '@farcaster/quick-auth'; + +const client = createClient(); + +export async function POST(request: Request) { + try { + const { token } = await request.json(); + + if (!token) { + return NextResponse.json({ error: 'Token is required' }, { status: 400 }); + } + + // Get domain from environment or request + const domain = process.env.NEXT_PUBLIC_URL + ? new URL(process.env.NEXT_PUBLIC_URL).hostname + : request.headers.get('host') || 'localhost'; + + try { + // Use the official QuickAuth library to verify the JWT + const payload = await client.verifyJwt({ + token, + domain, + }); + + return NextResponse.json({ + success: true, + user: { + fid: payload.sub, + }, + }); + } catch (e) { + if (e instanceof Errors.InvalidTokenError) { + console.info('Invalid token:', e.message); + return NextResponse.json({ error: 'Invalid token' }, { status: 401 }); + } + throw e; + } + } catch (error) { + console.error('Token validation error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 }, + ); + } +} \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b7a8dde..f009466 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,5 @@ import type { Metadata } from "next"; -import { getSession } from "~/auth" import "~/app/globals.css"; import { Providers } from "~/app/providers"; import { APP_NAME, APP_DESCRIPTION } from "~/lib/constants"; @@ -15,7 +14,19 @@ export default async function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { - const session = await getSession() + // Only get session if sponsored signer is enabled or seed phrase is provided + const sponsorSigner = process.env.SPONSOR_SIGNER === 'true'; + const hasSeedPhrase = !!process.env.SEED_PHRASE; + + let session = null; + if (sponsorSigner || hasSeedPhrase) { + try { + const { getSession } = await import("~/auth"); + session = await getSession(); + } catch (error) { + console.warn('Failed to get session:', error); + } + } return ( diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 90584eb..09a96b3 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -24,18 +24,36 @@ export function Providers({ }) { const solanaEndpoint = process.env.SOLANA_RPC_ENDPOINT || 'https://solana-rpc.publicnode.com'; + + // Only wrap with SessionProvider if session is provided + if (session) { + return ( + + + + + {children} + + + + + ); + } + + // Return without SessionProvider if no session return ( - - - - - {children} - - - - + + + + {children} + + + ); } diff --git a/src/components/ui/tabs/ActionsTab.tsx b/src/components/ui/tabs/ActionsTab.tsx index cc072d1..18e6e22 100644 --- a/src/components/ui/tabs/ActionsTab.tsx +++ b/src/components/ui/tabs/ActionsTab.tsx @@ -1,13 +1,23 @@ 'use client'; -import { useCallback, useState } from 'react'; +import { useCallback, useState, type ComponentType } from 'react'; import { useMiniApp } from '@neynar/react'; import { ShareButton } from '../Share'; import { Button } from '../Button'; import { SignIn } from '../wallet/SignIn'; import { type Haptics } from '@farcaster/miniapp-sdk'; import { APP_URL } from '~/lib/constants'; -import { NeynarAuthButton } from '../NeynarAuthButton/index'; + +// Optional import for NeynarAuthButton - may not exist in all templates +let NeynarAuthButton: ComponentType | null = null; +try { + const module = require('../NeynarAuthButton/index'); + NeynarAuthButton = module.NeynarAuthButton; +} catch (error) { + // Component doesn't exist, that's okay + console.log('NeynarAuthButton not available in this template'); +} + /** * ActionsTab component handles mini app actions like sharing, notifications, and haptic feedback. @@ -140,7 +150,7 @@ export function ActionsTab() { {/* Neynar Authentication */} - + {NeynarAuthButton && } {/* Mini app actions */} )} - {status === 'authenticated' && session?.provider === 'farcaster' && ( + {status === 'authenticated' && ( )} {/* Session Information */} - {session && ( + {authenticatedUser && (
-
Session
+
+ Authenticated User +
- {JSON.stringify(session, null, 2)} + {JSON.stringify(authenticatedUser, null, 2)}
)} @@ -142,20 +117,14 @@ export function SignIn() { {/* Error Display */} {signInFailure && !authState.signingIn && (
-
SIWF Result
-
{signInFailure}
-
- )} - - {/* Success Result Display */} - {signInResult && !authState.signingIn && ( -
-
SIWF Result
+
+ Authentication Error +
- {JSON.stringify(signInResult, null, 2)} + {signInFailure}
)} ); -} +} \ No newline at end of file diff --git a/src/hooks/useQuickAuth.ts b/src/hooks/useQuickAuth.ts new file mode 100644 index 0000000..bd982ff --- /dev/null +++ b/src/hooks/useQuickAuth.ts @@ -0,0 +1,207 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { sdk } from '@farcaster/miniapp-sdk'; + +/** + * Represents the current authenticated user state + */ +interface AuthenticatedUser { + /** The user's Farcaster ID (FID) */ + fid: number; +} + +/** + * Possible authentication states for QuickAuth + */ +type QuickAuthStatus = 'loading' | 'authenticated' | 'unauthenticated'; + +/** + * Return type for the useQuickAuth hook + */ +interface UseQuickAuthReturn { + /** Current authenticated user data, or null if not authenticated */ + authenticatedUser: AuthenticatedUser | null; + /** Current authentication status */ + status: QuickAuthStatus; + /** Function to initiate the sign-in process using QuickAuth */ + signIn: () => Promise; + /** Function to sign out and clear the current authentication state */ + signOut: () => Promise; + /** Function to retrieve the current authentication token */ + getToken: () => Promise; +} + +/** + * Custom hook for managing QuickAuth authentication state + * + * This hook provides a complete authentication flow using Farcaster's QuickAuth: + * - Automatically checks for existing authentication on mount + * - Validates tokens with the server-side API + * - Manages authentication state in memory (no persistence) + * - Provides sign-in/sign-out functionality + * + * QuickAuth tokens are managed in memory only, so signing out of the Farcaster + * client will automatically sign the user out of this mini app as well. + * + * @returns {UseQuickAuthReturn} Object containing user state and authentication methods + * + * @example + * ```tsx + * const { authenticatedUser, status, signIn, signOut } = useQuickAuth(); + * + * if (status === 'loading') return
Loading...
; + * if (status === 'unauthenticated') return ; + * + * return ( + *
+ *

Welcome, FID: {authenticatedUser?.fid}

+ * + *
+ * ); + * ``` + */ +export function useQuickAuth(): UseQuickAuthReturn { + // Current authenticated user data + const [authenticatedUser, setAuthenticatedUser] = + useState(null); + // Current authentication status + const [status, setStatus] = useState('loading'); + + /** + * Validates a QuickAuth token with the server-side API + * + * @param {string} authToken - The JWT token to validate + * @returns {Promise} User data if valid, null otherwise + */ + const validateTokenWithServer = async ( + authToken: string, + ): Promise => { + try { + const validationResponse = await fetch('/api/auth/validate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: authToken }), + }); + + if (validationResponse.ok) { + const responseData = await validationResponse.json(); + return responseData.user; + } + + return null; + } catch (error) { + console.error('Token validation failed:', error); + return null; + } + }; + + /** + * Checks for existing authentication token and validates it on component mount + * This runs automatically when the hook is first used + */ + useEffect(() => { + const checkExistingAuthentication = async () => { + try { + // Attempt to retrieve existing token from QuickAuth SDK + const { token } = await sdk.quickAuth.getToken(); + + if (token) { + // Validate the token with our server-side API + const validatedUserSession = await validateTokenWithServer(token); + + if (validatedUserSession) { + // Token is valid, set authenticated state + setAuthenticatedUser(validatedUserSession); + setStatus('authenticated'); + } else { + // Token is invalid or expired, clear authentication state + setStatus('unauthenticated'); + } + } else { + // No existing token found, user is not authenticated + setStatus('unauthenticated'); + } + } catch (error) { + console.error('Error checking existing authentication:', error); + setStatus('unauthenticated'); + } + }; + + checkExistingAuthentication(); + }, []); + + /** + * Initiates the QuickAuth sign-in process + * + * Uses sdk.quickAuth.getToken() to get a QuickAuth session token. + * If there is already a session token in memory that hasn't expired, + * it will be immediately returned, otherwise a fresh one will be acquired. + * + * @returns {Promise} True if sign-in was successful, false otherwise + */ + const signIn = useCallback(async (): Promise => { + try { + setStatus('loading'); + + // Get QuickAuth session token + const { token } = await sdk.quickAuth.getToken(); + + if (token) { + // Validate the token with our server-side API + const validatedUserSession = await validateTokenWithServer(token); + + if (validatedUserSession) { + // Authentication successful, update user state + setAuthenticatedUser(validatedUserSession); + setStatus('authenticated'); + return true; + } + } + + // Authentication failed, clear user state + setStatus('unauthenticated'); + return false; + } catch (error) { + console.error('Sign-in process failed:', error); + setStatus('unauthenticated'); + return false; + } + }, []); + + /** + * Signs out the current user and clears the authentication state + * + * Since QuickAuth tokens are managed in memory only, this simply clears + * the local user state. The actual token will be cleared when the + * user signs out of their Farcaster client. + */ + const signOut = useCallback(async (): Promise => { + // Clear local user state + setAuthenticatedUser(null); + setStatus('unauthenticated'); + }, []); + + /** + * Retrieves the current authentication token from QuickAuth + * + * @returns {Promise} The current auth token, or null if not authenticated + */ + const getToken = useCallback(async (): Promise => { + try { + const { token } = await sdk.quickAuth.getToken(); + return token; + } catch (error) { + console.error('Failed to retrieve authentication token:', error); + return null; + } + }, []); + + return { + authenticatedUser, + status, + signIn, + signOut, + getToken, + }; +} \ No newline at end of file