From b3df35d8bc0706864a25038a68921a8b5815a74e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 25 Feb 2026 17:52:58 +0000 Subject: [PATCH 1/7] Add secure env setup agent script and docs Co-authored-by: DealPatrol --- QUICK_START.md | 6 + README.md | 31 +++ package.json | 4 +- scripts/env-agent.mjs | 518 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 558 insertions(+), 1 deletion(-) create mode 100644 scripts/env-agent.mjs diff --git a/QUICK_START.md b/QUICK_START.md index f52365b..f79d7cc 100644 --- a/QUICK_START.md +++ b/QUICK_START.md @@ -18,6 +18,12 @@ Check your Vercel project settings and ensure these are set: - `BLOB_READ_WRITE_TOKEN` - Your Vercel Blob token - Stack Auth credentials (if not auto-configured) +Optional helper command: +```bash +pnpm env:setup +``` +This scans the codebase for required env vars, links provider docs, and writes `.env.local` from prompted values. + ### 2. Database Ready The database schema is already created in Neon with: - All 6 tables set up diff --git a/README.md b/README.md index b501e41..d4463c7 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,12 @@ STACK_PUBLISHED_CLIENT_KEY=... STACK_SECRET_SERVER_KEY=... ``` +Or run the env setup agent: +```bash +pnpm env:setup +``` +It scans your project for required environment variables, links provider docs, and writes `.env.local` with prompted values. + 4. **Run the development server** ```bash pnpm dev @@ -161,6 +167,31 @@ pnpm dev 5. **Access the application** Open http://localhost:3000 in your browser +## Environment Setup Agent + +This project includes a secure env setup agent at `scripts/env-agent.mjs`. + +What it does: +- Detects env vars used in code and docs +- Suggests likely provider docs (Neon, Vercel Blob, Stack Auth, etc.) +- Prompts you for values and writes `.env.local` + +What it does **not** do: +- It does not create API keys for you +- It does not fetch secrets from websites/accounts automatically + +Commands: +```bash +# scan only (no file writes) +pnpm env:scan + +# interactive setup (writes .env.local) +pnpm env:setup + +# generate template file without prompts +node scripts/env-agent.mjs --template-only --non-interactive --output .env.example.generated +``` + ## API Endpoints ### Projects diff --git a/package.json b/package.json index 3633a57..4f839a3 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint ." + "lint": "eslint .", + "env:scan": "node scripts/env-agent.mjs --scan-only", + "env:setup": "node scripts/env-agent.mjs" }, "dependencies": { "@hookform/resolvers": "^3.9.1", diff --git a/scripts/env-agent.mjs b/scripts/env-agent.mjs new file mode 100644 index 0000000..1a91aca --- /dev/null +++ b/scripts/env-agent.mjs @@ -0,0 +1,518 @@ +#!/usr/bin/env node + +import { promises as fs } from 'node:fs' +import path from 'node:path' +import readline from 'node:readline/promises' +import { stdin as input, stdout as output } from 'node:process' + +const DEFAULT_OUTPUT_PATH = '.env.local' +const MAX_FILE_SIZE_BYTES = 1024 * 1024 // 1MB + +const EXCLUDED_DIRS = new Set([ + '.git', + '.next', + '.turbo', + '.cursor', + 'node_modules', + 'dist', + 'build', + 'coverage', + '.vercel', +]) + +const SCAN_EXTENSIONS = new Set([ + '.ts', + '.tsx', + '.js', + '.jsx', + '.mjs', + '.cjs', + '.json', + '.md', + '.txt', + '.yml', + '.yaml', + '.env', + '.env.local', + '.env.example', + '.sh', + '.sql', +]) + +const SECRET_HINTS = ['SECRET', 'TOKEN', 'KEY', 'PASSWORD', 'PRIVATE', 'DATABASE_URL'] + +const PROVIDER_HINTS = [ + { + name: 'Neon', + url: 'https://neon.tech/docs/connect/connect-from-any-app', + matches: (key) => key === 'DATABASE_URL', + note: 'Create a project and copy the connection string.', + }, + { + name: 'Vercel Blob', + url: 'https://vercel.com/docs/storage/vercel-blob/quickstart', + matches: (key) => key.includes('BLOB') || key.includes('VERCEL_BLOB'), + note: 'Create a Blob store token with read/write access.', + }, + { + name: 'Stack Auth', + url: 'https://docs.stack-auth.com', + matches: (key) => key.startsWith('STACK_'), + note: 'Create a Stack Auth project and copy project/public/secret keys.', + }, + { + name: 'OpenAI', + url: 'https://platform.openai.com/api-keys', + matches: (key) => key.startsWith('OPENAI_'), + note: 'Create API key in the OpenAI dashboard.', + }, + { + name: 'Anthropic', + url: 'https://console.anthropic.com/settings/keys', + matches: (key) => key.startsWith('ANTHROPIC_'), + note: 'Create API key in Anthropic Console.', + }, + { + name: 'GitHub', + url: 'https://github.com/settings/tokens', + matches: (key) => key.startsWith('GITHUB_'), + note: 'Create a personal access token with required scopes.', + }, +] + +const USAGE = ` +Usage: + node scripts/env-agent.mjs [options] + +Options: + --scan-only Discover vars and print report (no writing) + --non-interactive Do not prompt for missing values + --overwrite Prompt for keys even if already present in output file + --output Output env file path (default: .env.local) + --template-only Write template placeholders and exit + --help Show this help + +Examples: + node scripts/env-agent.mjs --scan-only + node scripts/env-agent.mjs --output .env.local + node scripts/env-agent.mjs --template-only --output .env.example.generated +` + +function parseArgs(argv) { + const args = { + scanOnly: false, + nonInteractive: false, + overwrite: false, + outputPath: DEFAULT_OUTPUT_PATH, + templateOnly: false, + help: false, + } + + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index] + + if (token === '--scan-only') { + args.scanOnly = true + continue + } + + if (token === '--non-interactive') { + args.nonInteractive = true + continue + } + + if (token === '--overwrite') { + args.overwrite = true + continue + } + + if (token === '--template-only') { + args.templateOnly = true + continue + } + + if (token === '--help' || token === '-h') { + args.help = true + continue + } + + if (token === '--output') { + const value = argv[index + 1] + if (!value) { + throw new Error('Missing value for --output') + } + args.outputPath = value + index += 1 + continue + } + + if (token.startsWith('--output=')) { + args.outputPath = token.slice('--output='.length) + continue + } + + throw new Error(`Unknown option: ${token}`) + } + + return args +} + +function toPosixPath(filePath) { + return filePath.split(path.sep).join('/') +} + +function shouldScanFile(fileName, stat) { + if (!stat.isFile()) { + return false + } + + if (stat.size > MAX_FILE_SIZE_BYTES) { + return false + } + + const ext = path.extname(fileName) + if (SCAN_EXTENSIONS.has(ext)) { + return true + } + + return fileName.startsWith('.env') +} + +async function walkFiles(rootDir) { + const files = [] + + async function walk(currentDir) { + const entries = await fs.readdir(currentDir, { withFileTypes: true }) + + for (const entry of entries) { + if (entry.name.startsWith('.') && entry.name !== '.env' && !entry.name.startsWith('.env.')) { + if (entry.isDirectory()) { + continue + } + } + + if (entry.isDirectory()) { + if (EXCLUDED_DIRS.has(entry.name)) { + continue + } + + await walk(path.join(currentDir, entry.name)) + continue + } + + const filePath = path.join(currentDir, entry.name) + const stat = await fs.stat(filePath) + if (shouldScanFile(entry.name, stat)) { + files.push(filePath) + } + } + } + + await walk(rootDir) + return files +} + +function addMatch(store, key, filePath, reason) { + if (!/^[A-Z][A-Z0-9_]*$/.test(key)) { + return + } + + if (!store.has(key)) { + store.set(key, { + key, + sources: new Set(), + reasons: new Set(), + }) + } + + const value = store.get(key) + value.sources.add(filePath) + value.reasons.add(reason) +} + +function extractFromSource(content, filePath, store) { + const patterns = [ + { + regex: /process\.env(?:\?\.)?([A-Z][A-Z0-9_]*)/g, + reason: 'process.env access', + }, + { + regex: /process\.env\[['"`]([A-Z][A-Z0-9_]*)['"`]\]/g, + reason: 'process.env bracket access', + }, + { + regex: /import\.meta\.env\.([A-Z][A-Z0-9_]*)/g, + reason: 'import.meta.env access', + }, + { + regex: /^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=/gm, + reason: 'dotenv-style assignment', + }, + ] + + for (const pattern of patterns) { + let match = pattern.regex.exec(content) + while (match) { + addMatch(store, match[1], filePath, pattern.reason) + match = pattern.regex.exec(content) + } + } + + if (filePath.endsWith('.md')) { + const markdownCodeVarPattern = /`([A-Z][A-Z0-9_]*)`/g + let match = markdownCodeVarPattern.exec(content) + while (match) { + if (match[1].includes('_')) { + addMatch(store, match[1], filePath, 'markdown env docs') + } + match = markdownCodeVarPattern.exec(content) + } + } +} + +function classifyKey(key) { + const isPublic = key.startsWith('NEXT_PUBLIC_') || key.startsWith('PUBLIC_') + const isSecret = !isPublic && SECRET_HINTS.some((hint) => key.includes(hint)) + + if (isPublic) { + return 'public' + } + if (isSecret) { + return 'secret' + } + return 'unknown' +} + +function providerForKey(key) { + for (const provider of PROVIDER_HINTS) { + if (provider.matches(key)) { + return provider + } + } + return null +} + +function buildReport(discovered) { + return discovered + .map((entry) => { + const type = classifyKey(entry.key) + const provider = providerForKey(entry.key) + + return { + ...entry, + type, + provider, + } + }) + .sort((a, b) => { + if (a.type === b.type) { + return a.key.localeCompare(b.key) + } + return a.type.localeCompare(b.type) + }) +} + +function parseEnv(content) { + const values = new Map() + const lines = content.split(/\r?\n/) + + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) { + continue + } + + const equals = trimmed.indexOf('=') + if (equals === -1) { + continue + } + + const key = trimmed.slice(0, equals).replace(/^export\s+/, '').trim() + let value = trimmed.slice(equals + 1).trim() + + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1) + } + + values.set(key, value) + } + + return values +} + +function escapeEnvValue(value) { + if (value === '') { + return '""' + } + + if (/^[A-Za-z0-9._:/@-]+$/.test(value)) { + return value + } + + return `"${value + .replace(/\\/g, '\\\\') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/"/g, '\\"')}"` +} + +function renderEnvFile(report, envValues, includeUndetected = true) { + const lines = [] + lines.push('# Generated by scripts/env-agent.mjs') + lines.push(`# Updated: ${new Date().toISOString()}`) + lines.push('') + + const reportKeys = new Set(report.map((item) => item.key)) + + for (const item of report) { + const provider = item.provider ? `${item.provider.name} (${item.provider.url})` : 'Custom/unknown provider' + lines.push(`# ${item.type.toUpperCase()} | ${provider}`) + lines.push(`${item.key}=${escapeEnvValue(envValues.get(item.key) ?? '')}`) + lines.push('') + } + + if (includeUndetected) { + const undetected = Array.from(envValues.keys()) + .filter((key) => !reportKeys.has(key)) + .sort((a, b) => a.localeCompare(b)) + + if (undetected.length > 0) { + lines.push('# Preserved keys not detected in current scan') + for (const key of undetected) { + lines.push(`${key}=${escapeEnvValue(envValues.get(key) ?? '')}`) + } + lines.push('') + } + } + + return lines.join('\n') +} + +async function readExistingEnv(outputPath) { + try { + const content = await fs.readFile(outputPath, 'utf8') + return parseEnv(content) + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + return new Map() + } + throw error + } +} + +async function promptForValues(report, existingValues, shouldOverwrite) { + const rl = readline.createInterface({ input, output }) + const values = new Map(existingValues) + + try { + for (const item of report) { + const alreadySet = values.has(item.key) && values.get(item.key) !== '' + if (alreadySet && !shouldOverwrite) { + continue + } + + output.write(`\n${item.key}\n`) + output.write(` Type: ${item.type}\n`) + + if (item.provider) { + output.write(` Provider: ${item.provider.name}\n`) + output.write(` Docs: ${item.provider.url}\n`) + output.write(` Note: ${item.provider.note}\n`) + } else { + output.write(' Provider: Unknown (check project docs)\n') + } + + if (alreadySet) { + output.write(' Current value exists. Press enter to keep it.\n') + } else { + output.write(' Enter value (leave blank to skip for now).\n') + } + + const prompt = alreadySet ? 'New value: ' : 'Value: ' + const response = await rl.question(prompt) + + if (!response.trim()) { + continue + } + + values.set(item.key, response.trim()) + } + } finally { + rl.close() + } + + return values +} + +function printReport(report) { + if (report.length === 0) { + output.write('No environment variables were detected.\n') + return + } + + output.write(`Detected ${report.length} environment variable(s):\n\n`) + + for (const item of report) { + output.write(`- ${item.key} [${item.type}]\n`) + output.write(` Sources: ${Array.from(item.sources).slice(0, 4).join(', ')}\n`) + output.write(` Detection: ${Array.from(item.reasons).join(', ')}\n`) + + if (item.provider) { + output.write(` Provider: ${item.provider.name}\n`) + output.write(` Docs: ${item.provider.url}\n`) + } else { + output.write(' Provider: Unknown\n') + } + + output.write('\n') + } +} + +async function run() { + const args = parseArgs(process.argv.slice(2)) + + if (args.help) { + output.write(USAGE) + return + } + + output.write( + 'This agent discovers required env vars and helps write .env files. It does not create API keys or fetch secrets automatically.\n\n' + ) + + const rootDir = process.cwd() + const files = await walkFiles(rootDir) + const discoveredMap = new Map() + + for (const filePath of files) { + const content = await fs.readFile(filePath, 'utf8') + const relativePath = toPosixPath(path.relative(rootDir, filePath)) + extractFromSource(content, relativePath, discoveredMap) + } + + const report = buildReport(Array.from(discoveredMap.values())) + printReport(report) + + if (args.scanOnly) { + return + } + + const outputPath = path.resolve(rootDir, args.outputPath) + let values = args.templateOnly ? new Map() : await readExistingEnv(outputPath) + + if (!args.nonInteractive && report.length > 0 && !args.templateOnly) { + values = await promptForValues(report, values, args.overwrite) + } + + const fileContent = renderEnvFile(report, values, !args.templateOnly) + await fs.mkdir(path.dirname(outputPath), { recursive: true }) + await fs.writeFile(outputPath, fileContent, 'utf8') + + output.write(`\nWrote ${report.length} variable(s) to ${toPosixPath(path.relative(rootDir, outputPath))}\n`) + output.write('Tip: review values and keep this file out of source control.\n') +} + +run().catch((error) => { + console.error(`env-agent failed: ${error.message}`) + process.exitCode = 1 +}) From 8683bddee8fdf1ecddfcebbd1e8803d0e7cd6eb1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 25 Feb 2026 17:53:30 +0000 Subject: [PATCH 2/7] Fix process.env dot-notation detection in env scan Co-authored-by: DealPatrol --- scripts/env-agent.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/env-agent.mjs b/scripts/env-agent.mjs index 1a91aca..14b5045 100644 --- a/scripts/env-agent.mjs +++ b/scripts/env-agent.mjs @@ -233,7 +233,7 @@ function addMatch(store, key, filePath, reason) { function extractFromSource(content, filePath, store) { const patterns = [ { - regex: /process\.env(?:\?\.)?([A-Z][A-Z0-9_]*)/g, + regex: /process\.env(?:\?\.|\.)([A-Z][A-Z0-9_]*)/g, reason: 'process.env access', }, { From 192e2e5e901773acfb2d3b7d5de46e7fbc9f9874 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Feb 2026 21:31:27 +0000 Subject: [PATCH 3/7] Add provider autofetch for env setup agent Co-authored-by: DealPatrol --- QUICK_START.md | 10 +- README.md | 30 ++- package.json | 3 +- scripts/env-agent.mjs | 527 +++++++++++++++++++++++++++++++++++++++++- 4 files changed, 563 insertions(+), 7 deletions(-) diff --git a/QUICK_START.md b/QUICK_START.md index f79d7cc..abe65bb 100644 --- a/QUICK_START.md +++ b/QUICK_START.md @@ -22,7 +22,15 @@ Optional helper command: ```bash pnpm env:setup ``` -This scans the codebase for required env vars, links provider docs, and writes `.env.local` from prompted values. +This scans the codebase, attempts autofetch from authenticated provider sessions (Vercel/Neon), then prompts for any remaining values before writing `.env.local`. + +For best automation: +```bash +vercel login && vercel link +# optional Neon API autofetch +export NEON_API_KEY=... +export NEON_PROJECT_ID=... +``` ### 2. Database Ready The database schema is already created in Neon with: diff --git a/README.md b/README.md index d4463c7..0826620 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ Or run the env setup agent: ```bash pnpm env:setup ``` -It scans your project for required environment variables, links provider docs, and writes `.env.local` with prompted values. +It scans your project, attempts provider autofetch from authenticated sessions, then prompts only for remaining values before writing `.env.local`. 4. **Run the development server** ```bash @@ -174,20 +174,42 @@ This project includes a secure env setup agent at `scripts/env-agent.mjs`. What it does: - Detects env vars used in code and docs - Suggests likely provider docs (Neon, Vercel Blob, Stack Auth, etc.) -- Prompts you for values and writes `.env.local` +- Attempts autofetch from authenticated sources: + - Current shell environment (`process.env`) + - Vercel project vars via `vercel env pull` + - Neon `DATABASE_URL` via Neon API or Neon CLI +- Prompts you for any missing values and writes `.env.local` What it does **not** do: - It does not create API keys for you -- It does not fetch secrets from websites/accounts automatically +- It does not bypass authentication or scrape secrets from websites +- For account-based retrieval, you must sign in through official provider auth flows + +Autofetch prerequisites: +```bash +# For Vercel variables +vercel login +vercel link + +# Optional for Neon API autofetch +export NEON_API_KEY=... +export NEON_PROJECT_ID=... +``` Commands: ```bash # scan only (no file writes) pnpm env:scan -# interactive setup (writes .env.local) +# setup with autofetch + prompts (writes .env.local) pnpm env:setup +# setup with prompts only (disable autofetch) +pnpm env:setup:manual + +# pull from preview or a specific branch on Vercel +node scripts/env-agent.mjs --vercel-environment preview --vercel-git-branch main + # generate template file without prompts node scripts/env-agent.mjs --template-only --non-interactive --output .env.example.generated ``` diff --git a/package.json b/package.json index 4f839a3..09b1b28 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "start": "next start", "lint": "eslint .", "env:scan": "node scripts/env-agent.mjs --scan-only", - "env:setup": "node scripts/env-agent.mjs" + "env:setup": "node scripts/env-agent.mjs", + "env:setup:manual": "node scripts/env-agent.mjs --no-autofetch" }, "dependencies": { "@hookform/resolvers": "^3.9.1", diff --git a/scripts/env-agent.mjs b/scripts/env-agent.mjs index 14b5045..c0227f4 100644 --- a/scripts/env-agent.mjs +++ b/scripts/env-agent.mjs @@ -2,6 +2,8 @@ import { promises as fs } from 'node:fs' import path from 'node:path' +import os from 'node:os' +import { execFile } from 'node:child_process' import readline from 'node:readline/promises' import { stdin as input, stdout as output } from 'node:process' @@ -85,25 +87,32 @@ Usage: node scripts/env-agent.mjs [options] Options: + --no-autofetch Disable provider autofetch attempts --scan-only Discover vars and print report (no writing) --non-interactive Do not prompt for missing values --overwrite Prompt for keys even if already present in output file --output Output env file path (default: .env.local) + --vercel-environment Vercel env to pull (default: development) + --vercel-git-branch Pull branch-specific Vercel vars (optional) --template-only Write template placeholders and exit --help Show this help Examples: node scripts/env-agent.mjs --scan-only node scripts/env-agent.mjs --output .env.local + node scripts/env-agent.mjs --vercel-environment preview --vercel-git-branch main node scripts/env-agent.mjs --template-only --output .env.example.generated ` function parseArgs(argv) { const args = { + autofetch: true, scanOnly: false, nonInteractive: false, overwrite: false, outputPath: DEFAULT_OUTPUT_PATH, + vercelEnvironment: 'development', + vercelGitBranch: '', templateOnly: false, help: false, } @@ -116,6 +125,11 @@ function parseArgs(argv) { continue } + if (token === '--no-autofetch') { + args.autofetch = false + continue + } + if (token === '--non-interactive') { args.nonInteractive = true continue @@ -146,17 +160,517 @@ function parseArgs(argv) { continue } + if (token === '--vercel-environment') { + const value = argv[index + 1] + if (!value) { + throw new Error('Missing value for --vercel-environment') + } + args.vercelEnvironment = value + index += 1 + continue + } + + if (token === '--vercel-git-branch') { + const value = argv[index + 1] + if (!value) { + throw new Error('Missing value for --vercel-git-branch') + } + args.vercelGitBranch = value + index += 1 + continue + } + if (token.startsWith('--output=')) { args.outputPath = token.slice('--output='.length) continue } + if (token.startsWith('--vercel-environment=')) { + args.vercelEnvironment = token.slice('--vercel-environment='.length) + continue + } + + if (token.startsWith('--vercel-git-branch=')) { + args.vercelGitBranch = token.slice('--vercel-git-branch='.length) + continue + } + throw new Error(`Unknown option: ${token}`) } return args } +function runCommand(command, args, options = {}) { + return new Promise((resolve) => { + execFile( + command, + args, + { + cwd: options.cwd || process.cwd(), + env: options.env || process.env, + encoding: 'utf8', + timeout: options.timeout || 30000, + maxBuffer: 2 * 1024 * 1024, + }, + (error, stdout = '', stderr = '') => { + if (error) { + resolve({ + ok: false, + errorCode: typeof error.code === 'string' ? error.code : '', + errorMessage: error.message, + stdout, + stderr, + }) + return + } + + resolve({ + ok: true, + errorCode: '', + errorMessage: '', + stdout, + stderr, + }) + } + ) + }) +} + +function extractPostgresUriFromText(text) { + if (!text) { + return null + } + + const match = text.match(/postgres(?:ql)?:\/\/[^\s'"`]+/i) + if (!match) { + return null + } + + return match[0].replace(/[),.;]+$/, '') +} + +function findPostgresUriInValue(value, depth = 0) { + if (depth > 6 || value === null || value === undefined) { + return null + } + + if (typeof value === 'string') { + return extractPostgresUriFromText(value) + } + + if (Array.isArray(value)) { + for (const item of value) { + const found = findPostgresUriInValue(item, depth + 1) + if (found) { + return found + } + } + return null + } + + if (typeof value === 'object') { + for (const nested of Object.values(value)) { + const found = findPostgresUriInValue(nested, depth + 1) + if (found) { + return found + } + } + } + + return null +} + +function collectFromProcessEnv(allowedKeys) { + const values = new Map() + + for (const key of allowedKeys) { + const value = process.env[key] + if (typeof value === 'string' && value.trim() !== '') { + values.set(key, value.trim()) + } + } + + return values +} + +function mergeValuesFromSource({ + targetValues, + incomingValues, + allowedKeys, + overwrite, + sourceName, + keySources, +}) { + let applied = 0 + + for (const [key, rawValue] of incomingValues.entries()) { + if (!allowedKeys.has(key)) { + continue + } + + const value = typeof rawValue === 'string' ? rawValue.trim() : '' + if (!value) { + continue + } + + const hasExisting = targetValues.has(key) && targetValues.get(key) !== '' + if (hasExisting && !overwrite) { + continue + } + + targetValues.set(key, value) + keySources.set(key, sourceName) + applied += 1 + } + + return applied +} + +function removeCliOptionPair(args, optionName) { + const copy = [...args] + const index = copy.indexOf(optionName) + if (index !== -1) { + copy.splice(index, 2) + } + return copy +} + +async function runVercelCli(args, timeout = 120000) { + const directVersion = await runCommand('vercel', ['--version'], { timeout: 20000 }) + if (directVersion.ok) { + return runCommand('vercel', args, { timeout }) + } + + if (directVersion.errorCode !== 'ENOENT') { + return directVersion + } + + const npxVersion = await runCommand('npx', ['--yes', 'vercel', '--version'], { timeout: 120000 }) + if (!npxVersion.ok) { + return npxVersion + } + + return runCommand('npx', ['--yes', 'vercel', ...args], { timeout }) +} + +async function attemptVercelAutofetch(args) { + const result = { + provider: 'vercel-env-pull', + status: 'skipped', + pulled: 0, + applied: 0, + values: new Map(), + message: '', + } + + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'env-agent-vercel-')) + const tempEnvPath = path.join(tempDir, '.env.vercel.pull') + + try { + const pullArgs = ['env', 'pull', tempEnvPath, '--yes'] + + if (args.vercelEnvironment) { + pullArgs.push('--environment', args.vercelEnvironment) + } + + if (args.vercelGitBranch) { + pullArgs.push('--git-branch', args.vercelGitBranch) + } + + let pullResult = await runVercelCli(pullArgs, 180000) + + // Older CLI versions may not support branch-specific pulls. + if ( + !pullResult.ok && + args.vercelGitBranch && + /unknown option|did you mean|unexpected argument/i.test(`${pullResult.stdout}\n${pullResult.stderr}`) + ) { + const fallbackArgs = removeCliOptionPair(pullArgs, '--git-branch') + pullResult = await runVercelCli(fallbackArgs, 180000) + } + + if (!pullResult.ok) { + const diagnostics = `${pullResult.stdout}\n${pullResult.stderr}` + + if (/not logged in|login required|run vercel login|no existing credentials/i.test(diagnostics)) { + result.status = 'skipped' + result.message = 'Vercel CLI is not authenticated. Run `vercel login` then re-run.' + return result + } + + if (/not linked|link this directory|run vercel link/i.test(diagnostics)) { + result.status = 'skipped' + result.message = 'Project not linked to Vercel. Run `vercel link` then re-run.' + return result + } + + result.status = 'failed' + result.message = `Vercel env pull failed: ${pullResult.errorMessage || diagnostics.trim() || 'unknown error'}` + return result + } + + const pulledContent = await fs.readFile(tempEnvPath, 'utf8') + const pulledValues = parseEnv(pulledContent) + + result.values = pulledValues + result.pulled = pulledValues.size + result.status = pulledValues.size > 0 ? 'success' : 'skipped' + result.message = pulledValues.size > 0 ? 'Pulled project vars from Vercel.' : 'No variables found in pull result.' + + return result + } catch (error) { + result.status = 'failed' + result.message = `Vercel autofetch error: ${error.message}` + return result + } finally { + await fs.rm(tempDir, { recursive: true, force: true }) + } +} + +function boolFromEnv(value) { + if (!value) { + return false + } + return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase()) +} + +async function attemptNeonApiAutofetch() { + const result = { + provider: 'neon-api', + status: 'skipped', + pulled: 0, + applied: 0, + values: new Map(), + message: '', + } + + const apiKey = process.env.NEON_API_KEY + const projectId = process.env.NEON_PROJECT_ID + + if (!apiKey || !projectId) { + result.message = 'Set NEON_API_KEY and NEON_PROJECT_ID to enable Neon API autofetch.' + return result + } + + try { + const url = new URL(`https://console.neon.tech/api/v2/projects/${encodeURIComponent(projectId)}/connection_uri`) + + if (process.env.NEON_BRANCH_ID) { + url.searchParams.set('branch_id', process.env.NEON_BRANCH_ID) + } + if (process.env.NEON_DATABASE_NAME) { + url.searchParams.set('database_name', process.env.NEON_DATABASE_NAME) + } + if (process.env.NEON_ROLE_NAME) { + url.searchParams.set('role_name', process.env.NEON_ROLE_NAME) + } + if (process.env.NEON_ENDPOINT_ID) { + url.searchParams.set('endpoint_id', process.env.NEON_ENDPOINT_ID) + } + if (process.env.NEON_IS_POOLED) { + url.searchParams.set('is_pooled', boolFromEnv(process.env.NEON_IS_POOLED) ? 'true' : 'false') + } + + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: 'application/json', + }, + }) + + if (!response.ok) { + const body = await response.text() + result.status = 'failed' + result.message = `Neon API request failed (${response.status}): ${body.slice(0, 200)}` + return result + } + + const json = await response.json() + const connectionString = findPostgresUriInValue(json) + + if (!connectionString) { + result.status = 'failed' + result.message = 'Neon API response did not include a PostgreSQL connection string.' + return result + } + + result.values.set('DATABASE_URL', connectionString) + result.pulled = 1 + result.status = 'success' + result.message = 'Retrieved DATABASE_URL from Neon API.' + return result + } catch (error) { + result.status = 'failed' + result.message = `Neon API autofetch error: ${error.message}` + return result + } +} + +function supportsCliFlag(helpText, flagName) { + return new RegExp(`--${flagName}(\\b|\\s|=)`).test(helpText) +} + +async function attemptNeonCliAutofetch() { + const result = { + provider: 'neon-cli', + status: 'skipped', + pulled: 0, + applied: 0, + values: new Map(), + message: '', + } + + const version = await runCommand('neon', ['--version'], { timeout: 20000 }) + if (!version.ok) { + if (version.errorCode === 'ENOENT') { + result.message = 'Neon CLI not installed. Install it to enable DATABASE_URL autofetch.' + return result + } + + result.status = 'failed' + result.message = `Neon CLI unavailable: ${version.errorMessage}` + return result + } + + const help = await runCommand('neon', ['connection-string', '--help'], { timeout: 30000 }) + const helpText = `${help.stdout}\n${help.stderr}` + + const connectionArgs = ['connection-string'] + if (process.env.NEON_BRANCH) { + connectionArgs.push(process.env.NEON_BRANCH) + } + if (process.env.NEON_PROJECT_ID && supportsCliFlag(helpText, 'project-id')) { + connectionArgs.push('--project-id', process.env.NEON_PROJECT_ID) + } + if (process.env.NEON_ROLE_NAME && supportsCliFlag(helpText, 'role-name')) { + connectionArgs.push('--role-name', process.env.NEON_ROLE_NAME) + } + if (process.env.NEON_DATABASE_NAME && supportsCliFlag(helpText, 'database-name')) { + connectionArgs.push('--database-name', process.env.NEON_DATABASE_NAME) + } + if (process.env.NEON_IS_POOLED && boolFromEnv(process.env.NEON_IS_POOLED) && supportsCliFlag(helpText, 'pooled')) { + connectionArgs.push('--pooled') + } + + const connectionResult = await runCommand('neon', connectionArgs, { timeout: 60000 }) + if (!connectionResult.ok) { + const diagnostics = `${connectionResult.stdout}\n${connectionResult.stderr}` + if (/login|authenticate|auth required/i.test(diagnostics)) { + result.status = 'skipped' + result.message = 'Neon CLI is not authenticated. Run `neon auth` and retry.' + return result + } + + result.status = 'failed' + result.message = `Neon CLI connection-string failed: ${connectionResult.errorMessage || diagnostics.trim()}` + return result + } + + const connectionString = extractPostgresUriFromText(`${connectionResult.stdout}\n${connectionResult.stderr}`) + if (!connectionString) { + result.status = 'failed' + result.message = 'Neon CLI output did not include a PostgreSQL connection string.' + return result + } + + result.values.set('DATABASE_URL', connectionString) + result.pulled = 1 + result.status = 'success' + result.message = 'Retrieved DATABASE_URL from Neon CLI.' + return result +} + +async function autofetchValues(report, currentValues, args) { + const allowedKeys = new Set(report.map((entry) => entry.key)) + const values = new Map(currentValues) + const keySources = new Map() + const details = [] + + const localEnvValues = collectFromProcessEnv(allowedKeys) + const localApplied = mergeValuesFromSource({ + targetValues: values, + incomingValues: localEnvValues, + allowedKeys, + overwrite: args.overwrite, + sourceName: 'local process.env', + keySources, + }) + + details.push({ + provider: 'local-process-env', + status: localEnvValues.size > 0 ? 'success' : 'skipped', + pulled: localEnvValues.size, + applied: localApplied, + message: + localEnvValues.size > 0 + ? 'Read matching values from current shell environment.' + : 'No matching keys were present in current shell environment.', + }) + + const vercel = await attemptVercelAutofetch(args) + vercel.applied = mergeValuesFromSource({ + targetValues: values, + incomingValues: vercel.values, + allowedKeys, + overwrite: args.overwrite, + sourceName: 'vercel env pull', + keySources, + }) + details.push(vercel) + + if (allowedKeys.has('DATABASE_URL')) { + const neonApi = await attemptNeonApiAutofetch() + neonApi.applied = mergeValuesFromSource({ + targetValues: values, + incomingValues: neonApi.values, + allowedKeys, + overwrite: args.overwrite, + sourceName: 'neon api', + keySources, + }) + details.push(neonApi) + + const neonCli = await attemptNeonCliAutofetch() + neonCli.applied = mergeValuesFromSource({ + targetValues: values, + incomingValues: neonCli.values, + allowedKeys, + overwrite: args.overwrite, + sourceName: 'neon cli', + keySources, + }) + details.push(neonCli) + } + + return { values, details, keySources } +} + +function printAutofetchSummary(autofetchResult) { + output.write('\nAutofetch summary:\n') + + for (const detail of autofetchResult.details) { + output.write( + `- ${detail.provider}: ${detail.status} (pulled ${detail.pulled}, applied ${detail.applied})\n` + ) + if (detail.message) { + output.write(` ${detail.message}\n`) + } + } + + if (autofetchResult.keySources.size > 0) { + output.write('Applied key sources:\n') + const keys = Array.from(autofetchResult.keySources.keys()).sort((a, b) => a.localeCompare(b)) + for (const key of keys) { + output.write(` ${key} <= ${autofetchResult.keySources.get(key)}\n`) + } + } else { + output.write('No values were auto-applied.\n') + } + + output.write('\n') +} + function toPosixPath(filePath) { return filePath.split(path.sep).join('/') } @@ -477,8 +991,9 @@ async function run() { } output.write( - 'This agent discovers required env vars and helps write .env files. It does not create API keys or fetch secrets automatically.\n\n' + 'This agent discovers required env vars, attempts provider autofetch from authenticated sessions, and writes .env files.\n' ) + output.write('It never bypasses authentication or creates secrets without provider access.\n\n') const rootDir = process.cwd() const files = await walkFiles(rootDir) @@ -500,6 +1015,16 @@ async function run() { const outputPath = path.resolve(rootDir, args.outputPath) let values = args.templateOnly ? new Map() : await readExistingEnv(outputPath) + if (!args.templateOnly && report.length > 0) { + if (args.autofetch) { + const autofetchResult = await autofetchValues(report, values, args) + values = autofetchResult.values + printAutofetchSummary(autofetchResult) + } else { + output.write('Autofetch disabled. Use default mode (without --no-autofetch) to attempt provider pulls.\n\n') + } + } + if (!args.nonInteractive && report.length > 0 && !args.templateOnly) { values = await promptForValues(report, values, args.overwrite) } From 416e76be98d1123124cb2ff75578a0eca9a49177 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Feb 2026 21:32:53 +0000 Subject: [PATCH 4/7] Exclude autofetch helper keys from default env output Co-authored-by: DealPatrol --- QUICK_START.md | 1 + README.md | 5 +++++ scripts/env-agent.mjs | 30 +++++++++++++++++++++++++++++- 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/QUICK_START.md b/QUICK_START.md index abe65bb..b63f1a2 100644 --- a/QUICK_START.md +++ b/QUICK_START.md @@ -23,6 +23,7 @@ Optional helper command: pnpm env:setup ``` This scans the codebase, attempts autofetch from authenticated provider sessions (Vercel/Neon), then prompts for any remaining values before writing `.env.local`. +Helper keys used only for autofetch are excluded by default unless you pass `--include-agent-keys`. For best automation: ```bash diff --git a/README.md b/README.md index 0826620..44c76f0 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,8 @@ What it does: - Neon `DATABASE_URL` via Neon API or Neon CLI - Prompts you for any missing values and writes `.env.local` +By default it excludes helper keys used only for autofetch (`NEON_API_KEY`, `NEON_PROJECT_ID`, etc.) so your project env file only contains app/runtime values. + What it does **not** do: - It does not create API keys for you - It does not bypass authentication or scrape secrets from websites @@ -210,6 +212,9 @@ pnpm env:setup:manual # pull from preview or a specific branch on Vercel node scripts/env-agent.mjs --vercel-environment preview --vercel-git-branch main +# include helper keys used by autofetch logic +node scripts/env-agent.mjs --include-agent-keys + # generate template file without prompts node scripts/env-agent.mjs --template-only --non-interactive --output .env.example.generated ``` diff --git a/scripts/env-agent.mjs b/scripts/env-agent.mjs index c0227f4..e13f34d 100644 --- a/scripts/env-agent.mjs +++ b/scripts/env-agent.mjs @@ -43,6 +43,17 @@ const SCAN_EXTENSIONS = new Set([ const SECRET_HINTS = ['SECRET', 'TOKEN', 'KEY', 'PASSWORD', 'PRIVATE', 'DATABASE_URL'] +const AGENT_HELPER_KEYS = new Set([ + 'NEON_API_KEY', + 'NEON_PROJECT_ID', + 'NEON_BRANCH', + 'NEON_BRANCH_ID', + 'NEON_DATABASE_NAME', + 'NEON_ROLE_NAME', + 'NEON_ENDPOINT_ID', + 'NEON_IS_POOLED', +]) + const PROVIDER_HINTS = [ { name: 'Neon', @@ -88,6 +99,7 @@ Usage: Options: --no-autofetch Disable provider autofetch attempts + --include-agent-keys Include helper keys used only for autofetch --scan-only Discover vars and print report (no writing) --non-interactive Do not prompt for missing values --overwrite Prompt for keys even if already present in output file @@ -101,12 +113,14 @@ Examples: node scripts/env-agent.mjs --scan-only node scripts/env-agent.mjs --output .env.local node scripts/env-agent.mjs --vercel-environment preview --vercel-git-branch main + node scripts/env-agent.mjs --include-agent-keys node scripts/env-agent.mjs --template-only --output .env.example.generated ` function parseArgs(argv) { const args = { autofetch: true, + includeAgentKeys: false, scanOnly: false, nonInteractive: false, overwrite: false, @@ -130,6 +144,11 @@ function parseArgs(argv) { continue } + if (token === '--include-agent-keys') { + args.includeAgentKeys = true + continue + } + if (token === '--non-interactive') { args.nonInteractive = true continue @@ -826,6 +845,14 @@ function buildReport(discovered) { }) } +function filterAgentHelperKeys(discovered, includeAgentKeys) { + if (includeAgentKeys) { + return discovered + } + + return discovered.filter((entry) => !AGENT_HELPER_KEYS.has(entry.key)) +} + function parseEnv(content) { const values = new Map() const lines = content.split(/\r?\n/) @@ -1005,7 +1032,8 @@ async function run() { extractFromSource(content, relativePath, discoveredMap) } - const report = buildReport(Array.from(discoveredMap.values())) + const discovered = filterAgentHelperKeys(Array.from(discoveredMap.values()), args.includeAgentKeys) + const report = buildReport(discovered) printReport(report) if (args.scanOnly) { From b4b9615d8d0fec3901bda0ca668740425b8a4956 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 1 Mar 2026 06:08:56 +0000 Subject: [PATCH 5/7] Add Vercel auth bootstrap for env value autofetch Co-authored-by: DealPatrol --- QUICK_START.md | 5 +- README.md | 10 +- package.json | 3 +- scripts/env-agent.mjs | 212 +++++++++++++++++++++++++++++++++++++----- 4 files changed, 202 insertions(+), 28 deletions(-) diff --git a/QUICK_START.md b/QUICK_START.md index b63f1a2..d080713 100644 --- a/QUICK_START.md +++ b/QUICK_START.md @@ -21,13 +21,16 @@ Check your Vercel project settings and ensure these are set: Optional helper command: ```bash pnpm env:setup +# or to auto-bootstrap Vercel auth/link: +pnpm env:setup:auto ``` This scans the codebase, attempts autofetch from authenticated provider sessions (Vercel/Neon), then prompts for any remaining values before writing `.env.local`. Helper keys used only for autofetch are excluded by default unless you pass `--include-agent-keys`. For best automation: ```bash -vercel login && vercel link +pnpm dlx vercel login +pnpm dlx vercel link # optional Neon API autofetch export NEON_API_KEY=... export NEON_PROJECT_ID=... diff --git a/README.md b/README.md index 44c76f0..34cf2b9 100644 --- a/README.md +++ b/README.md @@ -190,8 +190,8 @@ What it does **not** do: Autofetch prerequisites: ```bash # For Vercel variables -vercel login -vercel link +pnpm dlx vercel login +pnpm dlx vercel link # Optional for Neon API autofetch export NEON_API_KEY=... @@ -206,6 +206,9 @@ pnpm env:scan # setup with autofetch + prompts (writes .env.local) pnpm env:setup +# setup with autofetch + auto-bootstrap vercel auth/link when needed +pnpm env:setup:auto + # setup with prompts only (disable autofetch) pnpm env:setup:manual @@ -215,6 +218,9 @@ node scripts/env-agent.mjs --vercel-environment preview --vercel-git-branch main # include helper keys used by autofetch logic node scripts/env-agent.mjs --include-agent-keys +# enable automatic Vercel login/link bootstrap in CLI mode +node scripts/env-agent.mjs --bootstrap-vercel + # generate template file without prompts node scripts/env-agent.mjs --template-only --non-interactive --output .env.example.generated ``` diff --git a/package.json b/package.json index 09b1b28..9812f06 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "lint": "eslint .", "env:scan": "node scripts/env-agent.mjs --scan-only", "env:setup": "node scripts/env-agent.mjs", - "env:setup:manual": "node scripts/env-agent.mjs --no-autofetch" + "env:setup:manual": "node scripts/env-agent.mjs --no-autofetch", + "env:setup:auto": "node scripts/env-agent.mjs --bootstrap-vercel" }, "dependencies": { "@hookform/resolvers": "^3.9.1", diff --git a/scripts/env-agent.mjs b/scripts/env-agent.mjs index e13f34d..bd9743d 100644 --- a/scripts/env-agent.mjs +++ b/scripts/env-agent.mjs @@ -3,7 +3,7 @@ import { promises as fs } from 'node:fs' import path from 'node:path' import os from 'node:os' -import { execFile } from 'node:child_process' +import { execFile, spawn } from 'node:child_process' import readline from 'node:readline/promises' import { stdin as input, stdout as output } from 'node:process' @@ -100,6 +100,7 @@ Usage: Options: --no-autofetch Disable provider autofetch attempts --include-agent-keys Include helper keys used only for autofetch + --bootstrap-vercel Auto-run Vercel login/link when needed (interactive) --scan-only Discover vars and print report (no writing) --non-interactive Do not prompt for missing values --overwrite Prompt for keys even if already present in output file @@ -112,6 +113,7 @@ Options: Examples: node scripts/env-agent.mjs --scan-only node scripts/env-agent.mjs --output .env.local + node scripts/env-agent.mjs --bootstrap-vercel node scripts/env-agent.mjs --vercel-environment preview --vercel-git-branch main node scripts/env-agent.mjs --include-agent-keys node scripts/env-agent.mjs --template-only --output .env.example.generated @@ -121,6 +123,7 @@ function parseArgs(argv) { const args = { autofetch: true, includeAgentKeys: false, + bootstrapVercel: false, scanOnly: false, nonInteractive: false, overwrite: false, @@ -149,6 +152,11 @@ function parseArgs(argv) { continue } + if (token === '--bootstrap-vercel') { + args.bootstrapVercel = true + continue + } + if (token === '--non-interactive') { args.nonInteractive = true continue @@ -355,22 +363,146 @@ function removeCliOptionPair(args, optionName) { return copy } -async function runVercelCli(args, timeout = 120000) { - const directVersion = await runCommand('vercel', ['--version'], { timeout: 20000 }) - if (directVersion.ok) { - return runCommand('vercel', args, { timeout }) +const VERCEL_RUNNER_CANDIDATES = [ + { label: 'vercel', command: 'vercel', prefix: [] }, + { label: 'npx vercel', command: 'npx', prefix: ['--yes', 'vercel'] }, + { label: 'pnpm dlx vercel', command: 'pnpm', prefix: ['dlx', 'vercel'] }, +] + +function formatRunnerCommand(runner, args = []) { + return [runner.command, ...runner.prefix, ...args].join(' ') +} + +function hasInteractiveTty() { + return Boolean(input.isTTY && output.isTTY) +} + +async function resolveVercelRunner() { + const attempts = [] + + for (const candidate of VERCEL_RUNNER_CANDIDATES) { + const result = await runCommand(candidate.command, [...candidate.prefix, '--version'], { timeout: 120000 }) + attempts.push({ candidate, result }) + + if (result.ok) { + return { + ok: true, + runner: candidate, + attempts, + } + } + } + + return { + ok: false, + runner: null, + attempts, + } +} + +async function runVercelCli(runner, args, timeout = 120000) { + return runCommand(runner.command, [...runner.prefix, ...args], { timeout }) +} + +function runInteractiveCommand(command, args, options = {}) { + return new Promise((resolve) => { + const child = spawn(command, args, { + cwd: options.cwd || process.cwd(), + env: options.env || process.env, + stdio: 'inherit', + shell: false, + }) + + child.on('error', (error) => { + resolve({ + ok: false, + errorCode: typeof error.code === 'string' ? error.code : '', + errorMessage: error.message, + }) + }) + + child.on('exit', (code, signal) => { + if (code === 0) { + resolve({ + ok: true, + errorCode: '', + errorMessage: '', + }) + return + } + + resolve({ + ok: false, + errorCode: signal || String(code ?? ''), + errorMessage: `Interactive command exited with code ${String(code ?? signal ?? 'unknown')}`, + }) + }) + }) +} + +async function runVercelCliInteractive(runner, args) { + return runInteractiveCommand(runner.command, [...runner.prefix, ...args]) +} + +function isVercelAuthFailure(diagnostics) { + return /not logged in|login required|run vercel login|no existing credentials|auth/i.test(diagnostics) +} + +function isVercelLinkFailure(diagnostics) { + return /not linked|link this directory|run vercel link/i.test(diagnostics) +} + +async function tryBootstrapVercel(runner, diagnostics) { + const needsLogin = isVercelAuthFailure(diagnostics) + const needsLink = isVercelLinkFailure(diagnostics) + + if (!needsLogin && !needsLink) { + return { ok: false, message: '' } } - if (directVersion.errorCode !== 'ENOENT') { - return directVersion + if (needsLogin) { + output.write(`Vercel auth required. Running: ${formatRunnerCommand(runner, ['login'])}\n`) + const login = await runVercelCliInteractive(runner, ['login']) + if (!login.ok) { + return { + ok: false, + message: 'Vercel login did not complete successfully.', + } + } } - const npxVersion = await runCommand('npx', ['--yes', 'vercel', '--version'], { timeout: 120000 }) - if (!npxVersion.ok) { - return npxVersion + if (needsLogin || needsLink) { + output.write(`Vercel project link required. Running: ${formatRunnerCommand(runner, ['link'])}\n`) + const link = await runVercelCliInteractive(runner, ['link']) + if (!link.ok) { + return { + ok: false, + message: 'Vercel project link did not complete successfully.', + } + } } - return runCommand('npx', ['--yes', 'vercel', ...args], { timeout }) + return { ok: true, message: 'Bootstrapped Vercel authentication/linking.' } +} + +function vercelRunnerInstallHelp() { + return 'Install/access Vercel CLI with one of: `npm i -g vercel`, `pnpm dlx vercel`, or `npx vercel`.' +} + +function summarizeRunnerAttempts(attempts) { + if (!attempts || attempts.length === 0) { + return '' + } + + const parts = attempts.map(({ candidate, result }) => { + if (result.ok) { + return `${candidate.label}: ok` + } + const detail = result.errorMessage || result.errorCode || 'failed' + return `${candidate.label}: ${detail}` + }) + + return parts.join(' | ') } async function attemptVercelAutofetch(args) { @@ -387,6 +519,16 @@ async function attemptVercelAutofetch(args) { const tempEnvPath = path.join(tempDir, '.env.vercel.pull') try { + const runnerResolution = await resolveVercelRunner() + if (!runnerResolution.ok || !runnerResolution.runner) { + result.status = 'skipped' + result.message = `No Vercel CLI runner available. ${vercelRunnerInstallHelp()} (${summarizeRunnerAttempts( + runnerResolution.attempts + )})` + return result + } + + const runner = runnerResolution.runner const pullArgs = ['env', 'pull', tempEnvPath, '--yes'] if (args.vercelEnvironment) { @@ -397,30 +539,52 @@ async function attemptVercelAutofetch(args) { pullArgs.push('--git-branch', args.vercelGitBranch) } - let pullResult = await runVercelCli(pullArgs, 180000) + const runPull = async () => { + let pull = await runVercelCli(runner, pullArgs, 180000) + if ( + !pull.ok && + args.vercelGitBranch && + /unknown option|did you mean|unexpected argument/i.test(`${pull.stdout}\n${pull.stderr}`) + ) { + // Older CLI versions may not support branch-specific pulls. + const fallbackArgs = removeCliOptionPair(pullArgs, '--git-branch') + pull = await runVercelCli(runner, fallbackArgs, 180000) + } + return pull + } + + let pullResult = await runPull() - // Older CLI versions may not support branch-specific pulls. - if ( - !pullResult.ok && - args.vercelGitBranch && - /unknown option|did you mean|unexpected argument/i.test(`${pullResult.stdout}\n${pullResult.stderr}`) - ) { - const fallbackArgs = removeCliOptionPair(pullArgs, '--git-branch') - pullResult = await runVercelCli(fallbackArgs, 180000) + if (!pullResult.ok && args.bootstrapVercel && hasInteractiveTty()) { + const diagnostics = `${pullResult.stdout}\n${pullResult.stderr}` + const bootstrapResult = await tryBootstrapVercel(runner, diagnostics) + if (bootstrapResult.ok) { + pullResult = await runPull() + } else if (bootstrapResult.message) { + result.status = 'skipped' + result.message = bootstrapResult.message + return result + } } if (!pullResult.ok) { const diagnostics = `${pullResult.stdout}\n${pullResult.stderr}` - if (/not logged in|login required|run vercel login|no existing credentials/i.test(diagnostics)) { + if (isVercelAuthFailure(diagnostics)) { result.status = 'skipped' - result.message = 'Vercel CLI is not authenticated. Run `vercel login` then re-run.' + result.message = + `Vercel CLI is not authenticated. Run \`${formatRunnerCommand(runner, [ + 'login', + ])}\` then re-run.` + (args.bootstrapVercel ? ' (Auto-bootstrap attempted but did not complete.)' : '') return result } - if (/not linked|link this directory|run vercel link/i.test(diagnostics)) { + if (isVercelLinkFailure(diagnostics)) { result.status = 'skipped' - result.message = 'Project not linked to Vercel. Run `vercel link` then re-run.' + result.message = + `Project not linked to Vercel. Run \`${formatRunnerCommand(runner, [ + 'link', + ])}\` then re-run.` + (args.bootstrapVercel ? ' (Auto-bootstrap attempted but did not complete.)' : '') return result } From 04ea7a2e610199cc64eaf9730eaa1d186e3fb103 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 1 Mar 2026 06:09:42 +0000 Subject: [PATCH 6/7] Skip Vercel bootstrap in non-interactive mode Co-authored-by: DealPatrol --- scripts/env-agent.mjs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/env-agent.mjs b/scripts/env-agent.mjs index bd9743d..4b456ee 100644 --- a/scripts/env-agent.mjs +++ b/scripts/env-agent.mjs @@ -555,7 +555,10 @@ async function attemptVercelAutofetch(args) { let pullResult = await runPull() - if (!pullResult.ok && args.bootstrapVercel && hasInteractiveTty()) { + let bootstrapAttempted = false + + if (!pullResult.ok && args.bootstrapVercel && !args.nonInteractive && hasInteractiveTty()) { + bootstrapAttempted = true const diagnostics = `${pullResult.stdout}\n${pullResult.stderr}` const bootstrapResult = await tryBootstrapVercel(runner, diagnostics) if (bootstrapResult.ok) { @@ -575,7 +578,7 @@ async function attemptVercelAutofetch(args) { result.message = `Vercel CLI is not authenticated. Run \`${formatRunnerCommand(runner, [ 'login', - ])}\` then re-run.` + (args.bootstrapVercel ? ' (Auto-bootstrap attempted but did not complete.)' : '') + ])}\` then re-run.` + (bootstrapAttempted ? ' (Auto-bootstrap attempted but did not complete.)' : '') return result } @@ -584,7 +587,7 @@ async function attemptVercelAutofetch(args) { result.message = `Project not linked to Vercel. Run \`${formatRunnerCommand(runner, [ 'link', - ])}\` then re-run.` + (args.bootstrapVercel ? ' (Auto-bootstrap attempted but did not complete.)' : '') + ])}\` then re-run.` + (bootstrapAttempted ? ' (Auto-bootstrap attempted but did not complete.)' : '') return result } From 019db824b0090418a843d926667cb81b0465aa82 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Mar 2026 00:40:42 +0000 Subject: [PATCH 7/7] Add Stack Auth env key defaults and alias autofill Co-authored-by: DealPatrol --- QUICK_START.md | 4 +- README.md | 7 +++- scripts/env-agent.mjs | 91 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 98 insertions(+), 4 deletions(-) diff --git a/QUICK_START.md b/QUICK_START.md index d080713..75461bd 100644 --- a/QUICK_START.md +++ b/QUICK_START.md @@ -16,7 +16,9 @@ A fully functional, production-ready project management SaaS with: Check your Vercel project settings and ensure these are set: - `DATABASE_URL` - Your Neon PostgreSQL connection string - `BLOB_READ_WRITE_TOKEN` - Your Vercel Blob token -- Stack Auth credentials (if not auto-configured) +- `NEXT_PUBLIC_STACK_PROJECT_ID` - Your Stack project ID +- `NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY` - Your Stack publishable client key +- `STACK_SECRET_SERVER_KEY` - Your Stack server key Optional helper command: ```bash diff --git a/README.md b/README.md index 34cf2b9..93ea386 100644 --- a/README.md +++ b/README.md @@ -148,8 +148,10 @@ DATABASE_URL=postgresql://... BLOB_READ_WRITE_TOKEN=... # Stack Auth -STACK_PROJECT_ID=... -STACK_PUBLISHED_CLIENT_KEY=... +NEXT_PUBLIC_STACK_PROJECT_ID=... +NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=... +# Optional compatibility alias used by some Stack integrations: +NEXT_PUBLIC_STACK_PUBLISHED_CLIENT_KEY=... STACK_SECRET_SERVER_KEY=... ``` @@ -178,6 +180,7 @@ What it does: - Current shell environment (`process.env`) - Vercel project vars via `vercel env pull` - Neon `DATABASE_URL` via Neon API or Neon CLI +- Auto-derives equivalent aliases for common key variants (for example Stack `PUBLISHABLE` / `PUBLISHED` key names) - Prompts you for any missing values and writes `.env.local` By default it excludes helper keys used only for autofetch (`NEON_API_KEY`, `NEON_PROJECT_ID`, etc.) so your project env file only contains app/runtime values. diff --git a/scripts/env-agent.mjs b/scripts/env-agent.mjs index 4b456ee..119d490 100644 --- a/scripts/env-agent.mjs +++ b/scripts/env-agent.mjs @@ -54,6 +54,26 @@ const AGENT_HELPER_KEYS = new Set([ 'NEON_IS_POOLED', ]) +const STACK_AUTH_REQUIRED_KEYS = [ + 'NEXT_PUBLIC_STACK_PROJECT_ID', + 'NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY', + 'NEXT_PUBLIC_STACK_PUBLISHED_CLIENT_KEY', + 'STACK_SECRET_SERVER_KEY', + 'STACK_PROJECT_ID', + 'STACK_PUBLISHABLE_CLIENT_KEY', + 'STACK_PUBLISHED_CLIENT_KEY', +] + +const ENV_ALIAS_GROUPS = [ + ['NEXT_PUBLIC_STACK_PROJECT_ID', 'STACK_PROJECT_ID'], + [ + 'NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY', + 'NEXT_PUBLIC_STACK_PUBLISHED_CLIENT_KEY', + 'STACK_PUBLISHABLE_CLIENT_KEY', + 'STACK_PUBLISHED_CLIENT_KEY', + ], +] + const PROVIDER_HINTS = [ { name: 'Neon', @@ -70,7 +90,7 @@ const PROVIDER_HINTS = [ { name: 'Stack Auth', url: 'https://docs.stack-auth.com', - matches: (key) => key.startsWith('STACK_'), + matches: (key) => key.startsWith('STACK_') || key.startsWith('NEXT_PUBLIC_STACK_'), note: 'Create a Stack Auth project and copy project/public/secret keys.', }, { @@ -354,6 +374,52 @@ function mergeValuesFromSource({ return applied } +function firstNonEmptyValue(values, keys) { + for (const key of keys) { + const value = values.get(key) + if (typeof value === 'string' && value.trim() !== '') { + return { + key, + value: value.trim(), + } + } + } + + return null +} + +function applyDerivedAliasValues(values, allowedKeys, keySources) { + let applied = 0 + + for (const group of ENV_ALIAS_GROUPS) { + const source = firstNonEmptyValue(values, group) + if (!source) { + continue + } + + for (const aliasKey of group) { + if (!allowedKeys.has(aliasKey)) { + continue + } + + const existingValue = values.get(aliasKey) + if (typeof existingValue === 'string' && existingValue.trim() !== '') { + continue + } + + values.set(aliasKey, source.value) + + if (!keySources.has(aliasKey)) { + keySources.set(aliasKey, `alias from ${source.key}`) + } + + applied += 1 + } + } + + return applied +} + function removeCliOptionPair(args, optionName) { const copy = [...args] const index = copy.indexOf(optionName) @@ -829,6 +895,18 @@ async function autofetchValues(report, currentValues, args) { details.push(neonCli) } + const aliasApplied = applyDerivedAliasValues(values, allowedKeys, keySources) + details.push({ + provider: 'derived-aliases', + status: aliasApplied > 0 ? 'success' : 'skipped', + pulled: aliasApplied, + applied: aliasApplied, + message: + aliasApplied > 0 + ? 'Filled equivalent env var names from discovered aliases.' + : 'No alias-based env values were applied.', + }) + return { values, details, keySources } } @@ -1192,11 +1270,22 @@ async function run() { const rootDir = process.cwd() const files = await walkFiles(rootDir) const discoveredMap = new Map() + let usesStackAuth = false for (const filePath of files) { const content = await fs.readFile(filePath, 'utf8') const relativePath = toPosixPath(path.relative(rootDir, filePath)) extractFromSource(content, relativePath, discoveredMap) + + if (content.includes('@stack-auth/nextjs')) { + usesStackAuth = true + } + } + + if (usesStackAuth) { + for (const key of STACK_AUTH_REQUIRED_KEYS) { + addMatch(discoveredMap, key, 'framework:stack-auth', 'stack-auth default requirement') + } } const discovered = filterAgentHelperKeys(Array.from(discoveredMap.values()), args.includeAgentKeys)