Compare commits

..

59 Commits

Author SHA1 Message Date
Shreyaschorge
501bc84512 Merge branch 'main' into fix-deploy-and-manifest-issue 2025-07-19 03:52:05 +05:30
Shreyaschorge
85b39a6397 return fragment 2025-07-19 03:48:49 +05:30
Shreyaschorge
61df6d6a64 fix: Unknown file extension '.ts' issue 2025-07-19 03:12:54 +05:30
Shreyaschorge
9ee370628d if button exists then render it 2025-07-19 02:53:18 +05:30
Shreyaschorge
882e4f166f fix imports 2025-07-19 02:43:31 +05:30
veganbeef
e8fa822638 feat: add more flags to script 2025-07-18 10:56:16 -07:00
Shreyaschorge
bade04b785 fix-deploy-and-manifest-issue 2025-07-18 22:55:10 +05:30
veganbeef
d9c74f163b fix: conditional imports 2025-07-17 14:29:24 -07:00
Shreyaschorge
2edd1bd2ae Merge pull request #22 from neynarxyz/shreyas/neyn-5928-resolve-customer-issue-with-broken-nsk-script
Handle session provider rendering
2025-07-17 03:04:45 +05:30
Shreyaschorge
76ad200a22 Handle session provider rendering 2025-07-17 03:02:54 +05:30
veganbeef
86b79e7f3f fix: use session provider whenever next auth is included 2025-07-16 13:12:48 -07:00
Shreyaschorge
aac3a739cd Merge pull request #21 from neynarxyz/veganbeef/reapply-quick-auth
feat: reapply quickauth changes conditionally
2025-07-17 00:08:18 +05:30
Shreyaschorge
f20f65f966 Bump up patch version 2025-07-17 00:07:47 +05:30
Shreyaschorge
a287d55641 set SPONSOR_SIGNER=true 2025-07-16 23:54:44 +05:30
Shreyaschorge
53d6ce6a94 sponsoredSigner -> useSponsoredSigner 2025-07-16 23:27:05 +05:30
Shreyaschorge
ebb8068ade remove update-session route 2025-07-16 23:03:01 +05:30
veganbeef
8124fe5f6c feat: add back og properties 2025-07-16 09:49:19 -07:00
veganbeef
9ea40a5f92 feat: add requiredChains support 2025-07-16 09:46:35 -07:00
veganbeef
e61bc88aaa feat: reapply quickauth changes conditionally 2025-07-16 09:16:58 -07:00
veganbeef
8eabbd3ba1 bump version number for revert 2025-07-16 08:44:39 -07:00
Lucas Myers
f7392dc3cb Merge pull request #20 from neynarxyz/sc/revert-to-5fbd9
revert to 5fbd9
2025-07-16 08:43:43 -07:00
Shreyaschorge
bf563154ca Fix merge conflict issues 2025-07-16 17:50:03 +05:30
Shreyaschorge
196378daeb Revert "format"
This reverts commit 5fbd9c5c09.
2025-07-16 17:33:02 +05:30
Shreyaschorge
89f82253ca Revert "feat: replace next-auth with quick auth"
This reverts commit 86029b2bd9.
2025-07-16 17:32:21 +05:30
Shreyaschorge
c786cabe84 Revert "fix: add back auth-kit"
This reverts commit 16c433a13c.
2025-07-16 17:31:34 +05:30
Shreyaschorge
760efdb96b Revert "format"
This reverts commit 378ea65154.
2025-07-16 17:30:47 +05:30
Shreyaschorge
5fd0e21532 Revert "fix: SIWN dependencies"
This reverts commit 4ba9480832.
2025-07-16 17:28:36 +05:30
Shreyaschorge
0d43b35c28 Revert "Merge pull request #15 from neynarxyz/shreyas-formatting"
This reverts commit b1fdfc19a9, reversing
changes made to b9e2087bd8.
2025-07-16 17:21:12 +05:30
Shreyaschorge
349cdea489 Revert "Merge pull request #18 from neynarxyz/veganbeef/fix-siwn"
This reverts commit 78626c2dc7, reversing
changes made to b1fdfc19a9.
2025-07-16 17:20:36 +05:30
veganbeef
181c364de4 fix: sponsor signer env var 2025-07-15 13:08:55 -07:00
Lucas Myers
78626c2dc7 Merge pull request #18 from neynarxyz/veganbeef/fix-siwn
fix: add back auth-kit
2025-07-15 09:34:40 -07:00
veganbeef
b72198575a Merge branch 'main' into veganbeef/fix-siwn 2025-07-15 09:29:35 -07:00
Lucas Myers
b1fdfc19a9 Merge pull request #15 from neynarxyz/shreyas-formatting
Formatting
2025-07-15 09:25:35 -07:00
veganbeef
4ba9480832 fix: SIWN dependencies 2025-07-15 09:25:08 -07:00
Shreyaschorge
378ea65154 format 2025-07-15 21:38:47 +05:30
Shreyaschorge
b366d97b53 Merge branch 'main' into shreyas-formatting 2025-07-15 21:35:11 +05:30
veganbeef
16c433a13c fix: add back auth-kit 2025-07-14 13:24:55 -07:00
veganbeef
b9e2087bd8 fix: remove duplicate app directory 2025-07-14 13:12:47 -07:00
veganbeef
86029b2bd9 feat: replace next-auth with quick auth 2025-07-14 13:01:46 -07:00
Shreyaschorge
5fbd9c5c09 format 2025-07-15 01:00:59 +05:30
Shreyaschorge
3815f45b44 Merge branch 'main' into shreyas-formatting 2025-07-15 00:00:11 +05:30
Shreyaschorge
c713d53054 Merge pull request #17 from neynarxyz/shreyas/show-modal-on-top
Show Dialog on top to show I am using my phone button
2025-07-14 23:51:52 +05:30
Shreyaschorge
d10bee4d4c Merge branch 'main' into shreyas/show-modal-on-top 2025-07-14 23:51:24 +05:30
veganbeef
44d0c6f905 fix: regex error 2025-07-14 10:03:01 -07:00
Lucas Myers
4f308d3f07 Merge pull request #16 from neynarxyz/veganbeef/deploy-script-update
feat: update deploy script to and manifest generation for simplicity [NEYN-5843]
2025-07-14 09:45:27 -07:00
veganbeef
4b370746cb Merge branch 'main' into veganbeef/deploy-script-update 2025-07-14 09:44:58 -07:00
Shreyaschorge
00fdd21044 remove unnecessary rules 2025-07-14 21:52:14 +05:30
Shreyaschorge
8475b28107 remove console logs 2025-07-14 20:51:03 +05:30
Shreyaschorge
e74b2581df Format after fixing conflicts 2025-07-14 20:04:44 +05:30
Shreyaschorge
505aa54b16 Merge branch 'main' into shreyas-formatting 2025-07-14 18:55:06 +05:30
Shreyaschorge
8de3f598b7 Show modal on top 2025-07-14 16:35:22 +05:30
veganbeef
fb4f8b8b53 feat: set up CI auto publish 2025-07-12 15:21:50 -07:00
veganbeef
5fa624a063 fix: auth kit url update and seed phrase input 2025-07-11 22:28:05 -07:00
Manan
36d2b5d0f7 Merge pull request #13 from neynarxyz/shreyas/neyn-5735-sign-in-connect-farcaster-using-developer-branded-signer
Shreyas/neyn 5735 sign in connect farcaster using developer branded signer
2025-07-11 16:26:45 -07:00
veganbeef
d487a3e8e5 feat: update deploy script to and manifest generation for simplicity 2025-07-11 09:51:42 -07:00
Shreyaschorge
65419a76ee Remove unnecessary file 2025-07-08 00:48:13 +05:30
Shreyaschorge
6b0350d477 Remove AI generated docs 2025-07-07 16:12:17 +05:30
Shreyaschorge
2a1a3d7c40 Correct formating imports 2025-07-07 16:07:33 +05:30
Shreyaschorge
193dffe03a formatting 2025-07-07 14:10:47 +05:30
22 changed files with 1276 additions and 979 deletions

View File

@@ -1,3 +1,32 @@
{ {
"extends": ["next/core-web-vitals", "next/typescript"] "extends": ["next/core-web-vitals", "next/typescript"],
"rules": {
// Disable img warnings since you're using them intentionally in specific contexts
"@next/next/no-img-element": "off",
// Allow @ts-ignore comments (though @ts-expect-error is preferred)
"@typescript-eslint/ban-ts-comment": "off",
// Allow explicit any types (sometimes necessary for dynamic imports and APIs)
"@typescript-eslint/no-explicit-any": "off",
// Allow unused variables that start with underscore
"@typescript-eslint/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_"
}
],
// Make display name warnings instead of errors for dynamic components
"react/display-name": "warn",
// Allow module assignment for dynamic imports
"@next/next/no-assign-module-variable": "warn",
// Make exhaustive deps a warning instead of error for complex hooks
"react-hooks/exhaustive-deps": "warn"
}
} }

30
.github/workflows/publish.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
name: Publish to npm 🚀
on:
push:
branches:
- main
paths:
- package.json
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Install dependencies
run: npm ci
- name: Publish to npm
run: npm publish --access public

View File

@@ -6,6 +6,11 @@ import { init } from './init.js';
const args = process.argv.slice(2); const args = process.argv.slice(2);
let projectName = null; let projectName = null;
let autoAcceptDefaults = false; let autoAcceptDefaults = false;
let apiKey = null;
let noWallet = false;
let noTunnel = false;
let sponsoredSigner = false;
let seedPhrase = null;
// Check for -y flag // Check for -y flag
const yIndex = args.indexOf('-y'); const yIndex = args.indexOf('-y');
@@ -14,18 +19,73 @@ if (yIndex !== -1) {
args.splice(yIndex, 1); // Remove -y from args args.splice(yIndex, 1); // Remove -y from args
} }
// If there's a remaining argument, it's the project name // Parse other arguments
if (args.length > 0) { for (let i = 0; i < args.length; i++) {
projectName = args[0]; const arg = args[i];
}
if (arg === '-p' || arg === '--project') {
if (i + 1 < args.length) {
projectName = args[i + 1];
if (projectName.startsWith('-')) {
console.error('Error: Project name cannot start with a dash (-)');
process.exit(1);
}
args.splice(i, 2); // Remove both the flag and its value
i--; // Adjust index since we removed 2 elements
} else {
console.error('Error: -p/--project requires a project name');
process.exit(1);
}
} else if (arg === '-k' || arg === '--api-key') {
if (i + 1 < args.length) {
apiKey = args[i + 1];
if (apiKey.startsWith('-')) {
console.error('Error: API key cannot start with a dash (-)');
process.exit(1);
}
args.splice(i, 2); // Remove both the flag and its value
i--; // Adjust index since we removed 2 elements
} else {
console.error('Error: -k/--api-key requires an API key');
process.exit(1);
}
} else if (arg === '--no-wallet') {
noWallet = true;
args.splice(i, 1); // Remove the flag
i--; // Adjust index since we removed 1 element
} else if (arg === '--no-tunnel') {
noTunnel = true;
args.splice(i, 1); // Remove the flag
i--; // Adjust index since we removed 1 element
} else if (arg === '--sponsored-signer') {
sponsoredSigner = true;
args.splice(i, 1); // Remove the flag
i--; // Adjust index since we removed 1 element
} else if (arg === '--seed-phrase') {
if (i + 1 < args.length) {
seedPhrase = args[i + 1];
if (seedPhrase.startsWith('-')) {
console.error('Error: Seed phrase cannot start with a dash (-)');
process.exit(1);
}
args.splice(i, 2); // Remove both the flag and its value
i--; // Adjust index since we removed 2 elements
} else {
console.error('Error: --seed-phrase requires a seed phrase');
process.exit(1);
}
}
}
// If -y is used without project name, we still need to ask for project name
// Validate that if -y is used, a project name must be provided
if (autoAcceptDefaults && !projectName) { if (autoAcceptDefaults && !projectName) {
// We'll handle this case in the init function by asking only for project name console.error('Error: -y flag requires a project name. Use -p/--project to specify the project name.');
autoAcceptDefaults = false; process.exit(1);
} }
init(projectName, autoAcceptDefaults).catch((err) => { init(projectName, autoAcceptDefaults, apiKey, noWallet, noTunnel, sponsoredSigner, seedPhrase).catch((err) => {
console.error('Error:', err); console.error('Error:', err);
process.exit(1); process.exit(1);
}); });

View File

@@ -63,7 +63,7 @@ async function queryNeynarApp(apiKey) {
} }
// Export the main CLI function for programmatic use // Export the main CLI function for programmatic use
export async function init(projectName = null, autoAcceptDefaults = false) { export async function init(projectName = null, autoAcceptDefaults = false, apiKey = null, noWallet = false, noTunnel = false, sponsoredSigner = false, seedPhrase = null) {
printWelcomeMessage(); printWelcomeMessage();
// Ask about Neynar usage // Ask about Neynar usage
@@ -101,52 +101,59 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
break; break;
} }
console.log( // Use provided API key if available, otherwise prompt for it
'\n🪐 Find your Neynar API key at: https://dev.neynar.com/app\n' if (apiKey) {
); neynarApiKey = apiKey;
let neynarKeyAnswer;
if (autoAcceptDefaults) {
neynarKeyAnswer = { neynarApiKey: null };
} else { } else {
neynarKeyAnswer = await inquirer.prompt([ if (!autoAcceptDefaults) {
{ console.log(
type: 'password', '\n🪐 Find your Neynar API key at: https://dev.neynar.com/app\n'
name: 'neynarApiKey', );
message: 'Enter your Neynar API key (or press enter to skip):', }
default: null,
},
]);
}
if (neynarKeyAnswer.neynarApiKey) { let neynarKeyAnswer;
neynarApiKey = neynarKeyAnswer.neynarApiKey;
} else {
let useDemoKey;
if (autoAcceptDefaults) { if (autoAcceptDefaults) {
useDemoKey = { useDemo: true }; neynarKeyAnswer = { neynarApiKey: null };
} else { } else {
useDemoKey = await inquirer.prompt([ neynarKeyAnswer = await inquirer.prompt([
{ {
type: 'confirm', type: 'password',
name: 'useDemo', name: 'neynarApiKey',
message: 'Would you like to try the demo Neynar API key?', message: 'Enter your Neynar API key (or press enter to skip):',
default: true, default: null,
}, },
]); ]);
} }
if (useDemoKey.useDemo) { if (neynarKeyAnswer.neynarApiKey) {
console.warn( neynarApiKey = neynarKeyAnswer.neynarApiKey;
'\n⚠ Note: the demo key is for development purposes only and is aggressively rate limited.' } else {
); let useDemoKey;
console.log( if (autoAcceptDefaults) {
'For production, please sign up for a Neynar account at https://neynar.com/ and configure the API key in your .env or .env.local file with NEYNAR_API_KEY.' useDemoKey = { useDemo: true };
); } else {
console.log( useDemoKey = await inquirer.prompt([
`\n${purple}${bright}${italic}Neynar now has a free tier! See https://neynar.com/#pricing for details.\n${reset}` {
); type: 'confirm',
neynarApiKey = 'FARCASTER_V2_FRAMES_DEMO'; name: 'useDemo',
message: 'Would you like to try the demo Neynar API key?',
default: true,
},
]);
}
if (useDemoKey.useDemo) {
console.warn(
'\n⚠ Note: the demo key is for development purposes only and is aggressively rate limited.'
);
console.log(
'For production, please sign up for a Neynar account at https://neynar.com/ and configure the API key in your .env or .env.local file with NEYNAR_API_KEY.'
);
console.log(
`\n${purple}${bright}${italic}Neynar now has a free tier! See https://neynar.com/#pricing for details.\n${reset}`
);
neynarApiKey = 'FARCASTER_V2_FRAMES_DEMO';
}
} }
} }
@@ -218,11 +225,11 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
primaryCategory: null, primaryCategory: null,
tags: [], tags: [],
buttonText: 'Launch Mini App', buttonText: 'Launch Mini App',
useWallet: true, useWallet: !noWallet,
useTunnel: true, useTunnel: true,
enableAnalytics: true, enableAnalytics: true,
seedPhrase: null, seedPhrase: null,
sponsorSigner: false, useSponsoredSigner: false,
}; };
} else { } else {
// If autoAcceptDefaults is false but we have a projectName, we still need to ask for other options // If autoAcceptDefaults is false but we have a projectName, we still need to ask for other options
@@ -305,38 +312,91 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
// Merge project name from the first prompt // Merge project name from the first prompt
answers.projectName = projectNamePrompt.projectName; answers.projectName = projectNamePrompt.projectName;
// Ask about wallet and transaction tooling // Ask about wallet and transaction tooling (skip if --no-wallet flag is used)
const walletAnswer = await inquirer.prompt([ if (noWallet) {
{ answers.useWallet = false;
type: 'confirm', } else {
name: 'useWallet', const walletAnswer = await inquirer.prompt([
message: {
'Would you like to include wallet and transaction tooling in your mini app?\n' + type: 'confirm',
'This includes:\n' + name: 'useWallet',
'- EVM wallet connection\n' + message:
'- Transaction signing\n' + 'Would you like to include wallet and transaction tooling in your mini app?\n' +
'- Message signing\n' + 'This includes:\n' +
'- Chain switching\n' + '- EVM wallet connection\n' +
'- Solana support\n\n' + '- Transaction signing\n' +
'Include wallet and transaction features?', '- Message signing\n' +
default: true, '- Chain switching\n' +
}, '- Solana support\n\n' +
]); 'Include wallet and transaction features?',
answers.useWallet = walletAnswer.useWallet; default: true,
},
]);
answers.useWallet = walletAnswer.useWallet;
}
// Ask about localhost vs tunnel // Ask about localhost vs tunnel
const hostingAnswer = await inquirer.prompt([ if (noTunnel) {
{ answers.useTunnel = false;
type: 'confirm', } else {
name: 'useTunnel', const hostingAnswer = await inquirer.prompt([
message: {
'Would you like to test on mobile and/or test the app with Warpcast developer tools?\n' + type: 'confirm',
`⚠️ ${yellow}${italic}Both mobile testing and the Warpcast debugger require setting up a tunnel to serve your app from localhost to the broader internet.\n${reset}` + name: 'useTunnel',
'Configure a tunnel for mobile testing and/or Warpcast developer tools?', message:
default: true, 'Would you like to test on mobile and/or test the app with Warpcast developer tools?\n' +
}, `⚠️ ${yellow}${italic}Both mobile testing and the Warpcast debugger require setting up a tunnel to serve your app from localhost to the broader internet.\n${reset}` +
]); 'Configure a tunnel for mobile testing and/or Warpcast developer tools?',
answers.useTunnel = hostingAnswer.useTunnel; default: true,
},
]);
answers.useTunnel = hostingAnswer.useTunnel;
}
// Ask about Neynar Sponsored Signers / SIWN
if (sponsoredSigner) {
answers.useSponsoredSigner = true;
if (seedPhrase) {
// Validate the provided seed phrase
if (!seedPhrase || seedPhrase.trim().split(' ').length < 12) {
console.error('Error: Seed phrase must be at least 12 words');
process.exit(1);
}
answers.seedPhrase = seedPhrase;
} else {
console.error('Error: --sponsored-signer requires --seed-phrase to be provided');
process.exit(1);
}
} else {
const sponsoredSignerAnswer = await inquirer.prompt([
{
type: 'confirm',
name: 'useSponsoredSigner',
message:
'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,
},
]);
answers.useSponsoredSigner = sponsoredSignerAnswer.useSponsoredSigner;
if (answers.useSponsoredSigner) {
const { seedPhrase } = await inquirer.prompt([
{
type: 'password',
name: 'seedPhrase',
message: 'Enter your Farcaster custody account seed phrase (required for Neynar Sponsored Signers/SIWN):',
validate: (input) => {
if (!input || input.trim().split(' ').length < 12) {
return 'Seed phrase must be at least 12 words';
}
return true;
},
},
]);
answers.seedPhrase = seedPhrase;
}
}
// Ask about analytics opt-out // Ask about analytics opt-out
const analyticsAnswer = await inquirer.prompt([ const analyticsAnswer = await inquirer.prompt([
@@ -409,13 +469,14 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
delete packageJson.devDependencies; delete packageJson.devDependencies;
// Add dependencies // Add dependencies
// question: remove auth-client?
packageJson.dependencies = { packageJson.dependencies = {
'@farcaster/auth-client': '>=0.3.0 <1.0.0', '@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-node': '>=0.1.5 <1.0.0',
'@farcaster/miniapp-sdk': '>=0.1.6 <1.0.0', '@farcaster/miniapp-sdk': '>=0.1.6 <1.0.0',
'@farcaster/miniapp-wagmi-connector': '^1.0.0', '@farcaster/miniapp-wagmi-connector': '^1.0.0',
'@farcaster/mini-app-solana': '>=0.0.17 <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', '@neynar/react': '^1.2.5',
'@radix-ui/react-label': '^2.1.1', '@radix-ui/react-label': '^2.1.1',
'@solana/wallet-adapter-react': '^0.15.38', '@solana/wallet-adapter-react': '^0.15.38',
@@ -427,7 +488,6 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
'lucide-react': '^0.469.0', 'lucide-react': '^0.469.0',
mipd: '^0.0.7', mipd: '^0.0.7',
next: '^15', next: '^15',
'next-auth': '^4.24.11',
react: '^19', react: '^19',
'react-dom': '^19', 'react-dom': '^19',
'tailwind-merge': '^2.6.0', 'tailwind-merge': '^2.6.0',
@@ -439,18 +499,20 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
}; };
packageJson.devDependencies = { packageJson.devDependencies = {
'@types/node': '^20', "@types/inquirer": "^9.0.8",
'@types/react': '^19', "@types/node": "^20",
'@types/react-dom': '^19', "@types/react": "^19",
'@vercel/sdk': '^1.9.0', "@types/react-dom": "^19",
crypto: '^1.0.1', "@vercel/sdk": "^1.9.0",
eslint: '^8', "crypto": "^1.0.1",
'eslint-config-next': '15.0.3', "eslint": "^8",
localtunnel: '^2.0.2', "eslint-config-next": "15.0.3",
'pino-pretty': '^13.0.0', "localtunnel": "^2.0.2",
postcss: '^8', "pino-pretty": "^13.0.0",
tailwindcss: '^3.4.1', "postcss": "^8",
typescript: '^5', "tailwindcss": "^3.4.1",
"ts-node": "^10.9.2",
"typescript": "^5"
}; };
// Add Neynar SDK if selected // Add Neynar SDK if selected
@@ -458,6 +520,12 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
packageJson.dependencies['@neynar/nodejs-sdk'] = '^2.19.0'; 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)); fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
// Handle .env file // Handle .env file
@@ -501,19 +569,19 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
return content; return content;
}; };
// Regex patterns that match whole lines with export const // Regex patterns that match whole lines with export const (with TypeScript types)
const patterns = { const patterns = {
APP_NAME: /^export const APP_NAME\s*=\s*['"`][^'"`]*['"`];$/m, APP_NAME: /^export const APP_NAME\s*:\s*string\s*=\s*['"`][^'"`]*['"`];$/m,
APP_DESCRIPTION: APP_DESCRIPTION:
/^export const APP_DESCRIPTION\s*=\s*['"`][^'"`]*['"`];$/m, /^export const APP_DESCRIPTION\s*:\s*string\s*=\s*['"`][^'"`]*['"`];$/m,
APP_PRIMARY_CATEGORY: APP_PRIMARY_CATEGORY:
/^export const APP_PRIMARY_CATEGORY\s*=\s*['"`][^'"`]*['"`];$/m, /^export const APP_PRIMARY_CATEGORY\s*:\s*string\s*=\s*['"`][^'"`]*['"`];$/m,
APP_TAGS: /^export const APP_TAGS\s*=\s*\[[^\]]*\];$/m, APP_TAGS: /^export const APP_TAGS\s*:\s*string\[\]\s*=\s*\[[^\]]*\];$/m,
APP_BUTTON_TEXT: APP_BUTTON_TEXT:
/^export const APP_BUTTON_TEXT\s*=\s*['"`][^'"`]*['"`];$/m, /^export const APP_BUTTON_TEXT\s*:\s*string\s*=\s*['"`][^'"`]*['"`];$/m,
USE_WALLET: /^export const USE_WALLET\s*=\s*(true|false);$/m, USE_WALLET: /^export const USE_WALLET\s*:\s*boolean\s*=\s*(true|false);$/m,
ANALYTICS_ENABLED: ANALYTICS_ENABLED:
/^export const ANALYTICS_ENABLED\s*=\s*(true|false);$/m, /^export const ANALYTICS_ENABLED\s*:\s*boolean\s*=\s*(true|false);$/m,
}; };
// Update APP_NAME // Update APP_NAME
@@ -587,10 +655,7 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
console.log('⚠️ constants.ts not found, skipping constants update'); console.log('⚠️ constants.ts not found, skipping constants update');
} }
fs.appendFileSync(
envPath,
`\nNEXTAUTH_SECRET="${crypto.randomBytes(32).toString('hex')}"`
);
if (useNeynar && neynarApiKey && neynarClientId) { if (useNeynar && neynarApiKey && neynarClientId) {
fs.appendFileSync(envPath, `\nNEYNAR_API_KEY="${neynarApiKey}"`); fs.appendFileSync(envPath, `\nNEYNAR_API_KEY="${neynarApiKey}"`);
fs.appendFileSync(envPath, `\nNEYNAR_CLIENT_ID="${neynarClientId}"`); fs.appendFileSync(envPath, `\nNEYNAR_CLIENT_ID="${neynarClientId}"`);
@@ -601,9 +666,15 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
} }
if (answers.seedPhrase) { if (answers.seedPhrase) {
fs.appendFileSync(envPath, `\nSEED_PHRASE="${answers.seedPhrase}"`); fs.appendFileSync(envPath, `\nSEED_PHRASE="${answers.seedPhrase}"`);
fs.appendFileSync(envPath, `\nSPONSOR_SIGNER="${answers.sponsorSigner}"`);
} }
fs.appendFileSync(envPath, `\nUSE_TUNNEL="${answers.useTunnel}"`); fs.appendFileSync(envPath, `\nUSE_TUNNEL="${answers.useTunnel}"`);
if (answers.useSponsoredSigner) {
fs.appendFileSync(envPath, `\nSPONSOR_SIGNER="true"`);
fs.appendFileSync(
envPath,
`\nNEXTAUTH_SECRET="${crypto.randomBytes(32).toString('hex')}"`
);
}
fs.unlinkSync(envExamplePath); fs.unlinkSync(envExamplePath);
} else { } else {
@@ -647,6 +718,32 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
fs.rmSync(binPath, { recursive: true, force: true }); 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 });
}
}
// 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 // Initialize git repository
console.log('\nInitializing git repository...'); console.log('\nInitializing git repository...');
execSync('git init', { cwd: projectPath }); execSync('git init', { cwd: projectPath });

View File

@@ -1,6 +1,6 @@
{ {
"name": "@neynar/create-farcaster-mini-app", "name": "@neynar/create-farcaster-mini-app",
"version": "1.5.7", "version": "1.7.11",
"type": "module", "type": "module",
"private": false, "private": false,
"access": "public", "access": "public",
@@ -31,11 +31,11 @@
], ],
"scripts": { "scripts": {
"dev": "node scripts/dev.js", "dev": "node scripts/dev.js",
"build": "node scripts/build.js", "build": "next build",
"build:raw": "next build", "build:raw": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"deploy:vercel": "node scripts/deploy.js", "deploy:vercel": "node --loader ts-node/esm scripts/deploy.ts",
"deploy:raw": "vercel --prod", "deploy:raw": "vercel --prod",
"cleanup": "node scripts/cleanup.js" "cleanup": "node scripts/cleanup.js"
}, },
@@ -52,4 +52,4 @@
"@types/node": "^22.13.10", "@types/node": "^22.13.10",
"typescript": "^5.6.3" "typescript": "^5.6.3"
} }
} }

View File

@@ -1,359 +0,0 @@
import { execSync } from "child_process";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import inquirer from "inquirer";
import dotenv from "dotenv";
import crypto from "crypto";
// Load environment variables in specific order
// First load .env for main config
dotenv.config({ path: ".env" });
async function loadEnvLocal() {
try {
if (fs.existsSync(".env.local")) {
const { loadLocal } = await inquirer.prompt([
{
type: "confirm",
name: "loadLocal",
message:
"Found .env.local, likely created by the install script - would you like to load its values?",
default: false,
},
]);
if (loadLocal) {
console.log("Loading values from .env.local...");
const localEnv = dotenv.parse(fs.readFileSync(".env.local"));
// Copy all values to .env
const envContent = fs.existsSync(".env")
? fs.readFileSync(".env", "utf8") + "\n"
: "";
let newEnvContent = envContent;
for (const [key, value] of Object.entries(localEnv)) {
// Update process.env
process.env[key] = value;
// Add to .env content if not already there
if (!envContent.includes(`${key}=`)) {
newEnvContent += `${key}="${value}"\n`;
}
}
// Write updated content to .env
fs.writeFileSync(".env", newEnvContent);
console.log("✅ Values from .env.local have been written to .env");
}
if (localEnv.SPONSOR_SIGNER) {
process.env.SPONSOR_SIGNER = localEnv.SPONSOR_SIGNER;
}
}
} catch (error) {
// Error reading .env.local, which is fine
console.log("Note: No .env.local file found");
}
}
// TODO: make sure rebuilding is supported
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 queryNeynarApp(apiKey) {
if (!apiKey) {
return null;
}
try {
const response = await fetch(
`https://api.neynar.com/portal/app_by_api_key`,
{
headers: {
"x-api-key": apiKey,
},
}
);
const data = await response.json();
return data;
} catch (error) {
console.error("Error querying Neynar app data:", error);
return null;
}
}
async function generateFarcasterMetadata(domain, webhookUrl) {
const tags = process.env.NEXT_PUBLIC_MINI_APP_TAGS?.split(",");
return {
accountAssociation: {
header: "",
payload: "",
signature: "",
},
frame: {
version: "1",
name: process.env.NEXT_PUBLIC_MINI_APP_NAME,
iconUrl: `https://${domain}/icon.png`,
homeUrl: `https://${domain}`,
imageUrl: `https://${domain}/api/opengraph-image`,
buttonTitle: process.env.NEXT_PUBLIC_MINI_APP_BUTTON_TEXT,
splashImageUrl: `https://${domain}/splash.png`,
splashBackgroundColor: "#f7f7f7",
webhookUrl,
description: process.env.NEXT_PUBLIC_MINI_APP_DESCRIPTION,
primaryCategory: process.env.NEXT_PUBLIC_MINI_APP_PRIMARY_CATEGORY,
tags,
},
};
}
async function main() {
try {
console.log("\n📝 Checking environment variables...");
console.log("Loading values from .env...");
// Load .env.local if user wants to
await loadEnvLocal();
// Get domain from user
const { domain } = await inquirer.prompt([
{
type: "input",
name: "domain",
message:
"Enter the domain where your mini app 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 mini app (e.g., My Cool Mini App):",
default: process.env.NEXT_PUBLIC_MINI_APP_NAME,
validate: (input) => {
if (input.trim() === "") {
return "Mini app 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 mini app button:",
default:
process.env.NEXT_PUBLIC_MINI_APP_BUTTON_TEXT || "Launch Mini App",
validate: (input) => {
if (input.trim() === "") {
return "Button text cannot be empty";
}
return true;
},
},
]);
// Get Neynar configuration
let neynarApiKey = process.env.NEYNAR_API_KEY;
let neynarClientId = process.env.NEYNAR_CLIENT_ID;
let useNeynar = true;
while (useNeynar) {
if (!neynarApiKey) {
const { neynarApiKey: inputNeynarApiKey } = await inquirer.prompt([
{
type: "password",
name: "neynarApiKey",
message:
"Enter your Neynar API key (optional - leave blank to skip):",
default: null,
},
]);
neynarApiKey = inputNeynarApiKey;
} else {
console.log("Using existing Neynar API key from .env");
}
if (!neynarApiKey) {
useNeynar = false;
break;
}
// Try to get client ID from API
if (!neynarClientId) {
const appInfo = await queryNeynarApp(neynarApiKey);
if (appInfo) {
neynarClientId = appInfo.app_uuid;
console.log("✅ Fetched Neynar app client ID");
break;
}
}
// We have a client ID (either from .env or fetched from API), so we can break out of the loop
if (neynarClientId) {
break;
}
// If we get here, the API key was invalid
console.log(
"\n⚠ Could not find Neynar app information. The API key may be incorrect."
);
const { retry } = await inquirer.prompt([
{
type: "confirm",
name: "retry",
message: "Would you like to try a different API key?",
default: true,
},
]);
// Reset for retry
neynarApiKey = null;
neynarClientId = null;
if (!retry) {
useNeynar = false;
break;
}
}
// Generate manifest
console.log("\n🔨 Generating mini app manifest...");
// Determine webhook URL based on environment variables
const webhookUrl =
neynarApiKey && neynarClientId
? `https://api.neynar.com/f/app/${neynarClientId}/event`
: `https://${domain}/api/webhook`;
const metadata = await generateFarcasterMetadata(domain, webhookUrl);
console.log("\n✅ Mini app manifest generated");
// 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}`,
// Mini app metadata
`NEXT_PUBLIC_MINI_APP_NAME="${frameName}"`,
`NEXT_PUBLIC_MINI_APP_DESCRIPTION="${
process.env.NEXT_PUBLIC_MINI_APP_DESCRIPTION || ""
}"`,
`NEXT_PUBLIC_MINI_APP_PRIMARY_CATEGORY="${
process.env.NEXT_PUBLIC_MINI_APP_PRIMARY_CATEGORY || ""
}"`,
`NEXT_PUBLIC_MINI_APP_TAGS="${
process.env.NEXT_PUBLIC_MINI_APP_TAGS || ""
}"`,
`NEXT_PUBLIC_MINI_APP_BUTTON_TEXT="${buttonText}"`,
// Analytics
`NEXT_PUBLIC_ANALYTICS_ENABLED="${
process.env.NEXT_PUBLIC_ANALYTICS_ENABLED || "false"
}"`,
// Neynar configuration (if it exists in current env)
...(process.env.NEYNAR_API_KEY
? [`NEYNAR_API_KEY="${process.env.NEYNAR_API_KEY}"`]
: []),
...(neynarClientId ? [`NEYNAR_CLIENT_ID="${neynarClientId}"`] : []),
...(process.env.SPONSOR_SIGNER ?
[`SPONSOR_SIGNER="${process.env.SPONSOR_SIGNER}"`] : []),
// FID (if it exists in current env)
...(process.env.FID ? [`FID="${process.env.FID}"`] : []),
`NEXT_PUBLIC_USE_WALLET="${
process.env.NEXT_PUBLIC_USE_WALLET || "false"
}"`,
// NextAuth configuration
`NEXTAUTH_SECRET="${
process.env.NEXTAUTH_SECRET || crypto.randomBytes(32).toString("hex")
}"`,
`NEXTAUTH_URL="https://${domain}"`,
// Mini app manifest with signature
`MINI_APP_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...");
const nextBin = path.normalize(
path.join(projectRoot, "node_modules", ".bin", "next")
);
execSync(`"${nextBin}" build`, {
cwd: projectRoot,
stdio: "inherit",
shell: process.platform === "win32",
});
console.log(
"\n✨ Build complete! Your mini app 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();

View File

@@ -7,6 +7,7 @@ import inquirer from 'inquirer';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import crypto from 'crypto'; import crypto from 'crypto';
import { Vercel } from '@vercel/sdk'; import { Vercel } from '@vercel/sdk';
import { APP_NAME, APP_BUTTON_TEXT } from '../src/lib/constants.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.join(__dirname, '..'); const projectRoot = path.join(__dirname, '..');
@@ -14,32 +15,10 @@ const projectRoot = path.join(__dirname, '..');
// Load environment variables in specific order // Load environment variables in specific order
dotenv.config({ path: '.env' }); dotenv.config({ path: '.env' });
async function generateFarcasterMetadata(domain, webhookUrl) { async function loadEnvLocal(): Promise<void> {
const trimmedDomain = domain.trim();
const tags = process.env.NEXT_PUBLIC_MINI_APP_TAGS?.split(',');
return {
frame: {
version: '1',
name: process.env.NEXT_PUBLIC_MINI_APP_NAME,
iconUrl: `https://${trimmedDomain}/icon.png`,
homeUrl: `https://${trimmedDomain}`,
imageUrl: `https://${trimmedDomain}/api/opengraph-image`,
buttonTitle: process.env.NEXT_PUBLIC_MINI_APP_BUTTON_TEXT,
splashImageUrl: `https://${trimmedDomain}/splash.png`,
splashBackgroundColor: '#f7f7f7',
webhookUrl: webhookUrl?.trim(),
description: process.env.NEXT_PUBLIC_MINI_APP_DESCRIPTION,
primaryCategory: process.env.NEXT_PUBLIC_MINI_APP_PRIMARY_CATEGORY,
tags,
},
};
}
async function loadEnvLocal() {
try { try {
if (fs.existsSync('.env.local')) { if (fs.existsSync('.env.local')) {
const { loadLocal } = await inquirer.prompt([ const { loadLocal }: { loadLocal: boolean } = await inquirer.prompt([
{ {
type: 'confirm', type: 'confirm',
name: 'loadLocal', name: 'loadLocal',
@@ -54,12 +33,7 @@ async function loadEnvLocal() {
const localEnv = dotenv.parse(fs.readFileSync('.env.local')); const localEnv = dotenv.parse(fs.readFileSync('.env.local'));
const allowedVars = [ const allowedVars = [
'NEXT_PUBLIC_MINI_APP_NAME', 'SEED_PHRASE',
'NEXT_PUBLIC_MINI_APP_DESCRIPTION',
'NEXT_PUBLIC_MINI_APP_PRIMARY_CATEGORY',
'NEXT_PUBLIC_MINI_APP_TAGS',
'NEXT_PUBLIC_MINI_APP_BUTTON_TEXT',
'NEXT_PUBLIC_ANALYTICS_ENABLED',
'NEYNAR_API_KEY', 'NEYNAR_API_KEY',
'NEYNAR_CLIENT_ID', 'NEYNAR_CLIENT_ID',
'SPONSOR_SIGNER', 'SPONSOR_SIGNER',
@@ -83,12 +57,12 @@ async function loadEnvLocal() {
console.log('✅ Values from .env.local have been written to .env'); console.log('✅ Values from .env.local have been written to .env');
} }
} }
} catch (error) { } catch (error: unknown) {
console.log('Note: No .env.local file found'); console.log('Note: No .env.local file found');
} }
} }
async function checkRequiredEnvVars() { async function checkRequiredEnvVars(): Promise<void> {
console.log('\n📝 Checking environment variables...'); console.log('\n📝 Checking environment variables...');
console.log('Loading values from .env...'); console.log('Loading values from .env...');
@@ -98,21 +72,21 @@ async function checkRequiredEnvVars() {
{ {
name: 'NEXT_PUBLIC_MINI_APP_NAME', name: 'NEXT_PUBLIC_MINI_APP_NAME',
message: 'Enter the name for your frame (e.g., My Cool Mini App):', message: 'Enter the name for your frame (e.g., My Cool Mini App):',
default: process.env.NEXT_PUBLIC_MINI_APP_NAME, default: APP_NAME,
validate: (input) => validate: (input: string) =>
input.trim() !== '' || 'Mini app name cannot be empty', input.trim() !== '' || 'Mini app name cannot be empty',
}, },
{ {
name: 'NEXT_PUBLIC_MINI_APP_BUTTON_TEXT', name: 'NEXT_PUBLIC_MINI_APP_BUTTON_TEXT',
message: 'Enter the text for your frame button:', message: 'Enter the text for your frame button:',
default: default: APP_BUTTON_TEXT ?? 'Launch Mini App',
process.env.NEXT_PUBLIC_MINI_APP_BUTTON_TEXT ?? 'Launch Mini App', validate: (input: string) =>
validate: (input) => input.trim() !== '' || 'Button text cannot be empty', input.trim() !== '' || 'Button text cannot be empty',
}, },
]; ];
const missingVars = requiredVars.filter( const missingVars = requiredVars.filter(
(varConfig) => !process.env[varConfig.name] varConfig => !process.env[varConfig.name],
); );
if (missingVars.length > 0) { if (missingVars.length > 0) {
@@ -138,7 +112,7 @@ async function checkRequiredEnvVars() {
const newLine = envContent ? '\n' : ''; const newLine = envContent ? '\n' : '';
fs.appendFileSync( fs.appendFileSync(
'.env', '.env',
`${newLine}${varConfig.name}="${value.trim()}"` `${newLine}${varConfig.name}="${value.trim()}"`,
); );
} }
@@ -158,14 +132,64 @@ async function checkRequiredEnvVars() {
process.env.SPONSOR_SIGNER = sponsorSigner.toString(); process.env.SPONSOR_SIGNER = sponsorSigner.toString();
if (storeSeedPhrase) { if (process.env.SEED_PHRASE) {
fs.appendFileSync( fs.appendFileSync(
'.env.local', '.env.local',
`\nSPONSOR_SIGNER="${sponsorSigner}"` `\nSPONSOR_SIGNER="${sponsorSigner}"`,
); );
console.log('✅ Sponsor signer preference stored in .env.local'); console.log('✅ Sponsor signer preference stored in .env.local');
} }
} }
// Ask about required chains
const { useRequiredChains } = await inquirer.prompt([
{
type: 'confirm',
name: 'useRequiredChains',
message:
'Does your mini app require support for specific blockchains?\n' +
'If yes, the host will only render your mini app if it supports all the chains you specify.\n' +
'If no, the mini app will be rendered regardless of chain support.',
default: false,
},
]);
let requiredChains: string[] = [];
if (useRequiredChains) {
const { selectedChains } = await inquirer.prompt([
{
type: 'checkbox',
name: 'selectedChains',
message: 'Select the required chains (CAIP-2 identifiers):',
choices: [
{ name: 'Ethereum Mainnet (eip155:1)', value: 'eip155:1' },
{ name: 'Polygon (eip155:137)', value: 'eip155:137' },
{ name: 'Arbitrum One (eip155:42161)', value: 'eip155:42161' },
{ name: 'Optimism (eip155:10)', value: 'eip155:10' },
{ name: 'Base (eip155:8453)', value: 'eip155:8453' },
{ name: 'Solana (solana:mainnet)', value: 'solana:mainnet' },
{ name: 'Solana Devnet (solana:devnet)', value: 'solana:devnet' },
],
},
]);
requiredChains = selectedChains;
}
// Update constants.ts with required chains
const constantsPath = path.join(projectRoot, 'src', 'lib', 'constants.ts');
if (fs.existsSync(constantsPath)) {
let constantsContent = fs.readFileSync(constantsPath, 'utf8');
// Replace the APP_REQUIRED_CHAINS line
const requiredChainsString = JSON.stringify(requiredChains);
constantsContent = constantsContent.replace(
/^export const APP_REQUIRED_CHAINS\s*:\s*string\[\]\s*=\s*\[[^\]]*\];$/m,
`export const APP_REQUIRED_CHAINS: string[] = ${requiredChainsString};`,
);
fs.writeFileSync(constantsPath, constantsContent);
console.log('✅ Required chains updated in constants.ts');
}
} }
} }
@@ -182,39 +206,43 @@ async function checkRequiredEnvVars() {
} }
} }
async function getGitRemote() { async function getGitRemote(): Promise<string | null> {
try { try {
const remoteUrl = execSync('git remote get-url origin', { const remoteUrl = execSync('git remote get-url origin', {
cwd: projectRoot, cwd: projectRoot,
encoding: 'utf8', encoding: 'utf8',
}).trim(); }).trim();
return remoteUrl; return remoteUrl;
} catch (error) { } catch (error: unknown) {
return null; if (error instanceof Error) {
return null;
}
throw error;
} }
} }
async function checkVercelCLI() { async function checkVercelCLI(): Promise<boolean> {
try { try {
execSync('vercel --version', { execSync('vercel --version', {
stdio: 'ignore', stdio: 'ignore',
shell: process.platform === 'win32',
}); });
return true; return true;
} catch (error) { } catch (error: unknown) {
return false; if (error instanceof Error) {
return false;
}
throw error;
} }
} }
async function installVercelCLI() { async function installVercelCLI(): Promise<void> {
console.log('Installing Vercel CLI...'); console.log('Installing Vercel CLI...');
execSync('npm install -g vercel', { execSync('npm install -g vercel', {
stdio: 'inherit', stdio: 'inherit',
shell: process.platform === 'win32',
}); });
} }
async function getVercelToken() { async function getVercelToken(): Promise<string | null> {
try { try {
// Try to get token from Vercel CLI config // Try to get token from Vercel CLI config
const configPath = path.join(os.homedir(), '.vercel', 'auth.json'); const configPath = path.join(os.homedir(), '.vercel', 'auth.json');
@@ -222,8 +250,10 @@ async function getVercelToken() {
const authConfig = JSON.parse(fs.readFileSync(configPath, 'utf8')); const authConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
return authConfig.token; return authConfig.token;
} }
} catch (error) { } catch (error: unknown) {
console.warn('Could not read Vercel token from config file'); if (error instanceof Error) {
console.warn('Could not read Vercel token from config file');
}
} }
// Try environment variable // Try environment variable
@@ -242,14 +272,17 @@ async function getVercelToken() {
// The token isn't directly exposed, so we'll need to use CLI for some operations // The token isn't directly exposed, so we'll need to use CLI for some operations
console.log('✅ Verified Vercel CLI authentication'); console.log('✅ Verified Vercel CLI authentication');
return null; // We'll fall back to CLI operations return null; // We'll fall back to CLI operations
} catch (error) { } catch (error: unknown) {
throw new Error( if (error instanceof 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;
} }
} }
async function loginToVercel() { async function loginToVercel(): Promise<boolean> {
console.log('\n🔑 Vercel Login'); console.log('\n🔑 Vercel Login');
console.log('You can either:'); console.log('You can either:');
console.log('1. Log in to an existing Vercel account'); console.log('1. Log in to an existing Vercel account');
@@ -260,22 +293,22 @@ async function loginToVercel() {
console.log('3. Complete the Vercel account setup in your browser'); 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('4. Return here once your Vercel account is created\n');
console.log( 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'], { const child = spawn('vercel', ['login'], {
stdio: 'inherit', stdio: 'inherit',
}); });
await new Promise((resolve, reject) => { await new Promise<void>((resolve, reject) => {
child.on('close', (code) => { child.on('close', code => {
resolve(); resolve();
}); });
}); });
console.log('\n📱 Waiting for login to complete...'); console.log('\n📱 Waiting for login to complete...');
console.log( 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++) { for (let i = 0; i < 150; i++) {
@@ -283,11 +316,14 @@ async function loginToVercel() {
execSync('vercel whoami', { stdio: 'ignore' }); execSync('vercel whoami', { stdio: 'ignore' });
console.log('✅ Successfully logged in to Vercel!'); console.log('✅ Successfully logged in to Vercel!');
return true; return true;
} catch (error) { } catch (error: unknown) {
if (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...'); console.log(' Waiting for Vercel account setup to complete...');
} }
await new Promise((resolve) => setTimeout(resolve, 2000)); await new Promise(resolve => setTimeout(resolve, 2000));
} }
} }
@@ -298,9 +334,14 @@ async function loginToVercel() {
return false; return false;
} }
async function setVercelEnvVarSDK(vercelClient, projectId, key, value) { async function setVercelEnvVarSDK(
vercelClient: Vercel,
projectId: string,
key: string,
value: string | object,
): Promise<boolean> {
try { try {
let processedValue; let processedValue: string;
if (typeof value === 'object') { if (typeof value === 'object') {
processedValue = JSON.stringify(value); processedValue = JSON.stringify(value);
} else { } else {
@@ -308,17 +349,26 @@ async function setVercelEnvVarSDK(vercelClient, projectId, key, value) {
} }
// Get existing environment variables // Get existing environment variables
const existingVars = await vercelClient.projects.getEnvironmentVariables({ const existingVars = await vercelClient.projects.filterProjectEnvs({
idOrName: projectId, idOrName: projectId,
}); });
const existingVar = existingVars.envs?.find( // Handle different response types
(env) => env.key === key && env.target?.includes('production') 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 // Update existing variable
await vercelClient.projects.editEnvironmentVariable({ await vercelClient.projects.editProjectEnv({
idOrName: projectId, idOrName: projectId,
id: existingVar.id, id: existingVar.id,
requestBody: { requestBody: {
@@ -329,7 +379,7 @@ async function setVercelEnvVarSDK(vercelClient, projectId, key, value) {
console.log(`✅ Updated environment variable: ${key}`); console.log(`✅ Updated environment variable: ${key}`);
} else { } else {
// Create new variable // Create new variable
await vercelClient.projects.createEnvironmentVariable({ await vercelClient.projects.createProjectEnv({
idOrName: projectId, idOrName: projectId,
requestBody: { requestBody: {
key: key, key: key,
@@ -342,16 +392,23 @@ async function setVercelEnvVarSDK(vercelClient, projectId, key, value) {
} }
return true; return true;
} catch (error) { } catch (error: unknown) {
console.warn( if (error instanceof Error) {
`⚠️ Warning: Failed to set environment variable ${key}:`, console.warn(
error.message `⚠️ Warning: Failed to set environment variable ${key}:`,
); error.message,
return false; );
return false;
}
throw error;
} }
} }
async function setVercelEnvVarCLI(key, value, projectRoot) { async function setVercelEnvVarCLI(
key: string,
value: string | object,
projectRoot: string,
): Promise<boolean> {
try { try {
// Remove existing env var // Remove existing env var
try { try {
@@ -360,11 +417,11 @@ async function setVercelEnvVarCLI(key, value, projectRoot) {
stdio: 'ignore', stdio: 'ignore',
env: process.env, env: process.env,
}); });
} catch (error) { } catch (error: unknown) {
// Ignore errors from removal // Ignore errors from removal
} }
let processedValue; let processedValue: string;
if (typeof value === 'object') { if (typeof value === 'object') {
processedValue = JSON.stringify(value); processedValue = JSON.stringify(value);
} else { } else {
@@ -376,7 +433,7 @@ async function setVercelEnvVarCLI(key, value, projectRoot) {
fs.writeFileSync(tempFilePath, processedValue, 'utf8'); fs.writeFileSync(tempFilePath, processedValue, 'utf8');
// Use appropriate command based on platform // Use appropriate command based on platform
let command; let command: string;
if (process.platform === 'win32') { if (process.platform === 'win32') {
command = `type "${tempFilePath}" | vercel env add ${key} production`; command = `type "${tempFilePath}" | vercel env add ${key} production`;
} else { } else {
@@ -386,35 +443,37 @@ async function setVercelEnvVarCLI(key, value, projectRoot) {
execSync(command, { execSync(command, {
cwd: projectRoot, cwd: projectRoot,
stdio: 'pipe', // Changed from 'inherit' to avoid interactive prompts stdio: 'pipe', // Changed from 'inherit' to avoid interactive prompts
shell: true,
env: process.env, env: process.env,
}); });
fs.unlinkSync(tempFilePath); fs.unlinkSync(tempFilePath);
console.log(`✅ Set environment variable: ${key}`); console.log(`✅ Set environment variable: ${key}`);
return true; return true;
} catch (error) { } catch (error: unknown) {
const tempFilePath = path.join(projectRoot, `${key}_temp.txt`); const tempFilePath = path.join(projectRoot, `${key}_temp.txt`);
if (fs.existsSync(tempFilePath)) { if (fs.existsSync(tempFilePath)) {
fs.unlinkSync(tempFilePath); fs.unlinkSync(tempFilePath);
} }
console.warn( if (error instanceof Error) {
`⚠️ Warning: Failed to set environment variable ${key}:`, console.warn(
error.message `⚠️ Warning: Failed to set environment variable ${key}:`,
); error.message,
return false; );
return false;
}
throw error;
} }
} }
async function setEnvironmentVariables( async function setEnvironmentVariables(
vercelClient, vercelClient: Vercel | null,
projectId, projectId: string | null,
envVars, envVars: Record<string, string | object>,
projectRoot projectRoot: string,
) { ): Promise<Array<{ key: string; success: boolean }>> {
console.log('\n📝 Setting up environment variables...'); console.log('\n📝 Setting up environment variables...');
const results = []; const results: Array<{ key: string; success: boolean }> = [];
for (const [key, value] of Object.entries(envVars)) { for (const [key, value] of Object.entries(envVars)) {
if (!value) continue; if (!value) continue;
@@ -435,12 +494,12 @@ async function setEnvironmentVariables(
} }
// Report results // Report results
const failed = results.filter((r) => !r.success); const failed = results.filter(r => !r.success);
if (failed.length > 0) { if (failed.length > 0) {
console.warn(`\n⚠ Failed to set ${failed.length} environment variables:`); 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( console.warn(
'\nYou may need to set these manually in the Vercel dashboard.' '\nYou may need to set these manually in the Vercel dashboard.',
); );
} }
@@ -448,22 +507,22 @@ async function setEnvironmentVariables(
} }
async function waitForDeployment( async function waitForDeployment(
vercelClient, vercelClient: Vercel | null,
projectId, projectId: string,
maxWaitTime = 300000 maxWaitTime = 300000,
) { ): Promise<any> {
// 5 minutes // 5 minutes
console.log('\n⏳ Waiting for deployment to complete...'); console.log('\n⏳ Waiting for deployment to complete...');
const startTime = Date.now(); const startTime = Date.now();
while (Date.now() - startTime < maxWaitTime) { while (Date.now() - startTime < maxWaitTime) {
try { try {
const deployments = await vercelClient.deployments.list({ const deployments = await vercelClient?.deployments.getDeployments({
projectId: projectId, projectId: projectId,
limit: 1, limit: 1,
}); });
if (deployments.deployments?.[0]) { if (deployments?.deployments?.[0]) {
const deployment = deployments.deployments[0]; const deployment = deployments.deployments[0];
console.log(`📊 Deployment status: ${deployment.state}`); console.log(`📊 Deployment status: ${deployment.state}`);
@@ -477,21 +536,24 @@ async function waitForDeployment(
} }
// Still building, wait and check again // 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 { } else {
console.log('⏳ No deployment found yet, waiting...'); console.log('⏳ No deployment found yet, waiting...');
await new Promise((resolve) => setTimeout(resolve, 5000)); await new Promise(resolve => setTimeout(resolve, 5000));
} }
} catch (error) { } catch (error: unknown) {
console.warn('⚠️ Could not check deployment status:', error.message); if (error instanceof Error) {
await new Promise((resolve) => setTimeout(resolve, 5000)); console.warn('⚠️ Could not check deployment status:', error.message);
await new Promise(resolve => setTimeout(resolve, 5000));
}
throw error;
} }
} }
throw new Error('Deployment timed out after 5 minutes'); throw new Error('Deployment timed out after 5 minutes');
} }
async function deployToVercel(useGitHub = false) { async function deployToVercel(useGitHub = false): Promise<void> {
try { try {
console.log('\n🚀 Deploying to Vercel...'); console.log('\n🚀 Deploying to Vercel...');
@@ -507,18 +569,18 @@ async function deployToVercel(useGitHub = false) {
framework: 'nextjs', framework: 'nextjs',
}, },
null, null,
2 2,
) ),
); );
} }
// Set up Vercel project // Set up Vercel project
console.log('\n📦 Setting up Vercel project...'); console.log('\n📦 Setting up Vercel project...');
console.log( 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( 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 // Use spawn instead of execSync for better error handling
@@ -526,11 +588,11 @@ async function deployToVercel(useGitHub = false) {
const vercelSetup = spawn('vercel', [], { const vercelSetup = spawn('vercel', [], {
cwd: projectRoot, cwd: projectRoot,
stdio: 'inherit', stdio: 'inherit',
shell: process.platform === 'win32', shell: process.platform === 'win32' ? true : undefined,
}); });
await new Promise((resolve, reject) => { await new Promise<void>((resolve, reject) => {
vercelSetup.on('close', (code) => { vercelSetup.on('close', code => {
if (code === 0 || code === null) { if (code === 0 || code === null) {
console.log('✅ Vercel project setup completed'); console.log('✅ Vercel project setup completed');
resolve(); resolve();
@@ -540,30 +602,33 @@ async function deployToVercel(useGitHub = false) {
} }
}); });
vercelSetup.on('error', (error) => { vercelSetup.on('error', error => {
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 resolve(); // Don't reject, as this is often expected
}); });
}); });
// Wait a moment for project files to be written // 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 // Load project info
let projectId; let projectId: string;
try { try {
const projectJson = JSON.parse( const projectJson = JSON.parse(
fs.readFileSync('.vercel/project.json', 'utf8') fs.readFileSync('.vercel/project.json', 'utf8'),
); );
projectId = projectJson.projectId; projectId = projectJson.projectId;
} catch (error) { } catch (error: unknown) {
throw new Error( if (error instanceof 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;
} }
// Get Vercel token and initialize SDK client // Get Vercel token and initialize SDK client
let vercelClient = null; let vercelClient: Vercel | null = null;
try { try {
const token = await getVercelToken(); const token = await getVercelToken();
if (token) { if (token) {
@@ -572,29 +637,40 @@ async function deployToVercel(useGitHub = false) {
}); });
console.log('✅ Initialized Vercel SDK client'); console.log('✅ Initialized Vercel SDK client');
} }
} catch (error) { } catch (error: unknown) {
console.warn( if (error instanceof Error) {
'⚠️ Could not initialize Vercel SDK, falling back to CLI operations' console.warn(
); '⚠️ Could not initialize Vercel SDK, falling back to CLI operations',
);
}
throw error;
} }
// Get project details // Get project details
console.log('\n🔍 Getting project details...'); console.log('\n🔍 Getting project details...');
let domain; let domain: string | undefined;
let projectName; let projectName: string | undefined;
if (vercelClient) { if (vercelClient) {
try { try {
const project = await vercelClient.projects.get({ const projects = await vercelClient.projects.getProjects({});
idOrName: projectId, const project = projects.projects.find(
}); (p: any) => p.id === projectId || p.name === projectId,
projectName = project.name;
domain = `${projectName}.vercel.app`;
console.log('🌐 Using project name for domain:', domain);
} catch (error) {
console.warn(
'⚠️ Could not get project details via SDK, using CLI fallback'
); );
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',
);
}
throw error;
} }
} }
@@ -606,7 +682,7 @@ async function deployToVercel(useGitHub = false) {
{ {
cwd: projectRoot, cwd: projectRoot,
encoding: 'utf8', encoding: 'utf8',
} },
); );
const nameMatch = inspectOutput.match(/Name\s+([^\n]+)/); const nameMatch = inspectOutput.match(/Name\s+([^\n]+)/);
@@ -622,39 +698,28 @@ async function deployToVercel(useGitHub = false) {
console.log('🌐 Using project name for domain:', domain); console.log('🌐 Using project name for domain:', domain);
} else { } else {
console.warn( 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 // Use a fallback domain based on project ID
domain = `project-${projectId.slice(-8)}.vercel.app`; domain = `project-${projectId.slice(-8)}.vercel.app`;
console.log('🌐 Using fallback domain:', domain); console.log('🌐 Using fallback domain:', domain);
} }
} }
} catch (error) { } catch (error: unknown) {
console.warn('⚠️ Could not inspect project, using fallback domain'); if (error instanceof Error) {
// Use a fallback domain based on project ID console.warn('⚠️ Could not inspect project, using fallback domain');
domain = `project-${projectId.slice(-8)}.vercel.app`; // Use a fallback domain based on project ID
console.log('🌐 Using fallback domain:', domain); domain = `project-${projectId.slice(-8)}.vercel.app`;
console.log('🌐 Using fallback domain:', domain);
}
throw error;
} }
} }
// Generate mini app metadata
console.log('\n🔨 Generating mini app metadata...');
const webhookUrl =
process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID
? `https://api.neynar.com/f/app/${process.env.NEYNAR_CLIENT_ID}/event`
: `https://${domain}/api/webhook`;
const miniAppMetadata = await generateFarcasterMetadata(domain, webhookUrl);
console.log('✅ Mini app metadata generated');
// Prepare environment variables // Prepare environment variables
const nextAuthSecret = const nextAuthSecret =
process.env.NEXTAUTH_SECRET || crypto.randomBytes(32).toString('hex'); process.env.NEXTAUTH_SECRET || crypto.randomBytes(32).toString('hex');
const vercelEnv = { const vercelEnv = {
NEXTAUTH_SECRET: nextAuthSecret,
AUTH_SECRET: nextAuthSecret,
NEXTAUTH_URL: `https://${domain}`,
NEXT_PUBLIC_URL: `https://${domain}`, NEXT_PUBLIC_URL: `https://${domain}`,
...(process.env.NEYNAR_API_KEY && { ...(process.env.NEYNAR_API_KEY && {
@@ -666,12 +731,18 @@ async function deployToVercel(useGitHub = false) {
...(process.env.SPONSOR_SIGNER && { ...(process.env.SPONSOR_SIGNER && {
SPONSOR_SIGNER: process.env.SPONSOR_SIGNER, SPONSOR_SIGNER: process.env.SPONSOR_SIGNER,
}), }),
...(miniAppMetadata && { MINI_APP_METADATA: miniAppMetadata }),
// 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.fromEntries(
Object.entries(process.env).filter(([key]) => Object.entries(process.env).filter(([key]) =>
key.startsWith('NEXT_PUBLIC_') key.startsWith('NEXT_PUBLIC_'),
) ),
), ),
}; };
@@ -680,7 +751,7 @@ async function deployToVercel(useGitHub = false) {
vercelClient, vercelClient,
projectId, projectId,
vercelEnv, vercelEnv,
projectRoot projectRoot,
); );
// Deploy the project // Deploy the project
@@ -703,8 +774,8 @@ async function deployToVercel(useGitHub = false) {
env: process.env, env: process.env,
}); });
await new Promise((resolve, reject) => { await new Promise<void>((resolve, reject) => {
vercelDeploy.on('close', (code) => { vercelDeploy.on('close', code => {
if (code === 0) { if (code === 0) {
console.log('✅ Vercel deployment command completed'); console.log('✅ Vercel deployment command completed');
resolve(); resolve();
@@ -714,23 +785,26 @@ async function deployToVercel(useGitHub = false) {
} }
}); });
vercelDeploy.on('error', (error) => { vercelDeploy.on('error', error => {
console.error('❌ Vercel deployment error:', error.message); console.error('❌ Vercel deployment error:', error.message);
reject(error); reject(error);
}); });
}); });
// Wait for deployment to actually complete // Wait for deployment to actually complete
let deployment; let deployment: any;
if (vercelClient) { if (vercelClient) {
try { try {
deployment = await waitForDeployment(vercelClient, projectId); deployment = await waitForDeployment(vercelClient, projectId);
} catch (error) { } catch (error: unknown) {
console.warn( if (error instanceof Error) {
'⚠️ Could not verify deployment completion:', console.warn(
error.message '⚠️ Could not verify deployment completion:',
); error.message,
console.log(' Proceeding with domain verification...'); );
console.log(' Proceeding with domain verification...');
}
throw error;
} }
} }
@@ -742,10 +816,13 @@ async function deployToVercel(useGitHub = false) {
try { try {
actualDomain = deployment.url || domain; actualDomain = deployment.url || domain;
console.log('🌐 Verified actual domain:', actualDomain); console.log('🌐 Verified actual domain:', actualDomain);
} catch (error) { } catch (error: unknown) {
console.warn( if (error instanceof Error) {
'⚠️ Could not verify domain via SDK, using assumed domain' console.warn(
); '⚠️ Could not verify domain via SDK, using assumed domain',
);
}
throw error;
} }
} }
@@ -753,32 +830,20 @@ async function deployToVercel(useGitHub = false) {
if (actualDomain !== domain) { if (actualDomain !== domain) {
console.log('🔄 Updating environment variables with correct domain...'); console.log('🔄 Updating environment variables with correct domain...');
const webhookUrl = const updatedEnv: Record<string, string | object> = {
process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID
? `https://api.neynar.com/f/app/${process.env.NEYNAR_CLIENT_ID}/event`
: `https://${actualDomain}/api/webhook`;
const updatedEnv = {
NEXTAUTH_URL: `https://${actualDomain}`,
NEXT_PUBLIC_URL: `https://${actualDomain}`, NEXT_PUBLIC_URL: `https://${actualDomain}`,
}; };
if (miniAppMetadata) { // Include NextAuth URL if SEED_PHRASE is present or SPONSOR_SIGNER is true
const updatedMetadata = await generateFarcasterMetadata( if (process.env.SEED_PHRASE || process.env.SPONSOR_SIGNER === 'true') {
actualDomain, updatedEnv.NEXTAUTH_URL = `https://${actualDomain}`;
fid,
await validateSeedPhrase(process.env.SEED_PHRASE),
process.env.SEED_PHRASE,
webhookUrl
);
updatedEnv.MINI_APP_METADATA = updatedMetadata;
} }
await setEnvironmentVariables( await setEnvironmentVariables(
vercelClient, vercelClient,
projectId, projectId,
updatedEnv, updatedEnv,
projectRoot projectRoot,
); );
console.log('\n📦 Redeploying with correct domain...'); console.log('\n📦 Redeploying with correct domain...');
@@ -788,8 +853,8 @@ async function deployToVercel(useGitHub = false) {
env: process.env, env: process.env,
}); });
await new Promise((resolve, reject) => { await new Promise<void>((resolve, reject) => {
vercelRedeploy.on('close', (code) => { vercelRedeploy.on('close', code => {
if (code === 0) { if (code === 0) {
console.log('✅ Redeployment completed'); console.log('✅ Redeployment completed');
resolve(); resolve();
@@ -799,7 +864,7 @@ async function deployToVercel(useGitHub = false) {
} }
}); });
vercelRedeploy.on('error', (error) => { vercelRedeploy.on('error', error => {
console.error('❌ Redeployment error:', error.message); console.error('❌ Redeployment error:', error.message);
reject(error); reject(error);
}); });
@@ -811,19 +876,71 @@ async function deployToVercel(useGitHub = false) {
console.log('\n✨ Deployment complete! Your mini app is now live at:'); console.log('\n✨ Deployment complete! Your mini app is now live at:');
console.log(`🌐 https://${domain}`); console.log(`🌐 https://${domain}`);
console.log( console.log(
'\n📝 You can manage your project at https://vercel.com/dashboard' '\n📝 You can manage your project at https://vercel.com/dashboard',
); );
} catch (error) {
console.error('\n❌ Deployment failed:', error.message); // Prompt user to sign manifest in browser and paste accountAssociation
process.exit(1); 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([
{
type: 'editor',
name: 'userAccountAssociation',
message: 'Paste the accountAssociation JSON here:',
validate: (input: string) => {
try {
const parsed = JSON.parse(input);
if (parsed.header && parsed.payload && parsed.signature) {
return true;
}
return 'Invalid accountAssociation: must have header, payload, and signature';
} catch (e) {
return 'Invalid JSON';
}
},
},
]);
const parsedAccountAssociation = JSON.parse(userAccountAssociation);
// Write APP_ACCOUNT_ASSOCIATION to src/lib/constants.ts
const constantsPath = path.join(projectRoot, 'src', 'lib', 'constants.ts');
let constantsContent = fs.readFileSync(constantsPath, 'utf8');
// Replace the APP_ACCOUNT_ASSOCIATION line using a robust, anchored, multiline regex
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,
);
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);
process.exit(1);
}
throw error;
} }
} }
async function main() { async function main(): Promise<void> {
try { try {
console.log('🚀 Vercel Mini App Deployment (SDK Edition)'); console.log('🚀 Vercel Mini App Deployment (SDK Edition)');
console.log( 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('\nThe script will:');
console.log('1. Check for required environment variables'); console.log('1. Check for required environment variables');
@@ -834,13 +951,16 @@ async function main() {
// Check if @vercel/sdk is installed // Check if @vercel/sdk is installed
try { try {
await import('@vercel/sdk'); await import('@vercel/sdk');
} catch (error) { } catch (error: unknown) {
console.log('📦 Installing @vercel/sdk...'); if (error instanceof Error) {
execSync('npm install @vercel/sdk', { console.log('📦 Installing @vercel/sdk...');
cwd: projectRoot, execSync('npm install @vercel/sdk', {
stdio: 'inherit', cwd: projectRoot,
}); stdio: 'inherit',
console.log('✅ @vercel/sdk installed successfully'); });
console.log('✅ @vercel/sdk installed successfully');
}
throw error;
} }
await checkRequiredEnvVars(); await checkRequiredEnvVars();
@@ -896,10 +1016,13 @@ async function main() {
} }
await deployToVercel(useGitHub); await deployToVercel(useGitHub);
} catch (error) { } catch (error: unknown) {
console.error('\n❌ Error:', error.message); if (error instanceof Error) {
process.exit(1); console.error('\n❌ Error:', error.message);
process.exit(1);
}
throw error;
} }
} }
main(); main();

View File

@@ -1,9 +1,9 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { getFarcasterMetadata } from '../../../lib/utils'; import { getFarcasterDomainManifest } from '~/lib/utils';
export async function GET() { export async function GET() {
try { try {
const config = await getFarcasterMetadata(); const config = await getFarcasterDomainManifest();
return NextResponse.json(config); return NextResponse.json(config);
} catch (error) { } catch (error) {
console.error('Error generating metadata:', error); console.error('Error generating metadata:', error);

View File

@@ -1,46 +0,0 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '~/auth';
export async function POST(request: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.fid) {
return NextResponse.json(
{ error: 'No authenticated session found' },
{ status: 401 }
);
}
const body = await request.json();
const { signers, user } = body;
if (!signers || !user) {
return NextResponse.json(
{ error: 'Signers and user are required' },
{ status: 400 }
);
}
// For NextAuth to update the session, we need to trigger the JWT callback
// This is typically done by calling the session endpoint with updated data
// However, we can't directly modify the session token from here
// Instead, we'll store the data temporarily and let the client refresh the session
// The session will be updated when the JWT callback is triggered
return NextResponse.json({
success: true,
message: 'Session update prepared',
signers,
user,
});
} catch (error) {
console.error('Error preparing session update:', error);
return NextResponse.json(
{ error: 'Failed to prepare session update' },
{ status: 500 }
);
}
}

View File

@@ -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 },
);
}
}

View File

@@ -1,9 +1,8 @@
import type { Metadata } from "next"; import type { Metadata } from 'next';
import { getSession } from "~/auth" import '~/app/globals.css';
import "~/app/globals.css"; import { Providers } from '~/app/providers';
import { Providers } from "~/app/providers"; import { APP_NAME, APP_DESCRIPTION } from '~/lib/constants';
import { APP_NAME, APP_DESCRIPTION } from "~/lib/constants";
export const metadata: Metadata = { export const metadata: Metadata = {
title: APP_NAME, title: APP_NAME,
@@ -14,13 +13,28 @@ export default async function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; 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;
const shouldUseSession = sponsorSigner || hasSeedPhrase;
let session = null;
if (shouldUseSession) {
try {
const { getSession } = await import('~/auth');
session = await getSession();
} catch (error) {
console.warn('Failed to get session:', error);
}
}
return ( return (
<html lang="en"> <html lang="en">
<body> <body>
<Providers session={session}>{children}</Providers> <Providers session={session} shouldUseSession={shouldUseSession}>
{children}
</Providers>
</body> </body>
</html> </html>
); );

View File

@@ -1,12 +1,10 @@
'use client'; 'use client';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import type { Session } from 'next-auth';
import { SessionProvider } from 'next-auth/react';
import { MiniAppProvider } from '@neynar/react'; import { MiniAppProvider } from '@neynar/react';
import { SafeFarcasterSolanaProvider } from '~/components/providers/SafeFarcasterSolanaProvider'; import { SafeFarcasterSolanaProvider } from '~/components/providers/SafeFarcasterSolanaProvider';
import { ANALYTICS_ENABLED } from '~/lib/constants'; import { ANALYTICS_ENABLED } from '~/lib/constants';
import { AuthKitProvider } from '@farcaster/auth-kit'; import React, { useState, useEffect } from 'react';
const WagmiProvider = dynamic( const WagmiProvider = dynamic(
() => import('~/components/providers/WagmiProvider'), () => import('~/components/providers/WagmiProvider'),
@@ -15,27 +13,119 @@ const WagmiProvider = dynamic(
} }
); );
// Helper component to conditionally render auth providers
function AuthProviders({
children,
session,
shouldUseSession,
}: {
children: React.ReactNode;
session: any;
shouldUseSession: boolean;
}) {
const [authComponents, setAuthComponents] = useState<{
SessionProvider: React.ComponentType<any> | null;
AuthKitProvider: React.ComponentType<any> | null;
loaded: boolean;
}>({
SessionProvider: null,
AuthKitProvider: null,
loaded: false,
});
useEffect(() => {
if (!shouldUseSession) {
setAuthComponents({
SessionProvider: null,
AuthKitProvider: null,
loaded: true,
});
return;
}
const loadAuthComponents = async () => {
try {
// Dynamic imports for auth modules
let SessionProvider = null;
let AuthKitProvider = null;
try {
const nextAuth = await import('next-auth/react');
SessionProvider = nextAuth.SessionProvider;
} catch (error) {
console.warn('NextAuth not available:', error);
}
try {
const authKit = await import('@farcaster/auth-kit');
AuthKitProvider = authKit.AuthKitProvider;
} catch (error) {
console.warn('Farcaster AuthKit not available:', error);
}
setAuthComponents({
SessionProvider,
AuthKitProvider,
loaded: true,
});
} catch (error) {
console.error('Error loading auth components:', error);
setAuthComponents({
SessionProvider: null,
AuthKitProvider: null,
loaded: true,
});
}
};
loadAuthComponents();
}, [shouldUseSession]);
if (!authComponents.loaded) {
return <></>;
}
if (!shouldUseSession || !authComponents.SessionProvider) {
return <>{children}</>;
}
const { SessionProvider, AuthKitProvider } = authComponents;
if (AuthKitProvider) {
return (
<SessionProvider session={session}>
<AuthKitProvider config={{}}>{children}</AuthKitProvider>
</SessionProvider>
);
}
return <SessionProvider session={session}>{children}</SessionProvider>;
}
export function Providers({ export function Providers({
session, session,
children, children,
shouldUseSession = false,
}: { }: {
session: Session | null; session: any | null;
children: React.ReactNode; children: React.ReactNode;
shouldUseSession?: boolean;
}) { }) {
const solanaEndpoint = const solanaEndpoint =
process.env.SOLANA_RPC_ENDPOINT || 'https://solana-rpc.publicnode.com'; process.env.SOLANA_RPC_ENDPOINT || 'https://solana-rpc.publicnode.com';
return ( return (
<SessionProvider session={session}> <WagmiProvider>
<WagmiProvider> <MiniAppProvider
<MiniAppProvider analyticsEnabled={ANALYTICS_ENABLED}
analyticsEnabled={ANALYTICS_ENABLED} backButtonEnabled={true}
backButtonEnabled={true} >
> <SafeFarcasterSolanaProvider endpoint={solanaEndpoint}>
<SafeFarcasterSolanaProvider endpoint={solanaEndpoint}> <AuthProviders session={session} shouldUseSession={shouldUseSession}>
<AuthKitProvider config={{}}>{children}</AuthKitProvider> {children}
</SafeFarcasterSolanaProvider> </AuthProviders>
</MiniAppProvider> </SafeFarcasterSolanaProvider>
</WagmiProvider> </MiniAppProvider>
</SessionProvider> </WagmiProvider>
); );
} }

View File

@@ -118,7 +118,7 @@ export function AuthDialog({
const content = getStepContent(); const content = getStepContent();
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"> <div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-md shadow-2xl border border-gray-200 dark:border-gray-700 max-h-[80vh] sm:max-h-[90vh] flex flex-col"> <div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-md shadow-2xl border border-gray-200 dark:border-gray-700 max-h-[80vh] sm:max-h-[90vh] flex flex-col">
<div className="flex justify-between items-center p-4 sm:p-6 pb-3 sm:pb-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0"> <div className="flex justify-between items-center p-4 sm:p-6 pb-3 sm:pb-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100"> <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
@@ -191,20 +191,22 @@ export function AuthDialog({
{content.showOpenButton && content.qrUrl && ( {content.showOpenButton && content.qrUrl && (
<button <button
onClick={() => onClick={() => {
window.open( if (content.qrUrl) {
content.qrUrl window.open(
.replace( content.qrUrl
'https://farcaster.xyz/', .replace(
'https://client.farcaster.xyz/deeplinks/' 'https://farcaster.xyz/',
) 'https://client.farcaster.xyz/deeplinks/'
.replace( )
'https://client.farcaster.xyz/deeplinks/', .replace(
'farcaster://' 'https://client.farcaster.xyz/deeplinks/signed-key-request',
), 'https://farcaster.xyz/~/connect'
'_blank' ),
) '_blank'
} );
}
}}
className="btn btn-outline flex items-center justify-center gap-2 w-full" className="btn btn-outline flex items-center justify-center gap-2 w-full"
> >
I&apos;m using my phone I&apos;m using my phone

View File

@@ -1,11 +1,10 @@
'use client'; 'use client';
import '@farcaster/auth-kit/styles.css'; import '@farcaster/auth-kit/styles.css';
import { useSignIn } from '@farcaster/auth-kit'; import { useSignIn, UseSignInData } from '@farcaster/auth-kit';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState, useRef } from 'react';
import { cn } from '~/lib/utils'; import { cn } from '~/lib/utils';
import { Button } from '~/components/ui/Button'; import { Button } from '~/components/ui/Button';
import { isMobile } from '~/lib/devices';
import { ProfileButton } from '~/components/ui/NeynarAuthButton/ProfileButton'; import { ProfileButton } from '~/components/ui/NeynarAuthButton/ProfileButton';
import { AuthDialog } from '~/components/ui/NeynarAuthButton/AuthDialog'; import { AuthDialog } from '~/components/ui/NeynarAuthButton/AuthDialog';
import { getItem, removeItem, setItem } from '~/lib/localStorage'; import { getItem, removeItem, setItem } from '~/lib/localStorage';
@@ -113,6 +112,8 @@ export function NeynarAuthButton() {
); );
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
const [signature, setSignature] = useState<string | null>(null); const [signature, setSignature] = useState<string | null>(null);
const [isSignerFlowRunning, setIsSignerFlowRunning] = useState(false);
const signerFlowStartedRef = useRef(false);
// Determine which flow to use based on context // Determine which flow to use based on context
const useBackendFlow = context !== undefined; const useBackendFlow = context !== undefined;
@@ -290,14 +291,46 @@ export function NeynarAuthButton() {
// Helper function to poll signer status // Helper function to poll signer status
const startPolling = useCallback( const startPolling = useCallback(
(signerUuid: string, message: string, signature: string) => { (signerUuid: string, message: string, signature: string) => {
// Clear any existing polling interval before starting a new one
if (pollingInterval) {
clearInterval(pollingInterval);
}
let retryCount = 0;
const maxRetries = 10; // Maximum 10 retries (20 seconds total)
const maxPollingTime = 60000; // Maximum 60 seconds of polling
const startTime = Date.now();
const interval = setInterval(async () => { const interval = setInterval(async () => {
// Check if we've been polling too long
if (Date.now() - startTime > maxPollingTime) {
clearInterval(interval);
setPollingInterval(null);
return;
}
try { try {
const response = await fetch( const response = await fetch(
`/api/auth/signer?signerUuid=${signerUuid}` `/api/auth/signer?signerUuid=${signerUuid}`
); );
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to poll signer status'); // Check if it's a rate limit error
if (response.status === 429) {
clearInterval(interval);
setPollingInterval(null);
return;
}
// Increment retry count for other errors
retryCount++;
if (retryCount >= maxRetries) {
clearInterval(interval);
setPollingInterval(null);
return;
}
throw new Error(`Failed to poll signer status: ${response.status}`);
} }
const signerData = await response.json(); const signerData = await response.json();
@@ -319,7 +352,7 @@ export function NeynarAuthButton() {
setPollingInterval(interval); setPollingInterval(interval);
}, },
[fetchAllSigners] [fetchAllSigners, pollingInterval]
); );
// Cleanup polling on unmount // Cleanup polling on unmount
@@ -328,6 +361,7 @@ export function NeynarAuthButton() {
if (pollingInterval) { if (pollingInterval) {
clearInterval(pollingInterval); clearInterval(pollingInterval);
} }
signerFlowStartedRef.current = false;
}; };
}, [pollingInterval]); }, [pollingInterval]);
@@ -362,11 +396,11 @@ export function NeynarAuthButton() {
// Success callback - this is critical! // Success callback - this is critical!
const onSuccessCallback = useCallback( const onSuccessCallback = useCallback(
async (res: unknown) => { async (res: UseSignInData) => {
if (!useBackendFlow) { if (!useBackendFlow) {
// Only handle localStorage for frontend flow // Only handle localStorage for frontend flow
const existingAuth = getItem<StoredAuthState>(STORAGE_KEY); const existingAuth = getItem<StoredAuthState>(STORAGE_KEY);
const user = await fetchUserData(res.fid); const user = res.fid ? await fetchUserData(res.fid) : null;
const authState: StoredAuthState = { const authState: StoredAuthState = {
...existingAuth, ...existingAuth,
isAuthenticated: true, isAuthenticated: true,
@@ -409,6 +443,11 @@ export function NeynarAuthButton() {
useEffect(() => { useEffect(() => {
setMessage(data?.message || null); setMessage(data?.message || null);
setSignature(data?.signature || null); setSignature(data?.signature || null);
// Reset the signer flow flag when message/signature change
if (data?.message && data?.signature) {
signerFlowStartedRef.current = false;
}
}, [data?.message, data?.signature]); }, [data?.message, data?.signature]);
// Connect for frontend flow when nonce is available // Connect for frontend flow when nonce is available
@@ -420,8 +459,16 @@ export function NeynarAuthButton() {
// Handle fetching signers after successful authentication // Handle fetching signers after successful authentication
useEffect(() => { useEffect(() => {
if (message && signature) { if (
message &&
signature &&
!isSignerFlowRunning &&
!signerFlowStartedRef.current
) {
signerFlowStartedRef.current = true;
const handleSignerFlow = async () => { const handleSignerFlow = async () => {
setIsSignerFlowRunning(true);
try { try {
const clientContext = context?.client as Record<string, unknown>; const clientContext = context?.client as Record<string, unknown>;
const isMobileContext = const isMobileContext =
@@ -437,6 +484,7 @@ export function NeynarAuthButton() {
// First, fetch existing signers // First, fetch existing signers
const signers = await fetchAllSigners(message, signature); const signers = await fetchAllSigners(message, signature);
if (useBackendFlow && isMobileContext) setSignersLoading(true); if (useBackendFlow && isMobileContext) setSignersLoading(true);
// Check if no signers exist or if we have empty signers // Check if no signers exist or if we have empty signers
@@ -457,8 +505,8 @@ export function NeynarAuthButton() {
setShowDialog(false); setShowDialog(false);
await sdk.actions.openUrl( await sdk.actions.openUrl(
signedKeyData.signer_approval_url.replace( signedKeyData.signer_approval_url.replace(
'https://client.farcaster.xyz/deeplinks/', 'https://client.farcaster.xyz/deeplinks/signed-key-request',
'farcaster://' 'https://farcaster.xyz/~/connect'
) )
); );
} else { } else {
@@ -481,21 +529,14 @@ export function NeynarAuthButton() {
setSignersLoading(false); setSignersLoading(false);
setShowDialog(false); setShowDialog(false);
setSignerApprovalUrl(null); setSignerApprovalUrl(null);
} finally {
setIsSignerFlowRunning(false);
} }
}; };
handleSignerFlow(); handleSignerFlow();
} }
}, [ }, [message, signature]); // Simplified dependencies
message,
signature,
fetchAllSigners,
createSigner,
generateSignedKeyRequest,
startPolling,
context,
useBackendFlow,
]);
// Backend flow using NextAuth // Backend flow using NextAuth
const handleBackendSignIn = useCallback(async () => { const handleBackendSignIn = useCallback(async () => {
@@ -568,6 +609,9 @@ export function NeynarAuthButton() {
clearInterval(pollingInterval); clearInterval(pollingInterval);
setPollingInterval(null); setPollingInterval(null);
} }
// Reset signer flow flag
signerFlowStartedRef.current = false;
} catch (error) { } catch (error) {
console.error('❌ Error during sign out:', error); console.error('❌ Error during sign out:', error);
// Optionally handle error state // Optionally handle error state

View File

@@ -4,6 +4,7 @@ import { useCallback, useState, useEffect } from 'react';
import { Button } from './Button'; import { Button } from './Button';
import { useMiniApp } from '@neynar/react'; import { useMiniApp } from '@neynar/react';
import { type ComposeCast } from "@farcaster/miniapp-sdk"; import { type ComposeCast } from "@farcaster/miniapp-sdk";
import { APP_URL } from '~/lib/constants';
interface EmbedConfig { interface EmbedConfig {
path?: string; path?: string;
@@ -72,7 +73,7 @@ export function ShareButton({ buttonText, cast, className = '', isLoading = fals
return embed; return embed;
} }
if (embed.path) { if (embed.path) {
const baseUrl = process.env.NEXT_PUBLIC_URL || window.location.origin; const baseUrl = APP_URL || window.location.origin;
const url = new URL(`${baseUrl}${embed.path}`); const url = new URL(`${baseUrl}${embed.path}`);
// Add UTM parameters // Add UTM parameters

View File

@@ -1,12 +1,22 @@
'use client'; 'use client';
import { useCallback, useState } from "react"; import dynamic from 'next/dynamic';
import { useMiniApp } from "@neynar/react"; import { useCallback, useState } from 'react';
import { ShareButton } from "../Share"; import { useMiniApp } from '@neynar/react';
import { Button } from "../Button"; import { ShareButton } from '../Share';
import { SignIn } from "../wallet/SignIn"; import { Button } from '../Button';
import { type Haptics } from "@farcaster/miniapp-sdk"; import { SignIn } from '../wallet/SignIn';
import { NeynarAuthButton } from '../NeynarAuthButton/index'; import { type Haptics } from '@farcaster/miniapp-sdk';
import { APP_URL } from '~/lib/constants';
// Import NeynarAuthButton
const NeynarAuthButton = dynamic(
() =>
import('../NeynarAuthButton').then((module) => ({
default: module.NeynarAuthButton,
})),
{ ssr: false }
);
/** /**
* ActionsTab component handles mini app actions like sharing, notifications, and haptic feedback. * ActionsTab component handles mini app actions like sharing, notifications, and haptic feedback.
@@ -96,7 +106,7 @@ export function ActionsTab() {
*/ */
const copyUserShareUrl = useCallback(async () => { const copyUserShareUrl = useCallback(async () => {
if (context?.user?.fid) { if (context?.user?.fid) {
const userShareUrl = `${process.env.NEXT_PUBLIC_URL}/share/${context.user.fid}`; const userShareUrl = `${APP_URL}/share/${context.user.fid}`;
await navigator.clipboard.writeText(userShareUrl); await navigator.clipboard.writeText(userShareUrl);
setNotificationState((prev) => ({ ...prev, shareUrlCopied: true })); setNotificationState((prev) => ({ ...prev, shareUrlCopied: true }));
setTimeout( setTimeout(
@@ -123,50 +133,48 @@ export function ActionsTab() {
// --- Render --- // --- Render ---
return ( return (
<div className='space-y-3 px-6 w-full max-w-md mx-auto'> <div className="space-y-3 px-6 w-full max-w-md mx-auto">
{/* Share functionality */} {/* Share functionality */}
<ShareButton <ShareButton
buttonText='Share Mini App' buttonText="Share Mini App"
cast={{ cast={{
text: 'Check out this awesome frame @1 @2 @3! 🚀🪐', text: 'Check out this awesome frame @1 @2 @3! 🚀🪐',
bestFriends: true, bestFriends: true,
embeds: [ embeds: [`${APP_URL}/share/${context?.user?.fid || ''}`],
`${process.env.NEXT_PUBLIC_URL}/share/${context?.user?.fid || ''}`,
],
}} }}
className='w-full' className="w-full"
/> />
{/* Authentication */} {/* Authentication */}
<SignIn /> <SignIn />
{/* Neynar Authentication */} {/* Neynar Authentication */}
<NeynarAuthButton /> {NeynarAuthButton && <NeynarAuthButton />}
{/* Mini app actions */} {/* Mini app actions */}
<Button <Button
onClick={() => onClick={() =>
actions.openUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ') actions.openUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
} }
className='w-full' className="w-full"
> >
Open Link Open Link
</Button> </Button>
<Button onClick={actions.addMiniApp} disabled={added} className='w-full'> <Button onClick={actions.addMiniApp} disabled={added} className="w-full">
Add Mini App to Client Add Mini App to Client
</Button> </Button>
{/* Notification functionality */} {/* Notification functionality */}
{notificationState.sendStatus && ( {notificationState.sendStatus && (
<div className='text-sm w-full'> <div className="text-sm w-full">
Send notification result: {notificationState.sendStatus} Send notification result: {notificationState.sendStatus}
</div> </div>
)} )}
<Button <Button
onClick={sendFarcasterNotification} onClick={sendFarcasterNotification}
disabled={!notificationDetails} disabled={!notificationDetails}
className='w-full' className="w-full"
> >
Send notification Send notification
</Button> </Button>
@@ -175,14 +183,14 @@ export function ActionsTab() {
<Button <Button
onClick={copyUserShareUrl} onClick={copyUserShareUrl}
disabled={!context?.user?.fid} disabled={!context?.user?.fid}
className='w-full' className="w-full"
> >
{notificationState.shareUrlCopied ? 'Copied!' : 'Copy share URL'} {notificationState.shareUrlCopied ? 'Copied!' : 'Copy share URL'}
</Button> </Button>
{/* Haptic feedback controls */} {/* Haptic feedback controls */}
<div className='space-y-2'> <div className="space-y-2">
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300'> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Haptic Intensity Haptic Intensity
</label> </label>
<select <select
@@ -192,7 +200,7 @@ export function ActionsTab() {
e.target.value as Haptics.ImpactOccurredType e.target.value as Haptics.ImpactOccurredType
) )
} }
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-primary' className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-primary"
> >
<option value={'light'}>Light</option> <option value={'light'}>Light</option>
<option value={'medium'}>Medium</option> <option value={'medium'}>Medium</option>
@@ -200,7 +208,7 @@ export function ActionsTab() {
<option value={'soft'}>Soft</option> <option value={'soft'}>Soft</option>
<option value={'rigid'}>Rigid</option> <option value={'rigid'}>Rigid</option>
</select> </select>
<Button onClick={triggerHapticFeedback} className='w-full'> <Button onClick={triggerHapticFeedback} className="w-full">
Trigger Haptic Feedback Trigger Haptic Feedback
</Button> </Button>
</div> </div>

View File

@@ -1,22 +1,20 @@
'use client'; 'use client';
import { useCallback, useState } from "react"; import { useCallback, useState } from 'react';
import { signIn, signOut, getCsrfToken } from "next-auth/react"; import { SignIn as SignInCore } from '@farcaster/miniapp-sdk';
import sdk, { SignIn as SignInCore } from "@farcaster/miniapp-sdk"; import { useQuickAuth } from '~/hooks/useQuickAuth';
import { useSession } from "next-auth/react"; import { Button } from '../Button';
import { Button } from "../Button";
/** /**
* SignIn component handles Farcaster authentication using Sign-In with Farcaster (SIWF). * SignIn component handles Farcaster authentication using QuickAuth.
* *
* This component provides a complete authentication flow for Farcaster users: * This component provides a complete authentication flow for Farcaster users:
* - Generates nonces for secure authentication * - Uses the built-in QuickAuth functionality from the Farcaster SDK
* - Handles the SIWF flow using the Farcaster SDK * - Manages authentication state in memory (no persistence)
* - Manages NextAuth session state
* - Provides sign-out functionality * - Provides sign-out functionality
* - Displays authentication status and results * - Displays authentication status and results
* *
* The component integrates with both the Farcaster Frame SDK and NextAuth * The component integrates with the Farcaster Frame SDK and QuickAuth
* to provide seamless authentication within mini apps. * to provide seamless authentication within mini apps.
* *
* @example * @example
@@ -36,52 +34,32 @@ export function SignIn() {
signingIn: false, signingIn: false,
signingOut: false, signingOut: false,
}); });
const [signInResult, setSignInResult] = useState<SignInCore.SignInResult>();
const [signInFailure, setSignInFailure] = useState<string>(); const [signInFailure, setSignInFailure] = useState<string>();
// --- Hooks --- // --- Hooks ---
const { data: session, status } = useSession(); const { authenticatedUser, status, signIn, signOut } = useQuickAuth();
// --- Handlers --- // --- Handlers ---
/** /**
* Generates a nonce for the sign-in process. * Handles the sign-in process using QuickAuth.
* *
* This function retrieves a CSRF token from NextAuth to use as a nonce * This function uses the built-in QuickAuth functionality:
* for the SIWF authentication flow. The nonce ensures the authentication * 1. Gets a token from QuickAuth (handles SIWF flow automatically)
* request is fresh and prevents replay attacks. * 2. Validates the token with our server
* * 3. Updates the session state
* @returns Promise<string> - The generated nonce token
* @throws Error if unable to generate nonce
*/
const getNonce = useCallback(async () => {
const nonce = await getCsrfToken();
if (!nonce) throw new Error('Unable to generate nonce');
return nonce;
}, []);
/**
* Handles the sign-in process using Farcaster SDK.
*
* This function orchestrates the complete SIWF flow:
* 1. Generates a nonce for security
* 2. Calls the Farcaster SDK to initiate sign-in
* 3. Submits the result to NextAuth for session management
* 4. Handles various error conditions including user rejection
* *
* @returns Promise<void> * @returns Promise<void>
*/ */
const handleSignIn = useCallback(async () => { const handleSignIn = useCallback(async () => {
try { try {
setAuthState((prev) => ({ ...prev, signingIn: true })); setAuthState(prev => ({ ...prev, signingIn: true }));
setSignInFailure(undefined); setSignInFailure(undefined);
const nonce = await getNonce();
const result = await sdk.actions.signIn({ nonce }); const success = await signIn();
setSignInResult(result);
await signIn('farcaster', { if (!success) {
message: result.message, setSignInFailure('Authentication failed');
signature: result.signature, }
redirect: false,
});
} catch (e) { } catch (e) {
if (e instanceof SignInCore.RejectedByUser) { if (e instanceof SignInCore.RejectedByUser) {
setSignInFailure('Rejected by user'); setSignInFailure('Rejected by user');
@@ -89,52 +67,49 @@ export function SignIn() {
} }
setSignInFailure('Unknown error'); setSignInFailure('Unknown error');
} finally { } finally {
setAuthState((prev) => ({ ...prev, signingIn: false })); setAuthState(prev => ({ ...prev, signingIn: false }));
} }
}, [getNonce]); }, [signIn]);
/** /**
* Handles the sign-out process. * Handles the sign-out process.
* *
* This function clears the NextAuth session only if the current session * This function clears the QuickAuth session and resets the local state.
* is using the Farcaster provider, and resets the local sign-in result state.
* *
* @returns Promise<void> * @returns Promise<void>
*/ */
const handleSignOut = useCallback(async () => { const handleSignOut = useCallback(async () => {
try { try {
setAuthState((prev) => ({ ...prev, signingOut: true })); setAuthState(prev => ({ ...prev, signingOut: true }));
// Only sign out if the current session is from Farcaster provider await signOut();
if (session?.provider === 'farcaster') {
await signOut({ redirect: false });
}
setSignInResult(undefined);
} finally { } finally {
setAuthState((prev) => ({ ...prev, signingOut: false })); setAuthState(prev => ({ ...prev, signingOut: false }));
} }
}, [session]); }, [signOut]);
// --- Render --- // --- Render ---
return ( return (
<> <>
{/* Authentication Buttons */} {/* Authentication Buttons */}
{(status !== 'authenticated' || session?.provider !== 'farcaster') && ( {status !== 'authenticated' && (
<Button onClick={handleSignIn} disabled={authState.signingIn}> <Button onClick={handleSignIn} disabled={authState.signingIn}>
Sign In with Farcaster Sign In with Farcaster
</Button> </Button>
)} )}
{status === 'authenticated' && session?.provider === 'farcaster' && ( {status === 'authenticated' && (
<Button onClick={handleSignOut} disabled={authState.signingOut}> <Button onClick={handleSignOut} disabled={authState.signingOut}>
Sign out Sign out
</Button> </Button>
)} )}
{/* Session Information */} {/* Session Information */}
{session && ( {authenticatedUser && (
<div className="my-2 p-2 text-xs overflow-x-scroll bg-gray-100 dark:bg-gray-900 rounded-lg font-mono"> <div className="my-2 p-2 text-xs overflow-x-scroll bg-gray-100 dark:bg-gray-900 rounded-lg font-mono">
<div className="font-semibold text-gray-500 dark:text-gray-300 mb-1">Session</div> <div className="font-semibold text-gray-500 dark:text-gray-300 mb-1">
Authenticated User
</div>
<div className="whitespace-pre text-gray-700 dark:text-gray-200"> <div className="whitespace-pre text-gray-700 dark:text-gray-200">
{JSON.stringify(session, null, 2)} {JSON.stringify(authenticatedUser, null, 2)}
</div> </div>
</div> </div>
)} )}
@@ -142,20 +117,14 @@ export function SignIn() {
{/* Error Display */} {/* Error Display */}
{signInFailure && !authState.signingIn && ( {signInFailure && !authState.signingIn && (
<div className="my-2 p-2 text-xs overflow-x-scroll bg-gray-100 dark:bg-gray-900 rounded-lg font-mono"> <div className="my-2 p-2 text-xs overflow-x-scroll bg-gray-100 dark:bg-gray-900 rounded-lg font-mono">
<div className="font-semibold text-gray-500 dark:text-gray-300 mb-1">SIWF Result</div> <div className="font-semibold text-gray-500 dark:text-gray-300 mb-1">
<div className="whitespace-pre text-gray-700 dark:text-gray-200">{signInFailure}</div> Authentication Error
</div> </div>
)}
{/* Success Result Display */}
{signInResult && !authState.signingIn && (
<div className="my-2 p-2 text-xs overflow-x-scroll bg-gray-100 dark:bg-gray-900 rounded-lg font-mono">
<div className="font-semibold text-gray-500 dark:text-gray-300 mb-1">SIWF Result</div>
<div className="whitespace-pre text-gray-700 dark:text-gray-200"> <div className="whitespace-pre text-gray-700 dark:text-gray-200">
{JSON.stringify(signInResult, null, 2)} {signInFailure}
</div> </div>
</div> </div>
)} )}
</> </>
); );
} }

207
src/hooks/useQuickAuth.ts Normal file
View File

@@ -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<boolean>;
/** Function to sign out and clear the current authentication state */
signOut: () => Promise<void>;
/** Function to retrieve the current authentication token */
getToken: () => Promise<string | null>;
}
/**
* 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 <div>Loading...</div>;
* if (status === 'unauthenticated') return <button onClick={signIn}>Sign In</button>;
*
* return (
* <div>
* <p>Welcome, FID: {authenticatedUser?.fid}</p>
* <button onClick={signOut}>Sign Out</button>
* </div>
* );
* ```
*/
export function useQuickAuth(): UseQuickAuthReturn {
// Current authenticated user data
const [authenticatedUser, setAuthenticatedUser] =
useState<AuthenticatedUser | null>(null);
// Current authentication status
const [status, setStatus] = useState<QuickAuthStatus>('loading');
/**
* Validates a QuickAuth token with the server-side API
*
* @param {string} authToken - The JWT token to validate
* @returns {Promise<AuthenticatedUser | null>} User data if valid, null otherwise
*/
const validateTokenWithServer = async (
authToken: string,
): Promise<AuthenticatedUser | null> => {
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<boolean>} True if sign-in was successful, false otherwise
*/
const signIn = useCallback(async (): Promise<boolean> => {
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<void> => {
// Clear local user state
setAuthenticatedUser(null);
setStatus('unauthenticated');
}, []);
/**
* Retrieves the current authentication token from QuickAuth
*
* @returns {Promise<string | null>} The current auth token, or null if not authenticated
*/
const getToken = useCallback(async (): Promise<string | null> => {
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,
};
}

View File

@@ -1,3 +1,5 @@
import { type AccountAssociation } from '@farcaster/miniapp-core/src/manifest';
/** /**
* Application constants and configuration values. * Application constants and configuration values.
* *
@@ -14,56 +16,64 @@
* The base URL of the application. * The base URL of the application.
* Used for generating absolute URLs for assets and API endpoints. * Used for generating absolute URLs for assets and API endpoints.
*/ */
export const APP_URL = process.env.NEXT_PUBLIC_URL!; export const APP_URL: string = process.env.NEXT_PUBLIC_URL!;
/** /**
* The name of the mini app as displayed to users. * The name of the mini app as displayed to users.
* Used in titles, headers, and app store listings. * Used in titles, headers, and app store listings.
*/ */
export const APP_NAME = 'shreyas-testing-mini-app'; export const APP_NAME: string = 'Starter Kit';
/** /**
* A brief description of the mini app's functionality. * A brief description of the mini app's functionality.
* Used in app store listings and metadata. * Used in app store listings and metadata.
*/ */
export const APP_DESCRIPTION = 'A Farcaster mini app created with Neynar'; export const APP_DESCRIPTION: string = 'A demo of the Neynar Starter Kit';
/** /**
* The primary category for the mini app. * The primary category for the mini app.
* Used for app store categorization and discovery. * Used for app store categorization and discovery.
*/ */
export const APP_PRIMARY_CATEGORY = ''; export const APP_PRIMARY_CATEGORY: string = 'developer-tools';
/** /**
* Tags associated with the mini app. * Tags associated with the mini app.
* Used for search and discovery in app stores. * Used for search and discovery in app stores.
*/ */
export const APP_TAGS = ['neynar', 'starter-kit', 'demo']; export const APP_TAGS: string[] = ['neynar', 'starter-kit', 'demo'];
// --- Asset URLs --- // --- Asset URLs ---
/** /**
* URL for the app's icon image. * URL for the app's icon image.
* Used in app store listings and UI elements. * Used in app store listings and UI elements.
*/ */
export const APP_ICON_URL = `${APP_URL}/icon.png`; export const APP_ICON_URL: string = `${APP_URL}/icon.png`;
/** /**
* URL for the app's Open Graph image. * URL for the app's Open Graph image.
* Used for social media sharing and previews. * Used for social media sharing and previews.
*/ */
export const APP_OG_IMAGE_URL = `${APP_URL}/api/opengraph-image`; export const APP_OG_IMAGE_URL: string = `${APP_URL}/api/opengraph-image`;
/** /**
* URL for the app's splash screen image. * URL for the app's splash screen image.
* Displayed during app loading. * Displayed during app loading.
*/ */
export const APP_SPLASH_URL = `${APP_URL}/splash.png`; export const APP_SPLASH_URL: string = `${APP_URL}/splash.png`;
/** /**
* Background color for the splash screen. * Background color for the splash screen.
* Used as fallback when splash image is loading. * Used as fallback when splash image is loading.
*/ */
export const APP_SPLASH_BACKGROUND_COLOR = '#f7f7f7'; export const APP_SPLASH_BACKGROUND_COLOR: string = '#f7f7f7';
/**
* Account association for the mini app.
* Used to associate the mini app with a Farcaster account.
* If not provided, the mini app will be unsigned and have limited capabilities.
*/
export const APP_ACCOUNT_ASSOCIATION: AccountAssociation | undefined =
undefined;
// --- UI Configuration --- // --- UI Configuration ---
/** /**
@@ -80,7 +90,7 @@ export const APP_BUTTON_TEXT = 'Launch Mini App';
* Neynar webhook endpoint. Otherwise, falls back to a local webhook * Neynar webhook endpoint. Otherwise, falls back to a local webhook
* endpoint for development and testing. * endpoint for development and testing.
*/ */
export const APP_WEBHOOK_URL = export const APP_WEBHOOK_URL: string =
process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID
? `https://api.neynar.com/f/app/${process.env.NEYNAR_CLIENT_ID}/event` ? `https://api.neynar.com/f/app/${process.env.NEYNAR_CLIENT_ID}/event`
: `${APP_URL}/api/webhook`; : `${APP_URL}/api/webhook`;
@@ -92,7 +102,7 @@ export const APP_WEBHOOK_URL =
* When false, wallet functionality is completely hidden from the UI. * When false, wallet functionality is completely hidden from the UI.
* Useful for mini apps that don't require wallet integration. * Useful for mini apps that don't require wallet integration.
*/ */
export const USE_WALLET = true; export const USE_WALLET = false;
/** /**
* Flag to enable/disable analytics tracking. * Flag to enable/disable analytics tracking.
@@ -103,6 +113,18 @@ export const USE_WALLET = true;
*/ */
export const ANALYTICS_ENABLED = true; export const ANALYTICS_ENABLED = true;
/**
* Required chains for the mini app.
*
* Contains an array of CAIP-2 identifiers for blockchains that the mini app requires.
* If the host does not support all chains listed here, it will not render the mini app.
* If empty or undefined, the mini app will be rendered regardless of chain support.
*
* Supported chains: eip155:1, eip155:137, eip155:42161, eip155:10, eip155:8453,
* solana:mainnet, solana:devnet
*/
export const APP_REQUIRED_CHAINS: string[] = [];
// PLEASE DO NOT UPDATE THIS // PLEASE DO NOT UPDATE THIS
export const SIGNED_KEY_REQUEST_VALIDATOR_EIP_712_DOMAIN = { export const SIGNED_KEY_REQUEST_VALIDATOR_EIP_712_DOMAIN = {
name: 'Farcaster SignedKeyRequestValidator', name: 'Farcaster SignedKeyRequestValidator',

View File

@@ -1,16 +1,18 @@
import { FrameNotificationDetails } from "@farcaster/miniapp-sdk"; import { MiniAppNotificationDetails } from '@farcaster/miniapp-sdk';
import { Redis } from "@upstash/redis"; import { Redis } from '@upstash/redis';
import { APP_NAME } from "./constants"; import { APP_NAME } from './constants';
// In-memory fallback storage // In-memory fallback storage
const localStore = new Map<string, FrameNotificationDetails>(); const localStore = new Map<string, MiniAppNotificationDetails>();
// Use Redis if KV env vars are present, otherwise use in-memory // Use Redis if KV env vars are present, otherwise use in-memory
const useRedis = process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN; const useRedis = process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN;
const redis = useRedis ? new Redis({ const redis = useRedis
url: process.env.KV_REST_API_URL!, ? new Redis({
token: process.env.KV_REST_API_TOKEN!, url: process.env.KV_REST_API_URL!,
}) : null; token: process.env.KV_REST_API_TOKEN!,
})
: null;
function getUserNotificationDetailsKey(fid: number): string { function getUserNotificationDetailsKey(fid: number): string {
return `${APP_NAME}:user:${fid}`; return `${APP_NAME}:user:${fid}`;
@@ -18,17 +20,17 @@ function getUserNotificationDetailsKey(fid: number): string {
export async function getUserNotificationDetails( export async function getUserNotificationDetails(
fid: number fid: number
): Promise<FrameNotificationDetails | null> { ): Promise<MiniAppNotificationDetails | null> {
const key = getUserNotificationDetailsKey(fid); const key = getUserNotificationDetailsKey(fid);
if (redis) { if (redis) {
return await redis.get<FrameNotificationDetails>(key); return await redis.get<MiniAppNotificationDetails>(key);
} }
return localStore.get(key) || null; return localStore.get(key) || null;
} }
export async function setUserNotificationDetails( export async function setUserNotificationDetails(
fid: number, fid: number,
notificationDetails: FrameNotificationDetails notificationDetails: MiniAppNotificationDetails
): Promise<void> { ): Promise<void> {
const key = getUserNotificationDetailsKey(fid); const key = getUserNotificationDetailsKey(fid);
if (redis) { if (redis) {

View File

@@ -1,6 +1,6 @@
import { type ClassValue, clsx } from "clsx"; import { type ClassValue, clsx } from 'clsx';
import { twMerge } from "tailwind-merge"; import { twMerge } from 'tailwind-merge';
import { mnemonicToAccount } from "viem/accounts"; import { Manifest } from '@farcaster/miniapp-core/src/manifest';
import { import {
APP_BUTTON_TEXT, APP_BUTTON_TEXT,
APP_DESCRIPTION, APP_DESCRIPTION,
@@ -9,35 +9,12 @@ import {
APP_OG_IMAGE_URL, APP_OG_IMAGE_URL,
APP_PRIMARY_CATEGORY, APP_PRIMARY_CATEGORY,
APP_SPLASH_BACKGROUND_COLOR, APP_SPLASH_BACKGROUND_COLOR,
APP_SPLASH_URL,
APP_TAGS, APP_TAGS,
APP_URL, APP_URL,
APP_WEBHOOK_URL, APP_WEBHOOK_URL,
} from "./constants"; APP_ACCOUNT_ASSOCIATION,
import { APP_SPLASH_URL } from "./constants"; } from './constants';
interface MiniAppMetadata {
version: string;
name: string;
iconUrl: string;
homeUrl: string;
imageUrl?: string;
buttonTitle?: string;
splashImageUrl?: string;
splashBackgroundColor?: string;
webhookUrl?: string;
description?: string;
primaryCategory?: string;
tags?: string[];
}
interface MiniAppManifest {
accountAssociation?: {
header: string;
payload: string;
signature: string;
};
frame: MiniAppMetadata;
}
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
@@ -45,12 +22,15 @@ export function cn(...inputs: ClassValue[]) {
export function getMiniAppEmbedMetadata(ogImageUrl?: string) { export function getMiniAppEmbedMetadata(ogImageUrl?: string) {
return { return {
version: "next", version: 'next',
imageUrl: ogImageUrl ?? APP_OG_IMAGE_URL, imageUrl: ogImageUrl ?? APP_OG_IMAGE_URL,
ogTitle: APP_NAME,
ogDescription: APP_DESCRIPTION,
ogImageUrl: ogImageUrl ?? APP_OG_IMAGE_URL,
button: { button: {
title: APP_BUTTON_TEXT, title: APP_BUTTON_TEXT,
action: { action: {
type: "launch_frame", type: 'launch_frame',
name: APP_NAME, name: APP_NAME,
url: APP_URL, url: APP_URL,
splashImageUrl: APP_SPLASH_URL, splashImageUrl: APP_SPLASH_URL,
@@ -64,48 +44,19 @@ export function getMiniAppEmbedMetadata(ogImageUrl?: string) {
}; };
} }
export async function getFarcasterMetadata(): Promise<MiniAppManifest> { export async function getFarcasterDomainManifest(): Promise<Manifest> {
// First check for MINI_APP_METADATA in .env and use that if it exists
if (process.env.MINI_APP_METADATA) {
try {
const metadata = JSON.parse(process.env.MINI_APP_METADATA);
console.log("Using pre-signed mini app metadata from environment");
return metadata;
} catch (error) {
console.warn(
"Failed to parse MINI_APP_METADATA from environment:",
error
);
}
}
if (!APP_URL) {
throw new Error("NEXT_PUBLIC_URL not configured");
}
// Get the domain from the URL (without https:// prefix)
const domain = new URL(APP_URL).hostname;
console.log("Using domain for manifest:", domain);
return { return {
accountAssociation: { accountAssociation: APP_ACCOUNT_ASSOCIATION!,
header: "", miniapp: {
payload: "", version: '1',
signature: "", name: APP_NAME ?? 'Neynar Starter Kit',
},
frame: {
version: "1",
name: APP_NAME ?? "Neynar Starter Kit",
iconUrl: APP_ICON_URL,
homeUrl: APP_URL, homeUrl: APP_URL,
iconUrl: APP_ICON_URL,
imageUrl: APP_OG_IMAGE_URL, imageUrl: APP_OG_IMAGE_URL,
buttonTitle: APP_BUTTON_TEXT ?? "Launch Mini App", buttonTitle: APP_BUTTON_TEXT ?? 'Launch Mini App',
splashImageUrl: APP_SPLASH_URL, splashImageUrl: APP_SPLASH_URL,
splashBackgroundColor: APP_SPLASH_BACKGROUND_COLOR, splashBackgroundColor: APP_SPLASH_BACKGROUND_COLOR,
webhookUrl: APP_WEBHOOK_URL, webhookUrl: APP_WEBHOOK_URL,
description: APP_DESCRIPTION,
primaryCategory: APP_PRIMARY_CATEGORY,
tags: APP_TAGS,
}, },
}; };
} }

View File

@@ -22,6 +22,13 @@
"~/*": ["./src/*"] "~/*": ["./src/*"]
} }
}, },
"ts-node": {
"esm": true,
"compilerOptions": {
"module": "ES2020",
"moduleResolution": "node"
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }