mirror of
https://github.com/neynarxyz/create-farcaster-mini-app.git
synced 2025-12-11 03:42:32 -05:00
Compare commits
72 Commits
shreyas/sh
...
shreyas/ne
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20c6113b8f | ||
|
|
343d64a77c | ||
|
|
c5ca1618ed | ||
|
|
db735d7568 | ||
|
|
ccd27f53b3 | ||
|
|
f14493e35b | ||
|
|
3af6ee0e71 | ||
|
|
54646a5035 | ||
|
|
be7d6b76ae | ||
|
|
09ef2e374e | ||
|
|
1cce5835d4 | ||
|
|
55c7c4b129 | ||
|
|
055dc4adbd | ||
|
|
98579bcea9 | ||
|
|
ea7ee37e71 | ||
|
|
feb9f3e161 | ||
|
|
8c5b8d8329 | ||
|
|
d0f8c75a2e | ||
|
|
e115520aa7 | ||
|
|
7dff4cd81a | ||
|
|
d1ec161f47 | ||
|
|
572ab9aa44 | ||
|
|
501bc84512 | ||
|
|
85b39a6397 | ||
|
|
61df6d6a64 | ||
|
|
9ee370628d | ||
|
|
882e4f166f | ||
|
|
e8fa822638 | ||
|
|
bade04b785 | ||
|
|
d9c74f163b | ||
|
|
2edd1bd2ae | ||
|
|
76ad200a22 | ||
|
|
86b79e7f3f | ||
|
|
aac3a739cd | ||
|
|
f20f65f966 | ||
|
|
a287d55641 | ||
|
|
53d6ce6a94 | ||
|
|
ebb8068ade | ||
|
|
8124fe5f6c | ||
|
|
9ea40a5f92 | ||
|
|
e61bc88aaa | ||
|
|
8eabbd3ba1 | ||
|
|
f7392dc3cb | ||
|
|
bf563154ca | ||
|
|
196378daeb | ||
|
|
89f82253ca | ||
|
|
c786cabe84 | ||
|
|
760efdb96b | ||
|
|
5fd0e21532 | ||
|
|
0d43b35c28 | ||
|
|
349cdea489 | ||
|
|
181c364de4 | ||
|
|
78626c2dc7 | ||
|
|
b72198575a | ||
|
|
b1fdfc19a9 | ||
|
|
4ba9480832 | ||
|
|
378ea65154 | ||
|
|
b366d97b53 | ||
|
|
16c433a13c | ||
|
|
b9e2087bd8 | ||
|
|
86029b2bd9 | ||
|
|
5fbd9c5c09 | ||
|
|
3815f45b44 | ||
|
|
c713d53054 | ||
|
|
00fdd21044 | ||
|
|
8475b28107 | ||
|
|
e74b2581df | ||
|
|
505aa54b16 | ||
|
|
65419a76ee | ||
|
|
6b0350d477 | ||
|
|
2a1a3d7c40 | ||
|
|
193dffe03a |
@@ -4,8 +4,3 @@ KV_REST_API_TOKEN=''
|
||||
KV_REST_API_URL=''
|
||||
NEXT_PUBLIC_URL='http://localhost:3000'
|
||||
NEXTAUTH_URL='http://localhost:3000'
|
||||
|
||||
NEXTAUTH_SECRET=""
|
||||
NEYNAR_API_KEY=""
|
||||
NEYNAR_CLIENT_ID=""
|
||||
USE_TUNNEL="false"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
45
bin/index.js
45
bin/index.js
@@ -7,6 +7,11 @@ const args = process.argv.slice(2);
|
||||
let projectName = null;
|
||||
let autoAcceptDefaults = false;
|
||||
let apiKey = null;
|
||||
let noWallet = false;
|
||||
let noTunnel = false;
|
||||
let sponsoredSigner = false;
|
||||
let seedPhrase = null;
|
||||
let returnUrl = null;
|
||||
|
||||
// Check for -y flag
|
||||
const yIndex = args.indexOf('-y');
|
||||
@@ -45,6 +50,44 @@ if (yIndex !== -1) {
|
||||
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);
|
||||
}
|
||||
} else if (arg === '-r' || arg === '--return-url') {
|
||||
if (i + 1 < args.length) {
|
||||
returnUrl = args[i + 1];
|
||||
if (returnUrl.startsWith('-')) {
|
||||
console.error('Error: Return URL 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: -r/--return-url requires a return URL');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +99,7 @@ if (autoAcceptDefaults && !projectName) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
init(projectName, autoAcceptDefaults, apiKey).catch((err) => {
|
||||
init(projectName, autoAcceptDefaults, apiKey, noWallet, noTunnel, sponsoredSigner, seedPhrase, returnUrl).catch((err) => {
|
||||
console.error('Error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
362
bin/init.js
362
bin/init.js
@@ -63,7 +63,16 @@ async function queryNeynarApp(apiKey) {
|
||||
}
|
||||
|
||||
// Export the main CLI function for programmatic use
|
||||
export async function init(projectName = null, autoAcceptDefaults = false, apiKey = null) {
|
||||
export async function init(
|
||||
projectName = null,
|
||||
autoAcceptDefaults = false,
|
||||
apiKey = null,
|
||||
noWallet = false,
|
||||
noTunnel = false,
|
||||
sponsoredSigner = false,
|
||||
seedPhrase = null,
|
||||
returnUrl = null
|
||||
) {
|
||||
printWelcomeMessage();
|
||||
|
||||
// Ask about Neynar usage
|
||||
@@ -219,17 +228,34 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
|
||||
|
||||
let answers;
|
||||
if (autoAcceptDefaults) {
|
||||
// Handle SIWN logic for autoAcceptDefaults
|
||||
let seedPhraseValue = null;
|
||||
let useSponsoredSignerValue = false;
|
||||
|
||||
// Only set seed phrase and sponsored signer if explicitly provided via flags
|
||||
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);
|
||||
}
|
||||
seedPhraseValue = seedPhrase;
|
||||
// If sponsoredSigner flag is provided, enable it; otherwise default to false
|
||||
useSponsoredSignerValue = sponsoredSigner;
|
||||
}
|
||||
|
||||
answers = {
|
||||
projectName: projectName || defaultMiniAppName || 'my-farcaster-mini-app',
|
||||
description: 'A Farcaster mini app created with Neynar',
|
||||
primaryCategory: null,
|
||||
tags: [],
|
||||
buttonText: 'Launch Mini App',
|
||||
useWallet: true,
|
||||
useWallet: !noWallet,
|
||||
useTunnel: true,
|
||||
enableAnalytics: true,
|
||||
seedPhrase: null,
|
||||
sponsorSigner: false,
|
||||
seedPhrase: seedPhraseValue,
|
||||
useSponsoredSigner: useSponsoredSignerValue,
|
||||
returnUrl: returnUrl,
|
||||
};
|
||||
} else {
|
||||
// If autoAcceptDefaults is false but we have a projectName, we still need to ask for other options
|
||||
@@ -312,74 +338,102 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
|
||||
// Merge project name from the first prompt
|
||||
answers.projectName = projectNamePrompt.projectName;
|
||||
|
||||
// Ask about wallet and transaction tooling
|
||||
const walletAnswer = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'useWallet',
|
||||
message:
|
||||
'Would you like to include wallet and transaction tooling in your mini app?\n' +
|
||||
'This includes:\n' +
|
||||
'- EVM wallet connection\n' +
|
||||
'- Transaction signing\n' +
|
||||
'- Message signing\n' +
|
||||
'- Chain switching\n' +
|
||||
'- Solana support\n\n' +
|
||||
'Include wallet and transaction features?',
|
||||
default: true,
|
||||
},
|
||||
]);
|
||||
answers.useWallet = walletAnswer.useWallet;
|
||||
|
||||
// Ask about localhost vs tunnel
|
||||
const hostingAnswer = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'useTunnel',
|
||||
message:
|
||||
'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?',
|
||||
default: true,
|
||||
},
|
||||
]);
|
||||
answers.useTunnel = hostingAnswer.useTunnel;
|
||||
|
||||
// Ask about Neynar Sponsored Signers / SIWN
|
||||
const sponsoredSignerAnswer = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'useSponsoredSigner',
|
||||
message:
|
||||
'Would you like to use Neynar Sponsored Signers and/or Sign In With Neynar (SIWN)?\n' +
|
||||
'This enables the simplest, most secure, and most user-friendly Farcaster authentication for your app.\n\n' +
|
||||
'Benefits of using Neynar Sponsored Signers/SIWN:\n' +
|
||||
'- No auth buildout or signer management required for developers\n' +
|
||||
'- Cost-effective for users (no gas for signers)\n' +
|
||||
'- Users can revoke signers at any time\n' +
|
||||
'- Plug-and-play for web and React Native\n' +
|
||||
'- Recommended for most developers\n' +
|
||||
'\n⚠️ A seed phrase is required for this option.\n',
|
||||
default: false,
|
||||
},
|
||||
]);
|
||||
answers.useSponsoredSigner = sponsoredSignerAnswer.useSponsoredSigner;
|
||||
|
||||
if (answers.useSponsoredSigner) {
|
||||
const { seedPhrase } = await inquirer.prompt([
|
||||
// Ask about wallet and transaction tooling (skip if --no-wallet flag is used)
|
||||
if (noWallet) {
|
||||
answers.useWallet = false;
|
||||
} else {
|
||||
const walletAnswer = 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;
|
||||
},
|
||||
type: 'confirm',
|
||||
name: 'useWallet',
|
||||
message:
|
||||
'Would you like to include wallet and transaction tooling in your mini app?\n' +
|
||||
'This includes:\n' +
|
||||
'- EVM wallet connection\n' +
|
||||
'- Transaction signing\n' +
|
||||
'- Message signing\n' +
|
||||
'- Chain switching\n' +
|
||||
'- Solana support\n\n' +
|
||||
'Include wallet and transaction features?',
|
||||
default: true,
|
||||
},
|
||||
]);
|
||||
answers.useWallet = walletAnswer.useWallet;
|
||||
}
|
||||
|
||||
// Ask about localhost vs tunnel
|
||||
if (noTunnel) {
|
||||
answers.useTunnel = false;
|
||||
} else {
|
||||
const hostingAnswer = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'useTunnel',
|
||||
message:
|
||||
'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?',
|
||||
default: true,
|
||||
},
|
||||
]);
|
||||
answers.useTunnel = hostingAnswer.useTunnel;
|
||||
}
|
||||
|
||||
// Ask about Sign In With Neynar (SIWN) - requires seed phrase
|
||||
if (seedPhrase) {
|
||||
// If --seed-phrase flag is used, validate it
|
||||
if (!seedPhrase || seedPhrase.trim().split(' ').length < 12) {
|
||||
console.error('Error: Seed phrase must be at least 12 words');
|
||||
process.exit(1);
|
||||
}
|
||||
answers.seedPhrase = seedPhrase;
|
||||
// If --sponsored-signer flag is also provided, enable it
|
||||
answers.useSponsoredSigner = sponsoredSigner;
|
||||
} else {
|
||||
const siwnAnswer = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'useSIWN',
|
||||
message:
|
||||
'Would you like to enable Sign In With Neynar (SIWN)? This allows your mini app to write data to Farcaster on behalf of users.\n' +
|
||||
'\n⚠️ A seed phrase is required for this option.\n',
|
||||
default: false,
|
||||
},
|
||||
]);
|
||||
|
||||
if (siwnAnswer.useSIWN) {
|
||||
const { seedPhrase } = await inquirer.prompt([
|
||||
{
|
||||
type: 'password',
|
||||
name: 'seedPhrase',
|
||||
message: 'Enter your Farcaster custody account seed phrase (required for 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 sponsor signer if seed phrase is provided
|
||||
const { sponsorSigner } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'sponsorSigner',
|
||||
message:
|
||||
'You have provided a seed phrase, which enables Sign In With Neynar (SIWN).\n' +
|
||||
'Do you want to sponsor the signer? (This will be used in Sign In With Neynar)\n' +
|
||||
'Note: If you choose to sponsor the signer, Neynar will sponsor it for you and you will be charged in CUs.\n' +
|
||||
'For more information, see https://docs.neynar.com/docs/two-ways-to-sponsor-a-farcaster-signer-via-neynar#sponsor-signers',
|
||||
default: false,
|
||||
},
|
||||
]);
|
||||
answers.useSponsoredSigner = sponsorSigner;
|
||||
} else {
|
||||
answers.useSponsoredSigner = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Ask about analytics opt-out
|
||||
@@ -453,14 +507,15 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
|
||||
delete packageJson.devDependencies;
|
||||
|
||||
// Add dependencies
|
||||
// question: remove auth-client?
|
||||
packageJson.dependencies = {
|
||||
'@farcaster/auth-client': '>=0.3.0 <1.0.0',
|
||||
'@farcaster/auth-kit': '>=0.6.0 <1.0.0',
|
||||
'@farcaster/miniapp-node': '>=0.1.5 <1.0.0',
|
||||
'@farcaster/miniapp-sdk': '>=0.1.6 <1.0.0',
|
||||
'@farcaster/miniapp-wagmi-connector': '^1.0.0',
|
||||
'@farcaster/mini-app-solana': '>=0.0.17 <1.0.0',
|
||||
'@neynar/react': '^1.2.5',
|
||||
'@farcaster/quick-auth': '>=0.0.7 <1.0.0',
|
||||
'@neynar/react': '^1.2.13',
|
||||
'@radix-ui/react-label': '^2.1.1',
|
||||
'@solana/wallet-adapter-react': '^0.15.38',
|
||||
'@tanstack/react-query': '^5.61.0',
|
||||
@@ -471,7 +526,6 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
|
||||
'lucide-react': '^0.469.0',
|
||||
mipd: '^0.0.7',
|
||||
next: '^15',
|
||||
'next-auth': '^4.24.11',
|
||||
react: '^19',
|
||||
'react-dom': '^19',
|
||||
'tailwind-merge': '^2.6.0',
|
||||
@@ -483,6 +537,7 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
|
||||
};
|
||||
|
||||
packageJson.devDependencies = {
|
||||
"@types/inquirer": "^9.0.8",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
@@ -490,12 +545,14 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
|
||||
"crypto": "^1.0.1",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "15.0.3",
|
||||
"inquirer": "^10.2.2",
|
||||
"localtunnel": "^2.0.2",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5",
|
||||
"ts-node": "^10.9.2"
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "^5"
|
||||
};
|
||||
|
||||
// Add Neynar SDK if selected
|
||||
@@ -503,6 +560,46 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
|
||||
packageJson.dependencies['@neynar/nodejs-sdk'] = '^2.19.0';
|
||||
}
|
||||
|
||||
// Add auth-kit and next-auth dependencies if SIWN is enabled (seed phrase is present)
|
||||
if (answers.seedPhrase) {
|
||||
packageJson.dependencies['@farcaster/auth-kit'] = '>=0.6.0 <1.0.0';
|
||||
packageJson.dependencies['next-auth'] = '^4.24.11';
|
||||
}
|
||||
|
||||
// Add security overrides for vulnerable packages (compatible with npm, Yarn, and pnpm)
|
||||
const securityOverrides = {
|
||||
"backslash": "0.2.0",
|
||||
"chalk-template": "1.1.0",
|
||||
"supports-hyperlinks": "4.1.0",
|
||||
"has-ansi": "6.0.0",
|
||||
"simple-swizzle": "0.2.2",
|
||||
"color-string": "2.1.0",
|
||||
"error-ex": "1.3.2",
|
||||
"color-name": "2.0.0",
|
||||
"is-arrayish": "0.3.2",
|
||||
"slice-ansi": "7.1.0",
|
||||
"color-convert": "3.1.0",
|
||||
"wrap-ansi": "9.0.0",
|
||||
"ansi-regex": "6.2.0",
|
||||
"supports-color": "10.2.0",
|
||||
"strip-ansi": "7.1.0",
|
||||
"chalk": "5.6.0",
|
||||
"ansi-styles": "6.2.1",
|
||||
"axios@^1": ">=1 <2",
|
||||
"axios@^0": ">=0 <1",
|
||||
};
|
||||
|
||||
// npm v8.3+ overrides
|
||||
packageJson.overrides = securityOverrides;
|
||||
|
||||
// Yarn (v1 and Berry) resolutions
|
||||
packageJson.resolutions = securityOverrides;
|
||||
|
||||
// pnpm overrides (namespaced)
|
||||
packageJson.pnpm = {
|
||||
overrides: securityOverrides
|
||||
};
|
||||
|
||||
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
|
||||
|
||||
// Handle .env file
|
||||
@@ -559,13 +656,14 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
|
||||
USE_WALLET: /^export const USE_WALLET\s*:\s*boolean\s*=\s*(true|false);$/m,
|
||||
ANALYTICS_ENABLED:
|
||||
/^export const ANALYTICS_ENABLED\s*:\s*boolean\s*=\s*(true|false);$/m,
|
||||
RETURN_URL: /^export const RETURN_URL\s*:\s*string\s*\|\s*undefined\s*=\s*(undefined|['"`][^'"`]*['"`]);$/m,
|
||||
};
|
||||
|
||||
// Update APP_NAME
|
||||
constantsContent = safeReplace(
|
||||
constantsContent,
|
||||
patterns.APP_NAME,
|
||||
`export const APP_NAME = '${escapeString(answers.projectName)}';`,
|
||||
`export const APP_NAME: string = '${escapeString(answers.projectName)}';`,
|
||||
'APP_NAME'
|
||||
);
|
||||
|
||||
@@ -573,7 +671,7 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
|
||||
constantsContent = safeReplace(
|
||||
constantsContent,
|
||||
patterns.APP_DESCRIPTION,
|
||||
`export const APP_DESCRIPTION = '${escapeString(
|
||||
`export const APP_DESCRIPTION: string = '${escapeString(
|
||||
answers.description
|
||||
)}';`,
|
||||
'APP_DESCRIPTION'
|
||||
@@ -583,7 +681,7 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
|
||||
constantsContent = safeReplace(
|
||||
constantsContent,
|
||||
patterns.APP_PRIMARY_CATEGORY,
|
||||
`export const APP_PRIMARY_CATEGORY = '${escapeString(
|
||||
`export const APP_PRIMARY_CATEGORY: string = '${escapeString(
|
||||
answers.primaryCategory || ''
|
||||
)}';`,
|
||||
'APP_PRIMARY_CATEGORY'
|
||||
@@ -597,7 +695,7 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
|
||||
constantsContent = safeReplace(
|
||||
constantsContent,
|
||||
patterns.APP_TAGS,
|
||||
`export const APP_TAGS = ${tagsString};`,
|
||||
`export const APP_TAGS: string[] = ${tagsString};`,
|
||||
'APP_TAGS'
|
||||
);
|
||||
|
||||
@@ -605,7 +703,7 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
|
||||
constantsContent = safeReplace(
|
||||
constantsContent,
|
||||
patterns.APP_BUTTON_TEXT,
|
||||
`export const APP_BUTTON_TEXT = '${escapeString(
|
||||
`export const APP_BUTTON_TEXT: string = '${escapeString(
|
||||
answers.buttonText || ''
|
||||
)}';`,
|
||||
'APP_BUTTON_TEXT'
|
||||
@@ -615,7 +713,7 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
|
||||
constantsContent = safeReplace(
|
||||
constantsContent,
|
||||
patterns.USE_WALLET,
|
||||
`export const USE_WALLET = ${answers.useWallet};`,
|
||||
`export const USE_WALLET: boolean = ${answers.useWallet};`,
|
||||
'USE_WALLET'
|
||||
);
|
||||
|
||||
@@ -623,19 +721,25 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
|
||||
constantsContent = safeReplace(
|
||||
constantsContent,
|
||||
patterns.ANALYTICS_ENABLED,
|
||||
`export const ANALYTICS_ENABLED = ${answers.enableAnalytics};`,
|
||||
`export const ANALYTICS_ENABLED: boolean = ${answers.enableAnalytics};`,
|
||||
'ANALYTICS_ENABLED'
|
||||
);
|
||||
|
||||
// Update RETURN_URL
|
||||
const returnUrlValue = answers.returnUrl ? `'${escapeString(answers.returnUrl)}'` : 'undefined';
|
||||
constantsContent = safeReplace(
|
||||
constantsContent,
|
||||
patterns.RETURN_URL,
|
||||
`export const RETURN_URL: string | undefined = ${returnUrlValue};`,
|
||||
'RETURN_URL'
|
||||
);
|
||||
|
||||
fs.writeFileSync(constantsPath, constantsContent);
|
||||
} else {
|
||||
console.log('⚠️ constants.ts not found, skipping constants update');
|
||||
}
|
||||
|
||||
fs.appendFileSync(
|
||||
envPath,
|
||||
`\nNEXTAUTH_SECRET="${crypto.randomBytes(32).toString('hex')}"`
|
||||
);
|
||||
|
||||
if (useNeynar && neynarApiKey && neynarClientId) {
|
||||
fs.appendFileSync(envPath, `\nNEYNAR_API_KEY="${neynarApiKey}"`);
|
||||
fs.appendFileSync(envPath, `\nNEYNAR_CLIENT_ID="${neynarClientId}"`);
|
||||
@@ -645,9 +749,18 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
|
||||
);
|
||||
}
|
||||
if (answers.seedPhrase) {
|
||||
console.log('✅ Writing SEED_PHRASE and NEXTAUTH_SECRET to .env.local');
|
||||
fs.appendFileSync(envPath, `\nSEED_PHRASE="${answers.seedPhrase}"`);
|
||||
// Add NextAuth secret for SIWN
|
||||
fs.appendFileSync(
|
||||
envPath,
|
||||
`\nNEXTAUTH_SECRET="${crypto.randomBytes(32).toString('hex')}"`
|
||||
);
|
||||
}
|
||||
fs.appendFileSync(envPath, `\nUSE_TUNNEL="${answers.useTunnel}"`);
|
||||
if (answers.useSponsoredSigner) {
|
||||
fs.appendFileSync(envPath, `\nSPONSOR_SIGNER="true"`);
|
||||
}
|
||||
|
||||
fs.unlinkSync(envExamplePath);
|
||||
} else {
|
||||
@@ -691,6 +804,87 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe
|
||||
fs.rmSync(binPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// Handle SIWN-related files based on whether seed phrase is provided
|
||||
if (!answers.seedPhrase) {
|
||||
// Remove SIWN-related files when SIWN is not enabled (no seed phrase)
|
||||
console.log('\nRemoving SIWN-related files (SIWN not enabled)...');
|
||||
|
||||
// Remove NeynarAuthButton directory
|
||||
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 });
|
||||
}
|
||||
|
||||
// Remove SIWN-specific files
|
||||
const actionsTabNeynarAuthPath = path.join(projectPath, 'src', 'components', 'ui', 'tabs', 'ActionsTab.NeynarAuth.tsx');
|
||||
if (fs.existsSync(actionsTabNeynarAuthPath)) {
|
||||
fs.rmSync(actionsTabNeynarAuthPath, { force: true });
|
||||
}
|
||||
|
||||
const layoutNeynarAuthPath = path.join(projectPath, 'src', 'app', 'layout.NeynarAuth.tsx');
|
||||
if (fs.existsSync(layoutNeynarAuthPath)) {
|
||||
fs.rmSync(layoutNeynarAuthPath, { force: true });
|
||||
}
|
||||
|
||||
const providersNeynarAuthPath = path.join(projectPath, 'src', 'app', 'providers.NeynarAuth.tsx');
|
||||
if (fs.existsSync(providersNeynarAuthPath)) {
|
||||
fs.rmSync(providersNeynarAuthPath, { force: true });
|
||||
}
|
||||
} else {
|
||||
// Move SIWN-specific files to replace the regular versions when SIWN is enabled
|
||||
console.log('\nMoving SIWN-specific files to replace regular versions (SIWN enabled)...');
|
||||
|
||||
// Move ActionsTab.NeynarAuth.tsx to ActionsTab.tsx
|
||||
const actionsTabNeynarAuthPath = path.join(projectPath, 'src', 'components', 'ui', 'tabs', 'ActionsTab.NeynarAuth.tsx');
|
||||
const actionsTabPath = path.join(projectPath, 'src', 'components', 'ui', 'tabs', 'ActionsTab.tsx');
|
||||
if (fs.existsSync(actionsTabNeynarAuthPath)) {
|
||||
if (fs.existsSync(actionsTabPath)) {
|
||||
fs.rmSync(actionsTabPath, { force: true }); // Delete original
|
||||
}
|
||||
fs.renameSync(actionsTabNeynarAuthPath, actionsTabPath);
|
||||
console.log('✅ Moved ActionsTab.NeynarAuth.tsx to ActionsTab.tsx');
|
||||
}
|
||||
|
||||
// Move layout.NeynarAuth.tsx to layout.tsx
|
||||
const layoutNeynarAuthPath = path.join(projectPath, 'src', 'app', 'layout.NeynarAuth.tsx');
|
||||
const layoutPath = path.join(projectPath, 'src', 'app', 'layout.tsx');
|
||||
if (fs.existsSync(layoutNeynarAuthPath)) {
|
||||
if (fs.existsSync(layoutPath)) {
|
||||
fs.rmSync(layoutPath, { force: true }); // Delete original
|
||||
}
|
||||
fs.renameSync(layoutNeynarAuthPath, layoutPath);
|
||||
console.log('✅ Moved layout.NeynarAuth.tsx to layout.tsx');
|
||||
}
|
||||
|
||||
// Move providers.NeynarAuth.tsx to providers.tsx
|
||||
const providersNeynarAuthPath = path.join(projectPath, 'src', 'app', 'providers.NeynarAuth.tsx');
|
||||
const providersPath = path.join(projectPath, 'src', 'app', 'providers.tsx');
|
||||
if (fs.existsSync(providersNeynarAuthPath)) {
|
||||
if (fs.existsSync(providersPath)) {
|
||||
fs.rmSync(providersPath, { force: true }); // Delete original
|
||||
}
|
||||
fs.renameSync(providersNeynarAuthPath, providersPath);
|
||||
console.log('✅ Moved providers.NeynarAuth.tsx to providers.tsx');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize git repository
|
||||
console.log('\nInitializing git repository...');
|
||||
execSync('git init', { cwd: projectPath });
|
||||
|
||||
1125
package-lock.json
generated
1125
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neynar/create-farcaster-mini-app",
|
||||
"version": "1.6.2",
|
||||
"version": "1.8.13",
|
||||
"type": "module",
|
||||
"private": false,
|
||||
"access": "public",
|
||||
@@ -35,7 +35,7 @@
|
||||
"build:raw": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"deploy:vercel": "ts-node scripts/deploy.ts",
|
||||
"deploy:vercel": "tsx scripts/deploy.ts",
|
||||
"deploy:raw": "vercel --prod",
|
||||
"cleanup": "node scripts/cleanup.js"
|
||||
},
|
||||
@@ -51,5 +51,17 @@
|
||||
"@neynar/nodejs-sdk": "^2.19.0",
|
||||
"@types/node": "^22.13.10",
|
||||
"typescript": "^5.6.3"
|
||||
},
|
||||
"overrides": {
|
||||
"chalk": "5.3.0",
|
||||
"strip-ansi": "6.0.1",
|
||||
"wrap-ansi": "8.1.0",
|
||||
"ansi-styles": "6.2.3",
|
||||
"color-convert": "2.0.1",
|
||||
"color-name": "1.1.4",
|
||||
"is-core-module": "2.13.1",
|
||||
"error-ex": "1.3.2",
|
||||
"simple-swizzle": "0.2.2",
|
||||
"has-ansi": "5.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import inquirer from 'inquirer';
|
||||
import dotenv from 'dotenv';
|
||||
import crypto from 'crypto';
|
||||
import { Vercel } from '@vercel/sdk';
|
||||
import { APP_NAME, APP_BUTTON_TEXT } from '../src/lib/constants';
|
||||
import { APP_NAME, APP_BUTTON_TEXT } from '../src/lib/constants.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.join(__dirname, '..');
|
||||
@@ -73,18 +73,20 @@ async function checkRequiredEnvVars(): Promise<void> {
|
||||
name: 'NEXT_PUBLIC_MINI_APP_NAME',
|
||||
message: 'Enter the name for your frame (e.g., My Cool Mini App):',
|
||||
default: APP_NAME,
|
||||
validate: (input: string) => input.trim() !== '' || 'Mini app name cannot be empty'
|
||||
validate: (input: string) =>
|
||||
input.trim() !== '' || 'Mini app name cannot be empty',
|
||||
},
|
||||
{
|
||||
name: 'NEXT_PUBLIC_MINI_APP_BUTTON_TEXT',
|
||||
message: 'Enter the text for your frame button:',
|
||||
default: APP_BUTTON_TEXT ?? 'Launch Mini App',
|
||||
validate: (input: string) => input.trim() !== '' || 'Button text cannot be empty'
|
||||
}
|
||||
validate: (input: string) =>
|
||||
input.trim() !== '' || 'Button text cannot be empty',
|
||||
},
|
||||
];
|
||||
|
||||
const missingVars = requiredVars.filter(
|
||||
(varConfig) => !process.env[varConfig.name]
|
||||
varConfig => !process.env[varConfig.name],
|
||||
);
|
||||
|
||||
if (missingVars.length > 0) {
|
||||
@@ -110,38 +112,150 @@ async function checkRequiredEnvVars(): Promise<void> {
|
||||
const newLine = envContent ? '\n' : '';
|
||||
fs.appendFileSync(
|
||||
'.env',
|
||||
`${newLine}${varConfig.name}="${value.trim()}"`
|
||||
`${newLine}${varConfig.name}="${value.trim()}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Ask about sponsor signer if SEED_PHRASE is provided
|
||||
if (!process.env.SPONSOR_SIGNER) {
|
||||
const { sponsorSigner } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'sponsorSigner',
|
||||
message:
|
||||
'Do you want to sponsor the signer? (This will be used in Sign In With Neynar)\n' +
|
||||
'Note: If you choose to sponsor the signer, Neynar will sponsor it for you and you will be charged in CUs.\n' +
|
||||
'For more information, see https://docs.neynar.com/docs/two-ways-to-sponsor-a-farcaster-signer-via-neynar#sponsor-signers',
|
||||
default: false,
|
||||
// Ask about SIWN if SEED_PHRASE is provided (moved outside the loop)
|
||||
if (process.env.SEED_PHRASE && !process.env.SPONSOR_SIGNER) {
|
||||
const { sponsorSigner } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'sponsorSigner',
|
||||
message:
|
||||
'You have provided a seed phrase, which enables Sign In With Neynar (SIWN).\n' +
|
||||
'Do you want to sponsor the signer? (This will be used in Sign In With Neynar)\n' +
|
||||
'Note: If you choose to sponsor the signer, Neynar will sponsor it for you and you will be charged in CUs.\n' +
|
||||
'For more information, see https://docs.neynar.com/docs/two-ways-to-sponsor-a-farcaster-signer-via-neynar#sponsor-signers',
|
||||
default: false,
|
||||
},
|
||||
]);
|
||||
|
||||
process.env.SPONSOR_SIGNER = sponsorSigner.toString();
|
||||
|
||||
fs.appendFileSync(
|
||||
'.env.local',
|
||||
`\nSPONSOR_SIGNER="${sponsorSigner}"`,
|
||||
);
|
||||
console.log('✅ Sponsor signer preference stored in .env.local');
|
||||
}
|
||||
|
||||
// Ask about required chains (moved outside the loop)
|
||||
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');
|
||||
}
|
||||
|
||||
// Ask for account association
|
||||
console.log(
|
||||
`\n⚠️ To complete your mini app manifest, you need to sign it using the Farcaster developer portal.`,
|
||||
);
|
||||
console.log(
|
||||
'1. Go to: https://farcaster.xyz/~/developers/mini-apps/manifest',
|
||||
);
|
||||
console.log(
|
||||
'2. Enter your app domain (you\'ll get this after deployment)',
|
||||
);
|
||||
console.log(
|
||||
'3. Click "Transfer Ownership" and follow the instructions to sign the manifest.',
|
||||
);
|
||||
console.log(
|
||||
'4. Copy the resulting accountAssociation JSON.',
|
||||
);
|
||||
console.log('5. Paste it below when prompted.');
|
||||
console.log(
|
||||
'\nNote: If you don\'t have the accountAssociation yet, you can press Ctrl+C to skip and add it later.',
|
||||
);
|
||||
|
||||
try {
|
||||
const { userAccountAssociation } = await inquirer.prompt([
|
||||
{
|
||||
type: 'editor',
|
||||
name: 'userAccountAssociation',
|
||||
message: 'Paste the accountAssociation JSON here (or press Ctrl+C to skip):',
|
||||
validate: (input: string) => {
|
||||
if (!input.trim()) {
|
||||
return 'You can press Ctrl+C to skip this step';
|
||||
}
|
||||
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);
|
||||
|
||||
process.env.SPONSOR_SIGNER = sponsorSigner.toString();
|
||||
|
||||
if (storeSeedPhrase) {
|
||||
fs.appendFileSync(
|
||||
'.env.local',
|
||||
`\nSPONSOR_SIGNER="${sponsorSigner}"`
|
||||
);
|
||||
console.log('✅ Sponsor signer preference stored in .env.local');
|
||||
}
|
||||
// Write APP_ACCOUNT_ASSOCIATION to constants.ts
|
||||
if (fs.existsSync(constantsPath)) {
|
||||
let constantsContent = fs.readFileSync(constantsPath, 'utf8');
|
||||
|
||||
// Replace the APP_ACCOUNT_ASSOCIATION line
|
||||
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('✅ APP_ACCOUNT_ASSOCIATION updated in constants.ts');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('\nℹ️ Skipping account association for now. You can add it later by updating APP_ACCOUNT_ASSOCIATION in src/lib/constants.ts');
|
||||
}
|
||||
}
|
||||
|
||||
// Load SPONSOR_SIGNER from .env.local if SEED_PHRASE exists but SPONSOR_SIGNER doesn't
|
||||
// Load SPONSOR_SIGNER from .env.local if SEED_PHRASE exists (SIWN enabled) but SPONSOR_SIGNER doesn't
|
||||
if (
|
||||
process.env.SEED_PHRASE &&
|
||||
!process.env.SPONSOR_SIGNER &&
|
||||
@@ -171,8 +285,8 @@ async function getGitRemote(): Promise<string | null> {
|
||||
|
||||
async function checkVercelCLI(): Promise<boolean> {
|
||||
try {
|
||||
execSync('vercel --version', {
|
||||
stdio: 'ignore'
|
||||
execSync('vercel --version', {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
@@ -185,8 +299,8 @@ async function checkVercelCLI(): Promise<boolean> {
|
||||
|
||||
async function installVercelCLI(): Promise<void> {
|
||||
console.log('Installing Vercel CLI...');
|
||||
execSync('npm install -g vercel', {
|
||||
stdio: 'inherit'
|
||||
execSync('npm install -g vercel', {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -222,7 +336,9 @@ async function getVercelToken(): Promise<string | null> {
|
||||
return null; // We'll fall back to CLI operations
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
throw new Error('Not logged in to Vercel CLI. Please run this script again to login.');
|
||||
throw new Error(
|
||||
'Not logged in to Vercel CLI. Please run this script again to login.',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -239,7 +355,7 @@ async function loginToVercel(): Promise<boolean> {
|
||||
console.log('3. Complete the Vercel account setup in your browser');
|
||||
console.log('4. Return here once your Vercel account is created\n');
|
||||
console.log(
|
||||
'\nNote: you may need to cancel this script with ctrl+c and run it again if creating a new vercel account'
|
||||
'\nNote: you may need to cancel this script with ctrl+c and run it again if creating a new vercel account',
|
||||
);
|
||||
|
||||
const child = spawn('vercel', ['login'], {
|
||||
@@ -247,14 +363,14 @@ async function loginToVercel(): Promise<boolean> {
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
child.on('close', (code) => {
|
||||
child.on('close', code => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
console.log('\n📱 Waiting for login to complete...');
|
||||
console.log(
|
||||
"If you're creating a new account, please complete the Vercel account setup in your browser first."
|
||||
"If you're creating a new account, please complete the Vercel account setup in your browser first.",
|
||||
);
|
||||
|
||||
for (let i = 0; i < 150; i++) {
|
||||
@@ -263,10 +379,13 @@ async function loginToVercel(): Promise<boolean> {
|
||||
console.log('✅ Successfully logged in to Vercel!');
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && error.message.includes('Account not found')) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message.includes('Account not found')
|
||||
) {
|
||||
console.log('ℹ️ Waiting for Vercel account setup to complete...');
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +396,12 @@ async function loginToVercel(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async function setVercelEnvVarSDK(vercelClient: Vercel, projectId: string, key: string, value: string | object): Promise<boolean> {
|
||||
async function setVercelEnvVarSDK(
|
||||
vercelClient: Vercel,
|
||||
projectId: string,
|
||||
key: string,
|
||||
value: string | object,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
let processedValue: string;
|
||||
if (typeof value === 'object') {
|
||||
@@ -287,17 +411,26 @@ async function setVercelEnvVarSDK(vercelClient: Vercel, projectId: string, key:
|
||||
}
|
||||
|
||||
// Get existing environment variables
|
||||
const existingVars = await vercelClient.projects.getEnvironmentVariables({
|
||||
const existingVars = await vercelClient.projects.filterProjectEnvs({
|
||||
idOrName: projectId,
|
||||
});
|
||||
|
||||
const existingVar = existingVars.envs?.find((env: any) =>
|
||||
env.key === key && env.target?.includes('production')
|
||||
// Handle different response types
|
||||
let envs: any[] = [];
|
||||
if ('envs' in existingVars && Array.isArray(existingVars.envs)) {
|
||||
envs = existingVars.envs;
|
||||
} else if ('target' in existingVars && 'key' in existingVars) {
|
||||
// Single environment variable response
|
||||
envs = [existingVars];
|
||||
}
|
||||
|
||||
const existingVar = envs.find(
|
||||
(env: any) => env.key === key && env.target?.includes('production'),
|
||||
);
|
||||
|
||||
if (existingVar) {
|
||||
if (existingVar && existingVar.id) {
|
||||
// Update existing variable
|
||||
await vercelClient.projects.editEnvironmentVariable({
|
||||
await vercelClient.projects.editProjectEnv({
|
||||
idOrName: projectId,
|
||||
id: existingVar.id,
|
||||
requestBody: {
|
||||
@@ -308,7 +441,7 @@ async function setVercelEnvVarSDK(vercelClient: Vercel, projectId: string, key:
|
||||
console.log(`✅ Updated environment variable: ${key}`);
|
||||
} else {
|
||||
// Create new variable
|
||||
await vercelClient.projects.createEnvironmentVariable({
|
||||
await vercelClient.projects.createProjectEnv({
|
||||
idOrName: projectId,
|
||||
requestBody: {
|
||||
key: key,
|
||||
@@ -323,14 +456,21 @@ async function setVercelEnvVarSDK(vercelClient: Vercel, projectId: string, key:
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
console.warn(`⚠️ Warning: Failed to set environment variable ${key}:`, error.message);
|
||||
console.warn(
|
||||
`⚠️ Warning: Failed to set environment variable ${key}:`,
|
||||
error.message,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function setVercelEnvVarCLI(key: string, value: string | object, projectRoot: string): Promise<boolean> {
|
||||
async function setVercelEnvVarCLI(
|
||||
key: string,
|
||||
value: string | object,
|
||||
projectRoot: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// Remove existing env var
|
||||
try {
|
||||
@@ -365,7 +505,7 @@ async function setVercelEnvVarCLI(key: string, value: string | object, projectRo
|
||||
execSync(command, {
|
||||
cwd: projectRoot,
|
||||
stdio: 'pipe', // Changed from 'inherit' to avoid interactive prompts
|
||||
env: process.env
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
fs.unlinkSync(tempFilePath);
|
||||
@@ -377,18 +517,26 @@ async function setVercelEnvVarCLI(key: string, value: string | object, projectRo
|
||||
fs.unlinkSync(tempFilePath);
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
console.warn(`⚠️ Warning: Failed to set environment variable ${key}:`, error.message);
|
||||
console.warn(
|
||||
`⚠️ Warning: Failed to set environment variable ${key}:`,
|
||||
error.message,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function setEnvironmentVariables(vercelClient: Vercel | null, projectId: string | null, envVars: Record<string, string | object>, projectRoot: string): Promise<Array<{ key: string; success: boolean }>> {
|
||||
async function setEnvironmentVariables(
|
||||
vercelClient: Vercel | null,
|
||||
projectId: string | null,
|
||||
envVars: Record<string, string | object>,
|
||||
projectRoot: string,
|
||||
): Promise<Array<{ key: string; success: boolean }>> {
|
||||
console.log('\n📝 Setting up environment variables...');
|
||||
|
||||
|
||||
const results: Array<{ key: string; success: boolean }> = [];
|
||||
|
||||
|
||||
for (const [key, value] of Object.entries(envVars)) {
|
||||
if (!value) continue;
|
||||
|
||||
@@ -408,29 +556,34 @@ async function setEnvironmentVariables(vercelClient: Vercel | null, projectId: s
|
||||
}
|
||||
|
||||
// Report results
|
||||
const failed = results.filter((r) => !r.success);
|
||||
const failed = results.filter(r => !r.success);
|
||||
if (failed.length > 0) {
|
||||
console.warn(`\n⚠️ Failed to set ${failed.length} environment variables:`);
|
||||
failed.forEach((r) => console.warn(` - ${r.key}`));
|
||||
failed.forEach(r => console.warn(` - ${r.key}`));
|
||||
console.warn(
|
||||
'\nYou may need to set these manually in the Vercel dashboard.'
|
||||
'\nYou may need to set these manually in the Vercel dashboard.',
|
||||
);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async function waitForDeployment(vercelClient: Vercel | null, projectId: string, maxWaitTime = 300000): Promise<any> { // 5 minutes
|
||||
async function waitForDeployment(
|
||||
vercelClient: Vercel | null,
|
||||
projectId: string,
|
||||
maxWaitTime = 300000,
|
||||
): Promise<any> {
|
||||
// 5 minutes
|
||||
console.log('\n⏳ Waiting for deployment to complete...');
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < maxWaitTime) {
|
||||
try {
|
||||
const deployments = await vercelClient?.deployments.list({
|
||||
const deployments = await vercelClient?.deployments.getDeployments({
|
||||
projectId: projectId,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
|
||||
if (deployments?.deployments?.[0]) {
|
||||
const deployment = deployments.deployments[0];
|
||||
console.log(`📊 Deployment status: ${deployment.state}`);
|
||||
@@ -445,10 +598,10 @@ async function waitForDeployment(vercelClient: Vercel | null, projectId: string,
|
||||
}
|
||||
|
||||
// Still building, wait and check again
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000)); // Wait 5 seconds
|
||||
await new Promise(resolve => setTimeout(resolve, 5000)); // Wait 5 seconds
|
||||
} else {
|
||||
console.log('⏳ No deployment found yet, waiting...');
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
@@ -478,58 +631,60 @@ async function deployToVercel(useGitHub = false): Promise<void> {
|
||||
framework: 'nextjs',
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Set up Vercel project
|
||||
console.log('\n📦 Setting up Vercel project...');
|
||||
console.log(
|
||||
'An initial deployment is required to get an assigned domain that can be used in the mini app manifest\n'
|
||||
'An initial deployment is required to get an assigned domain that can be used in the mini app manifest\n',
|
||||
);
|
||||
console.log(
|
||||
'\n⚠️ Note: choosing a longer, more unique project name will help avoid conflicts with other existing domains\n'
|
||||
'\n⚠️ Note: choosing a longer, more unique project name will help avoid conflicts with other existing domains\n',
|
||||
);
|
||||
|
||||
// Use spawn instead of execSync for better error handling
|
||||
const { spawn } = await import('child_process');
|
||||
const vercelSetup = spawn('vercel', [], {
|
||||
cwd: projectRoot,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32' ? true : undefined
|
||||
});
|
||||
const vercelSetup = spawn('vercel', [], {
|
||||
cwd: projectRoot,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32' ? true : undefined,
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
vercelSetup.on('close', (code) => {
|
||||
vercelSetup.on('close', code => {
|
||||
if (code === 0 || code === null) {
|
||||
console.log('✅ Vercel project setup completed');
|
||||
resolve();
|
||||
} else {
|
||||
console.log('⚠️ Vercel setup command completed (this is normal)');
|
||||
console.log('⚠️ Vercel setup command completed (this is normal)');
|
||||
resolve(); // Don't reject, as this is often expected
|
||||
}
|
||||
});
|
||||
|
||||
vercelSetup.on('error', (error) => {
|
||||
vercelSetup.on('error', error => {
|
||||
console.log('⚠️ Vercel setup command completed (this is normal)');
|
||||
resolve(); // Don't reject, as this is often expected
|
||||
});
|
||||
});
|
||||
|
||||
// Wait a moment for project files to be written
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// Load project info
|
||||
let projectId: string;
|
||||
try {
|
||||
const projectJson = JSON.parse(
|
||||
fs.readFileSync('.vercel/project.json', 'utf8')
|
||||
fs.readFileSync('.vercel/project.json', 'utf8'),
|
||||
);
|
||||
projectId = projectJson.projectId;
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
throw new Error('Failed to load project info. Please ensure the Vercel project was created successfully.');
|
||||
throw new Error(
|
||||
'Failed to load project info. Please ensure the Vercel project was created successfully.',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -540,13 +695,15 @@ async function deployToVercel(useGitHub = false): Promise<void> {
|
||||
const token = await getVercelToken();
|
||||
if (token) {
|
||||
vercelClient = new Vercel({
|
||||
bearerToken: token
|
||||
bearerToken: token,
|
||||
});
|
||||
console.log('✅ Initialized Vercel SDK client');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
console.warn('⚠️ Could not initialize Vercel SDK, falling back to CLI operations');
|
||||
console.warn(
|
||||
'⚠️ Could not initialize Vercel SDK, falling back to CLI operations',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -558,15 +715,22 @@ async function deployToVercel(useGitHub = false): Promise<void> {
|
||||
|
||||
if (vercelClient) {
|
||||
try {
|
||||
const project = await vercelClient.projects.get({
|
||||
idOrName: projectId,
|
||||
});
|
||||
projectName = project.name;
|
||||
domain = `${projectName}.vercel.app`;
|
||||
console.log('🌐 Using project name for domain:', domain);
|
||||
const projects = await vercelClient.projects.getProjects({});
|
||||
const project = projects.projects.find(
|
||||
(p: any) => p.id === projectId || p.name === projectId,
|
||||
);
|
||||
if (project) {
|
||||
projectName = project.name;
|
||||
domain = `${projectName}.vercel.app`;
|
||||
console.log('🌐 Using project name for domain:', domain);
|
||||
} else {
|
||||
throw new Error('Project not found');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
console.warn('⚠️ Could not get project details via SDK, using CLI fallback');
|
||||
console.warn(
|
||||
'⚠️ Could not get project details via SDK, using CLI fallback',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -580,7 +744,7 @@ async function deployToVercel(useGitHub = false): Promise<void> {
|
||||
{
|
||||
cwd: projectRoot,
|
||||
encoding: 'utf8',
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const nameMatch = inspectOutput.match(/Name\s+([^\n]+)/);
|
||||
@@ -596,7 +760,7 @@ async function deployToVercel(useGitHub = false): Promise<void> {
|
||||
console.log('🌐 Using project name for domain:', domain);
|
||||
} else {
|
||||
console.warn(
|
||||
'⚠️ Could not determine project name from inspection, using fallback'
|
||||
'⚠️ Could not determine project name from inspection, using fallback',
|
||||
);
|
||||
// Use a fallback domain based on project ID
|
||||
domain = `project-${projectId.slice(-8)}.vercel.app`;
|
||||
@@ -618,19 +782,30 @@ async function deployToVercel(useGitHub = false): Promise<void> {
|
||||
const nextAuthSecret =
|
||||
process.env.NEXTAUTH_SECRET || crypto.randomBytes(32).toString('hex');
|
||||
const vercelEnv = {
|
||||
NEXTAUTH_SECRET: nextAuthSecret,
|
||||
AUTH_SECRET: nextAuthSecret,
|
||||
NEXTAUTH_URL: `https://${domain}`,
|
||||
NEXT_PUBLIC_URL: `https://${domain}`,
|
||||
|
||||
...(process.env.NEYNAR_API_KEY && { NEYNAR_API_KEY: process.env.NEYNAR_API_KEY }),
|
||||
...(process.env.NEYNAR_CLIENT_ID && { NEYNAR_CLIENT_ID: process.env.NEYNAR_CLIENT_ID }),
|
||||
...(process.env.SPONSOR_SIGNER && { SPONSOR_SIGNER: process.env.SPONSOR_SIGNER }),
|
||||
|
||||
|
||||
...(process.env.NEYNAR_API_KEY && {
|
||||
NEYNAR_API_KEY: process.env.NEYNAR_API_KEY,
|
||||
}),
|
||||
...(process.env.NEYNAR_CLIENT_ID && {
|
||||
NEYNAR_CLIENT_ID: process.env.NEYNAR_CLIENT_ID,
|
||||
}),
|
||||
...(process.env.SPONSOR_SIGNER && {
|
||||
SPONSOR_SIGNER: process.env.SPONSOR_SIGNER,
|
||||
}),
|
||||
|
||||
// Include NextAuth environment variables if SEED_PHRASE is present (SIWN enabled)
|
||||
...(process.env.SEED_PHRASE && {
|
||||
SEED_PHRASE: process.env.SEED_PHRASE,
|
||||
NEXTAUTH_SECRET: nextAuthSecret,
|
||||
AUTH_SECRET: nextAuthSecret,
|
||||
NEXTAUTH_URL: `https://${domain}`,
|
||||
}),
|
||||
|
||||
...Object.fromEntries(
|
||||
Object.entries(process.env).filter(([key]) =>
|
||||
key.startsWith('NEXT_PUBLIC_')
|
||||
)
|
||||
key.startsWith('NEXT_PUBLIC_'),
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -639,7 +814,7 @@ async function deployToVercel(useGitHub = false): Promise<void> {
|
||||
vercelClient,
|
||||
projectId,
|
||||
vercelEnv,
|
||||
projectRoot
|
||||
projectRoot,
|
||||
);
|
||||
|
||||
// Deploy the project
|
||||
@@ -663,7 +838,7 @@ async function deployToVercel(useGitHub = false): Promise<void> {
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
vercelDeploy.on('close', (code) => {
|
||||
vercelDeploy.on('close', code => {
|
||||
if (code === 0) {
|
||||
console.log('✅ Vercel deployment command completed');
|
||||
resolve();
|
||||
@@ -673,7 +848,7 @@ async function deployToVercel(useGitHub = false): Promise<void> {
|
||||
}
|
||||
});
|
||||
|
||||
vercelDeploy.on('error', (error) => {
|
||||
vercelDeploy.on('error', error => {
|
||||
console.error('❌ Vercel deployment error:', error.message);
|
||||
reject(error);
|
||||
});
|
||||
@@ -686,7 +861,10 @@ async function deployToVercel(useGitHub = false): Promise<void> {
|
||||
deployment = await waitForDeployment(vercelClient, projectId);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
console.warn('⚠️ Could not verify deployment completion:', error.message);
|
||||
console.warn(
|
||||
'⚠️ Could not verify deployment completion:',
|
||||
error.message,
|
||||
);
|
||||
console.log('ℹ️ Proceeding with domain verification...');
|
||||
}
|
||||
throw error;
|
||||
@@ -700,10 +878,12 @@ async function deployToVercel(useGitHub = false): Promise<void> {
|
||||
if (vercelClient && deployment) {
|
||||
try {
|
||||
actualDomain = deployment.url || domain;
|
||||
console.log('🌐 Verified actual domain:', actualDomain);
|
||||
console.log('🌐 Verified actual domain:', actualDomain);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
console.warn('⚠️ Could not verify domain via SDK, using assumed domain');
|
||||
console.warn(
|
||||
'⚠️ Could not verify domain via SDK, using assumed domain',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -714,11 +894,20 @@ async function deployToVercel(useGitHub = false): Promise<void> {
|
||||
console.log('🔄 Updating environment variables with correct domain...');
|
||||
|
||||
const updatedEnv: Record<string, string | object> = {
|
||||
NEXTAUTH_URL: `https://${actualDomain}`,
|
||||
NEXT_PUBLIC_URL: `https://${actualDomain}`,
|
||||
};
|
||||
|
||||
await setEnvironmentVariables(vercelClient, projectId, updatedEnv, projectRoot);
|
||||
// Include NextAuth URL if SEED_PHRASE is present (SIWN enabled)
|
||||
if (process.env.SEED_PHRASE) {
|
||||
updatedEnv.NEXTAUTH_URL = `https://${actualDomain}`;
|
||||
}
|
||||
|
||||
await setEnvironmentVariables(
|
||||
vercelClient,
|
||||
projectId,
|
||||
updatedEnv,
|
||||
projectRoot,
|
||||
);
|
||||
|
||||
console.log('\n📦 Redeploying with correct domain...');
|
||||
const vercelRedeploy = spawn('vercel', ['deploy', '--prod'], {
|
||||
@@ -728,7 +917,7 @@ async function deployToVercel(useGitHub = false): Promise<void> {
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
vercelRedeploy.on('close', (code) => {
|
||||
vercelRedeploy.on('close', code => {
|
||||
if (code === 0) {
|
||||
console.log('✅ Redeployment completed');
|
||||
resolve();
|
||||
@@ -738,7 +927,7 @@ async function deployToVercel(useGitHub = false): Promise<void> {
|
||||
}
|
||||
});
|
||||
|
||||
vercelRedeploy.on('error', (error) => {
|
||||
vercelRedeploy.on('error', error => {
|
||||
console.error('❌ Redeployment error:', error.message);
|
||||
reject(error);
|
||||
});
|
||||
@@ -749,48 +938,20 @@ async function deployToVercel(useGitHub = false): Promise<void> {
|
||||
|
||||
console.log('\n✨ Deployment complete! Your mini app is now live at:');
|
||||
console.log(`🌐 https://${domain}`);
|
||||
console.log('\n📝 You can manage your project at https://vercel.com/dashboard');
|
||||
|
||||
// Prompt user to sign manifest in browser and paste accountAssociation
|
||||
console.log(`\n⚠️ To complete your mini app manifest, you must sign it using the Farcaster developer portal.`);
|
||||
console.log('1. Go to: https://farcaster.xyz/~/developers/mini-apps/manifest?domain=' + domain);
|
||||
console.log('2. Click "Transfer Ownership" and follow the instructions to sign the manifest.');
|
||||
console.log('3. Copy the resulting accountAssociation JSON from the browser.');
|
||||
console.log('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
|
||||
console.log(
|
||||
'\n📝 You can manage your project at https://vercel.com/dashboard',
|
||||
);
|
||||
|
||||
// Remind user about account association if not already set
|
||||
console.log(
|
||||
`\n💡 Remember: If you haven't already signed your manifest, go to:`,
|
||||
);
|
||||
console.log(
|
||||
` https://farcaster.xyz/~/developers/mini-apps/manifest?domain=${domain}`,
|
||||
);
|
||||
console.log(
|
||||
' to complete the ownership transfer and update APP_ACCOUNT_ASSOCIATION in src/lib/constants.ts',
|
||||
);
|
||||
fs.writeFileSync(constantsPath, constantsContent);
|
||||
console.log('\n✅ APP_ACCOUNT_ASSOCIATION updated in src/lib/constants.ts');
|
||||
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
console.error('\n❌ Deployment failed:', error.message);
|
||||
@@ -804,7 +965,7 @@ async function main(): Promise<void> {
|
||||
try {
|
||||
console.log('🚀 Vercel Mini App Deployment (SDK Edition)');
|
||||
console.log(
|
||||
'This script will deploy your mini app to Vercel using the Vercel SDK.'
|
||||
'This script will deploy your mini app to Vercel using the Vercel SDK.',
|
||||
);
|
||||
console.log('\nThe script will:');
|
||||
console.log('1. Check for required environment variables');
|
||||
@@ -818,9 +979,9 @@ async function main(): Promise<void> {
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
console.log('📦 Installing @vercel/sdk...');
|
||||
execSync('npm install @vercel/sdk', {
|
||||
execSync('npm install @vercel/sdk', {
|
||||
cwd: projectRoot,
|
||||
stdio: 'inherit'
|
||||
stdio: 'inherit',
|
||||
});
|
||||
console.log('✅ @vercel/sdk installed successfully');
|
||||
}
|
||||
@@ -880,7 +1041,6 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
await deployToVercel(useGitHub);
|
||||
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
console.error('\n❌ Error:', error.message);
|
||||
@@ -890,4 +1050,4 @@ async function main(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
main();
|
||||
@@ -7,6 +7,7 @@ export async function GET() {
|
||||
return NextResponse.json(config);
|
||||
} catch (error) {
|
||||
console.error('Error generating metadata:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
46
src/app/api/auth/validate/route.ts
Normal file
46
src/app/api/auth/validate/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ export async function POST(request: NextRequest) {
|
||||
// Only handle notifications if Neynar is not enabled
|
||||
// When Neynar is enabled, notifications are handled through their webhook
|
||||
switch (event.event) {
|
||||
case "frame_added":
|
||||
case "miniapp_added":
|
||||
if (event.notificationDetails) {
|
||||
await setUserNotificationDetails(fid, event.notificationDetails);
|
||||
await sendMiniAppNotification({
|
||||
@@ -69,7 +69,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
break;
|
||||
|
||||
case "frame_removed":
|
||||
case "miniapp_removed":
|
||||
await deleteUserNotificationDetails(fid);
|
||||
break;
|
||||
|
||||
|
||||
29
src/app/layout.NeynarAuth.tsx
Normal file
29
src/app/layout.NeynarAuth.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { getSession } from '~/auth';
|
||||
import '~/app/globals.css';
|
||||
import { Providers } from '~/app/providers';
|
||||
import { APP_NAME, APP_DESCRIPTION } from '~/lib/constants';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: APP_NAME,
|
||||
description: APP_DESCRIPTION,
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const session = await getSession();
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<Providers session={session}>
|
||||
{children}
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { getSession } from "~/auth"
|
||||
import "~/app/globals.css";
|
||||
import { Providers } from "~/app/providers";
|
||||
import { APP_NAME, APP_DESCRIPTION } from "~/lib/constants";
|
||||
import '~/app/globals.css';
|
||||
import { Providers } from '~/app/providers';
|
||||
import { APP_NAME, APP_DESCRIPTION } from '~/lib/constants';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: APP_NAME,
|
||||
@@ -14,13 +13,13 @@ export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const session = await getSession()
|
||||
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<Providers session={session}>{children}</Providers>
|
||||
<Providers>
|
||||
{children}
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
44
src/app/providers.NeynarAuth.tsx
Normal file
44
src/app/providers.NeynarAuth.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import type { Session } from 'next-auth';
|
||||
import { SessionProvider } from 'next-auth/react';
|
||||
import { AuthKitProvider } from '@farcaster/auth-kit';
|
||||
import { MiniAppProvider } from '@neynar/react';
|
||||
import { SafeFarcasterSolanaProvider } from '~/components/providers/SafeFarcasterSolanaProvider';
|
||||
import { ANALYTICS_ENABLED, RETURN_URL } from '~/lib/constants';
|
||||
|
||||
const WagmiProvider = dynamic(
|
||||
() => import('~/components/providers/WagmiProvider'),
|
||||
{
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
export function Providers({
|
||||
session,
|
||||
children,
|
||||
}: {
|
||||
session: Session | null;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const solanaEndpoint =
|
||||
process.env.SOLANA_RPC_ENDPOINT || 'https://solana-rpc.publicnode.com';
|
||||
return (
|
||||
<SessionProvider session={session}>
|
||||
<WagmiProvider>
|
||||
<MiniAppProvider
|
||||
analyticsEnabled={ANALYTICS_ENABLED}
|
||||
backButtonEnabled={true}
|
||||
returnUrl={RETURN_URL}
|
||||
>
|
||||
<SafeFarcasterSolanaProvider endpoint={solanaEndpoint}>
|
||||
<AuthKitProvider config={{}}>
|
||||
{children}
|
||||
</AuthKitProvider>
|
||||
</SafeFarcasterSolanaProvider>
|
||||
</MiniAppProvider>
|
||||
</WagmiProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import type { Session } from 'next-auth';
|
||||
import { SessionProvider } from 'next-auth/react';
|
||||
import { MiniAppProvider } from '@neynar/react';
|
||||
import { SafeFarcasterSolanaProvider } from '~/components/providers/SafeFarcasterSolanaProvider';
|
||||
import { ANALYTICS_ENABLED } from '~/lib/constants';
|
||||
import { AuthKitProvider } from '@farcaster/auth-kit';
|
||||
import { ANALYTICS_ENABLED, RETURN_URL } from '~/lib/constants';
|
||||
|
||||
const WagmiProvider = dynamic(
|
||||
() => import('~/components/providers/WagmiProvider'),
|
||||
@@ -16,26 +13,23 @@ const WagmiProvider = dynamic(
|
||||
);
|
||||
|
||||
export function Providers({
|
||||
session,
|
||||
children,
|
||||
}: {
|
||||
session: Session | null;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const solanaEndpoint =
|
||||
process.env.SOLANA_RPC_ENDPOINT || 'https://solana-rpc.publicnode.com';
|
||||
return (
|
||||
<SessionProvider session={session}>
|
||||
<WagmiProvider>
|
||||
<MiniAppProvider
|
||||
analyticsEnabled={ANALYTICS_ENABLED}
|
||||
backButtonEnabled={true}
|
||||
>
|
||||
<SafeFarcasterSolanaProvider endpoint={solanaEndpoint}>
|
||||
<AuthKitProvider config={{}}>{children}</AuthKitProvider>
|
||||
</SafeFarcasterSolanaProvider>
|
||||
</MiniAppProvider>
|
||||
</WagmiProvider>
|
||||
</SessionProvider>
|
||||
<WagmiProvider>
|
||||
<MiniAppProvider
|
||||
analyticsEnabled={ANALYTICS_ENABLED}
|
||||
backButtonEnabled={true}
|
||||
returnUrl={RETURN_URL}
|
||||
>
|
||||
<SafeFarcasterSolanaProvider endpoint={solanaEndpoint}>
|
||||
{children}
|
||||
</SafeFarcasterSolanaProvider>
|
||||
</MiniAppProvider>
|
||||
</WagmiProvider>
|
||||
);
|
||||
}
|
||||
|
||||
97
src/auth.ts
97
src/auth.ts
@@ -217,74 +217,6 @@ function getDomainFromUrl(urlString: string | undefined): string {
|
||||
export const authOptions: AuthOptions = {
|
||||
// Configure one or more authentication providers
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
id: 'farcaster',
|
||||
name: 'Sign in with Farcaster',
|
||||
credentials: {
|
||||
message: {
|
||||
label: 'Message',
|
||||
type: 'text',
|
||||
placeholder: '0x0',
|
||||
},
|
||||
signature: {
|
||||
label: 'Signature',
|
||||
type: 'text',
|
||||
placeholder: '0x0',
|
||||
},
|
||||
nonce: {
|
||||
label: 'Nonce',
|
||||
type: 'text',
|
||||
placeholder: 'Custom nonce (optional)',
|
||||
},
|
||||
// In a production app with a server, these should be fetched from
|
||||
// your Farcaster data indexer rather than have them accepted as part
|
||||
// of credentials.
|
||||
// question: should these natively use the Neynar API?
|
||||
name: {
|
||||
label: 'Name',
|
||||
type: 'text',
|
||||
placeholder: '0x0',
|
||||
},
|
||||
pfp: {
|
||||
label: 'Pfp',
|
||||
type: 'text',
|
||||
placeholder: '0x0',
|
||||
},
|
||||
},
|
||||
async authorize(credentials, req) {
|
||||
const nonce = req?.body?.csrfToken;
|
||||
|
||||
if (!nonce) {
|
||||
console.error('No nonce or CSRF token provided');
|
||||
return null;
|
||||
}
|
||||
const appClient = createAppClient({
|
||||
ethereum: viemConnector(),
|
||||
});
|
||||
|
||||
const domain = getDomainFromUrl(process.env.NEXTAUTH_URL);
|
||||
|
||||
const verifyResponse = await appClient.verifySignInMessage({
|
||||
message: credentials?.message as string,
|
||||
signature: credentials?.signature as `0x${string}`,
|
||||
domain,
|
||||
nonce,
|
||||
});
|
||||
|
||||
const { success, fid } = verifyResponse;
|
||||
|
||||
if (!success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: fid.toString(),
|
||||
name: credentials?.name || `User ${fid}`,
|
||||
image: credentials?.pfp || null,
|
||||
provider: 'farcaster',
|
||||
};
|
||||
},
|
||||
}),
|
||||
CredentialsProvider({
|
||||
id: 'neynar',
|
||||
name: 'Sign in with Neynar',
|
||||
@@ -333,10 +265,18 @@ export const authOptions: AuthOptions = {
|
||||
try {
|
||||
// Validate the signature using Farcaster's auth client (same as Farcaster provider)
|
||||
const appClient = createAppClient({
|
||||
// USE your own RPC URL or else you might get 401 error
|
||||
ethereum: viemConnector(),
|
||||
});
|
||||
|
||||
const domain = getDomainFromUrl(process.env.NEXTAUTH_URL);
|
||||
const baseUrl =
|
||||
process.env.VERCEL_ENV === 'production'
|
||||
? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
|
||||
: process.env.VERCEL_URL
|
||||
? `https://${process.env.VERCEL_URL}`
|
||||
: process.env.NEXTAUTH_URL || `http://localhost:${process.env.PORT ?? 3000}`;
|
||||
|
||||
const domain = getDomainFromUrl(baseUrl);
|
||||
|
||||
const verifyResponse = await appClient.verifySignInMessage({
|
||||
message: credentials?.message as string,
|
||||
@@ -377,12 +317,7 @@ export const authOptions: AuthOptions = {
|
||||
// Set provider at the root level
|
||||
session.provider = token.provider as string;
|
||||
|
||||
if (token.provider === 'farcaster') {
|
||||
// For Farcaster, simple structure
|
||||
session.user = {
|
||||
fid: parseInt(token.sub ?? ''),
|
||||
};
|
||||
} else if (token.provider === 'neynar') {
|
||||
if (token.provider === 'neynar') {
|
||||
// For Neynar, use full user data structure from user
|
||||
session.user = token.user as typeof session.user;
|
||||
session.signers = token.signers as typeof session.signers;
|
||||
@@ -404,26 +339,26 @@ export const authOptions: AuthOptions = {
|
||||
name: `next-auth.session-token`,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: 'none',
|
||||
sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
|
||||
path: '/',
|
||||
secure: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
},
|
||||
},
|
||||
callbackUrl: {
|
||||
name: `next-auth.callback-url`,
|
||||
options: {
|
||||
sameSite: 'none',
|
||||
sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
|
||||
path: '/',
|
||||
secure: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
},
|
||||
},
|
||||
csrfToken: {
|
||||
name: `next-auth.csrf-token`,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: 'none',
|
||||
sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
|
||||
path: '/',
|
||||
secure: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
signOut as backendSignOut,
|
||||
useSession,
|
||||
} from 'next-auth/react';
|
||||
import sdk, { SignIn as SignInCore } from '@farcaster/frame-sdk';
|
||||
import sdk, { SignIn as SignInCore } from '@farcaster/miniapp-sdk';
|
||||
|
||||
type User = {
|
||||
fid: number;
|
||||
@@ -308,7 +308,7 @@ export function NeynarAuthButton() {
|
||||
setPollingInterval(null);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/auth/signer?signerUuid=${signerUuid}`
|
||||
@@ -321,7 +321,7 @@ export function NeynarAuthButton() {
|
||||
setPollingInterval(null);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Increment retry count for other errors
|
||||
retryCount++;
|
||||
if (retryCount >= maxRetries) {
|
||||
@@ -329,7 +329,7 @@ export function NeynarAuthButton() {
|
||||
setPollingInterval(null);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
throw new Error(`Failed to poll signer status: ${response.status}`);
|
||||
}
|
||||
|
||||
@@ -443,7 +443,7 @@ export function NeynarAuthButton() {
|
||||
useEffect(() => {
|
||||
setMessage(data?.message || null);
|
||||
setSignature(data?.signature || null);
|
||||
|
||||
|
||||
// Reset the signer flow flag when message/signature change
|
||||
if (data?.message && data?.signature) {
|
||||
signerFlowStartedRef.current = false;
|
||||
@@ -459,9 +459,14 @@ export function NeynarAuthButton() {
|
||||
|
||||
// Handle fetching signers after successful authentication
|
||||
useEffect(() => {
|
||||
if (message && signature && !isSignerFlowRunning && !signerFlowStartedRef.current) {
|
||||
if (
|
||||
message &&
|
||||
signature &&
|
||||
!isSignerFlowRunning &&
|
||||
!signerFlowStartedRef.current
|
||||
) {
|
||||
signerFlowStartedRef.current = true;
|
||||
|
||||
|
||||
const handleSignerFlow = async () => {
|
||||
setIsSignerFlowRunning(true);
|
||||
try {
|
||||
@@ -479,7 +484,7 @@ export function NeynarAuthButton() {
|
||||
|
||||
// First, fetch existing signers
|
||||
const signers = await fetchAllSigners(message, signature);
|
||||
|
||||
|
||||
if (useBackendFlow && isMobileContext) setSignersLoading(true);
|
||||
|
||||
// Check if no signers exist or if we have empty signers
|
||||
@@ -564,6 +569,8 @@ export function NeynarAuthButton() {
|
||||
} else {
|
||||
console.error('❌ Backend sign-in error:', e);
|
||||
}
|
||||
} finally {
|
||||
setSignersLoading(false);
|
||||
}
|
||||
}, [nonce]);
|
||||
|
||||
@@ -604,7 +611,7 @@ export function NeynarAuthButton() {
|
||||
clearInterval(pollingInterval);
|
||||
setPollingInterval(null);
|
||||
}
|
||||
|
||||
|
||||
// Reset signer flow flag
|
||||
signerFlowStartedRef.current = false;
|
||||
} catch (error) {
|
||||
|
||||
208
src/components/ui/tabs/ActionsTab.NeynarAuth.tsx
Normal file
208
src/components/ui/tabs/ActionsTab.NeynarAuth.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useMiniApp } from '@neynar/react';
|
||||
import { ShareButton } from '../Share';
|
||||
import { Button } from '../Button';
|
||||
import { SignIn } from '../wallet/SignIn';
|
||||
import { type Haptics } from '@farcaster/miniapp-sdk';
|
||||
import { APP_URL } from '~/lib/constants';
|
||||
import { NeynarAuthButton } from '../NeynarAuthButton';
|
||||
|
||||
/**
|
||||
* ActionsTab component handles mini app actions like sharing, notifications, and haptic feedback.
|
||||
*
|
||||
* This component provides the main interaction interface for users to:
|
||||
* - Share the mini app with others
|
||||
* - Sign in with Farcaster
|
||||
* - Send notifications to their account
|
||||
* - Trigger haptic feedback
|
||||
* - Add the mini app to their client
|
||||
* - Copy share URLs
|
||||
*
|
||||
* The component uses the useMiniApp hook to access Farcaster context and actions.
|
||||
* All state is managed locally within this component.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ActionsTab />
|
||||
* ```
|
||||
*/
|
||||
export function ActionsTab() {
|
||||
// --- Hooks ---
|
||||
const { actions, added, notificationDetails, haptics, context } =
|
||||
useMiniApp();
|
||||
|
||||
// --- State ---
|
||||
const [notificationState, setNotificationState] = useState({
|
||||
sendStatus: '',
|
||||
shareUrlCopied: false,
|
||||
});
|
||||
const [selectedHapticIntensity, setSelectedHapticIntensity] =
|
||||
useState<Haptics.ImpactOccurredType>('medium');
|
||||
|
||||
// --- Handlers ---
|
||||
/**
|
||||
* Sends a notification to the current user's Farcaster account.
|
||||
*
|
||||
* This function makes a POST request to the /api/send-notification endpoint
|
||||
* with the user's FID and notification details. It handles different response
|
||||
* statuses including success (200), rate limiting (429), and errors.
|
||||
*
|
||||
* @returns Promise that resolves when the notification is sent or fails
|
||||
*/
|
||||
const sendFarcasterNotification = useCallback(async () => {
|
||||
setNotificationState((prev) => ({ ...prev, sendStatus: '' }));
|
||||
if (!notificationDetails || !context) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/api/send-notification', {
|
||||
method: 'POST',
|
||||
mode: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
fid: context.user.fid,
|
||||
notificationDetails,
|
||||
}),
|
||||
});
|
||||
if (response.status === 200) {
|
||||
setNotificationState((prev) => ({ ...prev, sendStatus: 'Success' }));
|
||||
return;
|
||||
} else if (response.status === 429) {
|
||||
setNotificationState((prev) => ({
|
||||
...prev,
|
||||
sendStatus: 'Rate limited',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const responseText = await response.text();
|
||||
setNotificationState((prev) => ({
|
||||
...prev,
|
||||
sendStatus: `Error: ${responseText}`,
|
||||
}));
|
||||
} catch (error) {
|
||||
setNotificationState((prev) => ({
|
||||
...prev,
|
||||
sendStatus: `Error: ${error}`,
|
||||
}));
|
||||
}
|
||||
}, [context, notificationDetails]);
|
||||
|
||||
/**
|
||||
* Copies the share URL for the current user to the clipboard.
|
||||
*
|
||||
* This function generates a share URL using the user's FID and copies it
|
||||
* to the clipboard. It shows a temporary "Copied!" message for 2 seconds.
|
||||
*/
|
||||
const copyUserShareUrl = useCallback(async () => {
|
||||
if (context?.user?.fid) {
|
||||
const userShareUrl = `${APP_URL}/share/${context.user.fid}`;
|
||||
await navigator.clipboard.writeText(userShareUrl);
|
||||
setNotificationState((prev) => ({ ...prev, shareUrlCopied: true }));
|
||||
setTimeout(
|
||||
() =>
|
||||
setNotificationState((prev) => ({ ...prev, shareUrlCopied: false })),
|
||||
2000
|
||||
);
|
||||
}
|
||||
}, [context?.user?.fid]);
|
||||
|
||||
/**
|
||||
* Triggers haptic feedback with the selected intensity.
|
||||
*
|
||||
* This function calls the haptics.impactOccurred method with the current
|
||||
* selectedHapticIntensity setting. It handles errors gracefully by logging them.
|
||||
*/
|
||||
const triggerHapticFeedback = useCallback(async () => {
|
||||
try {
|
||||
await haptics.impactOccurred(selectedHapticIntensity);
|
||||
} catch (error) {
|
||||
console.error('Haptic feedback failed:', error);
|
||||
}
|
||||
}, [haptics, selectedHapticIntensity]);
|
||||
|
||||
// --- Render ---
|
||||
return (
|
||||
<div className="space-y-3 px-6 w-full max-w-md mx-auto">
|
||||
{/* Share functionality */}
|
||||
<ShareButton
|
||||
buttonText="Share Mini App"
|
||||
cast={{
|
||||
text: 'Check out this awesome frame @1 @2 @3! 🚀🪐',
|
||||
bestFriends: true,
|
||||
embeds: [`${APP_URL}/share/${context?.user?.fid || ''}`],
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
{/* Authentication */}
|
||||
<SignIn />
|
||||
|
||||
{/* Neynar Authentication */}
|
||||
<NeynarAuthButton />
|
||||
|
||||
{/* Mini app actions */}
|
||||
<Button
|
||||
onClick={() =>
|
||||
actions.openUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
Open Link
|
||||
</Button>
|
||||
|
||||
<Button onClick={actions.addMiniApp} disabled={added} className="w-full">
|
||||
Add Mini App to Client
|
||||
</Button>
|
||||
|
||||
{/* Notification functionality */}
|
||||
{notificationState.sendStatus && (
|
||||
<div className="text-sm w-full">
|
||||
Send notification result: {notificationState.sendStatus}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={sendFarcasterNotification}
|
||||
disabled={!notificationDetails}
|
||||
className="w-full"
|
||||
>
|
||||
Send notification
|
||||
</Button>
|
||||
|
||||
{/* Share URL copying */}
|
||||
<Button
|
||||
onClick={copyUserShareUrl}
|
||||
disabled={!context?.user?.fid}
|
||||
className="w-full"
|
||||
>
|
||||
{notificationState.shareUrlCopied ? 'Copied!' : 'Copy share URL'}
|
||||
</Button>
|
||||
|
||||
{/* Haptic feedback controls */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Haptic Intensity
|
||||
</label>
|
||||
<select
|
||||
value={selectedHapticIntensity}
|
||||
onChange={(e) =>
|
||||
setSelectedHapticIntensity(
|
||||
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"
|
||||
>
|
||||
<option value={'light'}>Light</option>
|
||||
<option value={'medium'}>Medium</option>
|
||||
<option value={'heavy'}>Heavy</option>
|
||||
<option value={'soft'}>Soft</option>
|
||||
<option value={'rigid'}>Rigid</option>
|
||||
</select>
|
||||
<Button onClick={triggerHapticFeedback} className="w-full">
|
||||
Trigger Haptic Feedback
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useMiniApp } from "@neynar/react";
|
||||
import { ShareButton } from "../Share";
|
||||
import { Button } from "../Button";
|
||||
import { SignIn } from "../wallet/SignIn";
|
||||
import { type Haptics } from "@farcaster/miniapp-sdk";
|
||||
import { APP_URL } from "~/lib/constants";
|
||||
import { NeynarAuthButton } from '../NeynarAuthButton/index';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useMiniApp } from '@neynar/react';
|
||||
import { ShareButton } from '../Share';
|
||||
import { Button } from '../Button';
|
||||
import { SignIn } from '../wallet/SignIn';
|
||||
import { type Haptics } from '@farcaster/miniapp-sdk';
|
||||
import { APP_URL } from '~/lib/constants';
|
||||
|
||||
/**
|
||||
* ActionsTab component handles mini app actions like sharing, notifications, and haptic feedback.
|
||||
@@ -124,48 +123,45 @@ export function ActionsTab() {
|
||||
|
||||
// --- Render ---
|
||||
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 */}
|
||||
<ShareButton
|
||||
buttonText='Share Mini App'
|
||||
buttonText="Share Mini App"
|
||||
cast={{
|
||||
text: 'Check out this awesome frame @1 @2 @3! 🚀🪐',
|
||||
bestFriends: true,
|
||||
embeds: [`${APP_URL}/share/${context?.user?.fid || ''}`]
|
||||
embeds: [`${APP_URL}/share/${context?.user?.fid || ''}`],
|
||||
}}
|
||||
className='w-full'
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
{/* Authentication */}
|
||||
<SignIn />
|
||||
|
||||
{/* Neynar Authentication */}
|
||||
<NeynarAuthButton />
|
||||
|
||||
{/* Mini app actions */}
|
||||
<Button
|
||||
onClick={() =>
|
||||
actions.openUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
|
||||
}
|
||||
className='w-full'
|
||||
className="w-full"
|
||||
>
|
||||
Open Link
|
||||
</Button>
|
||||
|
||||
<Button onClick={actions.addMiniApp} disabled={added} className='w-full'>
|
||||
<Button onClick={actions.addMiniApp} disabled={added} className="w-full">
|
||||
Add Mini App to Client
|
||||
</Button>
|
||||
|
||||
{/* Notification functionality */}
|
||||
{notificationState.sendStatus && (
|
||||
<div className='text-sm w-full'>
|
||||
<div className="text-sm w-full">
|
||||
Send notification result: {notificationState.sendStatus}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={sendFarcasterNotification}
|
||||
disabled={!notificationDetails}
|
||||
className='w-full'
|
||||
className="w-full"
|
||||
>
|
||||
Send notification
|
||||
</Button>
|
||||
@@ -174,14 +170,14 @@ export function ActionsTab() {
|
||||
<Button
|
||||
onClick={copyUserShareUrl}
|
||||
disabled={!context?.user?.fid}
|
||||
className='w-full'
|
||||
className="w-full"
|
||||
>
|
||||
{notificationState.shareUrlCopied ? 'Copied!' : 'Copy share URL'}
|
||||
</Button>
|
||||
|
||||
{/* Haptic feedback controls */}
|
||||
<div className='space-y-2'>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Haptic Intensity
|
||||
</label>
|
||||
<select
|
||||
@@ -191,7 +187,7 @@ export function ActionsTab() {
|
||||
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={'medium'}>Medium</option>
|
||||
@@ -199,7 +195,7 @@ export function ActionsTab() {
|
||||
<option value={'soft'}>Soft</option>
|
||||
<option value={'rigid'}>Rigid</option>
|
||||
</select>
|
||||
<Button onClick={triggerHapticFeedback} className='w-full'>
|
||||
<Button onClick={triggerHapticFeedback} className="w-full">
|
||||
Trigger Haptic Feedback
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { signIn, signOut, getCsrfToken } from "next-auth/react";
|
||||
import sdk, { SignIn as SignInCore } from "@farcaster/miniapp-sdk";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Button } from "../Button";
|
||||
import { useCallback, useState } from 'react';
|
||||
import { SignIn as SignInCore } from '@farcaster/miniapp-sdk';
|
||||
import { useQuickAuth } from '~/hooks/useQuickAuth';
|
||||
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:
|
||||
* - Generates nonces for secure authentication
|
||||
* - Handles the SIWF flow using the Farcaster SDK
|
||||
* - Manages NextAuth session state
|
||||
* - Uses the built-in QuickAuth functionality from the Farcaster SDK
|
||||
* - Manages authentication state in memory (no persistence)
|
||||
* - Provides sign-out functionality
|
||||
* - 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.
|
||||
*
|
||||
* @example
|
||||
@@ -36,52 +34,32 @@ export function SignIn() {
|
||||
signingIn: false,
|
||||
signingOut: false,
|
||||
});
|
||||
const [signInResult, setSignInResult] = useState<SignInCore.SignInResult>();
|
||||
const [signInFailure, setSignInFailure] = useState<string>();
|
||||
|
||||
// --- Hooks ---
|
||||
const { data: session, status } = useSession();
|
||||
const { authenticatedUser, status, signIn, signOut } = useQuickAuth();
|
||||
|
||||
// --- 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
|
||||
* for the SIWF authentication flow. The nonce ensures the authentication
|
||||
* request is fresh and prevents replay attacks.
|
||||
*
|
||||
* @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
|
||||
* This function uses the built-in QuickAuth functionality:
|
||||
* 1. Gets a token from QuickAuth (handles SIWF flow automatically)
|
||||
* 2. Validates the token with our server
|
||||
* 3. Updates the session state
|
||||
*
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
const handleSignIn = useCallback(async () => {
|
||||
try {
|
||||
setAuthState((prev) => ({ ...prev, signingIn: true }));
|
||||
setAuthState(prev => ({ ...prev, signingIn: true }));
|
||||
setSignInFailure(undefined);
|
||||
const nonce = await getNonce();
|
||||
const result = await sdk.actions.signIn({ nonce });
|
||||
setSignInResult(result);
|
||||
await signIn('farcaster', {
|
||||
message: result.message,
|
||||
signature: result.signature,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
const success = await signIn();
|
||||
|
||||
if (!success) {
|
||||
setSignInFailure('Authentication failed');
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof SignInCore.RejectedByUser) {
|
||||
setSignInFailure('Rejected by user');
|
||||
@@ -89,52 +67,49 @@ export function SignIn() {
|
||||
}
|
||||
setSignInFailure('Unknown error');
|
||||
} finally {
|
||||
setAuthState((prev) => ({ ...prev, signingIn: false }));
|
||||
setAuthState(prev => ({ ...prev, signingIn: false }));
|
||||
}
|
||||
}, [getNonce]);
|
||||
}, [signIn]);
|
||||
|
||||
/**
|
||||
* Handles the sign-out process.
|
||||
*
|
||||
* This function clears the NextAuth session only if the current session
|
||||
* is using the Farcaster provider, and resets the local sign-in result state.
|
||||
* This function clears the QuickAuth session and resets the local state.
|
||||
*
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
const handleSignOut = useCallback(async () => {
|
||||
try {
|
||||
setAuthState((prev) => ({ ...prev, signingOut: true }));
|
||||
// Only sign out if the current session is from Farcaster provider
|
||||
if (session?.provider === 'farcaster') {
|
||||
await signOut({ redirect: false });
|
||||
}
|
||||
setSignInResult(undefined);
|
||||
setAuthState(prev => ({ ...prev, signingOut: true }));
|
||||
await signOut();
|
||||
} finally {
|
||||
setAuthState((prev) => ({ ...prev, signingOut: false }));
|
||||
setAuthState(prev => ({ ...prev, signingOut: false }));
|
||||
}
|
||||
}, [session]);
|
||||
}, [signOut]);
|
||||
|
||||
// --- Render ---
|
||||
return (
|
||||
<>
|
||||
{/* Authentication Buttons */}
|
||||
{(status !== 'authenticated' || session?.provider !== 'farcaster') && (
|
||||
{status !== 'authenticated' && (
|
||||
<Button onClick={handleSignIn} disabled={authState.signingIn}>
|
||||
Sign In with Farcaster
|
||||
</Button>
|
||||
)}
|
||||
{status === 'authenticated' && session?.provider === 'farcaster' && (
|
||||
{status === 'authenticated' && (
|
||||
<Button onClick={handleSignOut} disabled={authState.signingOut}>
|
||||
Sign out
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 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="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">
|
||||
{JSON.stringify(session, null, 2)}
|
||||
{JSON.stringify(authenticatedUser, null, 2)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -142,20 +117,14 @@ export function SignIn() {
|
||||
{/* Error Display */}
|
||||
{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="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">{signInFailure}</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="font-semibold text-gray-500 dark:text-gray-300 mb-1">
|
||||
Authentication Error
|
||||
</div>
|
||||
<div className="whitespace-pre text-gray-700 dark:text-gray-200">
|
||||
{JSON.stringify(signInResult, null, 2)}
|
||||
{signInFailure}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
207
src/hooks/useQuickAuth.ts
Normal file
207
src/hooks/useQuickAuth.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type AccountAssociation } from '@farcaster/miniapp-node';
|
||||
import { type AccountAssociation } from '@farcaster/miniapp-core/src/manifest';
|
||||
|
||||
/**
|
||||
* Application constants and configuration values.
|
||||
@@ -65,21 +65,22 @@ export const APP_SPLASH_URL: string = `${APP_URL}/splash.png`;
|
||||
* Background color for the splash screen.
|
||||
* Used as fallback when splash image is loading.
|
||||
*/
|
||||
export const APP_SPLASH_BACKGROUND_COLOR: string = "#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;
|
||||
export const APP_ACCOUNT_ASSOCIATION: AccountAssociation | undefined =
|
||||
undefined;
|
||||
|
||||
// --- UI Configuration ---
|
||||
/**
|
||||
* Text displayed on the main action button.
|
||||
* Used for the primary call-to-action in the mini app.
|
||||
*/
|
||||
export const APP_BUTTON_TEXT: string = 'Launch NSK';
|
||||
export const APP_BUTTON_TEXT: string = 'Launch Mini App';
|
||||
|
||||
// --- Integration Configuration ---
|
||||
/**
|
||||
@@ -89,7 +90,8 @@ export const APP_BUTTON_TEXT: string = 'Launch NSK';
|
||||
* Neynar webhook endpoint. Otherwise, falls back to a local webhook
|
||||
* endpoint for development and testing.
|
||||
*/
|
||||
export const APP_WEBHOOK_URL: string = process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID
|
||||
export const APP_WEBHOOK_URL: string =
|
||||
process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID
|
||||
? `https://api.neynar.com/f/app/${process.env.NEYNAR_CLIENT_ID}/event`
|
||||
: `${APP_URL}/api/webhook`;
|
||||
|
||||
@@ -100,7 +102,7 @@ export const APP_WEBHOOK_URL: string = process.env.NEYNAR_API_KEY && process.env
|
||||
* When false, wallet functionality is completely hidden from the UI.
|
||||
* Useful for mini apps that don't require wallet integration.
|
||||
*/
|
||||
export const USE_WALLET: boolean = true;
|
||||
export const USE_WALLET: boolean = false;
|
||||
|
||||
/**
|
||||
* Flag to enable/disable analytics tracking.
|
||||
@@ -111,6 +113,26 @@ export const USE_WALLET: boolean = true;
|
||||
*/
|
||||
export const ANALYTICS_ENABLED: boolean = 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[] = [];
|
||||
|
||||
/**
|
||||
* Return URL for the mini app.
|
||||
*
|
||||
* If provided, the mini app will be rendered with a return URL to be rendered if the
|
||||
* back button is pressed from the home page.
|
||||
*/
|
||||
export const RETURN_URL: string | undefined = undefined;
|
||||
|
||||
// PLEASE DO NOT UPDATE THIS
|
||||
export const SIGNED_KEY_REQUEST_VALIDATOR_EIP_712_DOMAIN = {
|
||||
name: 'Farcaster SignedKeyRequestValidator',
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { FrameNotificationDetails } from "@farcaster/miniapp-sdk";
|
||||
import { Redis } from "@upstash/redis";
|
||||
import { APP_NAME } from "./constants";
|
||||
import { MiniAppNotificationDetails } from '@farcaster/miniapp-sdk';
|
||||
import { Redis } from '@upstash/redis';
|
||||
import { APP_NAME } from './constants';
|
||||
|
||||
// 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
|
||||
const useRedis = process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN;
|
||||
const redis = useRedis ? new Redis({
|
||||
url: process.env.KV_REST_API_URL!,
|
||||
token: process.env.KV_REST_API_TOKEN!,
|
||||
}) : null;
|
||||
const redis = useRedis
|
||||
? new Redis({
|
||||
url: process.env.KV_REST_API_URL!,
|
||||
token: process.env.KV_REST_API_TOKEN!,
|
||||
})
|
||||
: null;
|
||||
|
||||
function getUserNotificationDetailsKey(fid: number): string {
|
||||
return `${APP_NAME}:user:${fid}`;
|
||||
@@ -18,17 +20,17 @@ function getUserNotificationDetailsKey(fid: number): string {
|
||||
|
||||
export async function getUserNotificationDetails(
|
||||
fid: number
|
||||
): Promise<FrameNotificationDetails | null> {
|
||||
): Promise<MiniAppNotificationDetails | null> {
|
||||
const key = getUserNotificationDetailsKey(fid);
|
||||
if (redis) {
|
||||
return await redis.get<FrameNotificationDetails>(key);
|
||||
return await redis.get<MiniAppNotificationDetails>(key);
|
||||
}
|
||||
return localStore.get(key) || null;
|
||||
}
|
||||
|
||||
export async function setUserNotificationDetails(
|
||||
fid: number,
|
||||
notificationDetails: FrameNotificationDetails
|
||||
notificationDetails: MiniAppNotificationDetails
|
||||
): Promise<void> {
|
||||
const key = getUserNotificationDetailsKey(fid);
|
||||
if (redis) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { type Manifest } from '@farcaster/miniapp-node';
|
||||
import { Manifest } from '@farcaster/miniapp-core/src/manifest';
|
||||
import {
|
||||
APP_BUTTON_TEXT,
|
||||
APP_DESCRIPTION,
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
APP_PRIMARY_CATEGORY,
|
||||
APP_SPLASH_BACKGROUND_COLOR,
|
||||
APP_SPLASH_URL,
|
||||
APP_TAGS, APP_URL,
|
||||
APP_TAGS,
|
||||
APP_URL,
|
||||
APP_WEBHOOK_URL,
|
||||
APP_ACCOUNT_ASSOCIATION,
|
||||
} from './constants';
|
||||
@@ -21,12 +22,15 @@ export function cn(...inputs: ClassValue[]) {
|
||||
|
||||
export function getMiniAppEmbedMetadata(ogImageUrl?: string) {
|
||||
return {
|
||||
version: "next",
|
||||
version: 'next',
|
||||
imageUrl: ogImageUrl ?? APP_OG_IMAGE_URL,
|
||||
ogTitle: APP_NAME,
|
||||
ogDescription: APP_DESCRIPTION,
|
||||
ogImageUrl: ogImageUrl ?? APP_OG_IMAGE_URL,
|
||||
button: {
|
||||
title: APP_BUTTON_TEXT,
|
||||
action: {
|
||||
type: "launch_frame",
|
||||
type: 'launch_frame',
|
||||
name: APP_NAME,
|
||||
url: APP_URL,
|
||||
splashImageUrl: APP_SPLASH_URL,
|
||||
@@ -42,20 +46,17 @@ export function getMiniAppEmbedMetadata(ogImageUrl?: string) {
|
||||
|
||||
export async function getFarcasterDomainManifest(): Promise<Manifest> {
|
||||
return {
|
||||
accountAssociation: APP_ACCOUNT_ASSOCIATION,
|
||||
accountAssociation: APP_ACCOUNT_ASSOCIATION!,
|
||||
miniapp: {
|
||||
version: "1",
|
||||
name: APP_NAME ?? "Neynar Starter Kit",
|
||||
iconUrl: APP_ICON_URL,
|
||||
version: '1',
|
||||
name: APP_NAME ?? 'Neynar Starter Kit',
|
||||
homeUrl: APP_URL,
|
||||
iconUrl: APP_ICON_URL,
|
||||
imageUrl: APP_OG_IMAGE_URL,
|
||||
buttonTitle: APP_BUTTON_TEXT ?? "Launch Mini App",
|
||||
buttonTitle: APP_BUTTON_TEXT ?? 'Launch Mini App',
|
||||
splashImageUrl: APP_SPLASH_URL,
|
||||
splashBackgroundColor: APP_SPLASH_BACKGROUND_COLOR,
|
||||
webhookUrl: APP_WEBHOOK_URL,
|
||||
description: APP_DESCRIPTION,
|
||||
primaryCategory: APP_PRIMARY_CATEGORY,
|
||||
tags: APP_TAGS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,6 +22,13 @@
|
||||
"~/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"ts-node": {
|
||||
"esm": true,
|
||||
"compilerOptions": {
|
||||
"module": "ES2020",
|
||||
"moduleResolution": "node"
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user