Merge branch 'main' into veganbeef/deploy-script-update

This commit is contained in:
veganbeef
2025-07-14 09:44:58 -07:00
22 changed files with 2355 additions and 400 deletions

View File

@@ -12,7 +12,9 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const REPO_URL = 'https://github.com/neynarxyz/create-farcaster-mini-app.git';
const SCRIPT_VERSION = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version;
const SCRIPT_VERSION = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')
).version;
// ANSI color codes
const purple = '\x1b[35m';
@@ -48,8 +50,8 @@ async function queryNeynarApp(apiKey) {
`https://api.neynar.com/portal/app_by_api_key?starter_kit=true`,
{
headers: {
'x-api-key': apiKey
}
'x-api-key': apiKey,
},
}
);
const data = await response.json();
@@ -61,7 +63,7 @@ async function queryNeynarApp(apiKey) {
}
// Export the main CLI function for programmatic use
export async function init(projectName = null, autoAcceptDefaults = false) {
export async function init(projectName = null, autoAcceptDefaults = false, apiKey = null) {
printWelcomeMessage();
// Ask about Neynar usage
@@ -80,16 +82,17 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
{
type: 'confirm',
name: 'useNeynar',
message: `🪐 ${purple}${bright}${italic}Neynar is an API that makes it easy to build on Farcaster.${reset}\n\n` +
'Benefits of using Neynar in your mini app:\n' +
'- Pre-configured webhook handling (no setup required)\n' +
'- Automatic mini app analytics in your dev portal\n' +
'- Send manual notifications from dev.neynar.com\n' +
'- Built-in rate limiting and error handling\n\n' +
`${purple}${bright}${italic}A demo API key is included if you would like to try out Neynar before signing up!${reset}\n\n` +
'Would you like to use Neynar in your mini app?',
default: true
}
message:
`🪐 ${purple}${bright}${italic}Neynar is an API that makes it easy to build on Farcaster.${reset}\n\n` +
'Benefits of using Neynar in your mini app:\n' +
'- Pre-configured webhook handling (no setup required)\n' +
'- Automatic mini app analytics in your dev portal\n' +
'- Send manual notifications from dev.neynar.com\n' +
'- Built-in rate limiting and error handling\n\n' +
`${purple}${bright}${italic}A demo API key is included if you would like to try out Neynar before signing up!${reset}\n\n` +
'Would you like to use Neynar in your mini app?',
default: true,
},
]);
}
@@ -98,44 +101,59 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
break;
}
console.log('\n🪐 Find your Neynar API key at: https://dev.neynar.com/app\n');
let neynarKeyAnswer;
if (autoAcceptDefaults) {
neynarKeyAnswer = { neynarApiKey: null };
// Use provided API key if available, otherwise prompt for it
if (apiKey) {
neynarApiKey = apiKey;
} else {
neynarKeyAnswer = await inquirer.prompt([
{
type: 'password',
name: 'neynarApiKey',
message: 'Enter your Neynar API key (or press enter to skip):',
default: null
}
]);
}
if (!autoAcceptDefaults) {
console.log(
'\n🪐 Find your Neynar API key at: https://dev.neynar.com/app\n'
);
}
if (neynarKeyAnswer.neynarApiKey) {
neynarApiKey = neynarKeyAnswer.neynarApiKey;
} else {
let useDemoKey;
let neynarKeyAnswer;
if (autoAcceptDefaults) {
useDemoKey = { useDemo: true };
neynarKeyAnswer = { neynarApiKey: null };
} else {
useDemoKey = await inquirer.prompt([
neynarKeyAnswer = await inquirer.prompt([
{
type: 'confirm',
name: 'useDemo',
message: 'Would you like to try the demo Neynar API key?',
default: true
}
type: 'password',
name: 'neynarApiKey',
message: 'Enter your Neynar API key (or press enter to skip):',
default: null,
},
]);
}
if (useDemoKey.useDemo) {
console.warn('\n⚠ Note: the demo key is for development purposes only and is aggressively rate limited.');
console.log('For production, please sign up for a Neynar account at https://neynar.com/ and configure the API key in your .env or .env.local file with NEYNAR_API_KEY.');
console.log(`\n${purple}${bright}${italic}Neynar now has a free tier! See https://neynar.com/#pricing for details.\n${reset}`);
neynarApiKey = 'FARCASTER_V2_FRAMES_DEMO';
if (neynarKeyAnswer.neynarApiKey) {
neynarApiKey = neynarKeyAnswer.neynarApiKey;
} else {
let useDemoKey;
if (autoAcceptDefaults) {
useDemoKey = { useDemo: true };
} else {
useDemoKey = await inquirer.prompt([
{
type: 'confirm',
name: 'useDemo',
message: 'Would you like to try the demo Neynar API key?',
default: true,
},
]);
}
if (useDemoKey.useDemo) {
console.warn(
'\n⚠ Note: the demo key is for development purposes only and is aggressively rate limited.'
);
console.log(
'For production, please sign up for a Neynar account at https://neynar.com/ and configure the API key in your .env or .env.local file with NEYNAR_API_KEY.'
);
console.log(
`\n${purple}${bright}${italic}Neynar now has a free tier! See https://neynar.com/#pricing for details.\n${reset}`
);
neynarApiKey = 'FARCASTER_V2_FRAMES_DEMO';
}
}
}
@@ -144,14 +162,16 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
useNeynar = false;
break;
}
console.log('\n⚠ No valid API key provided. Would you like to try again?');
console.log(
'\n⚠ No valid API key provided. Would you like to try again?'
);
const { retry } = await inquirer.prompt([
{
type: 'confirm',
name: 'retry',
message: 'Try configuring Neynar again?',
default: true
}
default: true,
},
]);
if (!retry) {
useNeynar = false;
@@ -176,9 +196,10 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
{
type: 'confirm',
name: 'retry',
message: '⚠️ Could not find a client ID for this API key. Would you like to try configuring Neynar again?',
default: true
}
message:
'⚠️ Could not find a client ID for this API key. Would you like to try configuring Neynar again?',
default: true,
},
]);
if (!retry) {
useNeynar = false;
@@ -191,7 +212,10 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
break;
}
const defaultMiniAppName = (neynarAppName && !neynarAppName.toLowerCase().includes('demo')) ? neynarAppName : undefined;
const defaultMiniAppName =
neynarAppName && !neynarAppName.toLowerCase().includes('demo')
? neynarAppName
: undefined;
let answers;
if (autoAcceptDefaults) {
@@ -203,7 +227,9 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
buttonText: 'Launch Mini App',
useWallet: true,
useTunnel: true,
enableAnalytics: true
enableAnalytics: true,
seedPhrase: null,
sponsorSigner: false,
};
} else {
// If autoAcceptDefaults is false but we have a projectName, we still need to ask for other options
@@ -218,21 +244,22 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
return 'Project name cannot be empty';
}
return true;
}
}
},
},
]);
answers = await inquirer.prompt([
{
type: 'input',
name: 'description',
message: 'Give a one-line description of your mini app (optional):',
default: 'A Farcaster mini app created with Neynar'
default: 'A Farcaster mini app created with Neynar',
},
{
type: 'list',
name: 'primaryCategory',
message: 'It is strongly recommended to choose a primary category and tags to help users discover your mini app.\n\nSelect a primary category:',
message:
'It is strongly recommended to choose a primary category and tags to help users discover your mini app.\n\nSelect a primary category:',
choices: [
new inquirer.Separator(),
{ name: 'Skip (not recommended)', value: null },
@@ -249,23 +276,24 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
{ name: 'Education', value: 'education' },
{ name: 'Developer Tools', value: 'developer-tools' },
{ name: 'Entertainment', value: 'entertainment' },
{ name: 'Art & Creativity', value: 'art-creativity' }
{ name: 'Art & Creativity', value: 'art-creativity' },
],
default: null
default: null,
},
{
type: 'input',
name: 'tags',
message: 'Enter tags for your mini app (separate with spaces or commas, optional):',
message:
'Enter tags for your mini app (separate with spaces or commas, optional):',
default: '',
filter: (input) => {
if (!input.trim()) return [];
// Split by both spaces and commas, trim whitespace, and filter out empty strings
return input
.split(/[,\s]+/)
.map(tag => tag.trim())
.filter(tag => tag.length > 0);
}
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0);
},
},
{
type: 'input',
@@ -277,8 +305,8 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
return 'Button text cannot be empty';
}
return true;
}
}
},
},
]);
// Merge project name from the first prompt
@@ -289,7 +317,8 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
{
type: 'confirm',
name: 'useWallet',
message: 'Would you like to include wallet and transaction tooling in your mini app?\n' +
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' +
@@ -297,8 +326,8 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
'- Chain switching\n' +
'- Solana support\n\n' +
'Include wallet and transaction features?',
default: true
}
default: true,
},
]);
answers.useWallet = walletAnswer.useWallet;
@@ -307,22 +336,61 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
{
type: 'confirm',
name: 'useTunnel',
message: 'Would you like to test on mobile and/or test the app with Warpcast developer tools?\n' +
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
}
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([
{
type: 'password',
name: 'seedPhrase',
message: 'Enter your Farcaster custody account seed phrase (required for Neynar Sponsored Signers/SIWN):',
validate: (input) => {
if (!input || input.trim().split(' ').length < 12) {
return 'Seed phrase must be at least 12 words';
}
return true;
},
},
]);
answers.seedPhrase = seedPhrase;
}
// Ask about analytics opt-out
const analyticsAnswer = await inquirer.prompt([
{
type: 'confirm',
name: 'enableAnalytics',
message: 'Would you like to help improve Neynar products by sharing usage data from your mini app?',
default: true
}
message:
'Would you like to help improve Neynar products by sharing usage data from your mini app?',
default: true,
},
]);
answers.enableAnalytics = analyticsAnswer.enableAnalytics;
}
@@ -337,19 +405,19 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
try {
console.log(`\nCloning repository from ${REPO_URL}...`);
// Use separate commands for better cross-platform compatibility
execSync(`git clone ${REPO_URL} "${projectPath}"`, {
execSync(`git clone ${REPO_URL} "${projectPath}"`, {
stdio: 'inherit',
shell: process.platform === 'win32'
shell: process.platform === 'win32',
});
execSync('git fetch origin main', {
cwd: projectPath,
execSync('git fetch origin main', {
cwd: projectPath,
stdio: 'inherit',
shell: process.platform === 'win32'
shell: process.platform === 'win32',
});
execSync('git reset --hard origin/main', {
cwd: projectPath,
execSync('git reset --hard origin/main', {
cwd: projectPath,
stdio: 'inherit',
shell: process.platform === 'win32'
shell: process.platform === 'win32',
});
} catch (error) {
console.error('\n❌ Error: Failed to create project directory.');
@@ -386,31 +454,32 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
// Add dependencies
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",
"@radix-ui/react-label": "^2.1.1",
"@solana/wallet-adapter-react": "^0.15.38",
"@tanstack/react-query": "^5.61.0",
"@upstash/redis": "^1.34.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "^16.4.7",
"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",
"tailwindcss-animate": "^1.0.7",
"viem": "^2.23.6",
"wagmi": "^2.14.12",
"zod": "^3.24.2"
'@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',
'@radix-ui/react-label': '^2.1.1',
'@solana/wallet-adapter-react': '^0.15.38',
'@tanstack/react-query': '^5.61.0',
'@upstash/redis': '^1.34.3',
'class-variance-authority': '^0.7.1',
clsx: '^2.1.1',
dotenv: '^16.4.7',
'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',
'tailwindcss-animate': '^1.0.7',
viem: '^2.23.6',
wagmi: '^2.14.12',
zod: '^3.24.2',
siwe: '^3.0.0',
};
packageJson.devDependencies = {
@@ -452,35 +521,46 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
const constantsPath = path.join(projectPath, 'src', 'lib', 'constants.ts');
if (fs.existsSync(constantsPath)) {
let constantsContent = fs.readFileSync(constantsPath, 'utf8');
// Helper function to escape single quotes in strings
const escapeString = (str) => str.replace(/'/g, "\\'");
// Helper function to safely replace constants with validation
const safeReplace = (content, pattern, replacement, constantName) => {
const match = content.match(pattern);
if (!match) {
console.log(`⚠️ Warning: Could not update ${constantName} in constants.ts. Pattern not found.`);
console.log(
`⚠️ Warning: Could not update ${constantName} in constants.ts. Pattern not found.`
);
console.log(`Pattern: ${pattern}`);
console.log(`Expected to match in: ${content.split('\n').find(line => line.includes(constantName)) || 'Not found'}`);
console.log(
`Expected to match in: ${
content.split('\n').find((line) => line.includes(constantName)) ||
'Not found'
}`
);
} else {
const newContent = content.replace(pattern, replacement);
return newContent;
}
return content;
};
// Regex patterns that match whole lines with export const
const patterns = {
APP_NAME: /^export const APP_NAME\s*=\s*['"`][^'"`]*['"`];$/m,
APP_DESCRIPTION: /^export const APP_DESCRIPTION\s*=\s*['"`][^'"`]*['"`];$/m,
APP_PRIMARY_CATEGORY: /^export const APP_PRIMARY_CATEGORY\s*=\s*['"`][^'"`]*['"`];$/m,
APP_DESCRIPTION:
/^export const APP_DESCRIPTION\s*=\s*['"`][^'"`]*['"`];$/m,
APP_PRIMARY_CATEGORY:
/^export const APP_PRIMARY_CATEGORY\s*=\s*['"`][^'"`]*['"`];$/m,
APP_TAGS: /^export const APP_TAGS\s*=\s*\[[^\]]*\];$/m,
APP_BUTTON_TEXT: /^export const APP_BUTTON_TEXT\s*=\s*['"`][^'"`]*['"`];$/m,
APP_BUTTON_TEXT:
/^export const APP_BUTTON_TEXT\s*=\s*['"`][^'"`]*['"`];$/m,
USE_WALLET: /^export const USE_WALLET\s*=\s*(true|false);$/m,
ANALYTICS_ENABLED: /^export const ANALYTICS_ENABLED\s*=\s*(true|false);$/m
ANALYTICS_ENABLED:
/^export const ANALYTICS_ENABLED\s*=\s*(true|false);$/m,
};
// Update APP_NAME
constantsContent = safeReplace(
constantsContent,
@@ -488,42 +568,49 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
`export const APP_NAME = '${escapeString(answers.projectName)}';`,
'APP_NAME'
);
// Update APP_DESCRIPTION
constantsContent = safeReplace(
constantsContent,
patterns.APP_DESCRIPTION,
`export const APP_DESCRIPTION = '${escapeString(answers.description)}';`,
`export const APP_DESCRIPTION = '${escapeString(
answers.description
)}';`,
'APP_DESCRIPTION'
);
// Update APP_PRIMARY_CATEGORY (always update, null becomes empty string)
constantsContent = safeReplace(
constantsContent,
patterns.APP_PRIMARY_CATEGORY,
`export const APP_PRIMARY_CATEGORY = '${escapeString(answers.primaryCategory || '')}';`,
`export const APP_PRIMARY_CATEGORY = '${escapeString(
answers.primaryCategory || ''
)}';`,
'APP_PRIMARY_CATEGORY'
);
// Update APP_TAGS
const tagsString = answers.tags.length > 0
? `['${answers.tags.map(tag => escapeString(tag)).join("', '")}']`
: "['neynar', 'starter-kit', 'demo']";
const tagsString =
answers.tags.length > 0
? `['${answers.tags.map((tag) => escapeString(tag)).join("', '")}']`
: "['neynar', 'starter-kit', 'demo']";
constantsContent = safeReplace(
constantsContent,
patterns.APP_TAGS,
`export const APP_TAGS = ${tagsString};`,
'APP_TAGS'
);
// Update APP_BUTTON_TEXT (always update, use answers value)
constantsContent = safeReplace(
constantsContent,
patterns.APP_BUTTON_TEXT,
`export const APP_BUTTON_TEXT = '${escapeString(answers.buttonText || '')}';`,
`export const APP_BUTTON_TEXT = '${escapeString(
answers.buttonText || ''
)}';`,
'APP_BUTTON_TEXT'
);
// Update USE_WALLET
constantsContent = safeReplace(
constantsContent,
@@ -531,7 +618,7 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
`export const USE_WALLET = ${answers.useWallet};`,
'USE_WALLET'
);
// Update ANALYTICS_ENABLED
constantsContent = safeReplace(
constantsContent,
@@ -539,24 +626,34 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
`export const ANALYTICS_ENABLED = ${answers.enableAnalytics};`,
'ANALYTICS_ENABLED'
);
fs.writeFileSync(constantsPath, constantsContent);
} else {
console.log('⚠️ constants.ts not found, skipping constants update');
}
fs.appendFileSync(envPath, `\nNEXTAUTH_SECRET="${crypto.randomBytes(32).toString('hex')}"`);
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}"`);
} else if (useNeynar) {
console.log('\n⚠ Could not find a Neynar client ID and/or API key. Please configure Neynar manually in .env.local with NEYNAR_API_KEY and NEYNAR_CLIENT_ID');
console.log(
'\n⚠ Could not find a Neynar client ID and/or API key. Please configure Neynar manually in .env.local with NEYNAR_API_KEY and NEYNAR_CLIENT_ID'
);
}
if (answers.seedPhrase) {
fs.appendFileSync(envPath, `\nSEED_PHRASE="${answers.seedPhrase}"`);
}
fs.appendFileSync(envPath, `\nUSE_TUNNEL="${answers.useTunnel}"`);
fs.unlinkSync(envExamplePath);
} else {
console.log('\n.env.example does not exist, skipping copy and remove operations');
console.log(
'\n.env.example does not exist, skipping copy and remove operations'
);
}
// Update README
@@ -564,7 +661,9 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
const readmePath = path.join(projectPath, 'README.md');
const prependText = `<!-- generated by @neynar/create-farcaster-mini-app version ${SCRIPT_VERSION} -->\n\n`;
if (fs.existsSync(readmePath)) {
const originalReadmeContent = fs.readFileSync(readmePath, { encoding: 'utf8' });
const originalReadmeContent = fs.readFileSync(readmePath, {
encoding: 'utf8',
});
const updatedReadmeContent = prependText + originalReadmeContent;
fs.writeFileSync(readmePath, updatedReadmeContent);
} else {
@@ -574,15 +673,15 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
// Install dependencies
console.log('\nInstalling dependencies...');
execSync('npm cache clean --force', {
cwd: projectPath,
execSync('npm cache clean --force', {
cwd: projectPath,
stdio: 'inherit',
shell: process.platform === 'win32'
shell: process.platform === 'win32',
});
execSync('npm install', {
cwd: projectPath,
execSync('npm install', {
cwd: projectPath,
stdio: 'inherit',
shell: process.platform === 'win32'
shell: process.platform === 'win32',
});
// Remove the bin directory
@@ -596,12 +695,15 @@ export async function init(projectName = null, autoAcceptDefaults = false) {
console.log('\nInitializing git repository...');
execSync('git init', { cwd: projectPath });
execSync('git add .', { cwd: projectPath });
execSync('git commit -m "initial commit from @neynar/create-farcaster-mini-app"', { cwd: projectPath });
execSync(
'git commit -m "initial commit from @neynar/create-farcaster-mini-app"',
{ cwd: projectPath }
);
// Calculate border length based on message length
const message = `✨🪐 Successfully created mini app ${finalProjectName} with git and dependencies installed! 🪐✨`;
const borderLength = message.length;
const borderStars = '✨'.repeat((borderLength / 2) + 1);
const borderStars = '✨'.repeat(borderLength / 2 + 1);
console.log(`\n${borderStars}`);
console.log(`${message}`);